Skip to content

Commit

Permalink
feat(app): add pipette selection screen to quick transfer flow (#14912)
Browse files Browse the repository at this point in the history
  • Loading branch information
smb2268 authored Apr 16, 2024
1 parent d77bbb7 commit 385d123
Show file tree
Hide file tree
Showing 9 changed files with 385 additions and 99 deletions.
3 changes: 3 additions & 0 deletions app/src/assets/localization/en/quick_transfer.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
{
"create_new_transfer": "Create new quick transfer",
"left_mount": "Left Mount",
"both_mounts": "Left + Right Mount",
"right_mount": "Right Mount",
"select_attached_pipette": "Select attached pipette",
"select_dest_labware": "Select destination labware",
"select_dest_wells": "Select destination wells",
Expand Down
13 changes: 13 additions & 0 deletions app/src/atoms/buttons/LargeButton.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,16 @@ export const Alert: Story = {
iconName: 'reset',
},
}
export const PrimaryNoIcon: Story = {
args: {
buttonText: 'Button text',
disabled: false,
},
}
export const PrimaryWithSubtext: Story = {
args: {
buttonText: 'Button text',
disabled: false,
subtext: 'Button subtext',
},
}
44 changes: 26 additions & 18 deletions app/src/atoms/buttons/LargeButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
DIRECTION_COLUMN,
DISPLAY_FLEX,
Icon,
Flex,
JUSTIFY_SPACE_BETWEEN,
SPACING,
StyledText,
Expand All @@ -20,7 +21,8 @@ interface LargeButtonProps extends StyleProps {
onClick: () => void
buttonType?: LargeButtonTypes
buttonText: React.ReactNode
iconName: IconName
iconName?: IconName
subtext?: string
disabled?: boolean
}

Expand All @@ -29,6 +31,7 @@ export function LargeButton(props: LargeButtonProps): JSX.Element {
buttonType = 'primary',
buttonText,
iconName,
subtext,
disabled = false,
...buttonProps
} = props
Expand Down Expand Up @@ -110,23 +113,28 @@ export function LargeButton(props: LargeButtonProps): JSX.Element {
disabled={disabled}
{...buttonProps}
>
<StyledText
fontSize="2rem"
fontWeight={TYPOGRAPHY.fontWeightSemiBold}
lineHeight="2.625rem"
>
{buttonText}
</StyledText>
<Icon
name={iconName}
aria-label={`${iconName} icon`}
color={
disabled
? COLORS.grey50
: LARGE_BUTTON_PROPS_BY_TYPE[buttonType].iconColor
}
size="5rem"
/>
<Flex flexDirection={DIRECTION_COLUMN}>
<StyledText css={TYPOGRAPHY.level3HeaderSemiBold}>
{buttonText}
</StyledText>
{subtext ? (
<StyledText css={TYPOGRAPHY.level3HeaderRegular}>
{subtext}
</StyledText>
) : null}
</Flex>
{iconName ? (
<Icon
name={iconName}
aria-label={`${iconName} icon`}
color={
disabled
? COLORS.grey50
: LARGE_BUTTON_PROPS_BY_TYPE[buttonType].iconColor
}
size="5rem"
/>
) : null}
</Btn>
)
}
114 changes: 114 additions & 0 deletions app/src/organisms/QuickTransferFlow/SelectPipette.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import {
Flex,
SPACING,
StyledText,
TYPOGRAPHY,
DIRECTION_COLUMN,
} from '@opentrons/components'
import { useInstrumentsQuery } from '@opentrons/react-api-client'
import { getPipetteSpecsV2, RIGHT, LEFT } from '@opentrons/shared-data'
import { SmallButton, LargeButton } from '../../atoms/buttons'
import { ChildNavigation } from '../ChildNavigation'

import type { PipetteData, Mount } from '@opentrons/api-client'
import type {
QuickTransferSetupState,
QuickTransferWizardAction,
} from './types'

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

