From 585f69e03907cc549d0ff07474c2f3dced8893a8 Mon Sep 17 00:00:00 2001 From: Jethary Rader <66035149+jerader@users.noreply.github.com> Date: Thu, 18 Apr 2024 12:01:07 -0400 Subject: [PATCH] feat(protocol-designer): edit multiple modules modal + row (#14933) closes AUTH-16 --- .../src/components/EditModules.tsx | 41 ++- .../components/__tests__/EditModules.test.tsx | 25 +- .../EditMultipleModulesModal.tsx | 274 ++++++++++++++++++ .../EditMultipleModulesModal.test.tsx | 106 +++++++ .../components/modules/EditModulesCard.tsx | 30 +- .../components/modules/MultipleModuleRow.tsx | 121 ++++++++ .../__tests__/MultipleModuleRow.test.tsx | 67 +++++ .../src/localization/en/alert.json | 5 + .../src/localization/en/modules.json | 1 + .../src/step-forms/selectors/index.ts | 5 +- protocol-designer/src/step-forms/types.ts | 2 +- 11 files changed, 655 insertions(+), 22 deletions(-) create mode 100644 protocol-designer/src/components/modals/EditModulesModal/EditMultipleModulesModal.tsx create mode 100644 protocol-designer/src/components/modals/EditModulesModal/__tests__/EditMultipleModulesModal.test.tsx create mode 100644 protocol-designer/src/components/modules/MultipleModuleRow.tsx create mode 100644 protocol-designer/src/components/modules/__tests__/MultipleModuleRow.test.tsx diff --git a/protocol-designer/src/components/EditModules.tsx b/protocol-designer/src/components/EditModules.tsx index 9df9defbdd9..7a4ef5b48c7 100644 --- a/protocol-designer/src/components/EditModules.tsx +++ b/protocol-designer/src/components/EditModules.tsx @@ -1,14 +1,21 @@ import * as React from 'react' import { useSelector, useDispatch } from 'react-redux' +import { + FLEX_ROBOT_TYPE, + TEMPERATURE_MODULE_TYPE, +} from '@opentrons/shared-data' import { selectors as stepFormSelectors, actions as stepFormActions, } from '../step-forms' import { moveDeckItem } from '../labware-ingred/actions/actions' +import { getRobotType } from '../file-data/selectors' +import { getEnableMoam } from '../feature-flags/selectors' +import { EditMultipleModulesModal } from './modals/EditModulesModal/EditMultipleModulesModal' import { useBlockingHint } from './Hints/useBlockingHint' import { MagneticModuleWarningModalContent } from './modals/EditModulesModal/MagneticModuleWarningModalContent' import { EditModulesModal } from './modals/EditModulesModal' -import { ModuleModel, ModuleType } from '@opentrons/shared-data' +import type { ModuleModel, ModuleType } from '@opentrons/shared-data' export interface EditModulesProps { moduleToEdit: { @@ -27,6 +34,12 @@ export const EditModules = (props: EditModulesProps): JSX.Element => { const { onCloseClick, moduleToEdit } = props const { moduleId, moduleType } = moduleToEdit const _initialDeckSetup = useSelector(stepFormSelectors.getInitialDeckSetup) + const robotType = useSelector(getRobotType) + const moamFf = useSelector(getEnableMoam) + const showMultipleModuleModal = + robotType === FLEX_ROBOT_TYPE && + moduleType === TEMPERATURE_MODULE_TYPE && + moamFf const moduleOnDeck = moduleId ? _initialDeckSetup.modules[moduleId] : null const [ @@ -74,16 +87,24 @@ export const EditModules = (props: EditModulesProps): JSX.Element => { enabled: changeModuleWarningInfo !== null, }) - return ( - changeModuleWarning ?? ( - + ) + if (showMultipleModuleModal) { + modal = ( + ) - ) + } + return changeModuleWarning ?? modal } diff --git a/protocol-designer/src/components/__tests__/EditModules.test.tsx b/protocol-designer/src/components/__tests__/EditModules.test.tsx index 2cb2ed8c55f..fb183a3e9e6 100644 --- a/protocol-designer/src/components/__tests__/EditModules.test.tsx +++ b/protocol-designer/src/components/__tests__/EditModules.test.tsx @@ -1,19 +1,29 @@ import * as React from 'react' import { screen } from '@testing-library/react' import { vi, beforeEach, describe, it } from 'vitest' +import { + FLEX_ROBOT_TYPE, + OT2_ROBOT_TYPE, + TEMPERATURE_MODULE_TYPE, +} from '@opentrons/shared-data' import { i18n } from '../../localization' import { getInitialDeckSetup } from '../../step-forms/selectors' import { getDismissedHints } from '../../tutorial/selectors' import { EditModules } from '../EditModules' import { EditModulesModal } from '../modals/EditModulesModal' import { renderWithProviders } from '../../__testing-utils__' +import { getEnableMoam } from '../../feature-flags/selectors' +import { getRobotType } from '../../file-data/selectors' +import { EditMultipleModulesModal } from '../modals/EditModulesModal/EditMultipleModulesModal' import type { HintKey } from '../../tutorial' vi.mock('../../step-forms/selectors') +vi.mock('../modals/EditModulesModal/EditMultipleModulesModal') vi.mock('../modals/EditModulesModal') vi.mock('../../tutorial/selectors') - +vi.mock('../../file-data/selectors') +vi.mock('../../feature-flags/selectors') const render = (props: React.ComponentProps) => { return renderWithProviders(, { i18nInstance: i18n, @@ -51,11 +61,22 @@ describe('EditModules', () => { vi.mocked(EditModulesModal).mockReturnValue( mock EditModulesModal ) + vi.mocked(EditMultipleModulesModal).mockReturnValue( + mock EditMultipleModulesModal + ) vi.mocked(getDismissedHints).mockReturnValue([hintKey]) + vi.mocked(getRobotType).mockReturnValue(OT2_ROBOT_TYPE) + vi.mocked(getEnableMoam).mockReturnValue(true) }) - it('renders the edit modules modal', () => { + it('renders the edit modules modal for single modules', () => { render(props) screen.getByText('mock EditModulesModal') }) + it('renders multiple edit modules modal', () => { + props.moduleToEdit.moduleType = TEMPERATURE_MODULE_TYPE + vi.mocked(getRobotType).mockReturnValue(FLEX_ROBOT_TYPE) + render(props) + screen.getByText('mock EditMultipleModulesModal') + }) }) diff --git a/protocol-designer/src/components/modals/EditModulesModal/EditMultipleModulesModal.tsx b/protocol-designer/src/components/modals/EditModulesModal/EditMultipleModulesModal.tsx new file mode 100644 index 00000000000..cc31c4eb071 --- /dev/null +++ b/protocol-designer/src/components/modals/EditModulesModal/EditMultipleModulesModal.tsx @@ -0,0 +1,274 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { useSelector, useDispatch } from 'react-redux' +import { Controller, useForm, useWatch } from 'react-hook-form' +import { + BUTTON_TYPE_SUBMIT, + OutlineButton, + ModalShell, + Flex, + SPACING, + DIRECTION_ROW, + Box, + Text, + ALIGN_CENTER, + JUSTIFY_FLEX_END, + JUSTIFY_END, + DeckConfigurator, + DIRECTION_COLUMN, +} from '@opentrons/components' +import { + DeckConfiguration, + SINGLE_RIGHT_SLOT_FIXTURE, + TEMPERATURE_MODULE_CUTOUTS, + TEMPERATURE_MODULE_TYPE, + TEMPERATURE_MODULE_V2, + TEMPERATURE_MODULE_V2_FIXTURE, +} from '@opentrons/shared-data' +import { createModule, deleteModule } from '../../../step-forms/actions' +import { getLabwareOnSlot, getSlotIsEmpty } from '../../../step-forms' +import { getInitialDeckSetup } from '../../../step-forms/selectors' +import { getLabwareIsCompatible } from '../../../utils/labwareModuleCompatibility' +import { PDAlert } from '../../alerts/PDAlert' +import type { Control, ControllerRenderProps } from 'react-hook-form' +import type { CutoutId, ModuleType } from '@opentrons/shared-data' +import type { ModuleOnDeck } from '../../../step-forms' + +export interface EditMultipleModulesModalValues { + selectedAddressableAreas: string[] +} + +interface EditMultipleModulesModalComponentProps + extends EditMultipleModulesModalProps { + control: Control + moduleLocations: string[] | null +} + +const EditMultipleModulesModalComponent = ( + props: EditMultipleModulesModalComponentProps +): JSX.Element => { + const { t } = useTranslation(['button', 'alert']) + const { + onCloseClick, + allModulesOnDeck, + control, + moduleLocations, + moduleType, + } = props + const initialDeckSetup = useSelector(getInitialDeckSetup) + + const selectedSlots = useWatch({ + control, + name: 'selectedAddressableAreas', + defaultValue: moduleLocations ?? [], + }) + const occupiedCutoutIds = selectedSlots + .map(slot => { + const hasModSlot = + allModulesOnDeck.find( + module => + module.type === moduleType && slot === `cutout${module.slot}` + ) != null + const labwareOnSlot = getLabwareOnSlot(initialDeckSetup, slot) + const isLabwareCompatible = + (labwareOnSlot && + getLabwareIsCompatible(labwareOnSlot.def, moduleType)) ?? + true + const isEmpty = + (getSlotIsEmpty(initialDeckSetup, slot, true) || hasModSlot) && + isLabwareCompatible + + return { slot, isEmpty } + }) + .filter(slot => !slot.isEmpty) + const hasConflictedSlot = occupiedCutoutIds.length > 0 + const mappedModules: DeckConfiguration = + moduleLocations != null + ? moduleLocations.flatMap(location => { + return [ + { + cutoutId: location as CutoutId, + cutoutFixtureId: TEMPERATURE_MODULE_V2_FIXTURE, + }, + ] + }) + : [] + const STANDARD_EMPTY_SLOTS: DeckConfiguration = TEMPERATURE_MODULE_CUTOUTS.map( + cutoutId => ({ + cutoutId, + cutoutFixtureId: SINGLE_RIGHT_SLOT_FIXTURE, + }) + ) + + STANDARD_EMPTY_SLOTS.forEach(emptySlot => { + if ( + !mappedModules.some(({ cutoutId }) => cutoutId === emptySlot.cutoutId) + ) { + mappedModules.push(emptySlot) + } + }) + + const selectableSlots = + mappedModules.length > 0 ? mappedModules : STANDARD_EMPTY_SLOTS + const [updatedSlots, setUpdatedSlots] = React.useState( + selectableSlots + ) + const handleClickAdd = ( + cutoutId: string, + field: ControllerRenderProps< + EditMultipleModulesModalValues, + 'selectedAddressableAreas' + > + ): void => { + const modifiedSlots: DeckConfiguration = updatedSlots.map(slot => { + if (slot.cutoutId === cutoutId) { + return { + ...slot, + cutoutFixtureId: TEMPERATURE_MODULE_V2_FIXTURE, + } + } + return slot + }) + setUpdatedSlots(modifiedSlots) + const updatedSelectedSlots = [...selectedSlots, cutoutId] + field.onChange(updatedSelectedSlots) + } + + const handleClickRemove = ( + cutoutId: string, + field: ControllerRenderProps< + EditMultipleModulesModalValues, + 'selectedAddressableAreas' + > + ): void => { + const modifiedSlots: DeckConfiguration = updatedSlots.map(slot => { + if (slot.cutoutId === cutoutId) { + return { ...slot, cutoutFixtureId: SINGLE_RIGHT_SLOT_FIXTURE } + } + return slot + }) + setUpdatedSlots(modifiedSlots) + + field.onChange(selectedSlots.filter(item => item !== cutoutId)) + } + const occupiedSlots = occupiedCutoutIds.map( + occupiedCutout => occupiedCutout.slot.split('cutout')[1] + ) + const alertDescription = t( + `alert:module_placement.SLOTS_OCCUPIED.${ + occupiedSlots.length === 1 ? 'single' : 'multi' + }`, + { + slotName: occupiedSlots, + } + ) + + return ( + <> + + + + {hasConflictedSlot ? ( + + ) : null} + + + ( + handleClickAdd(cutoutId, field)} + handleClickRemove={cutoutId => handleClickRemove(cutoutId, field)} + showExpansion={false} + /> + )} + /> + + + {t('cancel')} + + {t('save')} + + + > + ) +} + +export interface EditMultipleModulesModalProps { + onCloseClick: () => void + allModulesOnDeck: ModuleOnDeck[] + moduleType: ModuleType +} +export function EditMultipleModulesModal( + props: EditMultipleModulesModalProps +): JSX.Element { + const { onCloseClick, allModulesOnDeck, moduleType } = props + const { t } = useTranslation('modules') + const dispatch = useDispatch() + const { control, handleSubmit } = useForm() + const moduleLocations = Object.values(allModulesOnDeck) + .filter(module => module.type === moduleType) + .map(temp => `cutout${temp.slot}`) + + const onSaveClick = (data: EditMultipleModulesModalValues): void => { + onCloseClick() + + data.selectedAddressableAreas.forEach(aa => { + const moduleInSlot = Object.values(allModulesOnDeck).find(module => + aa.includes(module.slot) + ) + if (!moduleInSlot) { + dispatch( + createModule({ + slot: aa.split('cutout')[1], + type: TEMPERATURE_MODULE_TYPE, + model: TEMPERATURE_MODULE_V2, + }) + ) + } + }) + Object.values(allModulesOnDeck).forEach(module => { + const moduleCutout = `cutout${module.slot}` + if (!data.selectedAddressableAreas.includes(moduleCutout)) { + dispatch(deleteModule(module.id)) + } + }) + } + + return ( + + + + + {t('module_display_names.multipleTemperatureModuleTypes')} + + + + + + ) +} diff --git a/protocol-designer/src/components/modals/EditModulesModal/__tests__/EditMultipleModulesModal.test.tsx b/protocol-designer/src/components/modals/EditModulesModal/__tests__/EditMultipleModulesModal.test.tsx new file mode 100644 index 00000000000..fa01bd44ecf --- /dev/null +++ b/protocol-designer/src/components/modals/EditModulesModal/__tests__/EditMultipleModulesModal.test.tsx @@ -0,0 +1,106 @@ +import * as React from 'react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { fireEvent, screen, cleanup } from '@testing-library/react' +import { renderWithProviders } from '../../../../__testing-utils__' +import { i18n } from '../../../../localization' +import { getInitialDeckSetup } from '../../../../step-forms/selectors' +import { getLabwareIsCompatible } from '../../../../utils/labwareModuleCompatibility' +import { + getLabwareOnSlot, + getSlotIsEmpty, + ModuleOnDeck, +} from '../../../../step-forms' +import { EditMultipleModulesModal } from '../EditMultipleModulesModal' +import type * as Components from '@opentrons/components' + +vi.mock('../../../../step-forms/selectors') +vi.mock('../../../../utils/labwareModuleCompatibility') +vi.mock('../../../../step-forms') +vi.mock('@opentrons/components', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + DeckConfigurator: vi.fn(() => mock deck config), + } +}) + +const render = ( + props: React.ComponentProps +) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +const mockTemp: ModuleOnDeck = { + id: 'temperatureId', + type: 'temperatureModuleType', + model: 'temperatureModuleV2', + slot: 'C3', + moduleState: {} as any, +} +const mockTemp2: ModuleOnDeck = { + id: 'temperatureId', + type: 'temperatureModuleType', + model: 'temperatureModuleV2', + slot: 'A1', + moduleState: {} as any, +} +const mockHS: ModuleOnDeck = { + id: 'heaterShakerId', + type: 'heaterShakerModuleType', + model: 'heaterShakerModuleV1', + moduleState: {} as any, + slot: 'A1', +} +describe('EditMultipleModulesModal', () => { + let props: React.ComponentProps + beforeEach(() => { + props = { + moduleType: 'temperatureModuleType', + onCloseClick: vi.fn(), + allModulesOnDeck: [mockTemp, mockTemp2], + } + vi.mocked(getInitialDeckSetup).mockReturnValue({ + modules: { + temperatureId: mockTemp, + temperatureId2: mockTemp2, + }, + labware: {}, + additionalEquipmentOnDeck: {}, + pipettes: {}, + }) + vi.mocked(getLabwareOnSlot).mockReturnValue(null) + vi.mocked(getSlotIsEmpty).mockReturnValue(true) + }) + afterEach(() => { + cleanup() + }) + it('renders modal and buttons with no error', () => { + vi.mocked(getLabwareIsCompatible).mockReturnValue(true) + render(props) + screen.getByText('mock deck config') + screen.getByText('Multiple Temperatures') + fireEvent.click(screen.getByRole('button', { name: 'cancel' })) + expect(props.onCloseClick).toHaveBeenCalled() + screen.getByRole('button', { name: 'save' }) + }) + it('renders modal with a cannot place module error', () => { + vi.mocked(getLabwareOnSlot).mockReturnValue({ slot: 'A1' } as any) + vi.mocked(getLabwareIsCompatible).mockReturnValue(false) + vi.mocked(getSlotIsEmpty).mockReturnValue(false) + props.allModulesOnDeck = [mockTemp, mockTemp2, mockHS] + vi.mocked(getInitialDeckSetup).mockReturnValue({ + modules: { + heaterShakerId: mockHS, + }, + labware: {}, + additionalEquipmentOnDeck: {}, + pipettes: {}, + }) + render(props) + screen.getByText('warning') + screen.getByText('Cannot place module') + screen.getByText('Multiple slots are occupied') + }) +}) diff --git a/protocol-designer/src/components/modules/EditModulesCard.tsx b/protocol-designer/src/components/modules/EditModulesCard.tsx index 40df5ef14a2..896463c295c 100644 --- a/protocol-designer/src/components/modules/EditModulesCard.tsx +++ b/protocol-designer/src/components/modules/EditModulesCard.tsx @@ -27,10 +27,12 @@ import { CrashInfoBox } from './CrashInfoBox' import { ModuleRow } from './ModuleRow' import { AdditionalItemsRow } from './AdditionalItemsRow' import { isModuleWithCollisionIssue } from './utils' -import styles from './styles.module.css' -import { AdditionalEquipmentEntity } from '@opentrons/step-generation' import { StagingAreasRow } from './StagingAreasRow' +import { MultipleModuleRow } from './MultipleModuleRow' + +import type { AdditionalEquipmentEntity } from '@opentrons/step-generation' +import styles from './styles.module.css' export interface Props { modules: ModulesForEditModulesCard openEditModuleModal: (moduleType: ModuleType, moduleId?: string) => void @@ -38,6 +40,7 @@ export interface Props { export function EditModulesCard(props: Props): JSX.Element { const { modules, openEditModuleModal } = props + const pipettesByMount = useSelector( stepFormSelectors.getPipettesForEditPipetteForm ) @@ -67,10 +70,10 @@ export function EditModulesCard(props: Props): JSX.Element { ) const hasCrashableMagneticModule = magneticModuleOnDeck && - isModuleWithCollisionIssue(magneticModuleOnDeck.model) + isModuleWithCollisionIssue(magneticModuleOnDeck[0].model) const hasCrashableTempModule = temperatureModuleOnDeck && - isModuleWithCollisionIssue(temperatureModuleOnDeck.model) + isModuleWithCollisionIssue(temperatureModuleOnDeck[0].model) const isHeaterShakerOnDeck = Boolean(heaterShakerOnDeck) const showTempPipetteCollisons = @@ -130,22 +133,33 @@ export function EditModulesCard(props: Props): JSX.Element { ) : null} {SUPPORTED_MODULE_TYPES_FILTERED.map((moduleType, i) => { const moduleData = modules[moduleType] - if (moduleData) { + if (moduleData != null && moduleData.length === 1) { return ( ) + } else if (moduleData != null && moduleData.length > 1) { + return ( + + ) } else { return ( ) diff --git a/protocol-designer/src/components/modules/MultipleModuleRow.tsx b/protocol-designer/src/components/modules/MultipleModuleRow.tsx new file mode 100644 index 00000000000..b38978d7dfc --- /dev/null +++ b/protocol-designer/src/components/modules/MultipleModuleRow.tsx @@ -0,0 +1,121 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { + LabeledValue, + OutlineButton, + ModuleIcon, + C_DARK_GRAY, + SPACING, +} from '@opentrons/components' +import { actions as stepFormActions } from '../../step-forms' +import { DEFAULT_MODEL_FOR_MODULE_TYPE } from '../../constants' +import { ModuleDiagram } from './ModuleDiagram' +import { FlexSlotMap } from './FlexSlotMap' +import type { ModuleModel, ModuleType } from '@opentrons/shared-data' +import type { ModuleOnDeck } from '../../step-forms' + +import styles from './styles.module.css' + +interface MultipleModulesRowProps { + moduleType: ModuleType + openEditModuleModal: (moduleType: ModuleType, moduleId?: string) => void + moduleOnDeckType?: ModuleType + moduleOnDeckModel?: ModuleModel + moduleOnDeck?: ModuleOnDeck[] +} + +export function MultipleModuleRow(props: MultipleModulesRowProps): JSX.Element { + const { + moduleOnDeck, + openEditModuleModal, + moduleOnDeckModel, + moduleOnDeckType, + moduleType, + } = props + const { t } = useTranslation(['modules', 'shared']) + const dispatch = useDispatch() + + const type: ModuleType = moduleOnDeckType ?? moduleType + const occupiedSlots = moduleOnDeck?.map(module => module.slot) ?? [] + const occupiedSlotsDisplayName = ( + moduleOnDeck?.map(module => module.slot) ?? [] + ).join(', ') + + const setCurrentModule = (moduleType: ModuleType, moduleId?: string) => () => + openEditModuleModal(moduleType, moduleId) + + const addRemoveText = moduleOnDeck ? t('shared:remove') : t('shared:add') + + const handleAddOrRemove = (): void => { + if (moduleOnDeck != null) { + moduleOnDeck.forEach(module => { + dispatch(stepFormActions.deleteModule(module.id)) + }) + } else { + setCurrentModule(type) + } + } + const handleEditModule = + moduleOnDeck && setCurrentModule(type, moduleOnDeck[0].id) + + return ( + + + + {t( + `module_display_names.${ + occupiedSlots.length > 1 ? 'multipleTemperatureModuleTypes' : type + }` + )} + + + + + + + {moduleOnDeckModel && ( + + )} + + + {occupiedSlots.length > 0 ? ( + + ) : null} + + + {occupiedSlots.length > 0 ? ( + + ) : null} + + + {moduleOnDeck != null ? ( + + {t('shared:edit')} + + ) : null} + + {addRemoveText} + + + + + ) +} diff --git a/protocol-designer/src/components/modules/__tests__/MultipleModuleRow.test.tsx b/protocol-designer/src/components/modules/__tests__/MultipleModuleRow.test.tsx new file mode 100644 index 00000000000..5d5d90794d5 --- /dev/null +++ b/protocol-designer/src/components/modules/__tests__/MultipleModuleRow.test.tsx @@ -0,0 +1,67 @@ +import * as React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { fireEvent, screen } from '@testing-library/react' + +import { i18n } from '../../../localization' +import { renderWithProviders } from '../../../__testing-utils__' +import { MultipleModuleRow } from '../MultipleModuleRow' +import { + TEMPERATURE_MODULE_TYPE, + TEMPERATURE_MODULE_V2, +} from '@opentrons/shared-data' +import { FlexSlotMap } from '../FlexSlotMap' +import { deleteModule } from '../../../step-forms/actions' +import type { ModuleOnDeck } from '../../../step-forms' + +vi.mock('../../../step-forms/actions') +vi.mock('../FlexSlotMap') +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +const mockTemp: ModuleOnDeck = { + id: 'temperatureId', + type: 'temperatureModuleType', + model: 'temperatureModuleV2', + slot: 'C3', + moduleState: {} as any, +} +const mockTemp2: ModuleOnDeck = { + id: 'temperatureId', + type: 'temperatureModuleType', + model: 'temperatureModuleV2', + slot: 'A1', + moduleState: {} as any, +} + +describe('MultipleModuleRow', () => { + let props: React.ComponentProps + beforeEach(() => { + props = { + moduleType: TEMPERATURE_MODULE_TYPE, + openEditModuleModal: vi.fn(), + moduleOnDeckType: TEMPERATURE_MODULE_TYPE, + moduleOnDeckModel: TEMPERATURE_MODULE_V2, + moduleOnDeck: [mockTemp, mockTemp2], + } + vi.mocked(FlexSlotMap).mockReturnValue(mock FlexSlotMap) + }) + it('renders 2 modules in the row with text and buttons', () => { + render(props) + screen.getByText('Multiple Temperatures') + screen.getByText('Position:') + screen.getByText('C3, A1') + screen.getByText('mock FlexSlotMap') + fireEvent.click(screen.getByText('edit')) + expect(props.openEditModuleModal).toHaveBeenCalled() + fireEvent.click(screen.getByText('remove')) + expect(vi.mocked(deleteModule)).toHaveBeenCalled() + }) + it('renders no modules', () => { + props.moduleOnDeck = undefined + render(props) + screen.getByText('add') + }) +}) diff --git a/protocol-designer/src/localization/en/alert.json b/protocol-designer/src/localization/en/alert.json index b17e1028e3b..34ac8c33a02 100644 --- a/protocol-designer/src/localization/en/alert.json +++ b/protocol-designer/src/localization/en/alert.json @@ -218,6 +218,11 @@ "export": "Export", "import": "Import", "module_placement": { + "SLOTS_OCCUPIED": { + "title": "Cannot place module", + "single": "Slot {{slotName}} is occupied", + "multi": "Multiple slots are occupied" + }, "SLOT_OCCUPIED": { "title": "Cannot place module", "body": "Slot {{selectedSlot}} is occupied. Navigate to the design tab and remove the labware or remove the additional item to continue." diff --git a/protocol-designer/src/localization/en/modules.json b/protocol-designer/src/localization/en/modules.json index 10a50dc0775..5cad25ca050 100644 --- a/protocol-designer/src/localization/en/modules.json +++ b/protocol-designer/src/localization/en/modules.json @@ -6,6 +6,7 @@ "wasteChute": "Waste Chute" }, "module_display_names": { + "multipleTemperatureModuleTypes": "Multiple Temperatures", "temperatureModuleType": "Temperature", "magneticModuleType": "Magnetic", "thermocyclerModuleType": "Thermocycler", diff --git a/protocol-designer/src/step-forms/selectors/index.ts b/protocol-designer/src/step-forms/selectors/index.ts index 1c0be8ca60c..3e3cb161f81 100644 --- a/protocol-designer/src/step-forms/selectors/index.ts +++ b/protocol-designer/src/step-forms/selectors/index.ts @@ -454,7 +454,10 @@ export const getModulesForEditModulesCard: Selector< reduce( initialDeckSetup.modules, (acc, moduleOnDeck: ModuleOnDeck, id) => { - acc[moduleOnDeck.type] = moduleOnDeck + if (!acc[moduleOnDeck.type]) { + acc[moduleOnDeck.type] = [] + } + acc[moduleOnDeck.type]?.push(moduleOnDeck) return acc }, { diff --git a/protocol-designer/src/step-forms/types.ts b/protocol-designer/src/step-forms/types.ts index 81422cc985b..24dee9b0c46 100644 --- a/protocol-designer/src/step-forms/types.ts +++ b/protocol-designer/src/step-forms/types.ts @@ -72,7 +72,7 @@ export interface ModuleTemporalProperties { } export type ModuleOnDeck = ModuleEntity & ModuleTemporalProperties export type ModulesForEditModulesCard = Partial< - Record + Record > // =========== LABWARE ======== export type NormalizedLabwareById = Record<