From f49c33d20aedc6d49c4f5f6553d3b2094b7731f8 Mon Sep 17 00:00:00 2001 From: Sarah Breen Date: Wed, 5 Jun 2024 11:03:09 -0400 Subject: [PATCH] feat(app): add tip management tab and items (#15291) This PR adds the tip management tab with change tip and drop tip location settings. fix PLAT-224 Co-authored-by: Brian Cooper Co-authored-by: Brent Hagen --- .../localization/en/quick_transfer.json | 11 + .../ListItem/__tests__/ListItem.test.tsx | 11 +- app/src/atoms/ListItem/index.tsx | 4 +- .../QuickTransferFlow/SummaryAndSettings.tsx | 18 +- .../TipManagement/ChangeTip.tsx | 87 ++++++++ .../TipManagement/TipDropLocation.tsx | 109 ++++++++++ .../TipManagement/TipDropLocation.test.tsx | 68 ++++++ .../QuickTransferFlow/TipManagement/index.tsx | 104 +++++++++ .../__tests__/SummaryAndSettings.test.tsx | 2 +- .../TipManagement/ChangeTip.test.tsx | 197 ++++++++++++++++++ .../TipManagement/TipDropLocation.test.tsx | 66 ++++++ .../TipManagement/TipManagement.test.tsx | 57 +++++ .../utils/getInitialSummaryState.test.ts | 127 +++++++---- app/src/organisms/QuickTransferFlow/types.ts | 10 +- .../utils/createQuickTransferFile.ts | 20 +- .../utils/generateQuickTransferArgs.ts | 38 ++-- .../utils/getInitialSummaryState.ts | 89 +++++--- .../QuickTransferFlow/utils/index.ts | 2 + 18 files changed, 901 insertions(+), 119 deletions(-) create mode 100644 app/src/organisms/QuickTransferFlow/TipManagement/ChangeTip.tsx create mode 100644 app/src/organisms/QuickTransferFlow/TipManagement/TipDropLocation.tsx create mode 100644 app/src/organisms/QuickTransferFlow/TipManagement/TipManagement/TipDropLocation.test.tsx create mode 100644 app/src/organisms/QuickTransferFlow/TipManagement/index.tsx create mode 100644 app/src/organisms/QuickTransferFlow/__tests__/TipManagement/ChangeTip.test.tsx create mode 100644 app/src/organisms/QuickTransferFlow/__tests__/TipManagement/TipDropLocation.test.tsx create mode 100644 app/src/organisms/QuickTransferFlow/__tests__/TipManagement/TipManagement.test.tsx diff --git a/app/src/assets/localization/en/quick_transfer.json b/app/src/assets/localization/en/quick_transfer.json index f0337c73694..6dd7aba7191 100644 --- a/app/src/assets/localization/en/quick_transfer.json +++ b/app/src/assets/localization/en/quick_transfer.json @@ -1,9 +1,11 @@ { "advanced_settings": "Advanced settings", "all": "All labware", + "always": "Before every aspirate", "aspirate_volume": "Aspirate volume per well", "aspirate_volume_µL": "Aspirate volume per well (µL)", "both_mounts": "Left + Right Mount", + "change_tip": "Change tip", "create_new_transfer": "Create new quick transfer", "create_transfer": "Create transfer", "destination": "Destination", @@ -13,13 +15,17 @@ "exit_quick_transfer": "Exit quick transfer?", "left_mount": "Left Mount", "lose_all_progress": "You will lose all progress on this quick transfer.", + "once": "Once at the start of the transfer", "overview": "Overview", + "perDest": "Per destination well", + "perSource": "Per source well", "pipette": "Pipette", "quick_transfer_volume": "Quick Transfer {{volume}}µL", "right_mount": "Right Mount", "reservoir": "Reservoirs", "run_now": "Run now", "run_quick_transfer_now": "Do you want to run your quick transfer now?", + "save": "Save", "save_to_run_later": "Save your quick transfer to run it in the future.", "save_for_later": "Save for later", "source": "Source", @@ -35,14 +41,19 @@ "source_labware": "Source labware", "source_labware_d2": "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_drop_location": "Tip drop location", "tip_management": "Tip management", "tip_rack": "Tip rack", + "trashBin": "Trash bin", + "trashBin_location": "Trash bin in {{slotName}}", "tubeRack": "Tube racks", "volume_per_well": "Volume per well", "volume_per_well_µL": "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.", + "wasteChute": "Waste chute", + "wasteChute_location": "Waste chute in {{slotName}}", "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/atoms/ListItem/__tests__/ListItem.test.tsx b/app/src/atoms/ListItem/__tests__/ListItem.test.tsx index 71c398fff35..bad57f423aa 100644 --- a/app/src/atoms/ListItem/__tests__/ListItem.test.tsx +++ b/app/src/atoms/ListItem/__tests__/ListItem.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react' -import { describe, it, expect, beforeEach } from 'vitest' +import { vi, describe, it, expect, beforeEach } from 'vitest' import '@testing-library/jest-dom/vitest' -import { screen } from '@testing-library/react' +import { fireEvent, screen } from '@testing-library/react' import { BORDERS, COLORS, SPACING } from '@opentrons/components' import { renderWithProviders } from '../../../__testing-utils__' @@ -17,6 +17,7 @@ describe('ListItem', () => { props = { type: 'error', children:
mock listitem content
, + onClick: vi.fn(), } }) @@ -63,4 +64,10 @@ describe('ListItem', () => { ) expect(listItem).toHaveStyle(`borderRadius: ${BORDERS.borderRadius12}`) }) + it('should call on click when pressed', () => { + render(props) + const listItem = screen.getByText('mock listitem content') + fireEvent.click(listItem) + expect(props.onClick).toHaveBeenCalled() + }) }) diff --git a/app/src/atoms/ListItem/index.tsx b/app/src/atoms/ListItem/index.tsx index 8df8ed82938..1628c19eb2b 100644 --- a/app/src/atoms/ListItem/index.tsx +++ b/app/src/atoms/ListItem/index.tsx @@ -11,6 +11,7 @@ interface ListItemProps extends StyleProps { type: ListItemType /** ListItem contents */ children: React.ReactNode + onClick?: () => void } const LISTITEM_PROPS_BY_TYPE: Record< @@ -32,7 +33,7 @@ const LISTITEM_PROPS_BY_TYPE: Record< } export function ListItem(props: ListItemProps): JSX.Element { - const { type, children, ...styleProps } = props + const { type, children, onClick, ...styleProps } = props const listItemProps = LISTITEM_PROPS_BY_TYPE[type] return ( @@ -43,6 +44,7 @@ export function ListItem(props: ListItemProps): JSX.Element { padding={`${SPACING.spacing16} ${SPACING.spacing24}`} backgroundColor={listItemProps.backgroundColor} borderRadius={BORDERS.borderRadius12} + onClick={onClick} {...styleProps} > {children} diff --git a/app/src/organisms/QuickTransferFlow/SummaryAndSettings.tsx b/app/src/organisms/QuickTransferFlow/SummaryAndSettings.tsx index 9458df10c75..ed909e59385 100644 --- a/app/src/organisms/QuickTransferFlow/SummaryAndSettings.tsx +++ b/app/src/organisms/QuickTransferFlow/SummaryAndSettings.tsx @@ -21,9 +21,9 @@ import { useNotifyDeckConfigurationQuery } from '../../resources/deck_configurat import { TabbedButton } from '../../atoms/buttons' import { ChildNavigation } from '../ChildNavigation' import { Overview } from './Overview' +import { TipManagement } from './TipManagement' import { SaveOrRunModal } from './SaveOrRunModal' -import { getInitialSummaryState } from './utils' -import { createQuickTransferFile } from './utils/createQuickTransferFile' +import { getInitialSummaryState, createQuickTransferFile } from './utils' import { quickTransferSummaryReducer } from './reducers' import type { SmallButton } from '../../atoms/buttons' @@ -56,10 +56,13 @@ export function SummaryAndSettings( ) const deckConfig = useNotifyDeckConfigurationQuery().data ?? [] - // @ts-expect-error TODO figure out how to make this type non-null as we know - // none of these values will be undefined - const initialSummaryState = getInitialSummaryState(wizardFlowState) - const [state] = React.useReducer( + const initialSummaryState = getInitialSummaryState({ + // @ts-expect-error TODO figure out how to make this type non-null as we know + // none of these values will be undefined + state: wizardFlowState, + deckConfig, + }) + const [state, dispatch] = React.useReducer( quickTransferSummaryReducer, initialSummaryState ) @@ -135,6 +138,9 @@ export function SummaryAndSettings( ))} {selectedCategory === 'overview' ? : null} + {selectedCategory === 'tip_management' ? ( + + ) : null} ) diff --git a/app/src/organisms/QuickTransferFlow/TipManagement/ChangeTip.tsx b/app/src/organisms/QuickTransferFlow/TipManagement/ChangeTip.tsx new file mode 100644 index 00000000000..5567c611df9 --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/TipManagement/ChangeTip.tsx @@ -0,0 +1,87 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { createPortal } from 'react-dom' +import { + Flex, + SPACING, + DIRECTION_COLUMN, + POSITION_FIXED, + COLORS, +} from '@opentrons/components' +import { getTopPortalEl } from '../../../App/portal' +import { LargeButton } from '../../../atoms/buttons' +import { ChildNavigation } from '../../ChildNavigation' + +import type { + ChangeTipOptions, + QuickTransferSummaryState, + QuickTransferSummaryAction, +} from '../types' + +interface ChangeTipProps { + onBack: () => void + state: QuickTransferSummaryState + dispatch: React.Dispatch +} + +export function ChangeTip(props: ChangeTipProps): JSX.Element { + const { onBack, state, dispatch } = props + const { t } = useTranslation('quick_transfer') + + const allowedChangeTipOptions: ChangeTipOptions[] = ['once'] + if (state.sourceWells.length <= 96 && state.destinationWells.length <= 96) { + allowedChangeTipOptions.push('always') + } + if (state.path === 'single' && state.transferType === 'distribute') { + allowedChangeTipOptions.push('perDest') + } else if (state.path === 'single') { + allowedChangeTipOptions.push('perSource') + } + + const [ + selectedChangeTipOption, + setSelectedChangeTipOption, + ] = React.useState(state.changeTip) + + const handleClickSave = (): void => { + if (selectedChangeTipOption !== state.changeTip) { + dispatch({ + type: 'SET_CHANGE_TIP', + changeTip: selectedChangeTipOption, + }) + } + onBack() + } + return createPortal( + + + + {allowedChangeTipOptions.map(option => ( + { + setSelectedChangeTipOption(option) + }} + buttonText={t(`${option}`)} + /> + ))} + + , + getTopPortalEl() + ) +} diff --git a/app/src/organisms/QuickTransferFlow/TipManagement/TipDropLocation.tsx b/app/src/organisms/QuickTransferFlow/TipManagement/TipDropLocation.tsx new file mode 100644 index 00000000000..16b02159171 --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/TipManagement/TipDropLocation.tsx @@ -0,0 +1,109 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { createPortal } from 'react-dom' +import { + Flex, + SPACING, + DIRECTION_COLUMN, + POSITION_FIXED, + COLORS, +} from '@opentrons/components' +import { + WASTE_CHUTE_FIXTURES, + FLEX_SINGLE_SLOT_BY_CUTOUT_ID, + TRASH_BIN_ADAPTER_FIXTURE, +} from '@opentrons/shared-data' +import { getTopPortalEl } from '../../../App/portal' +import { LargeButton } from '../../../atoms/buttons' +import { useNotifyDeckConfigurationQuery } from '../../../resources/deck_configuration' +import { ChildNavigation } from '../../ChildNavigation' + +import type { + QuickTransferSummaryState, + QuickTransferSummaryAction, +} from '../types' +import type { CutoutConfig } from '@opentrons/shared-data' + +interface TipDropLocationProps { + onBack: () => void + state: QuickTransferSummaryState + dispatch: React.Dispatch +} + +export function TipDropLocation(props: TipDropLocationProps): JSX.Element { + const { onBack, state, dispatch } = props + const { t } = useTranslation('quick_transfer') + const deckConfig = useNotifyDeckConfigurationQuery().data ?? [] + + const tipDropLocationOptions = deckConfig.filter( + cutoutConfig => + WASTE_CHUTE_FIXTURES.includes(cutoutConfig.cutoutFixtureId) || + TRASH_BIN_ADAPTER_FIXTURE === cutoutConfig.cutoutFixtureId + ) + + // add trash bin in A3 if no trash or waste chute configured + if (tipDropLocationOptions.length === 0) { + tipDropLocationOptions.push({ + cutoutId: 'cutoutA3', + cutoutFixtureId: TRASH_BIN_ADAPTER_FIXTURE, + }) + } + + const [ + selectedTipDropLocation, + setSelectedTipDropLocation, + ] = React.useState(state.dropTipLocation) + + const handleClickSave = (): void => { + if (selectedTipDropLocation.cutoutId !== state.dropTipLocation.cutoutId) { + dispatch({ + type: 'SET_DROP_TIP_LOCATION', + location: selectedTipDropLocation, + }) + } + onBack() + } + return createPortal( + + + + {tipDropLocationOptions.map(option => ( + { + setSelectedTipDropLocation(option) + }} + buttonText={t( + `${ + option.cutoutFixtureId === TRASH_BIN_ADAPTER_FIXTURE + ? 'trashBin' + : 'wasteChute' + }_location`, + { + slotName: FLEX_SINGLE_SLOT_BY_CUTOUT_ID[option.cutoutId], + } + )} + /> + ))} + + , + getTopPortalEl() + ) +} diff --git a/app/src/organisms/QuickTransferFlow/TipManagement/TipManagement/TipDropLocation.test.tsx b/app/src/organisms/QuickTransferFlow/TipManagement/TipManagement/TipDropLocation.test.tsx new file mode 100644 index 00000000000..22fce9317d0 --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/TipManagement/TipManagement/TipDropLocation.test.tsx @@ -0,0 +1,68 @@ +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 { useNotifyDeckConfigurationQuery } from '../../../../resources/deck_configuration' +import { TipDropLocation } from '../../TipManagement/TipDropLocation' + +vi.mock('../../../../resources/deck_configuration') + +const render = ( + props: React.ComponentProps +): ReturnType => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('TipDropLocation', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + onBack: vi.fn(), + state: { + dropTipLocation: 'trashBin', + } as any, + dispatch: vi.fn(), + } + vi.mocked(useNotifyDeckConfigurationQuery).mockReturnValue({ + data: [ + { + cutoutId: 'cutoutC3', + cutoutFixtureId: 'wasteChuteRightAdapterCovered', + }, + { + cutoutId: 'cutoutA3', + cutoutFixtureId: 'trashBinAdapter', + }, + ], + } as any) + }) + afterEach(() => { + vi.resetAllMocks() + }) + + it('renders tip drop location screen, header and save button', () => { + render(props) + screen.getByText('Tip drop location') + const saveBtn = screen.getByText('Save') + fireEvent.click(saveBtn) + expect(props.onBack).toHaveBeenCalled() + }) + it('renders options for each dipsosal location in deck config', () => { + render(props) + screen.getByText('Trash bin in A3') + screen.getByText('Waste chute in C3') + }) + it('calls dispatch when you select a new option and save', () => { + render(props) + const wasteChute = screen.getByText('Waste chute in C3') + fireEvent.click(wasteChute) + const saveBtn = screen.getByText('Save') + fireEvent.click(saveBtn) + expect(props.dispatch).toHaveBeenCalled() + }) +}) diff --git a/app/src/organisms/QuickTransferFlow/TipManagement/index.tsx b/app/src/organisms/QuickTransferFlow/TipManagement/index.tsx new file mode 100644 index 00000000000..7fc8f3b5d02 --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/TipManagement/index.tsx @@ -0,0 +1,104 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { + Flex, + StyledText, + SPACING, + TYPOGRAPHY, + DIRECTION_COLUMN, + JUSTIFY_SPACE_BETWEEN, + COLORS, + TEXT_ALIGN_RIGHT, + Icon, + SIZE_2, + ALIGN_CENTER, +} from '@opentrons/components' +import { TRASH_BIN_ADAPTER_FIXTURE } from '@opentrons/shared-data' +import { ListItem } from '../../../atoms/ListItem' +import { ChangeTip } from './ChangeTip' +import { TipDropLocation } from './TipDropLocation' + +import type { + QuickTransferSummaryAction, + QuickTransferSummaryState, +} from '../types' + +interface TipManagementProps { + state: QuickTransferSummaryState + dispatch: React.Dispatch +} + +export function TipManagement(props: TipManagementProps): JSX.Element | null { + const { state, dispatch } = props + const { t } = useTranslation(['quick_transfer', 'shared']) + const [selectedSetting, setSelectedSetting] = React.useState( + null + ) + + const displayItems = [ + { + option: t('change_tip'), + value: t(`${state.changeTip}`), + onClick: () => setSelectedSetting('change_tip'), + }, + { + option: t('tip_drop_location'), + value: t( + `${ + state.dropTipLocation.cutoutFixtureId === TRASH_BIN_ADAPTER_FIXTURE + ? 'trashBin' + : 'wasteChute' + }` + ), + onClick: () => setSelectedSetting('tip_drop_location'), + }, + ] + + return ( + + {selectedSetting == null + ? displayItems.map(displayItem => ( + + + + {displayItem.option} + + + + {displayItem.value} + + + + + + )) + : null} + {selectedSetting === 'change_tip' ? ( + setSelectedSetting(null)} + /> + ) : null} + {selectedSetting === 'tip_drop_location' ? ( + setSelectedSetting(null)} + /> + ) : null} + + ) +} diff --git a/app/src/organisms/QuickTransferFlow/__tests__/SummaryAndSettings.test.tsx b/app/src/organisms/QuickTransferFlow/__tests__/SummaryAndSettings.test.tsx index 9c32814df7c..e0c9a7bbfd8 100644 --- a/app/src/organisms/QuickTransferFlow/__tests__/SummaryAndSettings.test.tsx +++ b/app/src/organisms/QuickTransferFlow/__tests__/SummaryAndSettings.test.tsx @@ -6,7 +6,7 @@ import { useCreateRunMutation, } from '@opentrons/react-api-client' import { useNotifyDeckConfigurationQuery } from '../../../resources/deck_configuration' -import { createQuickTransferFile } from '../utils/createQuickTransferFile' +import { createQuickTransferFile } from '../utils' import { renderWithProviders } from '../../../__testing-utils__' import { i18n } from '../../../i18n' import { SummaryAndSettings } from '../SummaryAndSettings' diff --git a/app/src/organisms/QuickTransferFlow/__tests__/TipManagement/ChangeTip.test.tsx b/app/src/organisms/QuickTransferFlow/__tests__/TipManagement/ChangeTip.test.tsx new file mode 100644 index 00000000000..0829f5de068 --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/__tests__/TipManagement/ChangeTip.test.tsx @@ -0,0 +1,197 @@ +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 { ChangeTip } from '../../TipManagement/ChangeTip' + +const render = (props: React.ComponentProps): any => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('ChangeTip', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + onBack: vi.fn(), + state: { + changeTip: 'once', + sourceWells: ['A1'], + destinationWells: ['A1'], + path: 'single', + transferType: 'transfer', + } as any, + dispatch: vi.fn(), + } + }) + afterEach(() => { + vi.resetAllMocks() + }) + + it('renders change tip screen, header and save button', () => { + render(props) + screen.getByText('Change tip') + const saveBtn = screen.getByText('Save') + fireEvent.click(saveBtn) + expect(props.onBack).toHaveBeenCalled() + }) + it('calls dispatch when you select a new option and save', () => { + render(props) + screen.getByText('Change tip') + screen.getByText('Once at the start of the transfer') + const perSource = screen.getByText('Before every aspirate') + fireEvent.click(perSource) + const saveBtn = screen.getByText('Save') + fireEvent.click(saveBtn) + expect(props.dispatch).toHaveBeenCalled() + }) + it('renders correct change tip options when single transfer of less than 96 wells', () => { + render(props) + screen.getByText('Change tip') + screen.getByText('Once at the start of the transfer') + screen.getByText('Before every aspirate') + screen.getByText('Per source well') + }) + it('renders correct change tip options for consolidate with less than 96 wells', () => { + render({ ...props, state: { ...props.state, transferType: 'consolidate' } }) + screen.getByText('Change tip') + screen.getByText('Once at the start of the transfer') + screen.getByText('Before every aspirate') + screen.getByText('Per source well') + }) + it('renders correct change tip options for distribute with less than 96 wells', () => { + render({ ...props, state: { ...props.state, transferType: 'distribute' } }) + screen.getByText('Change tip') + screen.getByText('Once at the start of the transfer') + screen.getByText('Before every aspirate') + screen.getByText('Per destination well') + }) + it('renders correct change tip options any transfer with more than 96 wells', () => { + render({ + ...props, + state: { + ...props.state, + destinationWells: [ + 'A1', + 'B1', + 'C1', + 'D1', + 'E1', + 'F1', + 'G1', + 'H1', + 'I1', + 'J1', + 'K1', + 'L1', + 'M1', + 'N1', + 'O1', + 'P1', + 'A2', + 'B2', + 'C2', + 'D2', + 'E2', + 'F2', + 'G2', + 'H2', + 'I2', + 'J2', + 'K2', + 'L2', + 'M2', + 'N2', + 'O2', + 'P2', + 'A3', + 'B3', + 'C3', + 'D3', + 'E3', + 'F3', + 'G3', + 'H3', + 'I3', + 'J3', + 'K3', + 'L3', + 'M3', + 'N3', + 'O3', + 'P3', + 'A4', + 'B4', + 'C4', + 'D4', + 'E4', + 'F4', + 'G4', + 'H4', + 'I4', + 'J4', + 'K4', + 'L4', + 'M4', + 'N4', + 'O4', + 'P4', + 'A5', + 'B5', + 'C5', + 'D5', + 'E5', + 'F5', + 'G5', + 'H5', + 'I5', + 'J5', + 'K5', + 'L5', + 'M5', + 'N5', + 'O5', + 'P5', + 'A6', + 'B6', + 'C6', + 'D6', + 'E6', + 'F6', + 'G6', + 'H6', + 'I6', + 'J6', + 'K6', + 'L6', + 'M6', + 'N6', + 'O6', + 'P6', + 'A7', + 'B7', + 'C7', + 'D7', + 'E7', + 'F7', + 'G7', + 'H7', + 'I7', + 'J7', + 'K7', + 'L7', + 'M7', + 'N7', + 'O7', + 'P7', + ], + }, + }) + screen.getByText('Change tip') + screen.getByText('Once at the start of the transfer') + }) +}) diff --git a/app/src/organisms/QuickTransferFlow/__tests__/TipManagement/TipDropLocation.test.tsx b/app/src/organisms/QuickTransferFlow/__tests__/TipManagement/TipDropLocation.test.tsx new file mode 100644 index 00000000000..faaf3c16ca7 --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/__tests__/TipManagement/TipDropLocation.test.tsx @@ -0,0 +1,66 @@ +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 { useNotifyDeckConfigurationQuery } from '../../../../resources/deck_configuration' +import { TipDropLocation } from '../../TipManagement/TipDropLocation' + +vi.mock('../../../../resources/deck_configuration') + +const render = (props: React.ComponentProps): any => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('TipDropLocation', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + onBack: vi.fn(), + state: { + dropTipLocation: 'trashBin', + } as any, + dispatch: vi.fn(), + } + vi.mocked(useNotifyDeckConfigurationQuery).mockReturnValue({ + data: [ + { + cutoutId: 'cutoutC3', + cutoutFixtureId: 'wasteChuteRightAdapterCovered', + }, + { + cutoutId: 'cutoutA3', + cutoutFixtureId: 'trashBinAdapter', + }, + ], + } as any) + }) + afterEach(() => { + vi.resetAllMocks() + }) + + it('renders tip drop location screen, header and save button', () => { + render(props) + screen.getByText('Tip drop location') + const saveBtn = screen.getByText('Save') + fireEvent.click(saveBtn) + expect(props.onBack).toHaveBeenCalled() + }) + it('renders options for each dipsosal location in deck config', () => { + render(props) + screen.getByText('Trash bin in A3') + screen.getByText('Waste chute in C3') + }) + it('calls dispatch when you select a new option and save', () => { + render(props) + const wasteChute = screen.getByText('Waste chute in C3') + fireEvent.click(wasteChute) + const saveBtn = screen.getByText('Save') + fireEvent.click(saveBtn) + expect(props.dispatch).toHaveBeenCalled() + }) +}) diff --git a/app/src/organisms/QuickTransferFlow/__tests__/TipManagement/TipManagement.test.tsx b/app/src/organisms/QuickTransferFlow/__tests__/TipManagement/TipManagement.test.tsx new file mode 100644 index 00000000000..97e231a15ec --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/__tests__/TipManagement/TipManagement.test.tsx @@ -0,0 +1,57 @@ +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 { ChangeTip } from '../../TipManagement/ChangeTip' +import { TipDropLocation } from '../../TipManagement/TipDropLocation' +import { TipManagement } from '../../TipManagement/' + +vi.mock('../../TipManagement/ChangeTip') +vi.mock('../../TipManagement/TipDropLocation') + +const render = (props: React.ComponentProps): any => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('TipManagement', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + state: { + changeTip: 'once', + dropTipLocation: { + cutoutFixtureId: 'trashBinAdapter', + }, + } as any, + dispatch: vi.fn(), + } + }) + afterEach(() => { + vi.resetAllMocks() + }) + + it('renders tip management options and their values', () => { + render(props) + screen.getByText('Change tip') + screen.getByText('Once at the start of the transfer') + screen.getByText('Tip drop location') + screen.getByText('Trash bin') + }) + it('renders Change tip component when seleted', () => { + render(props) + const changeTip = screen.getByText('Change tip') + fireEvent.click(changeTip) + expect(vi.mocked(ChangeTip)).toHaveBeenCalled() + }) + it('renders Drop tip location component when seleted', () => { + render(props) + const tipDrop = screen.getByText('Tip drop location') + fireEvent.click(tipDrop) + expect(vi.mocked(TipDropLocation)).toHaveBeenCalled() + }) +}) diff --git a/app/src/organisms/QuickTransferFlow/__tests__/utils/getInitialSummaryState.test.ts b/app/src/organisms/QuickTransferFlow/__tests__/utils/getInitialSummaryState.test.ts index 159de4e4512..7d5fec75857 100644 --- a/app/src/organisms/QuickTransferFlow/__tests__/utils/getInitialSummaryState.test.ts +++ b/app/src/organisms/QuickTransferFlow/__tests__/utils/getInitialSummaryState.test.ts @@ -6,36 +6,44 @@ vi.mock('../../utils/getVolumeRange') describe('getInitialSummaryState', () => { const props = { - pipette: { - liquids: { - default: { - supportedTips: { - t50: { - defaultAspirateFlowRate: { - default: 50, - }, - defaultDispenseFlowRate: { - default: 75, + state: { + pipette: { + liquids: { + default: { + supportedTips: { + t50: { + defaultAspirateFlowRate: { + default: 50, + }, + defaultDispenseFlowRate: { + default: 75, + }, }, }, }, }, - }, - } as any, - mount: 'left', - tipRack: { - wells: { - A1: { - totalLiquidVolume: 50, + } as any, + mount: 'left', + tipRack: { + wells: { + A1: { + totalLiquidVolume: 50, + }, }, - }, + } as any, + source: {} as any, + sourceWells: ['A1'], + destination: 'source', + destinationWells: ['A1'], + transferType: 'transfer', + volume: 25, } as any, - source: {} as any, - sourceWells: ['A1'], - destination: 'source', - destinationWells: ['A1'], - transferType: 'transfer', - volume: 25, + deckConfig: [ + { + cutoutId: 'cutoutA3', + cutoutFixtureId: 'trashBinAdapter', + }, + ], } as any beforeEach(() => { vi.mocked(getVolumeRange).mockReturnValue({ min: 5, max: 100 }) @@ -47,7 +55,7 @@ describe('getInitialSummaryState', () => { it('generates the summary state with correct default value for 1 to 1 transfer', () => { const initialSummaryState = getInitialSummaryState(props) expect(initialSummaryState).toEqual({ - ...props, + ...props.state, aspirateFlowRate: 50, dispenseFlowRate: 75, path: 'single', @@ -55,16 +63,22 @@ describe('getInitialSummaryState', () => { preWetTip: false, tipPositionDispense: 1, changeTip: 'always', - dropTipLocation: 'trashBin', + dropTipLocation: { + cutoutId: 'cutoutA3', + cutoutFixtureId: 'trashBinAdapter', + }, }) }) it('generates the summary state with correct default value for n to 1 transfer', () => { const initialSummaryState = getInitialSummaryState({ ...props, - transferType: 'consolidate', + state: { + ...props.state, + transferType: 'consolidate', + }, }) expect(initialSummaryState).toEqual({ - ...props, + ...props.state, transferType: 'consolidate', aspirateFlowRate: 50, dispenseFlowRate: 75, @@ -73,17 +87,23 @@ describe('getInitialSummaryState', () => { preWetTip: false, tipPositionDispense: 1, changeTip: 'always', - dropTipLocation: 'trashBin', + dropTipLocation: { + cutoutId: 'cutoutA3', + cutoutFixtureId: 'trashBinAdapter', + }, }) }) it('generates the summary state with correct default value for n to 1 transfer with too high of volume for multiAspirate', () => { const initialSummaryState = getInitialSummaryState({ ...props, - transferType: 'consolidate', - volume: 60, + state: { + ...props.state, + transferType: 'consolidate', + volume: 60, + }, }) expect(initialSummaryState).toEqual({ - ...props, + ...props.state, transferType: 'consolidate', volume: 60, aspirateFlowRate: 50, @@ -93,16 +113,22 @@ describe('getInitialSummaryState', () => { preWetTip: false, tipPositionDispense: 1, changeTip: 'always', - dropTipLocation: 'trashBin', + dropTipLocation: { + cutoutId: 'cutoutA3', + cutoutFixtureId: 'trashBinAdapter', + }, }) }) it('generates the summary state with correct default value for 1 to n transfer', () => { const initialSummaryState = getInitialSummaryState({ ...props, - transferType: 'distribute', + state: { + ...props.state, + transferType: 'distribute', + }, }) expect(initialSummaryState).toEqual({ - ...props, + ...props.state, transferType: 'distribute', aspirateFlowRate: 50, dispenseFlowRate: 75, @@ -111,17 +137,23 @@ describe('getInitialSummaryState', () => { preWetTip: false, tipPositionDispense: 1, changeTip: 'always', - dropTipLocation: 'trashBin', + dropTipLocation: { + cutoutId: 'cutoutA3', + cutoutFixtureId: 'trashBinAdapter', + }, }) }) it('generates the summary state with correct default value for 1 to n transfer with too high of volume for multiDispense', () => { const initialSummaryState = getInitialSummaryState({ ...props, - transferType: 'distribute', - volume: 60, + state: { + ...props.state, + transferType: 'distribute', + volume: 60, + }, }) expect(initialSummaryState).toEqual({ - ...props, + ...props.state, transferType: 'distribute', volume: 60, aspirateFlowRate: 50, @@ -131,7 +163,10 @@ describe('getInitialSummaryState', () => { preWetTip: false, tipPositionDispense: 1, changeTip: 'always', - dropTipLocation: 'trashBin', + dropTipLocation: { + cutoutId: 'cutoutA3', + cutoutFixtureId: 'trashBinAdapter', + }, }) }) it('generates the summary state with correct default change tip if too few tips', () => { @@ -251,10 +286,13 @@ describe('getInitialSummaryState', () => { ] const initialSummaryState = getInitialSummaryState({ ...props, - destinationWells: destWells, + state: { + ...props.state, + destinationWells: destWells, + }, }) expect(initialSummaryState).toEqual({ - ...props, + ...props.state, destinationWells: destWells, aspirateFlowRate: 50, dispenseFlowRate: 75, @@ -263,7 +301,10 @@ describe('getInitialSummaryState', () => { preWetTip: false, tipPositionDispense: 1, changeTip: 'once', - dropTipLocation: 'trashBin', + dropTipLocation: { + cutoutId: 'cutoutA3', + cutoutFixtureId: 'trashBinAdapter', + }, }) }) }) diff --git a/app/src/organisms/QuickTransferFlow/types.ts b/app/src/organisms/QuickTransferFlow/types.ts index 927bc7c84fa..afcf141947d 100644 --- a/app/src/organisms/QuickTransferFlow/types.ts +++ b/app/src/organisms/QuickTransferFlow/types.ts @@ -1,5 +1,9 @@ import type { Mount } from '@opentrons/api-client' -import type { LabwareDefinition2, PipetteV2Specs } from '@opentrons/shared-data' +import type { + CutoutConfig, + LabwareDefinition2, + PipetteV2Specs, +} from '@opentrons/shared-data' import type { ACTIONS, CONSOLIDATE, DISTRIBUTE, TRANSFER } from './constants' export interface QuickTransferWizardState { @@ -59,7 +63,7 @@ export interface QuickTransferSummaryState { blowOut?: string // trashBin or wasteChute or 'SOURCE_WELL' or 'DEST_WELL' airGapDispense?: number changeTip: ChangeTipOptions - dropTipLocation: string // trashBin or wasteChute or tiprack + dropTipLocation: CutoutConfig } export type TransferType = @@ -167,7 +171,7 @@ interface SetChangeTip { } interface SetDropTipLocation { type: typeof ACTIONS.SET_DROP_TIP_LOCATION - location: string + location: CutoutConfig } interface SelectPipetteAction { type: typeof ACTIONS.SELECT_PIPETTE diff --git a/app/src/organisms/QuickTransferFlow/utils/createQuickTransferFile.ts b/app/src/organisms/QuickTransferFlow/utils/createQuickTransferFile.ts index 2b55f0d91a7..54842dcbfd2 100644 --- a/app/src/organisms/QuickTransferFlow/utils/createQuickTransferFile.ts +++ b/app/src/organisms/QuickTransferFlow/utils/createQuickTransferFile.ts @@ -5,11 +5,13 @@ import { distribute, getWasteChuteAddressableAreaNamePip, } from '@opentrons/step-generation' -import { generateQuickTransferArgs } from './generateQuickTransferArgs' +import { generateQuickTransferArgs } from './' import { FLEX_ROBOT_TYPE, FLEX_STANDARD_DECKID, getDeckDefFromRobotType, + TRASH_BIN_ADAPTER_FIXTURE, + WASTE_CHUTE_FIXTURES, } from '@opentrons/shared-data' import type { AddressableAreaName, @@ -129,11 +131,11 @@ export function createQuickTransferFile( let finalDropTipCommands: CreateCommand[] = [] let addressableAreaName: AddressableAreaName | null = null - if (quickTransferState.dropTipLocation === 'trashBin') { - const trash = Object.values( - invariantContext.additionalEquipmentEntities - ).find(aE => aE.name === 'trashBin') - const trashLocation = trash != null ? (trash.location as CutoutId) : null + if ( + quickTransferState.dropTipLocation.cutoutFixtureId === + TRASH_BIN_ADAPTER_FIXTURE + ) { + const trashLocation = quickTransferState.dropTipLocation.cutoutId const deckDef = getDeckDefFromRobotType(FLEX_ROBOT_TYPE) const cutouts: Record | null = deckDef.cutoutFixtures.find( @@ -143,7 +145,11 @@ export function createQuickTransferFile( trashLocation != null && cutouts != null ? cutouts[trashLocation]?.[0] ?? null : null - } else if (quickTransferState.dropTipLocation === 'wasteChute') { + } else if ( + WASTE_CHUTE_FIXTURES.includes( + quickTransferState.dropTipLocation.cutoutFixtureId + ) + ) { addressableAreaName = getWasteChuteAddressableAreaNamePip( pipetteEntity.spec.channels ) diff --git a/app/src/organisms/QuickTransferFlow/utils/generateQuickTransferArgs.ts b/app/src/organisms/QuickTransferFlow/utils/generateQuickTransferArgs.ts index 54dfefca784..af8807261e3 100644 --- a/app/src/organisms/QuickTransferFlow/utils/generateQuickTransferArgs.ts +++ b/app/src/organisms/QuickTransferFlow/utils/generateQuickTransferArgs.ts @@ -6,6 +6,7 @@ import { getLabwareDefURI, getWellsDepth, getTipTypeFromTipRackDefinition, + TRASH_BIN_ADAPTER_FIXTURE, WASTE_CHUTE_FIXTURES, } from '@opentrons/shared-data' import { makeInitialRobotState } from '@opentrons/step-generation' @@ -139,13 +140,12 @@ function getInvariantContextAndRobotState( } } let additionalEquipmentEntities: AdditionalEquipmentEntities = {} + // TODO add check for blowout location here if ( - quickTransferState.dropTipLocation === 'trashBin' || - quickTransferState.blowOut === 'trashBin' + quickTransferState.dropTipLocation.cutoutFixtureId === + TRASH_BIN_ADAPTER_FIXTURE ) { - const trashLocation = deckConfig.find( - configCutout => configCutout.cutoutFixtureId === 'trashBinAdapter' - )?.cutoutId + const trashLocation = quickTransferState.dropTipLocation.cutoutId const trashId = `${uuid()}_trashBin` additionalEquipmentEntities = { [trashId]: { @@ -155,13 +155,13 @@ function getInvariantContextAndRobotState( }, } } + // TODO add check for blowout location here if ( - quickTransferState.dropTipLocation === 'wasteChute' || - quickTransferState.blowOut === 'wasteChute' + WASTE_CHUTE_FIXTURES.includes( + quickTransferState.dropTipLocation.cutoutFixtureId + ) ) { - const wasteChuteLocation = deckConfig.find(configCutout => - WASTE_CHUTE_FIXTURES.includes(configCutout.cutoutFixtureId) - )?.cutoutId + const wasteChuteLocation = quickTransferState.dropTipLocation.cutoutId const wasteChuteId = `${uuid()}_wasteChute` additionalEquipmentEntities = { ...additionalEquipmentEntities, @@ -242,18 +242,12 @@ export function generateQuickTransferArgs( blowoutLocation = wasteChuteEntity?.id } - let dropTipLocation = quickTransferState.dropTipLocation - if (quickTransferState.dropTipLocation === 'trashBin') { - const trashBinEntity = Object.values( - invariantContext.additionalEquipmentEntities - ).find(entity => entity.name === 'trashBin') - dropTipLocation = trashBinEntity?.id ?? 'trashBin' - } else if (quickTransferState.dropTipLocation === 'wasteChute') { - const wasteChuteEntity = Object.values( - invariantContext.additionalEquipmentEntities - ).find(entity => entity.name === 'wasteChute') - dropTipLocation = wasteChuteEntity?.id ?? 'wasteChute' - } + const dropTipLocationEntity = Object.values( + invariantContext.additionalEquipmentEntities + ).find( + entity => entity.location === quickTransferState.dropTipLocation.cutoutId + ) + const dropTipLocation = dropTipLocationEntity?.id ?? '' const tipType = getTipTypeFromTipRackDefinition(quickTransferState.tipRack) const flowRatesForSupportedTip = diff --git a/app/src/organisms/QuickTransferFlow/utils/getInitialSummaryState.ts b/app/src/organisms/QuickTransferFlow/utils/getInitialSummaryState.ts index a8cba8acb12..3bcbb953a2c 100644 --- a/app/src/organisms/QuickTransferFlow/utils/getInitialSummaryState.ts +++ b/app/src/organisms/QuickTransferFlow/utils/getInitialSummaryState.ts @@ -1,7 +1,15 @@ -import { getTipTypeFromTipRackDefinition } from '@opentrons/shared-data' +import { + getTipTypeFromTipRackDefinition, + TRASH_BIN_ADAPTER_FIXTURE, + WASTE_CHUTE_FIXTURES, +} from '@opentrons/shared-data' import { getVolumeRange } from './' -import type { LabwareDefinition2, PipetteV2Specs } from '@opentrons/shared-data' +import type { + LabwareDefinition2, + PipetteV2Specs, + DeckConfiguration, +} from '@opentrons/shared-data' import type { Mount } from '@opentrons/api-client' import type { QuickTransferSummaryState, @@ -10,62 +18,75 @@ import type { ChangeTipOptions, } from '../types' +interface InitialSummaryStateProps { + state: { + pipette: PipetteV2Specs + mount: Mount + tipRack: LabwareDefinition2 + source: LabwareDefinition2 + sourceWells: string[] + destination: LabwareDefinition2 | 'source' + destinationWells: string[] + transferType: TransferType + volume: number + } + deckConfig: DeckConfiguration +} + // sets up the initial summary state with defaults based on selections made // in the wizard flow -export function getInitialSummaryState(props: { - pipette: PipetteV2Specs - mount: Mount - tipRack: LabwareDefinition2 - source: LabwareDefinition2 - sourceWells: string[] - destination: LabwareDefinition2 | 'source' - destinationWells: string[] - transferType: TransferType - volume: number -}): QuickTransferSummaryState { - const tipType = getTipTypeFromTipRackDefinition(props.tipRack) +export function getInitialSummaryState( + props: InitialSummaryStateProps +): QuickTransferSummaryState { + const { state, deckConfig } = props + const tipType = getTipTypeFromTipRackDefinition(state.tipRack) const flowRatesForSupportedTip = - props.pipette.liquids.default.supportedTips[tipType] + state.pipette.liquids.default.supportedTips[tipType] - const volumeLimits = getVolumeRange(props) + const volumeLimits = getVolumeRange(state) let path: PathOption = 'single' if ( - props.transferType === 'consolidate' && - volumeLimits.max >= props.volume * 2 + state.transferType === 'consolidate' && + volumeLimits.max >= state.volume * 2 ) { path = 'multiDispense' } else if ( - props.transferType === 'distribute' && - volumeLimits.max >= props.volume * 2 + state.transferType === 'distribute' && + volumeLimits.max >= state.volume * 2 ) { path = 'multiAspirate' } let changeTip: ChangeTipOptions = 'always' - if (props.sourceWells.length > 96 || props.destinationWells.length > 96) { + if (state.sourceWells.length > 96 || state.destinationWells.length > 96) { changeTip = 'once' } + const trashConfigCutout = deckConfig.find( + configCutout => + WASTE_CHUTE_FIXTURES.includes(configCutout.cutoutFixtureId) || + TRASH_BIN_ADAPTER_FIXTURE === configCutout.cutoutFixtureId + // if no trash or waste chute found, default to a trash bin in A3 + ) ?? { cutoutId: 'cutoutA3', cutoutFixtureId: TRASH_BIN_ADAPTER_FIXTURE } + return { - pipette: props.pipette, - mount: props.mount, - tipRack: props.tipRack, - source: props.source, - sourceWells: props.sourceWells, - destination: props.destination, - destinationWells: props.destinationWells, - transferType: props.transferType, - volume: props.volume, + pipette: state.pipette, + mount: state.mount, + tipRack: state.tipRack, + source: state.source, + sourceWells: state.sourceWells, + destination: state.destination, + destinationWells: state.destinationWells, + transferType: state.transferType, + volume: state.volume, aspirateFlowRate: flowRatesForSupportedTip.defaultAspirateFlowRate.default, dispenseFlowRate: flowRatesForSupportedTip.defaultDispenseFlowRate.default, - path: path, + path, tipPositionAspirate: 1, preWetTip: false, tipPositionDispense: 1, - // TODO expand default logic for change tip depending on path, transfer type, number of tips changeTip, - // TODO add default logic for drop tip location depending on deck config - dropTipLocation: 'trashBin', + dropTipLocation: trashConfigCutout, } } diff --git a/app/src/organisms/QuickTransferFlow/utils/index.ts b/app/src/organisms/QuickTransferFlow/utils/index.ts index 42db68c6a6a..7cdecf34910 100644 --- a/app/src/organisms/QuickTransferFlow/utils/index.ts +++ b/app/src/organisms/QuickTransferFlow/utils/index.ts @@ -2,3 +2,5 @@ export { getCompatibleLabwareByCategory } from './getCompatibleLabwareByCategory export { getVolumeRange } from './getVolumeRange' export { generateCompatibleLabwareForPipette } from './generateCompatibleLabwareForPipette' export { getInitialSummaryState } from './getInitialSummaryState' +export { generateQuickTransferArgs } from './generateQuickTransferArgs' +export { createQuickTransferFile } from './createQuickTransferFile'