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 } +}