diff --git a/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx b/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx index bcebf6313c3..b1ad18b0752 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx @@ -30,7 +30,6 @@ import { getModuleDisplayName, getModuleType, FLEX_ROBOT_TYPE, - THERMOCYCLER_MODULE_TYPE, MAGNETIC_BLOCK_TYPE, } from '@opentrons/shared-data' import { getIsCrashablePipetteSelected } from '../../../step-forms' @@ -45,9 +44,8 @@ import { ModuleFields } from '../FilePipettesModal/ModuleFields' import { GoBack } from './GoBack' import { getCrashableModuleSelected, - getDisabledEquipment, - getNextAvailableModuleSlot, - getTrashBinOptionDisabled, + getIsSlotAvailable, + getTrashOptionDisabled, } from './utils' import { EquipmentOption } from './EquipmentOption' import { HandleEnter } from './HandleEnter' @@ -197,10 +195,6 @@ function FlexModuleFields(props: WizardTileProps): JSX.Element { const additionalEquipment = watch('additionalEquipment') const moduleTypesOnDeck = modules != null ? Object.values(modules).map(module => module.type) : [] - const trashBinDisabled = getTrashBinOptionDisabled({ - additionalEquipment, - modules, - }) const handleSetEquipmentOption = (equipment: AdditionalEquipment): void => { if (additionalEquipment.includes(equipment)) { @@ -209,6 +203,11 @@ function FlexModuleFields(props: WizardTileProps): JSX.Element { setValue('additionalEquipment', [...additionalEquipment, equipment]) } } + const trashBinDisabled = getTrashOptionDisabled({ + additionalEquipment, + modules, + trashType: 'trashBin', + }) React.useEffect(() => { if (trashBinDisabled) { @@ -220,21 +219,10 @@ function FlexModuleFields(props: WizardTileProps): JSX.Element { {FLEX_SUPPORTED_MODULE_MODELS.map(moduleModel => { const moduleType = getModuleType(moduleModel) - const moduleOnDeck = moduleTypesOnDeck.includes(moduleType) + const isModuleOnDeck = moduleTypesOnDeck.includes(moduleType) + + const isDisabled = !getIsSlotAvailable(modules, additionalEquipment) - let defaultSlot = getNextAvailableModuleSlot( - modules, - additionalEquipment - ) - if (moduleType === THERMOCYCLER_MODULE_TYPE) { - defaultSlot = 'B1' - } else if (moduleType === MAGNETIC_BLOCK_TYPE) { - defaultSlot = 'D2' - } - const isDisabled = getDisabledEquipment({ - additionalEquipment, - modules, - })?.includes(moduleType) const handleMultiplesClick = (num: number): void => { const temperatureModules = modules != null @@ -250,10 +238,7 @@ function FlexModuleFields(props: WizardTileProps): JSX.Element { [uuid()]: { model: moduleModel, type: moduleType, - slot: getNextAvailableModuleSlot( - modules, - additionalEquipment - ), + slot: null, }, }) } @@ -274,7 +259,7 @@ function FlexModuleFields(props: WizardTileProps): JSX.Element { (moduleType !== TEMPERATURE_MODULE_TYPE && enableMoamFf) || !enableMoamFf ) { - if (moduleOnDeck) { + if (isModuleOnDeck) { const updatedModules = modules != null ? Object.fromEntries( @@ -290,7 +275,7 @@ function FlexModuleFields(props: WizardTileProps): JSX.Element { [uuid()]: { model: moduleModel, type: moduleType, - slot: defaultSlot, + slot: DEFAULT_SLOT_MAP[moduleModel], }, }) } @@ -301,10 +286,14 @@ function FlexModuleFields(props: WizardTileProps): JSX.Element { } text={getModuleDisplayName(moduleModel)} - disabled={isDisabled && !moduleOnDeck} + disabled={ + moduleType === MAGNETIC_BLOCK_TYPE + ? false + : isDisabled && !isModuleOnDeck + } onClick={handleOnClick} multiples={ moduleType === TEMPERATURE_MODULE_TYPE && enableMoamFf @@ -345,11 +334,11 @@ function FlexModuleFields(props: WizardTileProps): JSX.Element { robotType={FLEX_ROBOT_TYPE} onClick={() => handleSetEquipmentOption('wasteChute')} isSelected={additionalEquipment.includes('wasteChute')} - disabled={ - modules != null - ? Object.values(modules).some(module => module.slot === 'D3') - : false - } + disabled={getTrashOptionDisabled({ + additionalEquipment, + modules, + trashType: 'wasteChute', + })} image={ { { cutoutId: 'cutoutD3', cutoutFixtureId: SINGLE_RIGHT_SLOT_FIXTURE }, ]) }) - describe('getNextAvailableModuleSlot', () => { - it('should return D1 when there are no modules or staging areas', () => { - const result = getNextAvailableModuleSlot(null, []) - expect(result).toStrictEqual('D1') - }) - it('should return a C3 when all the modules are on the deck', () => { - const result = getNextAvailableModuleSlot( - { - 0: { - model: 'magneticBlockV1', - type: 'magneticBlockType', - slot: 'D1', - }, - 1: { - model: 'thermocyclerModuleV2', - type: 'thermocyclerModuleType', - slot: 'B1', - }, - 2: { - model: 'temperatureModuleV2', - type: 'temperatureModuleType', - slot: 'C1', - }, - }, - [] - ) - expect(result).toStrictEqual('C3') - }) +}) +describe('getIsSlotAvailable', () => { + it('should return true when there are no modules or additional equipment', () => { + const result = getIsSlotAvailable(null, []) + expect(result).toBe(true) }) - it('should return an empty string when all the modules and staging area slots are on the deck without TC', () => { - const result = getNextAvailableModuleSlot( - { - 0: { - model: 'heaterShakerModuleV1', - type: 'heaterShakerModuleType', - slot: 'D1', - }, - 1: { - model: 'temperatureModuleV2', - type: 'temperatureModuleType', - slot: 'C1', - }, - 2: { - model: 'temperatureModuleV2', - type: 'temperatureModuleType', - slot: 'B1', - }, + it('should return false when there is a TC and 7 modules', () => { + const mockModules = { + 0: { + model: 'heaterShakerModuleV1', + type: 'heaterShakerModuleType', + slot: 'D1', }, - [ - 'stagingArea_cutoutA3', - 'stagingArea_cutoutB3', - 'stagingArea_cutoutC3', - 'stagingArea_cutoutD3', - 'trashBin', - ] - ) - expect(result).toStrictEqual('') - }) - it('should return an empty string when all the modules and staging area slots are on the deck with TC', () => { - const result = getNextAvailableModuleSlot( - { - 0: { - model: 'heaterShakerModuleV1', - type: 'heaterShakerModuleType', - slot: 'D1', - }, - 1: { - model: 'thermocyclerModuleV2', - type: 'thermocyclerModuleType', - slot: 'B1', - }, + 1: { + model: 'temperatureModuleV2', + type: 'temperatureModuleType', + slot: 'D3', }, - [ - 'stagingArea_cutoutA3', - 'stagingArea_cutoutB3', - 'stagingArea_cutoutC3', - 'stagingArea_cutoutD3', - 'trashBin', - ] - ) - expect(result).toStrictEqual('') + 2: { + model: 'temperatureModuleV2', + type: 'temperatureModuleType', + slot: 'C1', + }, + 3: { + model: 'temperatureModuleV2', + type: 'temperatureModuleType', + slot: 'B3', + }, + 4: { + model: 'thermocyclerModuleV2', + type: 'thermocyclerModuleType', + slot: 'B1', + }, + 5: { + model: 'temperatureModuleV2', + type: 'temperatureModuleType', + slot: 'A3', + }, + 6: { + model: 'temperatureModuleV2', + type: 'temperatureModuleType', + slot: 'C3', + }, + } as any + const result = getIsSlotAvailable(mockModules, []) + expect(result).toBe(false) + }) + it('should return true when there are 9 additional equipment and 1 is a waste chute on the staging area and one is a gripper', () => { + const mockAdditionalEquipment: AdditionalEquipment[] = [ + 'trashBin', + 'stagingArea_cutoutA3', + 'stagingArea_cutoutB3', + 'stagingArea_cutoutC3', + 'stagingArea_cutoutD3', + 'wasteChute', + 'trashBin', + 'gripper', + 'trashBin', + ] + const result = getIsSlotAvailable(null, mockAdditionalEquipment) + expect(result).toBe(true) }) }) -describe('getNextAvailableModuleSlot', () => { - it('should return nothing as disabled', () => { - const result = getDisabledEquipment({ - additionalEquipment: [], - modules: null, - }) - expect(result).toStrictEqual([]) +describe('getTrashSlot', () => { + it('should return the default slot A3 when there is no staging area or module in that slot', () => { + MOCK_FORM_STATE = { + ...MOCK_FORM_STATE, + additionalEquipment: ['trashBin'], + } + const result = getTrashSlot(MOCK_FORM_STATE) + expect(result).toBe(FLEX_TRASH_DEFAULT_SLOT) + }) + it('should return cutoutA1 when there is a staging area in slot A3', () => { + MOCK_FORM_STATE = { + ...MOCK_FORM_STATE, + additionalEquipment: ['stagingArea_cutoutA3'], + } + const result = getTrashSlot(MOCK_FORM_STATE) + expect(result).toBe('cutoutA1') }) - it('should return the TC as disabled', () => { - const result = getDisabledEquipment({ - additionalEquipment: [], +}) +describe('getTrashOptionDisabled', () => { + it('returns false when there is a trash bin already', () => { + const result = getTrashOptionDisabled({ + trashType: 'trashBin', + additionalEquipment: ['trashBin'], modules: { 0: { model: 'heaterShakerModuleV1', type: 'heaterShakerModuleType', - slot: 'A1', + slot: 'D1', }, }, }) - expect(result).toStrictEqual([THERMOCYCLER_MODULE_TYPE]) + expect(result).toBe(false) }) - it('should return all module types if there is no available slot', () => { - const result = getDisabledEquipment({ + it('returns false when there is an available slot', () => { + const result = getTrashOptionDisabled({ + trashType: 'trashBin', + additionalEquipment: ['trashBin'], + modules: null, + }) + expect(result).toBe(false) + }) + it('returns true when there is no available slot and trash bin is not selected yet', () => { + const result = getTrashOptionDisabled({ + trashType: 'trashBin', additionalEquipment: [ 'stagingArea_cutoutA3', 'stagingArea_cutoutB3', 'stagingArea_cutoutC3', 'stagingArea_cutoutD3', - 'trashBin', ], modules: { 0: { @@ -185,85 +181,13 @@ describe('getNextAvailableModuleSlot', () => { type: 'temperatureModuleType', slot: 'B1', }, - }, - }) - expect(result).toStrictEqual([ - THERMOCYCLER_MODULE_TYPE, - TEMPERATURE_MODULE_TYPE, - HEATERSHAKER_MODULE_TYPE, - ]) - }) -}) -describe('getTrashSlot', () => { - it('should return the default slot A3 when there is no staging area or module in that slot', () => { - MOCK_FORM_STATE = { - ...MOCK_FORM_STATE, - additionalEquipment: ['trashBin'], - } - const result = getTrashSlot(MOCK_FORM_STATE) - expect(result).toBe(FLEX_TRASH_DEFAULT_SLOT) - }) - it('should return cutoutA1 when there is a staging area in slot A3', () => { - MOCK_FORM_STATE = { - ...MOCK_FORM_STATE, - additionalEquipment: ['stagingArea_cutoutA3'], - } - const result = getTrashSlot(MOCK_FORM_STATE) - expect(result).toBe('cutoutA1') - }) - describe('getTrashBinOptionDisabled', () => { - it('returns false when there is a trash bin already', () => { - const result = getTrashBinOptionDisabled({ - additionalEquipment: ['trashBin'], - modules: { - 0: { - model: 'heaterShakerModuleV1', - type: 'heaterShakerModuleType', - slot: 'D1', - }, - }, - }) - expect(result).toBe(false) - }) - it('returns false when there is an available slot', () => { - const result = getTrashBinOptionDisabled({ - additionalEquipment: ['trashBin'], - modules: null, - }) - expect(result).toBe(false) - }) - it('returns true when there is no available slot and trash bin is not selected yet', () => { - const result = getTrashBinOptionDisabled({ - additionalEquipment: [ - 'stagingArea_cutoutA3', - 'stagingArea_cutoutB3', - 'stagingArea_cutoutC3', - 'stagingArea_cutoutD3', - ], - modules: { - 0: { - model: 'heaterShakerModuleV1', - type: 'heaterShakerModuleType', - slot: 'D1', - }, - 1: { - model: 'temperatureModuleV2', - type: 'temperatureModuleType', - slot: 'C1', - }, - 2: { - model: 'temperatureModuleV2', - type: 'temperatureModuleType', - slot: 'B1', - }, - 3: { - model: 'temperatureModuleV2', - type: 'temperatureModuleType', - slot: 'A1', - }, + 3: { + model: 'temperatureModuleV2', + type: 'temperatureModuleType', + slot: 'A1', }, - }) - expect(result).toBe(true) + }, }) + expect(result).toBe(true) }) }) diff --git a/protocol-designer/src/components/modals/CreateFileWizard/index.tsx b/protocol-designer/src/components/modals/CreateFileWizard/index.tsx index b19ab426f65..53ae9a88f6a 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/index.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/index.tsx @@ -43,6 +43,7 @@ import { createDeckFixture, toggleIsGripperRequired, } from '../../../step-forms/actions/additionalItems' +import { createModuleWithNoSlot } from '../../../modules' import { RobotTypeTile } from './RobotTypeTile' import { MetadataTile } from './MetadataTile' import { FirstPipetteTypeTile, SecondPipetteTypeTile } from './PipetteTypeTile' @@ -229,9 +230,29 @@ export function CreateFileWizard(): JSX.Element | null { } // create modules - modules.forEach(moduleArgs => - dispatch(stepFormActions.createModule(moduleArgs)) - ) + // sort so modules with slot are created first + // then modules without a slot are generated in remaining available slots + modules.sort((a, b) => { + if (a.slot == null && b.slot != null) { + return 1 + } + if (b.slot == null && a.slot != null) { + return -1 + } + return 0 + }) + + modules.forEach(moduleArgs => { + return moduleArgs.slot != null + ? dispatch(stepFormActions.createModule(moduleArgs)) + : dispatch( + createModuleWithNoSlot({ + model: moduleArgs.model, + type: moduleArgs.type, + }) + ) + }) + // add gripper if (values.additionalEquipment.includes('gripper')) { dispatch(toggleIsGripperRequired()) diff --git a/protocol-designer/src/components/modals/CreateFileWizard/utils.ts b/protocol-designer/src/components/modals/CreateFileWizard/utils.ts index 20abcf27cb3..7a23706a680 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/utils.ts +++ b/protocol-designer/src/components/modals/CreateFileWizard/utils.ts @@ -1,6 +1,4 @@ import { - HEATERSHAKER_MODULE_TYPE, - TEMPERATURE_MODULE_TYPE, THERMOCYCLER_MODULE_TYPE, WASTE_CHUTE_CUTOUT, } from '@opentrons/shared-data' @@ -13,41 +11,6 @@ import type { AdditionalEquipment, FormState } from './types' export const FLEX_TRASH_DEFAULT_SLOT = 'cutoutA3' -const MODULES_SLOTS_FLEX = [ - { - value: 'cutoutD1', - slot: 'D1', - }, - { - value: 'cutoutC3', - slot: 'C3', - }, - { - value: 'cutoutB1', - slot: 'B1', - }, - { - value: 'cutoutB3', - slot: 'B3', - }, - { - value: 'cutoutA3', - slot: 'A3', - }, - { - value: 'cutoutD3', - slot: 'D3', - }, - { - value: 'cutoutC1', - slot: 'C1', - }, - { - value: 'cutoutA1', - slot: 'A1', - }, -] - export const getCrashableModuleSelected = ( modules: FormModules | null, moduleType: ModuleType @@ -120,102 +83,57 @@ export const getUnoccupiedStagingAreaSlots = ( return unoccupiedSlots } -export const getNextAvailableModuleSlot = ( +const TOTAL_MODULE_SLOTS = 8 + +export const getIsSlotAvailable = ( modules: FormState['modules'], additionalEquipment: FormState['additionalEquipment'] -): string => { - const moduleSlots = - modules != null - ? Object.values(modules).flatMap(module => - module.type === THERMOCYCLER_MODULE_TYPE - ? [module.slot, 'A1'] - : module.slot - ) - : [] - const stagingAreas = additionalEquipment.filter(equipment => - equipment.includes('stagingArea') +): boolean => { + const moduleLength = modules != null ? Object.keys(modules).length : 0 + const additionalEquipmentLength = additionalEquipment.length + const hasTC = Object.values(modules || {}).some( + module => module.type === THERMOCYCLER_MODULE_TYPE ) - const stagingAreaCutouts = stagingAreas.map(cutout => cutout.split('_')[1]) - const hasWasteChute = additionalEquipment.find(equipment => + + const filteredModuleLength = hasTC ? moduleLength + 1 : moduleLength + const hasWasteChute = additionalEquipment.some(equipment => equipment.includes('wasteChute') ) - const wasteChuteSlot = Boolean(hasWasteChute) - ? [WASTE_CHUTE_CUTOUT as string] - : [] - const trashBin = additionalEquipment.find(equipment => - equipment.includes('trashBin') + const isStagingAreaInD3 = additionalEquipment + .filter(equipment => equipment.includes('stagingArea')) + .find(stagingArea => stagingArea.split('_')[1] === 'cutoutD3') + const hasGripper = additionalEquipment.some(equipment => + equipment.includes('gripper') ) - const hasTC = - modules != null - ? Object.values(modules).some( - module => module.type === THERMOCYCLER_MODULE_TYPE - ) - : false - // removing slot(s) for the trash if spaces are limited - let removeSlotForTrash = MODULES_SLOTS_FLEX - if (trashBin != null && hasTC) { - removeSlotForTrash = MODULES_SLOTS_FLEX.slice(0, -2) - } else if (trashBin != null && !hasTC) { - removeSlotForTrash = MODULES_SLOTS_FLEX.slice(0, -1) + let filteredAdditionalEquipmentLength = additionalEquipmentLength + if (hasWasteChute && isStagingAreaInD3) { + filteredAdditionalEquipmentLength = filteredAdditionalEquipmentLength - 1 } - const unoccupiedSlot = removeSlotForTrash.find( - cutout => - !stagingAreaCutouts.includes(cutout.value) && - !moduleSlots.includes(cutout.slot) && - !wasteChuteSlot.includes(cutout.value) - ) - if (unoccupiedSlot == null) { - return '' + if (hasGripper) { + filteredAdditionalEquipmentLength = filteredAdditionalEquipmentLength - 1 } - return unoccupiedSlot?.slot ?? '' + return ( + filteredModuleLength + filteredAdditionalEquipmentLength < + TOTAL_MODULE_SLOTS + ) } -interface DisabledEquipmentProps { +interface TrashOptionDisabledProps { + trashType: 'trashBin' | 'wasteChute' additionalEquipment: AdditionalEquipment[] modules: FormModules | null } -export const getDisabledEquipment = ( - props: DisabledEquipmentProps -): string[] => { - const { additionalEquipment, modules } = props - const nextAvailableSlot = getNextAvailableModuleSlot( - modules, - additionalEquipment - ) - const disabledEquipment: string[] = [] - - const moduleSlots = - modules != null - ? Object.values(modules).flatMap(module => - module.type === THERMOCYCLER_MODULE_TYPE - ? [module.slot, 'A1'] - : module.slot - ) - : [] - - if (moduleSlots.includes('A1') || moduleSlots.includes('B1')) { - disabledEquipment.push(THERMOCYCLER_MODULE_TYPE) - } - if (nextAvailableSlot === '') { - disabledEquipment.push(TEMPERATURE_MODULE_TYPE, HEATERSHAKER_MODULE_TYPE) - } - - return disabledEquipment -} - -export const getTrashBinOptionDisabled = ( - props: DisabledEquipmentProps +export const getTrashOptionDisabled = ( + props: TrashOptionDisabledProps ): boolean => { - const { additionalEquipment, modules } = props - const nextAvailableSlot = getNextAvailableModuleSlot( - modules, - additionalEquipment + const { additionalEquipment, modules, trashType } = props + return ( + !getIsSlotAvailable(modules, additionalEquipment) && + !additionalEquipment.includes(trashType) ) - const hasTrashBinAlready = additionalEquipment.includes('trashBin') - return nextAvailableSlot === '' && !hasTrashBinAlready } export const getTrashSlot = (values: FormState): string => { diff --git a/protocol-designer/src/components/modules/EditModulesCard.tsx b/protocol-designer/src/components/modules/EditModulesCard.tsx index 896463c295c..27dcc233ede 100644 --- a/protocol-designer/src/components/modules/EditModulesCard.tsx +++ b/protocol-designer/src/components/modules/EditModulesCard.tsx @@ -28,7 +28,7 @@ import { ModuleRow } from './ModuleRow' import { AdditionalItemsRow } from './AdditionalItemsRow' import { isModuleWithCollisionIssue } from './utils' import { StagingAreasRow } from './StagingAreasRow' -import { MultipleModuleRow } from './MultipleModuleRow' +import { MultipleModulesRow } from './MultipleModulesRow' import type { AdditionalEquipmentEntity } from '@opentrons/step-generation' @@ -146,7 +146,7 @@ export function EditModulesCard(props: Props): JSX.Element { ) } else if (moduleData != null && moduleData.length > 1) { return ( - ) => { - return renderWithProviders(, { +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { i18nInstance: i18n, })[0] } @@ -37,7 +37,7 @@ const mockTemp2: ModuleOnDeck = { } describe('MultipleModuleRow', () => { - let props: React.ComponentProps + let props: React.ComponentProps beforeEach(() => { props = { moduleType: TEMPERATURE_MODULE_TYPE, diff --git a/protocol-designer/src/modules/__tests__/moduleData.test.tsx b/protocol-designer/src/modules/__tests__/moduleData.test.tsx new file mode 100644 index 00000000000..9d27732bf56 --- /dev/null +++ b/protocol-designer/src/modules/__tests__/moduleData.test.tsx @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest' +import { getNextAvailableModuleSlot } from '../moduleData' +import type { InitialDeckSetup } from '../../step-forms' + +describe('getNextAvailableModuleSlot', () => { + it('renders slot D1 when no slots are occupied', () => { + const mockInitialDeckSetup: InitialDeckSetup = { + modules: {}, + labware: {}, + pipettes: {}, + additionalEquipmentOnDeck: {}, + } + const result = getNextAvailableModuleSlot(mockInitialDeckSetup) + expect(result).toBe('D1') + }) + it('renders slot C1 when other slots are occupied', () => { + const mockInitialDeckSetup: InitialDeckSetup = { + modules: {}, + labware: {}, + pipettes: {}, + additionalEquipmentOnDeck: { + wasteChuteId: { + name: 'wasteChute', + id: 'wasteChuteId', + location: 'D3', + }, + trashBinId: { + name: 'trashBin', + id: 'trashBinId', + location: 'D1', + }, + }, + } + const result = getNextAvailableModuleSlot(mockInitialDeckSetup) + expect(result).toBe('C1') + }) + it('renders undefined when all slots are occupied', () => { + const mockInitialDeckSetup: InitialDeckSetup = { + modules: { + thermocycler: { + model: 'thermocyclerModuleV2', + id: 'thermocycler', + type: 'thermocyclerModuleType', + slot: 'B1', + moduleState: {} as any, + }, + temperature: { + model: 'temperatureModuleV2', + id: 'temperature', + type: 'temperatureModuleType', + slot: 'C1', + moduleState: {} as any, + }, + }, + labware: {}, + pipettes: {}, + additionalEquipmentOnDeck: { + wasteChuteId: { + name: 'wasteChute', + id: 'wasteChuteId', + location: 'D3', + }, + trashBinId: { + name: 'trashBin', + id: 'trashBinId', + location: 'D1', + }, + stagingArea1: { + name: 'stagingArea', + id: 'stagingArea1', + location: 'A3', + }, + stagingArea2: { + name: 'stagingArea', + id: 'stagingArea2', + location: 'B3', + }, + stagingArea3: { + name: 'stagingArea', + id: 'stagingArea3', + location: 'C3', + }, + }, + } + const result = getNextAvailableModuleSlot(mockInitialDeckSetup) + expect(result).toBe(undefined) + }) +}) diff --git a/protocol-designer/src/modules/index.ts b/protocol-designer/src/modules/index.ts index 82b41275ed4..8ca029f4e14 100644 --- a/protocol-designer/src/modules/index.ts +++ b/protocol-designer/src/modules/index.ts @@ -1 +1,2 @@ export * from './moduleData' +export * from './thunks' diff --git a/protocol-designer/src/modules/moduleData.ts b/protocol-designer/src/modules/moduleData.ts index a2d05f33bc8..240a2e11eae 100644 --- a/protocol-designer/src/modules/moduleData.ts +++ b/protocol-designer/src/modules/moduleData.ts @@ -1,14 +1,27 @@ -import { SPAN7_8_10_11_SLOT } from '../constants' +import { COLUMN_4_SLOTS } from '@opentrons/step-generation' import { MAGNETIC_MODULE_TYPE, TEMPERATURE_MODULE_TYPE, THERMOCYCLER_MODULE_TYPE, HEATERSHAKER_MODULE_TYPE, - ModuleType, MAGNETIC_BLOCK_TYPE, + MOVABLE_TRASH_ADDRESSABLE_AREAS, + WASTE_CHUTE_ADDRESSABLE_AREAS, + FIXED_TRASH_ID, +} from '@opentrons/shared-data' +import { SPAN7_8_10_11_SLOT } from '../constants' +import { getStagingAreaAddressableAreas } from '../utils' +import { getSlotIsEmpty } from '../step-forms' +import type { + ModuleType, RobotType, + CutoutId, + AddressableAreaName, } from '@opentrons/shared-data' -import { DropdownOption } from '@opentrons/components' +import type { DropdownOption } from '@opentrons/components' +import type { InitialDeckSetup } from '../step-forms' +import type { DeckSlot } from '../types' + export const SUPPORTED_MODULE_TYPES: ModuleType[] = [ HEATERSHAKER_MODULE_TYPE, MAGNETIC_BLOCK_TYPE, @@ -270,3 +283,32 @@ export function getAllModuleSlotsByType( } return slot } + +const FLEX_MODULE_SLOTS = ['D1', 'D3', 'C1', 'C3', 'B1', 'B3', 'A1', 'A3'] + +export function getNextAvailableModuleSlot( + initialDeckSetup: InitialDeckSetup +): DeckSlot | undefined { + return FLEX_MODULE_SLOTS.find(slot => { + const cutoutIds = Object.values(initialDeckSetup.additionalEquipmentOnDeck) + .filter(ae => ae.name === 'stagingArea') + .map(ae => ae.location as CutoutId) + const stagingAreaAddressableAreaNames = getStagingAreaAddressableAreas( + cutoutIds + ) + const addressableAreaName = stagingAreaAddressableAreaNames.find( + aa => aa === slot + ) + let isSlotEmpty: boolean = getSlotIsEmpty(initialDeckSetup, slot, true) + if (addressableAreaName == null && COLUMN_4_SLOTS.includes(slot)) { + isSlotEmpty = false + } else if ( + MOVABLE_TRASH_ADDRESSABLE_AREAS.includes(slot as AddressableAreaName) || + WASTE_CHUTE_ADDRESSABLE_AREAS.includes(slot as AddressableAreaName) || + slot === FIXED_TRASH_ID + ) { + isSlotEmpty = false + } + return isSlotEmpty + }) +} diff --git a/protocol-designer/src/modules/thunks.ts b/protocol-designer/src/modules/thunks.ts new file mode 100644 index 00000000000..655eeb07a7c --- /dev/null +++ b/protocol-designer/src/modules/thunks.ts @@ -0,0 +1,33 @@ +import { selectors as stepFormSelectors } from '../step-forms' +import { uuid } from '../utils' +import { getNextAvailableModuleSlot } from './moduleData' +import type { ModuleModel, ModuleType } from '@opentrons/shared-data' +import type { CreateModuleAction } from '../step-forms/actions' +import type { ThunkAction } from '../types' + +interface CreateModuleWithNoSloArgs { + type: ModuleType + model: ModuleModel +} +export const createModuleWithNoSlot: ( + args: CreateModuleWithNoSloArgs +) => ThunkAction = args => (dispatch, getState) => { + const { model, type } = args + const state = getState() + const initialDeckSetup = stepFormSelectors.getInitialDeckSetup(state) + const slot = getNextAvailableModuleSlot(initialDeckSetup) + + if (slot == null) { + console.assert(slot, 'expected to find available slot but could not') + } + + dispatch({ + type: 'CREATE_MODULE', + payload: { + model, + type, + slot: slot ?? '', + id: `${uuid()}:${type}}`, + }, + }) +}