From 6ab37ba6cd31f5531605d19104c0edbd7d0e9e28 Mon Sep 17 00:00:00 2001 From: smb2268 Date: Tue, 23 Apr 2024 14:15:56 -0400 Subject: [PATCH 01/10] feat(app): add source and destination labware selection screens fix PLAT-176 --- .../localization/en/quick_transfer.json | 5 + .../QuickTransferFlow/SelectDestLabware.tsx | 143 ++++++++++++++++++ .../QuickTransferFlow/SelectSourceLabware.tsx | 132 ++++++++++++++++ .../QuickTransferFlow/SelectTipRack.tsx | 2 + app/src/organisms/QuickTransferFlow/index.tsx | 22 +++ app/src/organisms/QuickTransferFlow/types.ts | 4 +- app/src/organisms/QuickTransferFlow/utils.ts | 42 +++++ 7 files changed, 348 insertions(+), 2 deletions(-) create mode 100644 app/src/organisms/QuickTransferFlow/SelectDestLabware.tsx create mode 100644 app/src/organisms/QuickTransferFlow/SelectSourceLabware.tsx diff --git a/app/src/assets/localization/en/quick_transfer.json b/app/src/assets/localization/en/quick_transfer.json index b0e9e294dc4..e992034540d 100644 --- a/app/src/assets/localization/en/quick_transfer.json +++ b/app/src/assets/localization/en/quick_transfer.json @@ -1,8 +1,10 @@ { + "all": "All labware", "create_new_transfer": "Create new quick transfer", "left_mount": "Left Mount", "both_mounts": "Left + Right Mount", "right_mount": "Right Mount", + "reservoir": "Reservoirs", "select_attached_pipette": "Select attached pipette", "select_dest_labware": "Select destination labware", "select_dest_wells": "Select destination wells", @@ -12,10 +14,13 @@ "set_aspirate_volume": "Set aspirate volume", "set_dispense_volume": "Set dispense volume", "set_transfer_volume": "Set transfer volume", + "source_labware": "Source labware in D2", "use_deck_slots": "Quick transfers use deck slots B2-D2. These slots hold a tip rack, a source labware, and a destination labware.Make sure that your deck configuration is up to date to avoid collisions.", "tip_rack": "Tip rack", + "tubeRack": "Tube racks", "labware": "Labware", "pipette_currently_attached": "Quick transfer options depend on the pipettes currently attached to your robot.", + "wellPlate": "Well plates", "well_selection": "Well selection", "well_ratio": "Quick transfers with multiple source wells can either be one-to-one (select {{wells}} for this transfer) or consolidate (select 1 destination well)." } diff --git a/app/src/organisms/QuickTransferFlow/SelectDestLabware.tsx b/app/src/organisms/QuickTransferFlow/SelectDestLabware.tsx new file mode 100644 index 00000000000..4a144584778 --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/SelectDestLabware.tsx @@ -0,0 +1,143 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { + Flex, + SPACING, + DIRECTION_COLUMN, + DIRECTION_ROW, + COLORS, + POSITION_FIXED, + ALIGN_CENTER, +} from '@opentrons/components' + +import { SmallButton, LargeButton, TabbedButton } from '../../atoms/buttons' +import { ChildNavigation } from '../ChildNavigation' +import { getCompatibleLabwareByCategory } from './utils' + +import type { LabwareDefinition2 } from '@opentrons/shared-data' +import type { + QuickTransferSetupState, + QuickTransferWizardAction, +} from './types' +import { LabwareFilter } from '../../pages/Labware/types' + +interface SelectDestLabwareProps { + onNext: () => void + onBack: () => void + exitButtonProps: React.ComponentProps + state: QuickTransferSetupState + dispatch: React.Dispatch +} + +export function SelectDestLabware(props: SelectDestLabwareProps): JSX.Element { + const { onNext, onBack, exitButtonProps, state, dispatch } = props + const { i18n, t } = useTranslation(['quick_transfer', 'shared']) + const labwareDisplayCategoryFilters: LabwareFilter[] = [ + 'all', + 'wellPlate', + 'reservoir', + ] + if (state.pipette?.channels === 1) { + labwareDisplayCategoryFilters.push('tubeRack') + } + const [selectedCategory, setSelectedCategory] = React.useState( + 'all' + ) + const compatibleLabwareDefinitions = getCompatibleLabwareByCategory( + state.pipette, + selectedCategory + ) + + const [selectedLabware, setSelectedLabware] = React.useState< + LabwareDefinition2 | 'source' | undefined + >(state.destination) + + const handleClickNext = (): void => { + // the button will be disabled if this values is null + if (selectedLabware != null) { + dispatch({ + type: 'SET_DEST_LABWARE', + labware: selectedLabware, + }) + onNext() + } + } + return ( + + + + + {labwareDisplayCategoryFilters.map(category => ( + setSelectedCategory(category)} + height={SPACING.spacing60} + > + {t(category)} + + ))} + + + {selectedCategory === 'all' && state?.source != null ? ( + { + setSelectedLabware('source') + }} + buttonText={t('source_labware')} + subtext={state.source.metadata.displayName} + /> + ) : null} + {compatibleLabwareDefinitions?.map(definition => { + return definition.metadata.displayName != null ? ( + { + setSelectedLabware(definition) + }} + buttonText={definition.metadata.displayName} + /> + ) : null + })} + + + + ) +} diff --git a/app/src/organisms/QuickTransferFlow/SelectSourceLabware.tsx b/app/src/organisms/QuickTransferFlow/SelectSourceLabware.tsx new file mode 100644 index 00000000000..87b26719641 --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/SelectSourceLabware.tsx @@ -0,0 +1,132 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { + Flex, + SPACING, + DIRECTION_COLUMN, + DIRECTION_ROW, + COLORS, + POSITION_FIXED, + ALIGN_CENTER, +} from '@opentrons/components' + +import { SmallButton, LargeButton, TabbedButton } from '../../atoms/buttons' +import { ChildNavigation } from '../ChildNavigation' +import { getCompatibleLabwareByCategory } from './utils' + +import type { LabwareDefinition2 } from '@opentrons/shared-data' +import type { + QuickTransferSetupState, + QuickTransferWizardAction, +} from './types' +import { LabwareFilter } from '../../pages/Labware/types' + +interface SelectSourceLabwareProps { + onNext: () => void + onBack: () => void + exitButtonProps: React.ComponentProps + state: QuickTransferSetupState + dispatch: React.Dispatch +} + +export function SelectSourceLabware( + props: SelectSourceLabwareProps +): JSX.Element { + const { onNext, onBack, exitButtonProps, state, dispatch } = props + const { i18n, t } = useTranslation(['quick_transfer', 'shared']) + const labwareDisplayCategoryFilters: LabwareFilter[] = [ + 'all', + 'wellPlate', + 'reservoir', + ] + if (state.pipette?.channels === 1) { + labwareDisplayCategoryFilters.push('tubeRack') + } + const [selectedCategory, setSelectedCategory] = React.useState( + 'all' + ) + const compatibleLabwareDefinitions = getCompatibleLabwareByCategory( + state.pipette, + selectedCategory + ) + + const [selectedLabware, setSelectedLabware] = React.useState< + LabwareDefinition2 | undefined + >(state.source) + + const handleClickNext = (): void => { + // the button will be disabled if this values is null + if (selectedLabware != null) { + dispatch({ + type: 'SET_SOURCE_LABWARE', + labware: selectedLabware, + }) + onNext() + } + } + return ( + + + + + {labwareDisplayCategoryFilters.map(category => ( + setSelectedCategory(category)} + height={SPACING.spacing60} + > + {t(category)} + + ))} + + + {compatibleLabwareDefinitions?.map(definition => { + return definition.metadata.displayName != null ? ( + { + setSelectedLabware(definition) + }} + buttonText={definition.metadata.displayName} + /> + ) : null + })} + + + + ) +} diff --git a/app/src/organisms/QuickTransferFlow/SelectTipRack.tsx b/app/src/organisms/QuickTransferFlow/SelectTipRack.tsx index bed59baa54b..98b175b1229 100644 --- a/app/src/organisms/QuickTransferFlow/SelectTipRack.tsx +++ b/app/src/organisms/QuickTransferFlow/SelectTipRack.tsx @@ -34,6 +34,7 @@ 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, @@ -64,6 +65,7 @@ export function SelectTipRack(props: SelectTipRackProps): JSX.Element { return tipRackDef != null ? ( { exitButtonProps={exitButtonProps} /> ) + } else if (currentStep === 4) { + modalContent = ( + setCurrentStep(prevStep => prevStep - 1)} + onNext={() => setCurrentStep(prevStep => prevStep + 1)} + exitButtonProps={exitButtonProps} + /> + ) + } else if (currentStep === 6) { + modalContent = ( + setCurrentStep(prevStep => prevStep - 1)} + onNext={() => setCurrentStep(prevStep => prevStep + 1)} + exitButtonProps={exitButtonProps} + /> + ) } else { modalContent = null } diff --git a/app/src/organisms/QuickTransferFlow/types.ts b/app/src/organisms/QuickTransferFlow/types.ts index 1d43017a58c..b9c2d788ca8 100644 --- a/app/src/organisms/QuickTransferFlow/types.ts +++ b/app/src/organisms/QuickTransferFlow/types.ts @@ -8,7 +8,7 @@ export interface QuickTransferSetupState { tipRack?: LabwareDefinition2 source?: LabwareDefinition2 sourceWells?: string[] - destination?: LabwareDefinition2 + destination?: LabwareDefinition2 | 'source' destinationWells?: string[] volume?: number } @@ -41,7 +41,7 @@ interface SetSourceWellsAction { } interface SetDestLabwareAction { type: typeof ACTIONS.SET_DEST_LABWARE - labware: LabwareDefinition2 + labware: LabwareDefinition2 | 'source' } interface SetDestWellsAction { type: typeof ACTIONS.SET_DEST_WELLS diff --git a/app/src/organisms/QuickTransferFlow/utils.ts b/app/src/organisms/QuickTransferFlow/utils.ts index ee13d4c1720..d52748c1139 100644 --- a/app/src/organisms/QuickTransferFlow/utils.ts +++ b/app/src/organisms/QuickTransferFlow/utils.ts @@ -1,7 +1,15 @@ +import { makeWellSetHelpers } from '@opentrons/shared-data' +import { getAllDefinitions } from '../../pages/Labware/helpers/definitions' +import type { + LabwareDefinition2, + PipetteV2Specs, + WellSetHelpers, +} from '@opentrons/shared-data' import type { QuickTransferSetupState, QuickTransferWizardAction, } from './types' +import { LabwareFilter } from '../../pages/Labware/types' export function quickTransferReducer( state: QuickTransferSetupState, @@ -73,3 +81,37 @@ export function quickTransferReducer( } } } + +export function getCompatibleLabwareByCategory( + pipetteSpecs: PipetteV2Specs | undefined, + category: LabwareFilter +): LabwareDefinition2[] | undefined { + const allLabwareDefinitions = getAllDefinitions() + const wellSetHelpers: WellSetHelpers = makeWellSetHelpers() + const { canPipetteUseLabware } = wellSetHelpers + if (pipetteSpecs == null) return undefined + const compatibleLabwareDefinitions = allLabwareDefinitions.filter(def => + canPipetteUseLabware(pipetteSpecs, def) + ) + + if (category === 'all') { + return compatibleLabwareDefinitions.filter( + def => + def.metadata.displayCategory === 'reservoir' || + def.metadata.displayCategory === 'tubeRack' || + def.metadata.displayCategory === 'wellPlate' + ) + } else if (category === 'reservoir') { + return compatibleLabwareDefinitions.filter( + def => def.metadata.displayCategory === 'reservoir' + ) + } else if (category === 'tubeRack') { + return compatibleLabwareDefinitions.filter( + def => def.metadata.displayCategory === 'tubeRack' + ) + } else if (category === 'wellPlate') { + return compatibleLabwareDefinitions.filter( + def => def.metadata.displayCategory === 'wellPlate' + ) + } +} From 077e337107c819ade1ca26da7e3ea63cde529742 Mon Sep 17 00:00:00 2001 From: smb2268 Date: Thu, 25 Apr 2024 17:23:23 -0400 Subject: [PATCH 02/10] Only check well compatibility for multi channels --- app/src/organisms/QuickTransferFlow/utils.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/organisms/QuickTransferFlow/utils.ts b/app/src/organisms/QuickTransferFlow/utils.ts index d52748c1139..4d1f7633a16 100644 --- a/app/src/organisms/QuickTransferFlow/utils.ts +++ b/app/src/organisms/QuickTransferFlow/utils.ts @@ -90,9 +90,12 @@ export function getCompatibleLabwareByCategory( const wellSetHelpers: WellSetHelpers = makeWellSetHelpers() const { canPipetteUseLabware } = wellSetHelpers if (pipetteSpecs == null) return undefined - const compatibleLabwareDefinitions = allLabwareDefinitions.filter(def => - canPipetteUseLabware(pipetteSpecs, def) - ) + let compatibleLabwareDefinitions = allLabwareDefinitions + if (pipetteSpecs.channels !== 1) { + compatibleLabwareDefinitions = allLabwareDefinitions.filter(def => + canPipetteUseLabware(pipetteSpecs.channels, def) + ) + } if (category === 'all') { return compatibleLabwareDefinitions.filter( From 97f698ef3d60922958c9a0c7ffc9f4bfb3f394ee Mon Sep 17 00:00:00 2001 From: smb2268 Date: Thu, 2 May 2024 13:28:43 -0400 Subject: [PATCH 03/10] feat(app): add labware selection and volume entry screens fix PLAT-176, PLAT-177 --- .../localization/en/quick_transfer.json | 4 + .../NumericalKeyboard/index.css | 1 + .../QuickTransferFlow/SelectDestWells.tsx | 59 +++++++++ .../QuickTransferFlow/SelectSourceWells.tsx | 54 ++++++++ .../QuickTransferFlow/SelectTipRack.tsx | 1 - .../QuickTransferFlow/VolumeEntry.tsx | 125 ++++++++++++++++++ .../__tests__/SelectDestLabware.test.tsx | 102 ++++++++++++++ .../__tests__/SelectSourceLabware.test.tsx | 77 +++++++++++ .../__tests__/VolumeEntry.test.tsx | 116 ++++++++++++++++ .../QuickTransferFlow/__tests__/utils.test.ts | 80 +++++++++++ app/src/organisms/QuickTransferFlow/index.tsx | 102 ++++++-------- app/src/organisms/QuickTransferFlow/utils.ts | 69 ++++++++++ 12 files changed, 730 insertions(+), 60 deletions(-) create mode 100644 app/src/organisms/QuickTransferFlow/SelectDestWells.tsx create mode 100644 app/src/organisms/QuickTransferFlow/SelectSourceWells.tsx create mode 100644 app/src/organisms/QuickTransferFlow/VolumeEntry.tsx create mode 100644 app/src/organisms/QuickTransferFlow/__tests__/SelectDestLabware.test.tsx create mode 100644 app/src/organisms/QuickTransferFlow/__tests__/SelectSourceLabware.test.tsx create mode 100644 app/src/organisms/QuickTransferFlow/__tests__/VolumeEntry.test.tsx create mode 100644 app/src/organisms/QuickTransferFlow/__tests__/utils.test.ts diff --git a/app/src/assets/localization/en/quick_transfer.json b/app/src/assets/localization/en/quick_transfer.json index e992034540d..93892b830b9 100644 --- a/app/src/assets/localization/en/quick_transfer.json +++ b/app/src/assets/localization/en/quick_transfer.json @@ -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", @@ -18,6 +20,8 @@ "use_deck_slots": "Quick transfers use deck slots B2-D2. These slots hold a tip rack, a source labware, and a destination labware.Make sure that your deck configuration is up to date to avoid collisions.", "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", diff --git a/app/src/atoms/SoftwareKeyboard/NumericalKeyboard/index.css b/app/src/atoms/SoftwareKeyboard/NumericalKeyboard/index.css index 28fe3159979..3df1453b84c 100644 --- a/app/src/atoms/SoftwareKeyboard/NumericalKeyboard/index.css +++ b/app/src/atoms/SoftwareKeyboard/NumericalKeyboard/index.css @@ -10,6 +10,7 @@ .simple-keyboard.oddTheme1.hg-theme-default { width: 100%; height: 100%; + border-radius: 0; background-color: #cbcccc; /* grey35 */ font-family: 'Public Sans', sans-serif; padding: 8px; diff --git a/app/src/organisms/QuickTransferFlow/SelectDestWells.tsx b/app/src/organisms/QuickTransferFlow/SelectDestWells.tsx new file mode 100644 index 00000000000..218d0954a99 --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/SelectDestWells.tsx @@ -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 + state: QuickTransferSetupState + dispatch: React.Dispatch +} + +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) { + dispatch({ + type: 'SET_DEST_WELLS', + wells: Object.keys(state.destination.wells), + }) + } + onNext() + } + return ( + + + + TODO: Add destination well selection deck map + + + ) +} diff --git a/app/src/organisms/QuickTransferFlow/SelectSourceWells.tsx b/app/src/organisms/QuickTransferFlow/SelectSourceWells.tsx new file mode 100644 index 00000000000..1cdc2b75595 --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/SelectSourceWells.tsx @@ -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 + state: QuickTransferSetupState + dispatch: React.Dispatch +} + +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 ( + + + + TODO: Add source well selection deck map + + + ) +} diff --git a/app/src/organisms/QuickTransferFlow/SelectTipRack.tsx b/app/src/organisms/QuickTransferFlow/SelectTipRack.tsx index 98b175b1229..ec5e3cad0e0 100644 --- a/app/src/organisms/QuickTransferFlow/SelectTipRack.tsx +++ b/app/src/organisms/QuickTransferFlow/SelectTipRack.tsx @@ -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, diff --git a/app/src/organisms/QuickTransferFlow/VolumeEntry.tsx b/app/src/organisms/QuickTransferFlow/VolumeEntry.tsx new file mode 100644 index 00000000000..84b807eed21 --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/VolumeEntry.tsx @@ -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 + state: QuickTransferSetupState + dispatch: React.Dispatch +} + +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('') + 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 != '' && + (volumeAsNumber < volumeRange.min || volumeAsNumber > volumeRange.max) + ? t(`value_out_of_range`, { + min: volumeRange.min, + max: volumeRange.max, + }) + : null + + return ( + + + + + + + + setVolume(e)} + /> + + + + ) +} diff --git a/app/src/organisms/QuickTransferFlow/__tests__/SelectDestLabware.test.tsx b/app/src/organisms/QuickTransferFlow/__tests__/SelectDestLabware.test.tsx new file mode 100644 index 00000000000..25e5206ac01 --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/__tests__/SelectDestLabware.test.tsx @@ -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) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('SelectDestLabware', () => { + let props: React.ComponentProps + + 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() + }) +}) diff --git a/app/src/organisms/QuickTransferFlow/__tests__/SelectSourceLabware.test.tsx b/app/src/organisms/QuickTransferFlow/__tests__/SelectSourceLabware.test.tsx new file mode 100644 index 00000000000..c340ef25547 --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/__tests__/SelectSourceLabware.test.tsx @@ -0,0 +1,77 @@ +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 { SelectSourceLabware } from '../SelectSourceLabware' + +vi.mock('@opentrons/react-api-client') +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('SelectSourceLabware', () => { + let props: React.ComponentProps + + 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 source labware screen, header, and exit button', () => { + render(props) + screen.getByText('Select source 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: { source: { 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') + }) +}) diff --git a/app/src/organisms/QuickTransferFlow/__tests__/VolumeEntry.test.tsx b/app/src/organisms/QuickTransferFlow/__tests__/VolumeEntry.test.tsx new file mode 100644 index 00000000000..9da08bbcc08 --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/__tests__/VolumeEntry.test.tsx @@ -0,0 +1,116 @@ +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 { InputField } from '../../../atoms/InputField' +import { NumericalKeyboard } from '../../../atoms/SoftwareKeyboard' +import { VolumeEntry } from '../VolumeEntry' + +vi.mock('../../../atoms/InputField') +vi.mock('../../../atoms/SoftwareKeyboard') + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('VolumeEntry', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + onNext: vi.fn(), + onBack: vi.fn(), + exitButtonProps: { + buttonType: 'tertiaryLowLight', + buttonText: 'Exit', + onClick: vi.fn(), + }, + state: { + mount: 'left', + pipette: { + channels: 1, + } as any, + sourceWells: ['A1'], + destinationWells: ['A1'], + }, + dispatch: vi.fn(), + } + }) + afterEach(() => { + vi.resetAllMocks() + }) + + it('renders the volume entry screen, continue, and exit buttons', () => { + render(props) + const exitBtn = screen.getByText('Exit') + fireEvent.click(exitBtn) + expect(props.exitButtonProps.onClick).toHaveBeenCalled() + expect(vi.mocked(InputField)).toHaveBeenCalled() + expect(vi.mocked(NumericalKeyboard)).toHaveBeenCalled() + const continueBtn = screen.getByTestId('ChildNavigation_Primary_Button') + expect(continueBtn).toBeDisabled() + }) + + it('renders transfer text if there are more destination wells than source wells', () => { + render(props) + screen.getByText('Set transfer volume') + expect(vi.mocked(InputField)).toHaveBeenCalledWith( + { + title: 'Volume per well (µL)', + error: null, + readOnly: true, + type: 'text', + value: '', + }, + {} + ) + }) + + it('renders dispense text if there are more destination wells than source wells', () => { + render({ + ...props, + state: { + sourceWells: ['A1'], + destinationWells: ['A1', 'A2'], + }, + }) + render(props) + screen.getByText('Set dispense volume') + expect(vi.mocked(InputField)).toHaveBeenCalledWith( + { + title: 'Dispense volume per well (µL)', + error: null, + readOnly: true, + type: 'text', + value: '', + }, + {} + ) + }) + + it('renders aspirate text if there are more destination wells than source wells', () => { + render({ + ...props, + state: { + sourceWells: ['A1', 'A2'], + destinationWells: ['A1'], + }, + }) + render(props) + screen.getByText('Set aspirate volume') + expect(vi.mocked(InputField)).toHaveBeenCalledWith( + { + title: 'Aspirate volume per well (µL)', + error: null, + readOnly: true, + type: 'text', + value: '', + }, + {} + ) + }) +}) diff --git a/app/src/organisms/QuickTransferFlow/__tests__/utils.test.ts b/app/src/organisms/QuickTransferFlow/__tests__/utils.test.ts new file mode 100644 index 00000000000..063a810daa7 --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/__tests__/utils.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest' +import { getVolumeLimits } from '../utils' + +import type { QuickTransferSetupState } from '../types' + +describe('getVolumeLimits', () => { + let state: QuickTransferSetupState = { + pipette: { + liquids: [ + { + maxVolume: 1000, + minVolume: 5, + }, + ] as any, + } as any, + tipRack: { + wells: { + A1: { + totalLiquidVolume: 200, + }, + } as any, + } as any, + source: { + wells: { + A1: { + totalLiquidVolume: 200, + }, + A2: { + totalLiquidVolume: 75, + }, + A3: { + totalLiquidVolume: 100, + }, + } as any, + } as any, + sourceWells: ['A1'], + destination: { + wells: { + A1: { + totalLiquidVolume: 1000, + }, + A2: { + totalLiquidVolume: 1000, + }, + } as any, + } as any, + destinationWells: ['A1'], + } + it('calculates the range for a 1 to 1 transfer', () => { + const result = getVolumeLimits(state) + expect(result.min).toEqual(5) + // should equal lesser of pipette max, tip capacity, volume of all selected wells + expect(result.max).toEqual(200) + }) + it('calculates the range for an n to 1 transfer', () => { + const result = getVolumeLimits({ ...state, sourceWells: ['A1', 'A2'] }) + expect(result.min).toEqual(5) + // should equal lesser of pipette max, tip capacity, volume of all + // selected source wells and 1 / 2 volume of destination well + expect(result.max).toEqual(75) + }) + it('calculates the range for an 1 to n transfer', () => { + const result = getVolumeLimits({ ...state, destinationWells: ['A1', 'A2'] }) + expect(result.min).toEqual(5) + // should equal lesser of pipette max, tip capacity, volume of all + // selected destination wells and 1 / 2 volume of source well + expect(result.max).toEqual(100) + }) + it('calculates the range for 1 to n transfer with same labware', () => { + const result = getVolumeLimits({ + ...state, + destination: 'source', + destinationWells: ['A2', 'A3'], + }) + expect(result.min).toEqual(5) + // should equal lesser of pipette max, tip capacity, volume of all + // selected destination wells and 1 / 2 volume of source well + expect(result.max).toEqual(75) + }) +}) diff --git a/app/src/organisms/QuickTransferFlow/index.tsx b/app/src/organisms/QuickTransferFlow/index.tsx index 262156d28df..804acf36a95 100644 --- a/app/src/organisms/QuickTransferFlow/index.tsx +++ b/app/src/organisms/QuickTransferFlow/index.tsx @@ -1,19 +1,16 @@ import * as React from 'react' import { useHistory } from 'react-router-dom' import { useTranslation } from 'react-i18next' -import { - Flex, - StepMeter, - SPACING, - POSITION_STICKY, -} from '@opentrons/components' +import { StepMeter, POSITION_STICKY } from '@opentrons/components' import { SmallButton } from '../../atoms/buttons' -import { ChildNavigation } from '../ChildNavigation' import { CreateNewTransfer } from './CreateNewTransfer' import { SelectPipette } from './SelectPipette' import { SelectTipRack } from './SelectTipRack' import { SelectSourceLabware } from './SelectSourceLabware' +import { SelectSourceWells } from './SelectSourceWells' import { SelectDestLabware } from './SelectDestLabware' +import { SelectDestWells } from './SelectDestWells' +import { VolumeEntry } from './VolumeEntry' import { quickTransferReducer } from './utils' import type { QuickTransferSetupState } from './types' @@ -29,11 +26,6 @@ export const QuickTransferFlow = (): JSX.Element => { initialQuickTransferState ) const [currentStep, setCurrentStep] = React.useState(1) - const [continueIsDisabled] = React.useState(false) - - // every child component will take state as a prop, an anonymous - // dispatch function related to that step (except create new), - // and a function to disable the continue button const exitButtonProps: React.ComponentProps = { buttonType: 'tertiaryLowLight', @@ -43,19 +35,15 @@ export const QuickTransferFlow = (): JSX.Element => { }, } - // these will be moved to the child components once they all exist - const ORDERED_STEP_HEADERS: string[] = [ - t('create_new_transfer'), - t('select_attached_pipette'), - t('select_tip_rack'), - t('select_source_labware'), - t('select_source_wells'), - t('select_dest_labware'), - t('select_dest_wells'), - t('set_transfer_volume'), - ] + React.useEffect(() => { + if (state.volume != null) { + // until summary screen is implemented, log the final state and close flow + // once volume is set + console.log('final quick transfer flow state:', state) + history.push('protocols') + } + }, [state.volume]) - const header = ORDERED_STEP_HEADERS[currentStep - 1] let modalContent: JSX.Element | null = null if (currentStep === 1) { modalContent = ( @@ -94,6 +82,16 @@ export const QuickTransferFlow = (): JSX.Element => { exitButtonProps={exitButtonProps} /> ) + } else if (currentStep === 5) { + modalContent = ( + setCurrentStep(prevStep => prevStep - 1)} + onNext={() => setCurrentStep(prevStep => prevStep + 1)} + exitButtonProps={exitButtonProps} + /> + ) } else if (currentStep === 6) { modalContent = ( { exitButtonProps={exitButtonProps} /> ) + } else if (currentStep === 7) { + modalContent = ( + setCurrentStep(prevStep => prevStep - 1)} + onNext={() => setCurrentStep(prevStep => prevStep + 1)} + exitButtonProps={exitButtonProps} + /> + ) + } else if (currentStep === 8) { + modalContent = ( + setCurrentStep(prevStep => prevStep - 1)} + onNext={() => {}} + exitButtonProps={exitButtonProps} + /> + ) } else { modalContent = null } - // until each page is wired up, show header title with empty screen return ( <> { position={POSITION_STICKY} top="0" /> - {modalContent == null ? ( - - { - setCurrentStep(prevStep => prevStep - 1) - } - } - buttonText={i18n.format(t('shared:continue'), 'capitalize')} - onClickButton={() => { - if (currentStep === 8) { - history.push('protocols') - } else { - setCurrentStep(prevStep => prevStep + 1) - } - }} - buttonIsDisabled={continueIsDisabled} - secondaryButtonProps={{ - buttonType: 'tertiaryLowLight', - buttonText: i18n.format(t('shared:exit'), 'capitalize'), - onClick: () => { - history.push('protocols') - }, - }} - top={SPACING.spacing8} - /> - {modalContent} - - ) : ( - modalContent - )} + {modalContent} ) } diff --git a/app/src/organisms/QuickTransferFlow/utils.ts b/app/src/organisms/QuickTransferFlow/utils.ts index 4d1f7633a16..647faa598e1 100644 --- a/app/src/organisms/QuickTransferFlow/utils.ts +++ b/app/src/organisms/QuickTransferFlow/utils.ts @@ -118,3 +118,72 @@ export function getCompatibleLabwareByCategory( ) } } + +export function getVolumeLimits( + state: QuickTransferSetupState +): { min: number; max: number } { + if ( + state.pipette == null || + state.tipRack == null || + state.source == null || + state.sourceWells == null || + state.destination == null || + state.destinationWells == null + ) { + // this should only be called once all state values are set + return { min: 0, max: 0 } + } + + const minPipetteVolume = Object.values(state.pipette.liquids)[0].minVolume + const maxPipetteVolume = Object.values(state.pipette.liquids)[0].maxVolume + const tipRackVolume = Object.values(state.tipRack.wells)[0].totalLiquidVolume + const sourceLabwareVolume = Math.min( + ...state.sourceWells.map(well => state.source.wells[well].totalLiquidVolume) + ) + + const destLabwareVolume = Math.min( + ...state.destinationWells.map(well => + state.destination === 'source' + ? state.source.wells[well].totalLiquidVolume + : state.destination.wells[well].totalLiquidVolume + ) + ) + let maxVolume = maxPipetteVolume + if (state.sourceWells.length === state.destinationWells.length) { + // 1 to 1 transfer + maxVolume = Math.min( + ...[ + maxPipetteVolume, + tipRackVolume, + sourceLabwareVolume, + destLabwareVolume, + ] + ) + } else if (state.sourceWells.length < state.destinationWells.length) { + // 1 to n transfer + const ratio = state.sourceWells.length / state.destinationWells.length + + maxVolume = Math.min( + ...[ + maxPipetteVolume, + tipRackVolume, + sourceLabwareVolume * ratio, + destLabwareVolume, + ] + ) + } else if (state.sourceWells.length > state.destinationWells.length) { + // n to 1 transfer + const ratio = state.destinationWells.length / state.sourceWells.length + + maxVolume = Math.min( + ...[ + maxPipetteVolume, + tipRackVolume, + sourceLabwareVolume, + destLabwareVolume * ratio, + ] + ) + } + + return { min: minPipetteVolume, max: maxVolume } +} From 90eb952942a3fbe0aeeded70ddfc470954a15442 Mon Sep 17 00:00:00 2001 From: smb2268 Date: Thu, 2 May 2024 14:49:29 -0400 Subject: [PATCH 04/10] Add generated labware compatibility to speed up the flow --- .../QuickTransferFlow/SelectDestLabware.tsx | 7 +- .../QuickTransferFlow/SelectPipette.tsx | 1 + .../QuickTransferFlow/SelectSourceLabware.tsx | 6 +- .../__tests__/SelectDestLabware.test.tsx | 16 +- .../__tests__/SelectSourceLabware.test.tsx | 14 +- .../QuickTransferFlow/__tests__/utils.test.ts | 30 +++- .../organisms/QuickTransferFlow/constants.ts | 168 ++++++++++++++++++ app/src/organisms/QuickTransferFlow/utils.ts | 62 +++++-- 8 files changed, 285 insertions(+), 19 deletions(-) diff --git a/app/src/organisms/QuickTransferFlow/SelectDestLabware.tsx b/app/src/organisms/QuickTransferFlow/SelectDestLabware.tsx index 4a144584778..dcd08680bc2 100644 --- a/app/src/organisms/QuickTransferFlow/SelectDestLabware.tsx +++ b/app/src/organisms/QuickTransferFlow/SelectDestLabware.tsx @@ -29,7 +29,9 @@ interface SelectDestLabwareProps { dispatch: React.Dispatch } -export function SelectDestLabware(props: SelectDestLabwareProps): JSX.Element { +export function SelectDestLabware( + props: SelectDestLabwareProps +): JSX.Element | null { const { onNext, onBack, exitButtonProps, state, dispatch } = props const { i18n, t } = useTranslation(['quick_transfer', 'shared']) const labwareDisplayCategoryFilters: LabwareFilter[] = [ @@ -43,8 +45,9 @@ export function SelectDestLabware(props: SelectDestLabwareProps): JSX.Element { const [selectedCategory, setSelectedCategory] = React.useState( 'all' ) + if (state.pipette == null) return null const compatibleLabwareDefinitions = getCompatibleLabwareByCategory( - state.pipette, + state.pipette.channels, selectedCategory ) diff --git a/app/src/organisms/QuickTransferFlow/SelectPipette.tsx b/app/src/organisms/QuickTransferFlow/SelectPipette.tsx index 6ef31157fdf..f919eb7f6f0 100644 --- a/app/src/organisms/QuickTransferFlow/SelectPipette.tsx +++ b/app/src/organisms/QuickTransferFlow/SelectPipette.tsx @@ -11,6 +11,7 @@ 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 { generateCompatibleLabwareForPipette } from './utils' import type { PipetteData, Mount } from '@opentrons/api-client' import type { diff --git a/app/src/organisms/QuickTransferFlow/SelectSourceLabware.tsx b/app/src/organisms/QuickTransferFlow/SelectSourceLabware.tsx index 87b26719641..5a450fce2ac 100644 --- a/app/src/organisms/QuickTransferFlow/SelectSourceLabware.tsx +++ b/app/src/organisms/QuickTransferFlow/SelectSourceLabware.tsx @@ -31,7 +31,7 @@ interface SelectSourceLabwareProps { export function SelectSourceLabware( props: SelectSourceLabwareProps -): JSX.Element { +): JSX.Element | null { const { onNext, onBack, exitButtonProps, state, dispatch } = props const { i18n, t } = useTranslation(['quick_transfer', 'shared']) const labwareDisplayCategoryFilters: LabwareFilter[] = [ @@ -45,8 +45,10 @@ export function SelectSourceLabware( const [selectedCategory, setSelectedCategory] = React.useState( 'all' ) + if (state.pipette == null) return null + const compatibleLabwareDefinitions = getCompatibleLabwareByCategory( - state.pipette, + state.pipette.channels, selectedCategory ) diff --git a/app/src/organisms/QuickTransferFlow/__tests__/SelectDestLabware.test.tsx b/app/src/organisms/QuickTransferFlow/__tests__/SelectDestLabware.test.tsx index 25e5206ac01..a2d2430c268 100644 --- a/app/src/organisms/QuickTransferFlow/__tests__/SelectDestLabware.test.tsx +++ b/app/src/organisms/QuickTransferFlow/__tests__/SelectDestLabware.test.tsx @@ -54,7 +54,19 @@ describe('SelectDestLabware', () => { }) it('selects labware by default if there is one in state, button will be enabled', () => { - render({ ...props, state: { destination: { def: 'definition' } as any } }) + render({ + ...props, + state: { + pipette: { + channels: 1, + } as any, + destination: { + metadata: { + displayName: 'destination labware name', + }, + } as any, + }, + }) const continueBtn = screen.getByTestId('ChildNavigation_Primary_Button') expect(continueBtn).toBeEnabled() fireEvent.click(continueBtn) @@ -79,6 +91,7 @@ describe('SelectDestLabware', () => { render({ ...props, state: { + pipette: { channels: 8 } as any, source: { metadata: { displayName: 'source labware name' } } as any, }, }) @@ -90,6 +103,7 @@ describe('SelectDestLabware', () => { render({ ...props, state: { + pipette: { channels: 8 } as any, source: { metadata: { displayName: 'source labware name' } } as any, }, }) diff --git a/app/src/organisms/QuickTransferFlow/__tests__/SelectSourceLabware.test.tsx b/app/src/organisms/QuickTransferFlow/__tests__/SelectSourceLabware.test.tsx index c340ef25547..95c76e9e2c7 100644 --- a/app/src/organisms/QuickTransferFlow/__tests__/SelectSourceLabware.test.tsx +++ b/app/src/organisms/QuickTransferFlow/__tests__/SelectSourceLabware.test.tsx @@ -54,7 +54,19 @@ describe('SelectSourceLabware', () => { }) it('selects labware by default if there is one in state, button will be enabled', () => { - render({ ...props, state: { source: { def: 'definition' } as any } }) + render({ + ...props, + state: { + pipette: { + channels: 1, + } as any, + source: { + metadata: { + displayName: 'source display name', + }, + } as any, + }, + }) const continueBtn = screen.getByTestId('ChildNavigation_Primary_Button') expect(continueBtn).toBeEnabled() fireEvent.click(continueBtn) diff --git a/app/src/organisms/QuickTransferFlow/__tests__/utils.test.ts b/app/src/organisms/QuickTransferFlow/__tests__/utils.test.ts index 063a810daa7..5e84e5368d3 100644 --- a/app/src/organisms/QuickTransferFlow/__tests__/utils.test.ts +++ b/app/src/organisms/QuickTransferFlow/__tests__/utils.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest' -import { getVolumeLimits } from '../utils' +import { getVolumeLimits, generateCompatibleLabwareForPipette } from '../utils' +import { + SINGLE_CHANNEL_COMPATIBLE_LABWARE, + EIGHT_CHANNEL_COMPATIBLE_LABWARE, + NINETY_SIX_CHANNEL_COMPATIBLE_LABWARE, +} from '../constants' import type { QuickTransferSetupState } from '../types' @@ -78,3 +83,26 @@ describe('getVolumeLimits', () => { expect(result.max).toEqual(75) }) }) + +// if one of these fails, it is likely that a new definition has been added +// and you need to regenerate the lists stored at ../constants +describe('generateCompatibleLabwareForPipette', () => { + it('generates the list for single channel pipettes', () => { + const compatibleLabwareUris = generateCompatibleLabwareForPipette({ + channels: 1, + } as any) + expect(compatibleLabwareUris).toEqual(SINGLE_CHANNEL_COMPATIBLE_LABWARE) + }) + it('generates the list for eight channel pipettes', () => { + const compatibleLabwareUris = generateCompatibleLabwareForPipette({ + channels: 8, + } as any) + expect(compatibleLabwareUris).toEqual(EIGHT_CHANNEL_COMPATIBLE_LABWARE) + }) + it('generates the list for 96 channel pipettes', () => { + const compatibleLabwareUris = generateCompatibleLabwareForPipette({ + channels: 96, + } as any) + expect(compatibleLabwareUris).toEqual(NINETY_SIX_CHANNEL_COMPATIBLE_LABWARE) + }) +}) diff --git a/app/src/organisms/QuickTransferFlow/constants.ts b/app/src/organisms/QuickTransferFlow/constants.ts index 3241759a044..9fa3a8fc28b 100644 --- a/app/src/organisms/QuickTransferFlow/constants.ts +++ b/app/src/organisms/QuickTransferFlow/constants.ts @@ -7,3 +7,171 @@ export const ACTIONS = { SET_DEST_WELLS: 'SET_DEST_WELLS', SET_VOLUME: 'SET_VOLUME', } as const + +// these lists are generated by the util generateCompatibleLabwareForPipette in ./utils +export const SINGLE_CHANNEL_COMPATIBLE_LABWARE = [ + 'opentrons/agilent_1_reservoir_290ml/1', + 'opentrons/appliedbiosystemsmicroamp_384_wellplate_40ul/1', + 'opentrons/axygen_1_reservoir_90ml/1', + 'opentrons/biorad_384_wellplate_50ul/2', + 'opentrons/biorad_96_wellplate_200ul_pcr/2', + 'opentrons/corning_12_wellplate_6.9ml_flat/2', + 'opentrons/corning_24_wellplate_3.4ml_flat/2', + 'opentrons/corning_384_wellplate_112ul_flat/2', + 'opentrons/corning_48_wellplate_1.6ml_flat/2', + 'opentrons/corning_6_wellplate_16.8ml_flat/2', + 'opentrons/corning_96_wellplate_360ul_flat/2', + 'opentrons/geb_96_tiprack_1000ul/1', + 'opentrons/geb_96_tiprack_10ul/1', + 'opentrons/nest_12_reservoir_15ml/1', + 'opentrons/nest_1_reservoir_195ml/2', + 'opentrons/nest_1_reservoir_290ml/1', + 'opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2', + 'opentrons/nest_96_wellplate_200ul_flat/2', + 'opentrons/nest_96_wellplate_2ml_deep/2', + 'opentrons/opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical/1', + 'opentrons/opentrons_10_tuberack_nest_4x50ml_6x15ml_conical/1', + 'opentrons/opentrons_15_tuberack_falcon_15ml_conical/1', + 'opentrons/opentrons_15_tuberack_nest_15ml_conical/1', + 'opentrons/opentrons_24_aluminumblock_generic_2ml_screwcap/2', + 'opentrons/opentrons_24_aluminumblock_nest_0.5ml_screwcap/1', + 'opentrons/opentrons_24_aluminumblock_nest_1.5ml_screwcap/1', + 'opentrons/opentrons_24_aluminumblock_nest_1.5ml_snapcap/1', + 'opentrons/opentrons_24_aluminumblock_nest_2ml_screwcap/1', + 'opentrons/opentrons_24_aluminumblock_nest_2ml_snapcap/1', + 'opentrons/opentrons_24_tuberack_eppendorf_1.5ml_safelock_snapcap/1', + 'opentrons/opentrons_24_tuberack_eppendorf_2ml_safelock_snapcap/1', + 'opentrons/opentrons_24_tuberack_generic_2ml_screwcap/1', + 'opentrons/opentrons_24_tuberack_nest_0.5ml_screwcap/1', + 'opentrons/opentrons_24_tuberack_nest_1.5ml_screwcap/1', + 'opentrons/opentrons_24_tuberack_nest_1.5ml_snapcap/1', + 'opentrons/opentrons_24_tuberack_nest_2ml_screwcap/1', + 'opentrons/opentrons_24_tuberack_nest_2ml_snapcap/1', + 'opentrons/opentrons_6_tuberack_falcon_50ml_conical/1', + 'opentrons/opentrons_6_tuberack_nest_50ml_conical/1', + 'opentrons/opentrons_96_aluminumblock_biorad_wellplate_200ul/1', + 'opentrons/opentrons_96_aluminumblock_generic_pcr_strip_200ul/2', + 'opentrons/opentrons_96_aluminumblock_nest_wellplate_100ul/1', + 'opentrons/opentrons_96_deep_well_adapter/1', + 'opentrons/opentrons_96_deep_well_adapter_nest_wellplate_2ml_deep/1', + 'opentrons/opentrons_96_filtertiprack_1000ul/1', + 'opentrons/opentrons_96_filtertiprack_10ul/1', + 'opentrons/opentrons_96_filtertiprack_200ul/1', + 'opentrons/opentrons_96_filtertiprack_20ul/1', + 'opentrons/opentrons_96_flat_bottom_adapter/1', + 'opentrons/opentrons_96_flat_bottom_adapter_nest_wellplate_200ul_flat/1', + 'opentrons/opentrons_96_pcr_adapter/1', + 'opentrons/opentrons_96_pcr_adapter_nest_wellplate_100ul_pcr_full_skirt/1', + 'opentrons/opentrons_96_tiprack_1000ul/1', + 'opentrons/opentrons_96_tiprack_10ul/1', + 'opentrons/opentrons_96_tiprack_20ul/1', + 'opentrons/opentrons_96_tiprack_300ul/1', + 'opentrons/opentrons_96_well_aluminum_block/1', + 'opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2', + 'opentrons/opentrons_aluminum_flat_bottom_plate/1', + 'opentrons/opentrons_flex_96_filtertiprack_1000ul/1', + 'opentrons/opentrons_flex_96_filtertiprack_200ul/1', + 'opentrons/opentrons_flex_96_filtertiprack_50ul/1', + 'opentrons/opentrons_flex_96_tiprack_1000ul/1', + 'opentrons/opentrons_flex_96_tiprack_200ul/1', + 'opentrons/opentrons_flex_96_tiprack_50ul/1', + 'opentrons/opentrons_flex_96_tiprack_adapter/1', + 'opentrons/opentrons_universal_flat_adapter/1', + 'opentrons/opentrons_universal_flat_adapter_corning_384_wellplate_112ul_flat/1', + 'opentrons/thermoscientificnunc_96_wellplate_1300ul/1', + 'opentrons/thermoscientificnunc_96_wellplate_2000ul/1', + 'opentrons/usascientific_12_reservoir_22ml/1', + 'opentrons/usascientific_96_wellplate_2.4ml_deep/1', +] + +export const EIGHT_CHANNEL_COMPATIBLE_LABWARE = [ + 'opentrons/agilent_1_reservoir_290ml/1', + 'opentrons/appliedbiosystemsmicroamp_384_wellplate_40ul/1', + 'opentrons/axygen_1_reservoir_90ml/1', + 'opentrons/biorad_384_wellplate_50ul/2', + 'opentrons/biorad_96_wellplate_200ul_pcr/2', + 'opentrons/corning_384_wellplate_112ul_flat/2', + 'opentrons/corning_96_wellplate_360ul_flat/2', + 'opentrons/geb_96_tiprack_1000ul/1', + 'opentrons/geb_96_tiprack_10ul/1', + 'opentrons/nest_12_reservoir_15ml/1', + 'opentrons/nest_1_reservoir_195ml/2', + 'opentrons/nest_1_reservoir_290ml/1', + 'opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2', + 'opentrons/nest_96_wellplate_200ul_flat/2', + 'opentrons/nest_96_wellplate_2ml_deep/2', + 'opentrons/opentrons_96_aluminumblock_biorad_wellplate_200ul/1', + 'opentrons/opentrons_96_aluminumblock_generic_pcr_strip_200ul/2', + 'opentrons/opentrons_96_aluminumblock_nest_wellplate_100ul/1', + 'opentrons/opentrons_96_deep_well_adapter_nest_wellplate_2ml_deep/1', + 'opentrons/opentrons_96_filtertiprack_1000ul/1', + 'opentrons/opentrons_96_filtertiprack_10ul/1', + 'opentrons/opentrons_96_filtertiprack_200ul/1', + 'opentrons/opentrons_96_filtertiprack_20ul/1', + 'opentrons/opentrons_96_flat_bottom_adapter_nest_wellplate_200ul_flat/1', + 'opentrons/opentrons_96_pcr_adapter/1', + 'opentrons/opentrons_96_pcr_adapter_nest_wellplate_100ul_pcr_full_skirt/1', + 'opentrons/opentrons_96_tiprack_1000ul/1', + 'opentrons/opentrons_96_tiprack_10ul/1', + 'opentrons/opentrons_96_tiprack_20ul/1', + 'opentrons/opentrons_96_tiprack_300ul/1', + 'opentrons/opentrons_96_well_aluminum_block/1', + 'opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2', + 'opentrons/opentrons_flex_96_filtertiprack_1000ul/1', + 'opentrons/opentrons_flex_96_filtertiprack_200ul/1', + 'opentrons/opentrons_flex_96_filtertiprack_50ul/1', + 'opentrons/opentrons_flex_96_tiprack_1000ul/1', + 'opentrons/opentrons_flex_96_tiprack_200ul/1', + 'opentrons/opentrons_flex_96_tiprack_50ul/1', + 'opentrons/opentrons_universal_flat_adapter_corning_384_wellplate_112ul_flat/1', + 'opentrons/thermoscientificnunc_96_wellplate_1300ul/1', + 'opentrons/thermoscientificnunc_96_wellplate_2000ul/1', + 'opentrons/usascientific_12_reservoir_22ml/1', + 'opentrons/usascientific_96_wellplate_2.4ml_deep/1', +] + +export const NINETY_SIX_CHANNEL_COMPATIBLE_LABWARE = [ + 'opentrons/agilent_1_reservoir_290ml/1', + 'opentrons/appliedbiosystemsmicroamp_384_wellplate_40ul/1', + 'opentrons/axygen_1_reservoir_90ml/1', + 'opentrons/biorad_384_wellplate_50ul/2', + 'opentrons/biorad_96_wellplate_200ul_pcr/2', + 'opentrons/corning_384_wellplate_112ul_flat/2', + 'opentrons/corning_96_wellplate_360ul_flat/2', + 'opentrons/geb_96_tiprack_1000ul/1', + 'opentrons/geb_96_tiprack_10ul/1', + 'opentrons/nest_12_reservoir_15ml/1', + 'opentrons/nest_1_reservoir_195ml/2', + 'opentrons/nest_1_reservoir_290ml/1', + 'opentrons/nest_96_wellplate_100ul_pcr_full_skirt/2', + 'opentrons/nest_96_wellplate_200ul_flat/2', + 'opentrons/nest_96_wellplate_2ml_deep/2', + 'opentrons/opentrons_96_aluminumblock_biorad_wellplate_200ul/1', + 'opentrons/opentrons_96_aluminumblock_generic_pcr_strip_200ul/2', + 'opentrons/opentrons_96_aluminumblock_nest_wellplate_100ul/1', + 'opentrons/opentrons_96_deep_well_adapter_nest_wellplate_2ml_deep/1', + 'opentrons/opentrons_96_filtertiprack_1000ul/1', + 'opentrons/opentrons_96_filtertiprack_10ul/1', + 'opentrons/opentrons_96_filtertiprack_200ul/1', + 'opentrons/opentrons_96_filtertiprack_20ul/1', + 'opentrons/opentrons_96_flat_bottom_adapter_nest_wellplate_200ul_flat/1', + 'opentrons/opentrons_96_pcr_adapter/1', + 'opentrons/opentrons_96_pcr_adapter_nest_wellplate_100ul_pcr_full_skirt/1', + 'opentrons/opentrons_96_tiprack_1000ul/1', + 'opentrons/opentrons_96_tiprack_10ul/1', + 'opentrons/opentrons_96_tiprack_20ul/1', + 'opentrons/opentrons_96_tiprack_300ul/1', + 'opentrons/opentrons_96_well_aluminum_block/1', + 'opentrons/opentrons_96_wellplate_200ul_pcr_full_skirt/2', + 'opentrons/opentrons_flex_96_filtertiprack_1000ul/1', + 'opentrons/opentrons_flex_96_filtertiprack_200ul/1', + 'opentrons/opentrons_flex_96_filtertiprack_50ul/1', + 'opentrons/opentrons_flex_96_tiprack_1000ul/1', + 'opentrons/opentrons_flex_96_tiprack_200ul/1', + 'opentrons/opentrons_flex_96_tiprack_50ul/1', + 'opentrons/opentrons_universal_flat_adapter_corning_384_wellplate_112ul_flat/1', + 'opentrons/thermoscientificnunc_96_wellplate_1300ul/1', + 'opentrons/thermoscientificnunc_96_wellplate_2000ul/1', + 'opentrons/usascientific_12_reservoir_22ml/1', + 'opentrons/usascientific_96_wellplate_2.4ml_deep/1', +] diff --git a/app/src/organisms/QuickTransferFlow/utils.ts b/app/src/organisms/QuickTransferFlow/utils.ts index 647faa598e1..caab760bb2b 100644 --- a/app/src/organisms/QuickTransferFlow/utils.ts +++ b/app/src/organisms/QuickTransferFlow/utils.ts @@ -1,5 +1,15 @@ -import { makeWellSetHelpers } from '@opentrons/shared-data' -import { getAllDefinitions } from '../../pages/Labware/helpers/definitions' +import { + makeWellSetHelpers, + getLabwareDefURI, + getAllDefinitions, +} from '@opentrons/shared-data' +import { getAllDefinitions as getAllLatestDefValues } from '../../pages/Labware/helpers/definitions' +import { + SINGLE_CHANNEL_COMPATIBLE_LABWARE, + EIGHT_CHANNEL_COMPATIBLE_LABWARE, + NINETY_SIX_CHANNEL_COMPATIBLE_LABWARE, +} from './constants' + import type { LabwareDefinition2, PipetteV2Specs, @@ -9,7 +19,7 @@ import type { QuickTransferSetupState, QuickTransferWizardAction, } from './types' -import { LabwareFilter } from '../../pages/Labware/types' +import type { LabwareFilter } from '../../pages/Labware/types' export function quickTransferReducer( state: QuickTransferSetupState, @@ -83,20 +93,23 @@ export function quickTransferReducer( } export function getCompatibleLabwareByCategory( - pipetteSpecs: PipetteV2Specs | undefined, + pipetteChannels: 1 | 8 | 96, category: LabwareFilter ): LabwareDefinition2[] | undefined { const allLabwareDefinitions = getAllDefinitions() - const wellSetHelpers: WellSetHelpers = makeWellSetHelpers() - const { canPipetteUseLabware } = wellSetHelpers - if (pipetteSpecs == null) return undefined - let compatibleLabwareDefinitions = allLabwareDefinitions - if (pipetteSpecs.channels !== 1) { - compatibleLabwareDefinitions = allLabwareDefinitions.filter(def => - canPipetteUseLabware(pipetteSpecs.channels, def) - ) + let compatibleLabwareUris: string[] = SINGLE_CHANNEL_COMPATIBLE_LABWARE + if (pipetteChannels === 8) { + compatibleLabwareUris = EIGHT_CHANNEL_COMPATIBLE_LABWARE + } else if (pipetteChannels === 96) { + compatibleLabwareUris = NINETY_SIX_CHANNEL_COMPATIBLE_LABWARE } + const compatibleLabwareDefinitions = compatibleLabwareUris.reduce< + LabwareDefinition2[] + >((acc, defUri) => { + return [...acc, allLabwareDefinitions[defUri]] + }, []) + if (category === 'all') { return compatibleLabwareDefinitions.filter( def => @@ -187,3 +200,28 @@ export function getVolumeLimits( return { min: minPipetteVolume, max: maxVolume } } + +export function generateCompatibleLabwareForPipette( + pipetteSpecs: PipetteV2Specs +): string[] { + const allLabwareDefinitions = getAllLatestDefValues() + const wellSetHelpers: WellSetHelpers = makeWellSetHelpers() + const { canPipetteUseLabware } = wellSetHelpers + + const compatibleDefUriList = allLabwareDefinitions.reduce( + (acc, definition) => { + if (pipetteSpecs.channels == 1) { + return [...acc, getLabwareDefURI(definition)] + } else { + const isCompatible = canPipetteUseLabware(pipetteSpecs, definition) + return isCompatible ? [...acc, getLabwareDefURI(definition)] : acc + } + }, + [] + ) + + // console.log(JSON.stringify(compatibleDefUriList)) + // to update this list, uncomment the above log statement and + // paste the result into the const in ./constants.ts + return compatibleDefUriList +} From 3e1ba3e6bbc6adae71a71dd32f6f1a18d0ee4c4c Mon Sep 17 00:00:00 2001 From: smb2268 Date: Thu, 2 May 2024 16:36:26 -0400 Subject: [PATCH 05/10] Updated linting and ts errors --- .../QuickTransferFlow/SelectDestLabware.tsx | 4 ++-- .../QuickTransferFlow/SelectDestWells.tsx | 2 +- .../QuickTransferFlow/SelectPipette.tsx | 1 - .../QuickTransferFlow/SelectSourceLabware.tsx | 4 ++-- .../QuickTransferFlow/VolumeEntry.tsx | 4 ++-- .../QuickTransferFlow/__tests__/utils.test.ts | 2 +- app/src/organisms/QuickTransferFlow/utils.ts | 19 ++++++++++++------- 7 files changed, 20 insertions(+), 16 deletions(-) diff --git a/app/src/organisms/QuickTransferFlow/SelectDestLabware.tsx b/app/src/organisms/QuickTransferFlow/SelectDestLabware.tsx index dcd08680bc2..b996053845c 100644 --- a/app/src/organisms/QuickTransferFlow/SelectDestLabware.tsx +++ b/app/src/organisms/QuickTransferFlow/SelectDestLabware.tsx @@ -34,18 +34,18 @@ export function SelectDestLabware( ): JSX.Element | null { const { onNext, onBack, exitButtonProps, state, dispatch } = props const { i18n, t } = useTranslation(['quick_transfer', 'shared']) + if (state.pipette == null) return null const labwareDisplayCategoryFilters: LabwareFilter[] = [ 'all', 'wellPlate', 'reservoir', ] - if (state.pipette?.channels === 1) { + if (state.pipette.channels === 1) { labwareDisplayCategoryFilters.push('tubeRack') } const [selectedCategory, setSelectedCategory] = React.useState( 'all' ) - if (state.pipette == null) return null const compatibleLabwareDefinitions = getCompatibleLabwareByCategory( state.pipette.channels, selectedCategory diff --git a/app/src/organisms/QuickTransferFlow/SelectDestWells.tsx b/app/src/organisms/QuickTransferFlow/SelectDestWells.tsx index 218d0954a99..39ef17cffe9 100644 --- a/app/src/organisms/QuickTransferFlow/SelectDestWells.tsx +++ b/app/src/organisms/QuickTransferFlow/SelectDestWells.tsx @@ -29,7 +29,7 @@ export function SelectDestWells(props: SelectDestWellsProps): JSX.Element { type: 'SET_DEST_WELLS', wells: Object.keys(state.source.wells), }) - } else if (state.destination != 'source' && state.destination != null) { + } else if (state.destination !== 'source' && state.destination != null) { dispatch({ type: 'SET_DEST_WELLS', wells: Object.keys(state.destination.wells), diff --git a/app/src/organisms/QuickTransferFlow/SelectPipette.tsx b/app/src/organisms/QuickTransferFlow/SelectPipette.tsx index f919eb7f6f0..6ef31157fdf 100644 --- a/app/src/organisms/QuickTransferFlow/SelectPipette.tsx +++ b/app/src/organisms/QuickTransferFlow/SelectPipette.tsx @@ -11,7 +11,6 @@ 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 { generateCompatibleLabwareForPipette } from './utils' import type { PipetteData, Mount } from '@opentrons/api-client' import type { diff --git a/app/src/organisms/QuickTransferFlow/SelectSourceLabware.tsx b/app/src/organisms/QuickTransferFlow/SelectSourceLabware.tsx index 5a450fce2ac..95e623be5cd 100644 --- a/app/src/organisms/QuickTransferFlow/SelectSourceLabware.tsx +++ b/app/src/organisms/QuickTransferFlow/SelectSourceLabware.tsx @@ -34,18 +34,18 @@ export function SelectSourceLabware( ): JSX.Element | null { const { onNext, onBack, exitButtonProps, state, dispatch } = props const { i18n, t } = useTranslation(['quick_transfer', 'shared']) + if (state.pipette == null) return null const labwareDisplayCategoryFilters: LabwareFilter[] = [ 'all', 'wellPlate', 'reservoir', ] - if (state.pipette?.channels === 1) { + if (state.pipette.channels === 1) { labwareDisplayCategoryFilters.push('tubeRack') } const [selectedCategory, setSelectedCategory] = React.useState( 'all' ) - if (state.pipette == null) return null const compatibleLabwareDefinitions = getCompatibleLabwareByCategory( state.pipette.channels, diff --git a/app/src/organisms/QuickTransferFlow/VolumeEntry.tsx b/app/src/organisms/QuickTransferFlow/VolumeEntry.tsx index 84b807eed21..0613aac652a 100644 --- a/app/src/organisms/QuickTransferFlow/VolumeEntry.tsx +++ b/app/src/organisms/QuickTransferFlow/VolumeEntry.tsx @@ -65,7 +65,7 @@ export function VolumeEntry(props: VolumeEntryProps): JSX.Element { } const error = - volume != '' && + volume !== '' && (volumeAsNumber < volumeRange.min || volumeAsNumber > volumeRange.max) ? t(`value_out_of_range`, { min: volumeRange.min, @@ -82,7 +82,7 @@ export function VolumeEntry(props: VolumeEntryProps): JSX.Element { onClickButton={handleClickNext} secondaryButtonProps={exitButtonProps} top={SPACING.spacing8} - buttonIsDisabled={error != null || volume == ''} + buttonIsDisabled={error != null || volume === ''} /> { - let state: QuickTransferSetupState = { + const state: QuickTransferSetupState = { pipette: { liquids: [ { diff --git a/app/src/organisms/QuickTransferFlow/utils.ts b/app/src/organisms/QuickTransferFlow/utils.ts index caab760bb2b..907c08a52a6 100644 --- a/app/src/organisms/QuickTransferFlow/utils.ts +++ b/app/src/organisms/QuickTransferFlow/utils.ts @@ -151,15 +151,20 @@ export function getVolumeLimits( const maxPipetteVolume = Object.values(state.pipette.liquids)[0].maxVolume const tipRackVolume = Object.values(state.tipRack.wells)[0].totalLiquidVolume const sourceLabwareVolume = Math.min( - ...state.sourceWells.map(well => state.source.wells[well].totalLiquidVolume) + ...state.sourceWells.map(well => + state.source ? state.source.wells[well].totalLiquidVolume : 0 + ) ) const destLabwareVolume = Math.min( - ...state.destinationWells.map(well => - state.destination === 'source' - ? state.source.wells[well].totalLiquidVolume - : state.destination.wells[well].totalLiquidVolume - ) + ...state.destinationWells.map(well => { + { + if (state.source == null || state.destination == null) return 0 + return state.destination === 'source' + ? state.source.wells[well].totalLiquidVolume + : state.destination.wells[well].totalLiquidVolume + } + }) ) let maxVolume = maxPipetteVolume if (state.sourceWells.length === state.destinationWells.length) { @@ -210,7 +215,7 @@ export function generateCompatibleLabwareForPipette( const compatibleDefUriList = allLabwareDefinitions.reduce( (acc, definition) => { - if (pipetteSpecs.channels == 1) { + if (pipetteSpecs.channels === 1) { return [...acc, getLabwareDefURI(definition)] } else { const isCompatible = canPipetteUseLabware(pipetteSpecs, definition) From f4ca1c223253d5624a35d96b6017d74224ed6dcb Mon Sep 17 00:00:00 2001 From: smb2268 Date: Thu, 2 May 2024 17:17:41 -0400 Subject: [PATCH 06/10] Add exit prevention screen --- .../QuickTransferFlow/ConfirmExitModal.tsx | 54 +++++++++++++++++++ .../QuickTransferFlow/SelectDestLabware.tsx | 11 ++-- .../QuickTransferFlow/SelectSourceLabware.tsx | 13 ++--- .../__tests__/ConfirmExitModal.test.tsx | 42 +++++++++++++++ app/src/organisms/QuickTransferFlow/index.tsx | 37 +++++++++---- app/src/organisms/QuickTransferFlow/utils.ts | 10 ++-- 6 files changed, 139 insertions(+), 28 deletions(-) create mode 100644 app/src/organisms/QuickTransferFlow/ConfirmExitModal.tsx create mode 100644 app/src/organisms/QuickTransferFlow/__tests__/ConfirmExitModal.test.tsx diff --git a/app/src/organisms/QuickTransferFlow/ConfirmExitModal.tsx b/app/src/organisms/QuickTransferFlow/ConfirmExitModal.tsx new file mode 100644 index 00000000000..817e9fe9a1d --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/ConfirmExitModal.tsx @@ -0,0 +1,54 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { + SPACING, + COLORS, + StyledText, + Flex, + DIRECTION_COLUMN, + TYPOGRAPHY, +} from '@opentrons/components' +import { Modal } from '../../molecules/Modal' +import { SmallButton } from '../../atoms/buttons' + +interface ConfirmExitModalProps { + confirmExit: () => void + cancelExit: () => void +} + +export const ConfirmExitModal = (props: ConfirmExitModalProps): JSX.Element => { + const { i18n, t } = useTranslation(['quick_transfer', 'shared']) + + return ( + + + + {t('lose_all_progress')} + + + + + + + + ) +} diff --git a/app/src/organisms/QuickTransferFlow/SelectDestLabware.tsx b/app/src/organisms/QuickTransferFlow/SelectDestLabware.tsx index b996053845c..8ba80f2ce8a 100644 --- a/app/src/organisms/QuickTransferFlow/SelectDestLabware.tsx +++ b/app/src/organisms/QuickTransferFlow/SelectDestLabware.tsx @@ -34,7 +34,6 @@ export function SelectDestLabware( ): JSX.Element | null { const { onNext, onBack, exitButtonProps, state, dispatch } = props const { i18n, t } = useTranslation(['quick_transfer', 'shared']) - if (state.pipette == null) return null const labwareDisplayCategoryFilters: LabwareFilter[] = [ 'all', 'wellPlate', @@ -46,15 +45,17 @@ export function SelectDestLabware( const [selectedCategory, setSelectedCategory] = React.useState( 'all' ) + const [selectedLabware, setSelectedLabware] = React.useState< + LabwareDefinition2 | 'source' | undefined + >(state.destination) + + if (state.pipette == null) return null + const compatibleLabwareDefinitions = getCompatibleLabwareByCategory( state.pipette.channels, selectedCategory ) - const [selectedLabware, setSelectedLabware] = React.useState< - LabwareDefinition2 | 'source' | undefined - >(state.destination) - const handleClickNext = (): void => { // the button will be disabled if this values is null if (selectedLabware != null) { diff --git a/app/src/organisms/QuickTransferFlow/SelectSourceLabware.tsx b/app/src/organisms/QuickTransferFlow/SelectSourceLabware.tsx index 95e623be5cd..5c2889b91f7 100644 --- a/app/src/organisms/QuickTransferFlow/SelectSourceLabware.tsx +++ b/app/src/organisms/QuickTransferFlow/SelectSourceLabware.tsx @@ -34,28 +34,29 @@ export function SelectSourceLabware( ): JSX.Element | null { const { onNext, onBack, exitButtonProps, state, dispatch } = props const { i18n, t } = useTranslation(['quick_transfer', 'shared']) - if (state.pipette == null) return null const labwareDisplayCategoryFilters: LabwareFilter[] = [ 'all', 'wellPlate', 'reservoir', ] - if (state.pipette.channels === 1) { + if (state.pipette?.channels === 1) { labwareDisplayCategoryFilters.push('tubeRack') } const [selectedCategory, setSelectedCategory] = React.useState( 'all' ) + const [selectedLabware, setSelectedLabware] = React.useState< + LabwareDefinition2 | undefined + >(state.source) + + if (state.pipette == null) return null + const compatibleLabwareDefinitions = getCompatibleLabwareByCategory( state.pipette.channels, selectedCategory ) - const [selectedLabware, setSelectedLabware] = React.useState< - LabwareDefinition2 | undefined - >(state.source) - const handleClickNext = (): void => { // the button will be disabled if this values is null if (selectedLabware != null) { diff --git a/app/src/organisms/QuickTransferFlow/__tests__/ConfirmExitModal.test.tsx b/app/src/organisms/QuickTransferFlow/__tests__/ConfirmExitModal.test.tsx new file mode 100644 index 00000000000..f1e3681a93a --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/__tests__/ConfirmExitModal.test.tsx @@ -0,0 +1,42 @@ +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 { ConfirmExitModal } from '../ConfirmExitModal' + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('ConfirmExitModal', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + confirmExit: vi.fn(), + cancelExit: vi.fn(), + } + }) + afterEach(() => { + vi.resetAllMocks() + }) + + it('renders the create new transfer screen and header', () => { + render(props) + screen.getByText('Exit quick transfer?') + screen.getByText('You will lose all progress on this quick transfer.') + }) + it('renders exit and cancel buttons and they work as expected', () => { + render(props) + const cancelBtn = screen.getByText('Cancel') + fireEvent.click(cancelBtn) + expect(props.cancelExit).toHaveBeenCalled() + const deleteBtn = screen.getByText('Delete') + fireEvent.click(deleteBtn) + expect(props.confirmExit).toHaveBeenCalled() + }) +}) diff --git a/app/src/organisms/QuickTransferFlow/index.tsx b/app/src/organisms/QuickTransferFlow/index.tsx index 804acf36a95..a6ddcf55840 100644 --- a/app/src/organisms/QuickTransferFlow/index.tsx +++ b/app/src/organisms/QuickTransferFlow/index.tsx @@ -1,8 +1,13 @@ import * as React from 'react' import { useHistory } from 'react-router-dom' import { useTranslation } from 'react-i18next' -import { StepMeter, POSITION_STICKY } from '@opentrons/components' +import { + useConditionalConfirm, + StepMeter, + POSITION_STICKY, +} from '@opentrons/components' import { SmallButton } from '../../atoms/buttons' +import { ConfirmExitModal } from './ConfirmExitModal' import { CreateNewTransfer } from './CreateNewTransfer' import { SelectPipette } from './SelectPipette' import { SelectTipRack } from './SelectTipRack' @@ -27,12 +32,16 @@ export const QuickTransferFlow = (): JSX.Element => { ) const [currentStep, setCurrentStep] = React.useState(1) + const { + confirm: confirmExit, + showConfirmation: showConfirmExit, + cancel: cancelExit, + } = useConditionalConfirm(() => history.push('protocols'), true) + const exitButtonProps: React.ComponentProps = { buttonType: 'tertiaryLowLight', buttonText: i18n.format(t('shared:exit'), 'capitalize'), - onClick: () => { - history.push('protocols') - }, + onClick: confirmExit, } React.useEffect(() => { @@ -128,13 +137,19 @@ export const QuickTransferFlow = (): JSX.Element => { return ( <> - - {modalContent} + {showConfirmExit ? ( + + ) : ( + <> + + {modalContent} + + )} ) } diff --git a/app/src/organisms/QuickTransferFlow/utils.ts b/app/src/organisms/QuickTransferFlow/utils.ts index 907c08a52a6..41de2d86269 100644 --- a/app/src/organisms/QuickTransferFlow/utils.ts +++ b/app/src/organisms/QuickTransferFlow/utils.ts @@ -158,12 +158,10 @@ export function getVolumeLimits( const destLabwareVolume = Math.min( ...state.destinationWells.map(well => { - { - if (state.source == null || state.destination == null) return 0 - return state.destination === 'source' - ? state.source.wells[well].totalLiquidVolume - : state.destination.wells[well].totalLiquidVolume - } + if (state.source == null || state.destination == null) return 0 + return state.destination === 'source' + ? state.source.wells[well].totalLiquidVolume + : state.destination.wells[well].totalLiquidVolume }) ) let maxVolume = maxPipetteVolume From 2ed694198d65e1de9b61f4f77016bce9d9363dbb Mon Sep 17 00:00:00 2001 From: smb2268 Date: Thu, 2 May 2024 17:34:41 -0400 Subject: [PATCH 07/10] fix ts error --- app/src/assets/localization/en/quick_transfer.json | 2 ++ app/src/organisms/QuickTransferFlow/SelectDestLabware.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/assets/localization/en/quick_transfer.json b/app/src/assets/localization/en/quick_transfer.json index 93892b830b9..d03c15d2b1b 100644 --- a/app/src/assets/localization/en/quick_transfer.json +++ b/app/src/assets/localization/en/quick_transfer.json @@ -5,6 +5,8 @@ "left_mount": "Left Mount", "both_mounts": "Left + Right Mount", "dispense_volume": "Dispense volume per well (µL)", + "exit_quick_transfer": "Exit quick transfer?", + "lose_all_progress": "You will lose all progress on this quick transfer.", "right_mount": "Right Mount", "reservoir": "Reservoirs", "select_attached_pipette": "Select attached pipette", diff --git a/app/src/organisms/QuickTransferFlow/SelectDestLabware.tsx b/app/src/organisms/QuickTransferFlow/SelectDestLabware.tsx index 8ba80f2ce8a..d9ec6e99189 100644 --- a/app/src/organisms/QuickTransferFlow/SelectDestLabware.tsx +++ b/app/src/organisms/QuickTransferFlow/SelectDestLabware.tsx @@ -39,7 +39,7 @@ export function SelectDestLabware( 'wellPlate', 'reservoir', ] - if (state.pipette.channels === 1) { + if (state.pipette?.channels === 1) { labwareDisplayCategoryFilters.push('tubeRack') } const [selectedCategory, setSelectedCategory] = React.useState( From efa3ffd6230d5147779bf1e52c2c27658904db46 Mon Sep 17 00:00:00 2001 From: smb2268 Date: Fri, 3 May 2024 13:39:51 -0400 Subject: [PATCH 08/10] A few more test cases, code review cleanup --- .../QuickTransferFlow/VolumeEntry.tsx | 4 +- .../__tests__/SelectSourceLabware.test.tsx | 12 ++ .../__tests__/VolumeEntry.test.tsx | 42 +++++++ app/src/organisms/QuickTransferFlow/index.tsx | 116 +++++------------- 4 files changed, 86 insertions(+), 88 deletions(-) diff --git a/app/src/organisms/QuickTransferFlow/VolumeEntry.tsx b/app/src/organisms/QuickTransferFlow/VolumeEntry.tsx index 0613aac652a..4c40eb7e048 100644 --- a/app/src/organisms/QuickTransferFlow/VolumeEntry.tsx +++ b/app/src/organisms/QuickTransferFlow/VolumeEntry.tsx @@ -31,7 +31,9 @@ export function VolumeEntry(props: VolumeEntryProps): JSX.Element { const { i18n, t } = useTranslation(['quick_transfer', 'shared']) const keyboardRef = React.useRef(null) - const [volume, setVolume] = React.useState('') + const [volume, setVolume] = React.useState( + state.volume ? state.volume.toString() : '' + ) const volumeRange = getVolumeLimits(state) let headerCopy = t('set_transfer_volume') let textEntryCopy = t('volume_per_well') diff --git a/app/src/organisms/QuickTransferFlow/__tests__/SelectSourceLabware.test.tsx b/app/src/organisms/QuickTransferFlow/__tests__/SelectSourceLabware.test.tsx index 95c76e9e2c7..43b8c1a1e15 100644 --- a/app/src/organisms/QuickTransferFlow/__tests__/SelectSourceLabware.test.tsx +++ b/app/src/organisms/QuickTransferFlow/__tests__/SelectSourceLabware.test.tsx @@ -86,4 +86,16 @@ describe('SelectSourceLabware', () => { render({ ...props, state: { pipette: { channels: 8 } as any } }) screen.getByText('Tube racks') }) + + it('enables continue button if you select a labware', () => { + render(props) + const continueBtn = screen.getByTestId('ChildNavigation_Primary_Button') + expect(continueBtn).toBeDisabled() + const labwareOption = screen.getByText('Bio-Rad 384 Well Plate 50 µL') + fireEvent.click(labwareOption) + expect(continueBtn).toBeEnabled() + fireEvent.click(continueBtn) + expect(props.onNext).toHaveBeenCalled() + expect(props.dispatch).toHaveBeenCalled() + }) }) diff --git a/app/src/organisms/QuickTransferFlow/__tests__/VolumeEntry.test.tsx b/app/src/organisms/QuickTransferFlow/__tests__/VolumeEntry.test.tsx index 9da08bbcc08..dd2bb6dae4c 100644 --- a/app/src/organisms/QuickTransferFlow/__tests__/VolumeEntry.test.tsx +++ b/app/src/organisms/QuickTransferFlow/__tests__/VolumeEntry.test.tsx @@ -6,10 +6,12 @@ import { renderWithProviders } from '../../../__testing-utils__' import { i18n } from '../../../i18n' import { InputField } from '../../../atoms/InputField' import { NumericalKeyboard } from '../../../atoms/SoftwareKeyboard' +import { getVolumeLimits } from '../utils' import { VolumeEntry } from '../VolumeEntry' vi.mock('../../../atoms/InputField') vi.mock('../../../atoms/SoftwareKeyboard') +vi.mock('../utils') const render = (props: React.ComponentProps) => { return renderWithProviders(, { @@ -39,6 +41,7 @@ describe('VolumeEntry', () => { }, dispatch: vi.fn(), } + vi.mocked(getVolumeLimits).mockReturnValue({ min: 5, max: 50 }) }) afterEach(() => { vi.resetAllMocks() @@ -113,4 +116,43 @@ describe('VolumeEntry', () => { {} ) }) + + it('calls on next and dispatch if you press continue when volume is non-null and within range', () => { + render({ + ...props, + state: { + sourceWells: ['A1', 'A2'], + destinationWells: ['A1'], + volume: 20, + }, + }) + const continueBtn = screen.getByTestId('ChildNavigation_Primary_Button') + expect(continueBtn).toBeEnabled() + fireEvent.click(continueBtn) + expect(vi.mocked(props.onNext)).toHaveBeenCalled() + expect(vi.mocked(props.dispatch)).toHaveBeenCalled() + }) + + it('displays an error and disables continue when volume is outside of range', () => { + render({ + ...props, + state: { + sourceWells: ['A1', 'A2'], + destinationWells: ['A1'], + volume: 90, + }, + }) + const continueBtn = screen.getByTestId('ChildNavigation_Primary_Button') + expect(continueBtn).toBeDisabled() + expect(vi.mocked(InputField)).toHaveBeenCalledWith( + { + title: 'Aspirate volume per well (µL)', + error: 'Value must be between 5-50', + readOnly: true, + type: 'text', + value: '90', + }, + {} + ) + }) }) diff --git a/app/src/organisms/QuickTransferFlow/index.tsx b/app/src/organisms/QuickTransferFlow/index.tsx index a6ddcf55840..7bb697eb8a6 100644 --- a/app/src/organisms/QuickTransferFlow/index.tsx +++ b/app/src/organisms/QuickTransferFlow/index.tsx @@ -38,12 +38,6 @@ export const QuickTransferFlow = (): JSX.Element => { cancel: cancelExit, } = useConditionalConfirm(() => history.push('protocols'), true) - const exitButtonProps: React.ComponentProps = { - buttonType: 'tertiaryLowLight', - buttonText: i18n.format(t('shared:exit'), 'capitalize'), - onClick: confirmExit, - } - React.useEffect(() => { if (state.volume != null) { // until summary screen is implemented, log the final state and close flow @@ -53,88 +47,36 @@ export const QuickTransferFlow = (): JSX.Element => { } }, [state.volume]) - let modalContent: JSX.Element | null = null - if (currentStep === 1) { - modalContent = ( - setCurrentStep(prevStep => prevStep + 1)} - exitButtonProps={exitButtonProps} - /> - ) - } else if (currentStep === 2) { - modalContent = ( - setCurrentStep(prevStep => prevStep - 1)} - onNext={() => setCurrentStep(prevStep => prevStep + 1)} - exitButtonProps={exitButtonProps} - /> - ) - } else if (currentStep === 3) { - modalContent = ( - setCurrentStep(prevStep => prevStep - 1)} - onNext={() => setCurrentStep(prevStep => prevStep + 1)} - exitButtonProps={exitButtonProps} - /> - ) - } else if (currentStep === 4) { - modalContent = ( - setCurrentStep(prevStep => prevStep - 1)} - onNext={() => setCurrentStep(prevStep => prevStep + 1)} - exitButtonProps={exitButtonProps} - /> - ) - } else if (currentStep === 5) { - modalContent = ( - setCurrentStep(prevStep => prevStep - 1)} - onNext={() => setCurrentStep(prevStep => prevStep + 1)} - exitButtonProps={exitButtonProps} - /> - ) - } else if (currentStep === 6) { - modalContent = ( - setCurrentStep(prevStep => prevStep - 1)} - onNext={() => setCurrentStep(prevStep => prevStep + 1)} - exitButtonProps={exitButtonProps} - /> - ) - } else if (currentStep === 7) { - modalContent = ( - setCurrentStep(prevStep => prevStep - 1)} - onNext={() => setCurrentStep(prevStep => prevStep + 1)} - exitButtonProps={exitButtonProps} - /> - ) - } else if (currentStep === 8) { - modalContent = ( - setCurrentStep(prevStep => prevStep - 1)} - onNext={() => {}} - exitButtonProps={exitButtonProps} - /> - ) - } else { - modalContent = null + const exitButtonProps: React.ComponentProps = { + buttonType: 'tertiaryLowLight', + buttonText: i18n.format(t('shared:exit'), 'capitalize'), + onClick: confirmExit, + } + const sharedMiddleStepProps = { + state, + dispatch, + onBack: () => setCurrentStep(prevStep => prevStep - 1), + onNext: () => { + console.log('next step') + setCurrentStep(prevStep => prevStep + 1) + }, + exitButtonProps, } + const modalContentInOrder: JSX.Element[] = [ + setCurrentStep(prevStep => prevStep + 1)} + exitButtonProps={exitButtonProps} + />, + , + , + , + , + , + , + {}} />, + ] + return ( <> {showConfirmExit ? ( @@ -147,7 +89,7 @@ export const QuickTransferFlow = (): JSX.Element => { position={POSITION_STICKY} top="0" /> - {modalContent} + {modalContentInOrder[currentStep]} )} From 110931733d95e73da4b2cef6aaf0dddd1a0300aa Mon Sep 17 00:00:00 2001 From: smb2268 Date: Fri, 3 May 2024 13:44:15 -0400 Subject: [PATCH 09/10] Remove log statement --- app/src/organisms/QuickTransferFlow/index.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/src/organisms/QuickTransferFlow/index.tsx b/app/src/organisms/QuickTransferFlow/index.tsx index 7bb697eb8a6..d1f16874723 100644 --- a/app/src/organisms/QuickTransferFlow/index.tsx +++ b/app/src/organisms/QuickTransferFlow/index.tsx @@ -56,10 +56,7 @@ export const QuickTransferFlow = (): JSX.Element => { state, dispatch, onBack: () => setCurrentStep(prevStep => prevStep - 1), - onNext: () => { - console.log('next step') - setCurrentStep(prevStep => prevStep + 1) - }, + onNext: () => setCurrentStep(prevStep => prevStep + 1), exitButtonProps, } From b6398fb52004c6a8733de2650b9c86bcddf19bec Mon Sep 17 00:00:00 2001 From: smb2268 Date: Fri, 3 May 2024 14:13:19 -0400 Subject: [PATCH 10/10] Add missing key props for new array --- app/src/organisms/QuickTransferFlow/index.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/app/src/organisms/QuickTransferFlow/index.tsx b/app/src/organisms/QuickTransferFlow/index.tsx index d1f16874723..fe70233bdde 100644 --- a/app/src/organisms/QuickTransferFlow/index.tsx +++ b/app/src/organisms/QuickTransferFlow/index.tsx @@ -62,16 +62,17 @@ export const QuickTransferFlow = (): JSX.Element => { const modalContentInOrder: JSX.Element[] = [ setCurrentStep(prevStep => prevStep + 1)} exitButtonProps={exitButtonProps} />, - , - , - , - , - , - , - {}} />, + , + , + , + , + , + , + {}} />, ] return (