Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(app): add labware selection and volume entry screens #15074

Merged
merged 10 commits into from
May 3, 2024
Prev Previous commit
Next Next commit
feat(app): add labware selection and volume entry screens
  • Loading branch information
smb2268 committed May 2, 2024
commit 97f698ef3d60922958c9a0c7ffc9f4bfb3f394ee
4 changes: 4 additions & 0 deletions app/src/assets/localization/en/quick_transfer.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
{
"all": "All labware",
"aspirate_volume": "Aspirate volume per well (µL)",
"create_new_transfer": "Create new quick transfer",
"left_mount": "Left Mount",
"both_mounts": "Left + Right Mount",
"dispense_volume": "Dispense volume per well (µL)",
"right_mount": "Right Mount",
"reservoir": "Reservoirs",
"select_attached_pipette": "Select attached pipette",
Expand All @@ -18,6 +20,8 @@
"use_deck_slots": "<block>Quick transfers use deck slots B2-D2. These slots hold a tip rack, a source labware, and a destination labware.</block><block>Make sure that your deck configuration is up to date to avoid collisions.</block>",
"tip_rack": "Tip rack",
"tubeRack": "Tube racks",
"volume_per_well": "Volume per well (µL)",
"value_out_of_range": "Value must be between {{min}}-{{max}}",
"labware": "Labware",
"pipette_currently_attached": "Quick transfer options depend on the pipettes currently attached to your robot.",
"wellPlate": "Well plates",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
.simple-keyboard.oddTheme1.hg-theme-default {
width: 100%;
height: 100%;
border-radius: 0;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Designs for this component show no rounded edges, the default for the keyboard component we use is border-radius 5

background-color: #cbcccc; /* grey35 */
font-family: 'Public Sans', sans-serif;
padding: 8px;
Expand Down
59 changes: 59 additions & 0 deletions app/src/organisms/QuickTransferFlow/SelectDestWells.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { Flex, SPACING } from '@opentrons/components'

import { SmallButton } from '../../atoms/buttons'
import { ChildNavigation } from '../ChildNavigation'

import type {
QuickTransferSetupState,
QuickTransferWizardAction,
} from './types'

interface SelectDestWellsProps {
onNext: () => void
onBack: () => void
exitButtonProps: React.ComponentProps<typeof SmallButton>
state: QuickTransferSetupState
dispatch: React.Dispatch<QuickTransferWizardAction>
}

export function SelectDestWells(props: SelectDestWellsProps): JSX.Element {
const { onNext, onBack, exitButtonProps, state, dispatch } = props
const { i18n, t } = useTranslation(['quick_transfer', 'shared'])

const handleClickNext = (): void => {
// until well selection is implemented, select all wells and proceed to the next step
if (state.destination === 'source' && state.source != null) {
dispatch({
type: 'SET_DEST_WELLS',
wells: Object.keys(state.source.wells),
})
} else if (state.destination != 'source' && state.destination != null) {

Check failure on line 32 in app/src/organisms/QuickTransferFlow/SelectDestWells.tsx

View workflow job for this annotation

GitHub Actions / js checks

Expected '!==' and instead saw '!='
dispatch({
type: 'SET_DEST_WELLS',
wells: Object.keys(state.destination.wells),
})
}
onNext()
}
return (
<Flex>
<ChildNavigation
header={t('select_dest_wells')}
onClickBack={onBack}
buttonText={i18n.format(t('shared:continue'), 'capitalize')}
onClickButton={handleClickNext}
buttonIsDisabled={false}
secondaryButtonProps={exitButtonProps}
top={SPACING.spacing8}
/>
<Flex
marginTop={SPACING.spacing120}
padding={`${SPACING.spacing16} ${SPACING.spacing60} ${SPACING.spacing40} ${SPACING.spacing60}`}
>
TODO: Add destination well selection deck map
</Flex>
</Flex>
)
}
54 changes: 54 additions & 0 deletions app/src/organisms/QuickTransferFlow/SelectSourceWells.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { Flex, SPACING } from '@opentrons/components'

import { SmallButton } from '../../atoms/buttons'
import { ChildNavigation } from '../ChildNavigation'

import type {
QuickTransferSetupState,
QuickTransferWizardAction,
} from './types'

interface SelectSourceWellsProps {
onNext: () => void
onBack: () => void
exitButtonProps: React.ComponentProps<typeof SmallButton>
state: QuickTransferSetupState
dispatch: React.Dispatch<QuickTransferWizardAction>
}

export function SelectSourceWells(props: SelectSourceWellsProps): JSX.Element {
const { onNext, onBack, exitButtonProps, state, dispatch } = props
const { i18n, t } = useTranslation(['quick_transfer', 'shared'])

const handleClickNext = (): void => {
// until well selection is implemented, select all wells and proceed to the next step
if (state.source?.wells != null) {
dispatch({
type: 'SET_SOURCE_WELLS',
wells: Object.keys(state.source.wells),
})
onNext()
}
}
return (
<Flex>
<ChildNavigation
header={t('select_source_wells')}
onClickBack={onBack}
buttonText={i18n.format(t('shared:continue'), 'capitalize')}
onClickButton={handleClickNext}
buttonIsDisabled={false}
secondaryButtonProps={exitButtonProps}
top={SPACING.spacing8}
/>
<Flex
marginTop={SPACING.spacing120}
padding={`${SPACING.spacing16} ${SPACING.spacing60} ${SPACING.spacing40} ${SPACING.spacing60}`}
>
TODO: Add source well selection deck map
</Flex>
</Flex>
)
}
1 change: 0 additions & 1 deletion app/src/organisms/QuickTransferFlow/SelectTipRack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ export function SelectTipRack(props: SelectTipRackProps): JSX.Element {
const handleClickNext = (): void => {
// the button will be disabled if this values is null
if (selectedTipRack != null) {
console.log('submitting tip rack')
dispatch({
type: 'SELECT_TIP_RACK',
tipRack: selectedTipRack,
Expand Down
125 changes: 125 additions & 0 deletions app/src/organisms/QuickTransferFlow/VolumeEntry.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import {
Flex,
SPACING,
DIRECTION_COLUMN,
ALIGN_CENTER,
} from '@opentrons/components'

import { SmallButton } from '../../atoms/buttons'
import { ChildNavigation } from '../ChildNavigation'
import { InputField } from '../../atoms/InputField'
import { NumericalKeyboard } from '../../atoms/SoftwareKeyboard'
import { getVolumeLimits } from './utils'

import type {
QuickTransferSetupState,
QuickTransferWizardAction,
} from './types'

interface VolumeEntryProps {
onNext: () => void
onBack: () => void
exitButtonProps: React.ComponentProps<typeof SmallButton>
state: QuickTransferSetupState
dispatch: React.Dispatch<QuickTransferWizardAction>
}

export function VolumeEntry(props: VolumeEntryProps): JSX.Element {
const { onNext, onBack, exitButtonProps, state, dispatch } = props
const { i18n, t } = useTranslation(['quick_transfer', 'shared'])
const keyboardRef = React.useRef(null)

const [volume, setVolume] = React.useState<string>('')
const volumeRange = getVolumeLimits(state)
let headerCopy = t('set_transfer_volume')
let textEntryCopy = t('volume_per_well')
if (
state.sourceWells != null &&
state.destinationWells != null &&
state.sourceWells.length > state.destinationWells?.length
) {
headerCopy = t('set_aspirate_volume')
textEntryCopy = t('aspirate_volume')
} else if (
state.sourceWells != null &&
state.destinationWells != null &&
state.sourceWells.length < state.destinationWells.length
) {
headerCopy = t('set_dispense_volume')
textEntryCopy = t('dispense_volume')
}

const volumeAsNumber = Number(volume)

const handleClickNext = (): void => {
// the button will be disabled if this values is null
if (volumeAsNumber != null) {
dispatch({
type: 'SET_VOLUME',
volume: volumeAsNumber,
})
onNext()
}
}

const error =
volume != '' &&

Check failure on line 68 in app/src/organisms/QuickTransferFlow/VolumeEntry.tsx

View workflow job for this annotation

GitHub Actions / js checks

Expected '!==' and instead saw '!='
(volumeAsNumber < volumeRange.min || volumeAsNumber > volumeRange.max)
? t(`value_out_of_range`, {
min: volumeRange.min,
max: volumeRange.max,
})
: null

return (
<Flex>
<ChildNavigation
header={headerCopy}
buttonText={i18n.format(t('shared:continue'), 'capitalize')}
onClickBack={onBack}
onClickButton={handleClickNext}
secondaryButtonProps={exitButtonProps}
top={SPACING.spacing8}
buttonIsDisabled={error != null || volume == ''}

Check failure on line 85 in app/src/organisms/QuickTransferFlow/VolumeEntry.tsx

View workflow job for this annotation

GitHub Actions / js checks

Expected '===' and instead saw '=='
/>
<Flex
alignSelf={ALIGN_CENTER}
gridGap={SPACING.spacing48}
paddingX={SPACING.spacing40}
padding={`${SPACING.spacing16} ${SPACING.spacing40} ${SPACING.spacing40}`}
marginTop="7.75rem" // using margin rather than justify due to content moving with error message
alignItems={ALIGN_CENTER}
height="22rem"
>
<Flex
width="30.5rem"
height="100%"
gridGap={SPACING.spacing24}
flexDirection={DIRECTION_COLUMN}
marginTop={SPACING.spacing68}
>
<InputField
type="text"
value={volume}
title={textEntryCopy}
error={error}
readOnly
/>
</Flex>
<Flex
paddingX={SPACING.spacing24}
height="21.25rem"
marginTop="7.75rem"
borderRadius="0"
>
<NumericalKeyboard
keyboardRef={keyboardRef}
onChange={e => setVolume(e)}
/>
</Flex>
</Flex>
</Flex>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import * as React from 'react'
import { fireEvent, screen } from '@testing-library/react'
import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest'

import { renderWithProviders } from '../../../__testing-utils__'
import { i18n } from '../../../i18n'
import { SelectDestLabware } from '../SelectDestLabware'

vi.mock('@opentrons/react-api-client')
const render = (props: React.ComponentProps<typeof SelectDestLabware>) => {
return renderWithProviders(<SelectDestLabware {...props} />, {
i18nInstance: i18n,
})
}

describe('SelectDestLabware', () => {
let props: React.ComponentProps<typeof SelectDestLabware>

beforeEach(() => {
props = {
onNext: vi.fn(),
onBack: vi.fn(),
exitButtonProps: {
buttonType: 'tertiaryLowLight',
buttonText: 'Exit',
onClick: vi.fn(),
},
state: {
mount: 'left',
pipette: {
channels: 1,
} as any,
},
dispatch: vi.fn(),
}
})
afterEach(() => {
vi.resetAllMocks()
})

it('renders the select destination labware screen, header, and exit button', () => {
render(props)
screen.getByText('Select destination labware')
const exitBtn = screen.getByText('Exit')
fireEvent.click(exitBtn)
expect(props.exitButtonProps.onClick).toHaveBeenCalled()
})

it('renders continue button and it is disabled if no labware is selected', () => {
render(props)
screen.getByText('Continue')
const continueBtn = screen.getByTestId('ChildNavigation_Primary_Button')
expect(continueBtn).toBeDisabled()
})

it('selects labware by default if there is one in state, button will be enabled', () => {
render({ ...props, state: { destination: { def: 'definition' } as any } })
const continueBtn = screen.getByTestId('ChildNavigation_Primary_Button')
expect(continueBtn).toBeEnabled()
fireEvent.click(continueBtn)
expect(props.onNext).toHaveBeenCalled()
expect(props.dispatch).toHaveBeenCalled()
})

it('renders all categories for a single channel pipette', () => {
render(props)
screen.getByText('All labware')
screen.getByText('Well plates')
screen.getByText('Reservoirs')
screen.getByText('Tube racks')
})

it.fails('does not render tube rack tab for multi channel pipette', () => {
render({ ...props, state: { pipette: { channels: 8 } as any } })
screen.getByText('Tube racks')
})

it('renders the source labware as the first option', () => {
render({
...props,
state: {
source: { metadata: { displayName: 'source labware name' } } as any,
},
})
render(props)
screen.getByText('Source labware in D2')
screen.getByText('source labware name')
})
it('enables continue button if you select a labware', () => {
render({
...props,
state: {
source: { metadata: { displayName: 'source labware name' } } as any,
},
})
const continueBtn = screen.getByTestId('ChildNavigation_Primary_Button')
expect(continueBtn).toBeDisabled()
const sourceLabware = screen.getByText('Source labware in D2')
fireEvent.click(sourceLabware)
expect(continueBtn).toBeEnabled()
})
})
Loading
Loading