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 pipette selection screen to quick transfer flow #14912

Merged
merged 5 commits into from
Apr 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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({
smb2268 marked this conversation as resolved.
Show resolved Hide resolved
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
Loading