export function SelectPipette(props: SelectPipetteProps): JSX.Element {
const { onNext, onBack, exitButtonProps, state, dispatch } = props
const { i18n, t } = useTranslation(['quick_transfer', 'shared'])
const { data: attachedInstruments } = useInstrumentsQuery()

const leftPipette = attachedInstruments?.data.find(
(i): i is PipetteData => i.ok && i.mount === LEFT
)
const leftPipetteSpecs =
leftPipette != null ? getPipetteSpecsV2(leftPipette.instrumentModel) : null

const rightPipette = attachedInstruments?.data.find(
(i): i is PipetteData => i.ok && i.mount === RIGHT
)
const rightPipetteSpecs =
rightPipette != null
? getPipetteSpecsV2(rightPipette.instrumentModel)
: null

// automatically select 96 channel if it is attached
const [selectedPipette, setSelectedPipette] = React.useState<
Mount | undefined
>(leftPipetteSpecs?.channels === 96 ? LEFT : state.mount)

const handleClickNext = (): void => {
const selectedPipetteSpecs =
selectedPipette === LEFT ? leftPipetteSpecs : rightPipetteSpecs

// the button will be disabled if these values are null
if (selectedPipette != null && selectedPipetteSpecs != null) {
dispatch({
type: 'SELECT_PIPETTE',
pipette: selectedPipetteSpecs,
mount: selectedPipette,
})
onNext()
}
}
return (
<Flex>
<ChildNavigation
header={t('select_attached_pipette')}
buttonText={i18n.format(t('shared:continue'), 'capitalize')}
onClickBack={onBack}
onClickButton={handleClickNext}
secondaryButtonProps={exitButtonProps}
top={SPACING.spacing8}
buttonIsDisabled={selectedPipette == null}
/>
<Flex
marginTop={SPACING.spacing120}
flexDirection={DIRECTION_COLUMN}
padding={`${SPACING.spacing16} ${SPACING.spacing60} ${SPACING.spacing40} ${SPACING.spacing60}`}
gridGap={SPACING.spacing16}
>
<StyledText css={TYPOGRAPHY.level4HeaderRegular}>
{t('pipette_currently_attached')}
</StyledText>
{leftPipetteSpecs != null ? (
<LargeButton
buttonType={selectedPipette === LEFT ? 'primary' : 'secondary'}
onClick={() => {
setSelectedPipette(LEFT)
}}
buttonText={
leftPipetteSpecs.channels === 96
? t('both_mounts')
: t('left_mount')
}
subtext={leftPipetteSpecs.displayName}
/>
) : null}
{rightPipetteSpecs != null ? (
<LargeButton
buttonType={selectedPipette === RIGHT ? 'primary' : 'secondary'}
onClick={() => {
setSelectedPipette(RIGHT)
}}
buttonText={t('right_mount')}
subtext={rightPipetteSpecs.displayName}
/>
) : null}
</Flex>
</Flex>
)
}
126 changes: 126 additions & 0 deletions app/src/organisms/QuickTransferFlow/__tests__/SelectPipette.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import * as React from 'react'
import { fireEvent, screen } from '@testing-library/react'
import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest'
import { useInstrumentsQuery } from '@opentrons/react-api-client'

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

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

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

beforeEach(() => {
props = {
onNext: vi.fn(),
onBack: vi.fn(),
exitButtonProps: {
buttonType: 'tertiaryLowLight',
buttonText: 'Exit',
onClick: vi.fn(),
},
state: {},
dispatch: vi.fn(),
}
vi.mocked(useInstrumentsQuery).mockReturnValue({
data: {
data: [
{
instrumentType: 'pipette',
mount: 'left',
ok: true,
firmwareVersion: 12,
instrumentName: 'p10_single',
instrumentModel: 'p1000_multi_v3.4',
data: {},
} as any,
{
instrumentType: 'pipette',
mount: 'right',
ok: true,
firmwareVersion: 12,
instrumentName: 'p10_single',
instrumentModel: 'p1000_multi_v3.4',
data: {},
} as any,
],
},
} as any)
})
afterEach(() => {
vi.resetAllMocks()
})

it('renders the select pipette screen, header, and exit button', () => {
render(props)
screen.getByText('Select attached pipette')
screen.getByText(
'Quick transfer options depend on the pipettes currently attached to your robot.'
)
const exitBtn = screen.getByText('Exit')
fireEvent.click(exitBtn)
expect(props.exitButtonProps.onClick).toHaveBeenCalled()
})

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

it('renders both pipette buttons if there are two attached', () => {
render(props)
screen.getByText('Left Mount')
screen.getByText('Right Mount')
})

it('selects pipette by default if there is one in state, button will be enabled', () => {
render({ ...props, state: { mount: 'left' } })
const continueBtn = screen.getByTestId('ChildNavigation_Primary_Button')
expect(continueBtn).toBeEnabled()
fireEvent.click(continueBtn)
expect(props.onNext).toHaveBeenCalled()
})

it('enables continue button if you click a pipette', () => {
render(props)
const continueBtn = screen.getByTestId('ChildNavigation_Primary_Button')
expect(continueBtn).toBeDisabled()
const leftButton = screen.getByText('Left Mount')
fireEvent.click(leftButton)
expect(continueBtn).toBeEnabled()
fireEvent.click(continueBtn)
expect(props.dispatch).toHaveBeenCalled()
expect(props.onNext).toHaveBeenCalled()
})

it('renders left and right button if 96 is attached and automatically selects the pipette', () => {
vi.mocked(useInstrumentsQuery).mockReturnValue({
data: {
data: [
{
instrumentType: 'pipette',
mount: 'left',
ok: true,
firmwareVersion: 12,
instrumentName: 'p1000_96',
instrumentModel: 'p1000_96_v1',
data: {},
} as any,
],
},
} as any)
render(props)
screen.getByText('Left + Right Mount')
const continueBtn = screen.getByTestId('ChildNavigation_Primary_Button')
expect(continueBtn).toBeEnabled()
})
})
Loading

0 comments on commit 385d123

Please sign in to comment.