diff --git a/protocol-designer/src/components/modals/CreateFileWizard/EquipmentOption.tsx b/protocol-designer/src/components/modals/CreateFileWizard/EquipmentOption.tsx index f234e879167..013c731c43c 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/EquipmentOption.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/EquipmentOption.tsx @@ -1,6 +1,8 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import { css } from 'styled-components' +import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' + import { Flex, Text, @@ -15,6 +17,7 @@ import { Tooltip, } from '@opentrons/components' import type { StyleProps } from '@opentrons/components' +import type { RobotType } from '@opentrons/shared-data' const EQUIPMENT_OPTION_STYLE = css` background-color: ${COLORS.white}; @@ -58,6 +61,7 @@ interface EquipmentOptionProps extends StyleProps { onClick: React.MouseEventHandler isSelected: boolean text: React.ReactNode + robotType: RobotType image?: React.ReactNode showCheckbox?: boolean disabled?: boolean @@ -70,6 +74,7 @@ export function EquipmentOption(props: EquipmentOptionProps): JSX.Element { image = null, showCheckbox = false, disabled = false, + robotType, ...styleProps } = props const { t } = useTranslation('tooltip') @@ -80,7 +85,25 @@ export function EquipmentOption(props: EquipmentOptionProps): JSX.Element { equpimentOptionStyle = EQUIPMENT_OPTION_DISABLED_STYLE } else if (isSelected) { equpimentOptionStyle = EQUIPMENT_OPTION_SELECTED_STYLE - } else equpimentOptionStyle = EQUIPMENT_OPTION_STYLE + } else { + equpimentOptionStyle = EQUIPMENT_OPTION_STYLE + } + let iconInfo: JSX.Element | null = null + if (showCheckbox && !disabled) { + iconInfo = ( + + ) + } else if (showCheckbox && disabled) { + iconInfo = + } + return ( <> - {showCheckbox ? ( - - ) : null} + {iconInfo} {disabled ? ( - {t('disabled_no_space_additional_items')} + {t( + robotType === FLEX_ROBOT_TYPE + ? 'disabled_no_space_additional_items' + : 'disabled_you_can_add_one_type' + )} ) : null} diff --git a/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx b/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx index 1c43b69fe53..39b194be74d 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx @@ -35,6 +35,7 @@ import { getIsCrashablePipetteSelected } from '../../../step-forms' import gripperImage from '../../../images/flex_gripper.png' import wasteChuteImage from '../../../images/waste_chute.png' import trashBinImage from '../../../images/flex_trash_bin.png' +import { uuid } from '../../../utils' import { selectors as featureFlagSelectors } from '../../../feature-flags' import { CrashInfoBox, ModuleDiagram } from '../../modules' import { ModuleFields } from '../FilePipettesModal/ModuleFields' @@ -48,7 +49,6 @@ import { EquipmentOption } from './EquipmentOption' import { HandleEnter } from './HandleEnter' import type { AdditionalEquipment, WizardTileProps } from './types' -import { uuid } from '../../../utils' export const DEFAULT_SLOT_MAP: { [moduleModel in ModuleModel]?: string } = { [THERMOCYCLER_MODULE_V2]: 'B1', @@ -64,20 +64,10 @@ export const FLEX_SUPPORTED_MODULE_MODELS: ModuleModel[] = [ ] export function ModulesAndOtherTile(props: WizardTileProps): JSX.Element { - const { - formState, - getValues, - setValue, - goBack, - proceed, - control, - trigger, - watch, - } = props + const { getValues, goBack, proceed, watch } = props const { t } = useTranslation(['modal', 'tooltip']) const { fields, pipettesByMount, additionalEquipment } = getValues() const modules = watch('modules') - const { errors, touchedFields } = formState const robotType = fields.robotType const moduleRestrictionsDisabled = useSelector( featureFlagSelectors.getDisableModuleRestrictions @@ -106,12 +96,20 @@ export function ModulesAndOtherTile(props: WizardTileProps): JSX.Element { ) != null : false + const leftPipetteSpecs = + left.pipetteName != null && left.pipetteName !== '' + ? getPipetteSpecsV2(left.pipetteName as PipetteName) + : undefined + const rightPipetteSpecs = + right.pipetteName != null && right.pipetteName !== '' + ? getPipetteSpecsV2(right.pipetteName as PipetteName) + : undefined + const showHeaterShakerPipetteCollisions = hasHeaterShakerSelected && - [ - getPipetteSpecsV2(left.pipetteName as PipetteName), - getPipetteSpecsV2(right.pipetteName as PipetteName), - ].some(pipetteSpecs => pipetteSpecs && pipetteSpecs.channels !== 1) + [leftPipetteSpecs, rightPipetteSpecs].some( + pipetteSpecs => pipetteSpecs && pipetteSpecs.channels !== 1 + ) const crashablePipetteSelected = getIsCrashablePipetteSelected( pipettesByMount @@ -140,20 +138,11 @@ export function ModulesAndOtherTile(props: WizardTileProps): JSX.Element { gridGap={SPACING.spacing32} > {t('choose_additional_items')} - {/* {robotType === OT2_ROBOT_TYPE ? ( - - ) : ( */} - - {/* )} */} + {robotType === OT2_ROBOT_TYPE ? ( + + ) : ( + + )} {robotType === OT2_ROBOT_TYPE && moduleRestrictionsDisabled !== true ? modCrashWarning : null} @@ -196,11 +185,9 @@ export function ModulesAndOtherTile(props: WizardTileProps): JSX.Element { } function FlexModuleFields(props: WizardTileProps): JSX.Element { - const { getValues, watch, setValue } = props - const { fields } = getValues() + const { watch, setValue } = props const modules = watch('modules') const additionalEquipment = watch('additionalEquipment') - const isFlex = fields.robotType === FLEX_ROBOT_TYPE const moduleTypesOnDeck = modules != null ? Object.values(modules).map(module => module.type) : [] const trashBinDisabled = getTrashBinOptionDisabled({ @@ -229,6 +216,7 @@ function FlexModuleFields(props: WizardTileProps): JSX.Element { const moduleOnDeck = moduleTypesOnDeck.includes(moduleType) return ( } @@ -266,6 +254,7 @@ function FlexModuleFields(props: WizardTileProps): JSX.Element { ) })} handleSetEquipmentOption('gripper')} isSelected={additionalEquipment.includes('gripper')} image={ @@ -277,35 +266,31 @@ function FlexModuleFields(props: WizardTileProps): JSX.Element { text="Gripper" showCheckbox /> - {isFlex ? ( - <> - handleSetEquipmentOption('wasteChute')} - isSelected={additionalEquipment.includes('wasteChute')} - image={ - - } - text="Waste Chute" - showCheckbox - /> - handleSetEquipmentOption('trashBin')} - isSelected={additionalEquipment.includes('trashBin')} - image={ - - } - text="Trash Bin" - showCheckbox - disabled={trashBinDisabled} + + handleSetEquipmentOption('wasteChute')} + isSelected={additionalEquipment.includes('wasteChute')} + image={ + - - ) : null} + } + text="Waste Chute" + showCheckbox + /> + handleSetEquipmentOption('trashBin')} + isSelected={additionalEquipment.includes('trashBin')} + image={ + + } + text="Trash Bin" + showCheckbox + disabled={trashBinDisabled} + /> ) } diff --git a/protocol-designer/src/components/modals/CreateFileWizard/PipetteTipsTile.tsx b/protocol-designer/src/components/modals/CreateFileWizard/PipetteTipsTile.tsx index b6bb1db7394..0a154592345 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/PipetteTipsTile.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/PipetteTipsTile.tsx @@ -147,8 +147,9 @@ interface PipetteTipsFieldProps extends UseFormReturn { } function PipetteTipsField(props: PipetteTipsFieldProps): JSX.Element | null { - const { mount, watch, setValue } = props + const { mount, watch, setValue, getValues } = props const { t } = useTranslation('modal') + const { fields } = getValues() const pipettesByMount = watch('pipettesByMount') const allowAllTipracks = useSelector(getAllowAllTipracks) const dispatch = useDispatch>() @@ -197,6 +198,7 @@ function PipetteTipsField(props: PipetteTipsFieldProps): JSX.Element | null { {defaultTiprackOptions.map(o => ( {customTiprackOptions.map(o => ( { onClick: vi.fn(), isSelected: false, text: 'mockText', + robotType: FLEX_ROBOT_TYPE, } }) afterEach(() => { diff --git a/protocol-designer/src/components/modals/CreateFileWizard/__tests__/ModulesAndOtherTile.test.tsx b/protocol-designer/src/components/modals/CreateFileWizard/__tests__/ModulesAndOtherTile.test.tsx index 86228712389..ba9924ee13e 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/__tests__/ModulesAndOtherTile.test.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/__tests__/ModulesAndOtherTile.test.tsx @@ -33,15 +33,10 @@ const values = { robotType: FLEX_ROBOT_TYPE, }, pipettesByMount: { - left: { pipetteName: 'mockPipetteName', tiprackDefURI: ['mocktip'] }, + left: { pipetteName: 'p1000_single_flex', tiprackDefURI: ['mocktip'] }, right: { pipetteName: null, tiprackDefURI: null }, } as FormPipettesByMount, - modulesByType: { - heaterShakerModuleType: { onDeck: false, model: null, slot: '1' }, - magneticBlockType: { onDeck: false, model: null, slot: '2' }, - temperatureModuleType: { onDeck: false, model: null, slot: '3' }, - thermocyclerModuleType: { onDeck: false, model: null, slot: '4' }, - }, + modules: {}, additionalEquipment: ['gripper'], } as FormState @@ -109,12 +104,7 @@ describe('ModulesAndOtherTile', () => { left: { pipetteName: 'p1000_single', tiprackDefURI: ['mocktip'] }, right: { pipetteName: null, tiprackDefURI: null }, } as FormPipettesByMount, - modulesByType: { - heaterShakerModuleType: { onDeck: false, model: null, slot: '1' }, - magneticModuleType: { onDeck: false, model: null, slot: '2' }, - temperatureModuleType: { onDeck: false, model: null, slot: '3' }, - thermocyclerModuleType: { onDeck: false, model: null, slot: '4' }, - }, + modules: {}, } as FormState const mockWizardTileProps: Partial = { diff --git a/protocol-designer/src/components/modals/CreateFileWizard/__tests__/PipetteTipsTile.test.tsx b/protocol-designer/src/components/modals/CreateFileWizard/__tests__/PipetteTipsTile.test.tsx index deab82c01d8..821acd65ef6 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/__tests__/PipetteTipsTile.test.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/__tests__/PipetteTipsTile.test.tsx @@ -42,12 +42,7 @@ const values = { }, right: { pipetteName: null, tiprackDefURI: null }, } as FormPipettesByMount, - modulesByType: { - heaterShakerModuleType: { onDeck: false, model: null, slot: '1' }, - magneticBlockType: { onDeck: false, model: null, slot: '2' }, - temperatureModuleType: { onDeck: false, model: null, slot: '3' }, - thermocyclerModuleType: { onDeck: false, model: null, slot: '4' }, - }, + modules: {}, additionalEquipment: ['gripper'], } as FormState @@ -55,6 +50,7 @@ const mockWizardTileProps: Partial = { goBack: vi.fn(), proceed: vi.fn(), watch: vi.fn((name: keyof typeof values) => values[name]) as any, + getValues: vi.fn(() => values) as any, } const fixtureTipRack10ul = { @@ -154,12 +150,7 @@ describe('PipetteTipsTile', () => { }, right: { pipetteName: null, tiprackDefURI: null }, } as FormPipettesByMount, - modulesByType: { - heaterShakerModuleType: { onDeck: false, model: null, slot: '1' }, - magneticBlockType: { onDeck: false, model: null, slot: '2' }, - temperatureModuleType: { onDeck: false, model: null, slot: '3' }, - thermocyclerModuleType: { onDeck: false, model: null, slot: '4' }, - }, + modules: {}, additionalEquipment: ['gripper'], } as FormState diff --git a/protocol-designer/src/components/modals/CreateFileWizard/__tests__/PipetteTypeTile.test.tsx b/protocol-designer/src/components/modals/CreateFileWizard/__tests__/PipetteTypeTile.test.tsx index 035629f851e..2a3790ba0c4 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/__tests__/PipetteTypeTile.test.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/__tests__/PipetteTypeTile.test.tsx @@ -30,12 +30,7 @@ const values = { left: { pipetteName: null, tiprackDefURI: null }, right: { pipetteName: null, tiprackDefURI: null }, } as FormPipettesByMount, - modulesByType: { - heaterShakerModuleType: { onDeck: false, model: null, slot: '1' }, - magneticBlockType: { onDeck: false, model: null, slot: '2' }, - temperatureModuleType: { onDeck: false, model: null, slot: '3' }, - thermocyclerModuleType: { onDeck: false, model: null, slot: '4' }, - }, + modules: {}, additionalEquipment: ['gripper'], } as FormState @@ -94,12 +89,7 @@ describe('PipetteTypeTile', () => { left: { pipetteName: null, tiprackDefURI: null }, right: { pipetteName: null, tiprackDefURI: null }, } as FormPipettesByMount, - modulesByType: { - heaterShakerModuleType: { onDeck: false, model: null, slot: '1' }, - magneticBlockType: { onDeck: false, model: null, slot: '2' }, - temperatureModuleType: { onDeck: false, model: null, slot: '3' }, - thermocyclerModuleType: { onDeck: false, model: null, slot: '4' }, - }, + modules: {}, additionalEquipment: ['gripper'], } as FormState diff --git a/protocol-designer/src/components/modals/CreateFileWizard/__tests__/utils.test.tsx b/protocol-designer/src/components/modals/CreateFileWizard/__tests__/utils.test.tsx index ed2242f1f87..213f3466c0e 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/__tests__/utils.test.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/__tests__/utils.test.tsx @@ -1,6 +1,8 @@ import { FLEX_ROBOT_TYPE, + HEATERSHAKER_MODULE_TYPE, TEMPERATURE_MODULE_TYPE, + THERMOCYCLER_MODULE_TYPE, } from '@opentrons/shared-data' import { it, describe, expect } from 'vitest' import { @@ -8,11 +10,8 @@ import { getLastCheckedEquipment, getTrashSlot, } from '../utils' -import type { - FormModulesByType, - FormPipettesByMount, -} from '../../../../step-forms' -import type { FormState } from '../types' +import type { FormPipettesByMount } from '../../../../step-forms' +import type { AdditionalEquipment, FormState } from '../types' let MOCK_FORM_STATE = { fields: { @@ -25,49 +24,41 @@ let MOCK_FORM_STATE = { left: { pipetteName: 'mockPipetteName', tiprackDefURI: ['mocktip'] }, right: { pipetteName: null, tiprackDefURI: null }, } as FormPipettesByMount, - modulesByType: { - heaterShakerModuleType: { onDeck: false, model: null, slot: 'D1' }, - magneticBlockType: { onDeck: false, model: null, slot: 'D2' }, - temperatureModuleType: { onDeck: false, model: null, slot: 'C1' }, - thermocyclerModuleType: { onDeck: false, model: null, slot: 'B1' }, - } as FormModulesByType, + modules: {}, additionalEquipment: [], } as FormState describe('getLastCheckedEquipment', () => { it('should return null when there is no trash bin', () => { - const result = getLastCheckedEquipment(MOCK_FORM_STATE) + const result = getLastCheckedEquipment({ + additionalEquipment: [], + moduleTypesOnDeck: [], + }) expect(result).toBe(null) }) it('should return null if not all the modules or staging areas are selected', () => { - MOCK_FORM_STATE = { - ...MOCK_FORM_STATE, - additionalEquipment: ['trashBin'], - modulesByType: { - ...MOCK_FORM_STATE.modulesByType, - temperatureModuleType: { onDeck: true, model: null, slot: 'C1' }, - }, + const LastCheckedProps = { + additionalEquipment: [ + 'trashBin', + 'stagingArea_cutoutD3', + ] as AdditionalEquipment[], + moduleTypesOnDeck: [THERMOCYCLER_MODULE_TYPE], } - const result = getLastCheckedEquipment(MOCK_FORM_STATE) + const result = getLastCheckedEquipment(LastCheckedProps) expect(result).toBe(null) }) it('should return temperature module if other modules and staging areas are selected', () => { - MOCK_FORM_STATE = { - ...MOCK_FORM_STATE, + const LastCheckedProps = { additionalEquipment: [ 'trashBin', 'stagingArea_cutoutA3', 'stagingArea_cutoutB3', 'stagingArea_cutoutC3', 'stagingArea_cutoutD3', - ], - modulesByType: { - ...MOCK_FORM_STATE.modulesByType, - heaterShakerModuleType: { onDeck: true, model: null, slot: 'D1' }, - thermocyclerModuleType: { onDeck: true, model: null, slot: 'B1' }, - }, + ] as AdditionalEquipment[], + moduleTypesOnDeck: [THERMOCYCLER_MODULE_TYPE, HEATERSHAKER_MODULE_TYPE], } - const result = getLastCheckedEquipment(MOCK_FORM_STATE) + const result = getLastCheckedEquipment(LastCheckedProps) expect(result).toBe(TEMPERATURE_MODULE_TYPE) }) }) @@ -87,6 +78,6 @@ describe('getTrashSlot', () => { additionalEquipment: ['stagingArea_cutoutA3'], } const result = getTrashSlot(MOCK_FORM_STATE) - expect(result).toBe('cutoutB3') + expect(result).toBe('cutoutA1') }) }) diff --git a/protocol-designer/src/components/modals/CreateFileWizard/index.tsx b/protocol-designer/src/components/modals/CreateFileWizard/index.tsx index f569a4f03ce..eea2264199a 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/index.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/index.tsx @@ -14,18 +14,11 @@ import { ModuleModel, PipetteName, OT2_ROBOT_TYPE, - MAGNETIC_BLOCK_TYPE, TEMPERATURE_MODULE_TYPE, - MAGNETIC_BLOCK_V1, HEATERSHAKER_MODULE_TYPE, - HEATERSHAKER_MODULE_V1, MAGNETIC_MODULE_TYPE, THERMOCYCLER_MODULE_TYPE, - SPAN7_8_10_11_SLOT, FLEX_ROBOT_TYPE, - MAGNETIC_MODULE_V2, - THERMOCYCLER_MODULE_V2, - TEMPERATURE_MODULE_V2, WASTE_CHUTE_CUTOUT, } from '@opentrons/shared-data' import { @@ -139,20 +132,22 @@ export function CreateFileWizard(): JSX.Element | null { [] ) - const modules: ModuleCreationArgs[] = Object.entries( - values.modulesByType - ).reduce((acc, [moduleType, formModule]) => { - return formModule?.onDeck - ? [ - ...acc, - { - type: moduleType as ModuleType, - model: formModule.model || ('' as ModuleModel), - slot: formModule.slot, + const modules: ModuleCreationArgs[] = + values.modules != null + ? Object.entries(values.modules).reduce( + (acc, [number, formModule]) => { + return [ + ...acc, + { + type: formModule.type, + model: formModule.model || ('' as ModuleModel), + slot: formModule.slot, + }, + ] }, - ] - : acc - }, []) + [] + ) + : [] const heaterShakerIndex = modules.findIndex( mod => mod.type === HEATERSHAKER_MODULE_TYPE ) @@ -319,33 +314,7 @@ const initialFormState: FormState = { left: { pipetteName: undefined, tiprackDefURI: undefined }, right: { pipetteName: undefined, tiprackDefURI: undefined }, }, - modulesByType: { - [MAGNETIC_BLOCK_TYPE]: { - onDeck: false, - model: MAGNETIC_BLOCK_V1, - slot: '2', - }, - [HEATERSHAKER_MODULE_TYPE]: { - onDeck: false, - model: HEATERSHAKER_MODULE_V1, - slot: '1', - }, - [MAGNETIC_MODULE_TYPE]: { - onDeck: false, - model: MAGNETIC_MODULE_V2, - slot: '1', - }, - [TEMPERATURE_MODULE_TYPE]: { - onDeck: false, - model: TEMPERATURE_MODULE_V2, - slot: '3', - }, - [THERMOCYCLER_MODULE_TYPE]: { - onDeck: false, - model: THERMOCYCLER_MODULE_V2, - slot: SPAN7_8_10_11_SLOT, - }, - }, + modules: {}, // defaulting to selecting trashBin already to avoid user having to // click to add a trash bin/waste chute. Delete once we support returnTip() additionalEquipment: ['trashBin'], @@ -364,14 +333,8 @@ const pipetteValidationShape = Yup.object().shape({ }) // any typing this because TS says there are too many possibilities of what this could be const moduleValidationShape: any = Yup.object().shape({ - onDeck: Yup.boolean().default(false), - model: Yup.string() - .nullable() - .when('onDeck', { - is: true, - then: schema => schema.required('Required'), - otherwise: schema => schema.nullable(), - }), + type: Yup.string(), + model: Yup.string(), slot: Yup.string(), }) diff --git a/protocol-designer/src/components/modals/CreateFileWizard/utils.ts b/protocol-designer/src/components/modals/CreateFileWizard/utils.ts index 2b8cea68263..989dabe2839 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/utils.ts +++ b/protocol-designer/src/components/modals/CreateFileWizard/utils.ts @@ -84,7 +84,7 @@ export const getTrashBinOptionDisabled = (props: LastCheckedProps): boolean => { const allModulesInSideSlotsOnDeck = moduleTypesOnDeck.includes(HEATERSHAKER_MODULE_TYPE) && moduleTypesOnDeck.includes(TEMPERATURE_MODULE_TYPE) && - moduleTypesOnDeck.includes(HEATERSHAKER_MODULE_TYPE) + moduleTypesOnDeck.includes(THERMOCYCLER_MODULE_TYPE) return allStagingAreasInUse && allModulesInSideSlotsOnDeck } diff --git a/protocol-designer/src/components/modals/FilePipettesModal/ModuleFields.tsx b/protocol-designer/src/components/modals/FilePipettesModal/ModuleFields.tsx index 17b909e102e..1b023be7fac 100644 --- a/protocol-designer/src/components/modals/FilePipettesModal/ModuleFields.tsx +++ b/protocol-designer/src/components/modals/FilePipettesModal/ModuleFields.tsx @@ -1,142 +1,91 @@ import * as React from 'react' -import { Control, Controller, UseFormTrigger } from 'react-hook-form' +import { Flex, SPACING, WRAP, ALIGN_CENTER } from '@opentrons/components' import { - DeprecatedCheckboxField, - DropdownField, - FormGroup, -} from '@opentrons/components' -import { - DEFAULT_MODEL_FOR_MODULE_TYPE, - MODELS_FOR_MODULE_TYPE, -} from '../../../constants' -import { FormModulesByType } from '../../../step-forms' + HEATERSHAKER_MODULE_TYPE, + HEATERSHAKER_MODULE_V1, + MAGNETIC_MODULE_TYPE, + MAGNETIC_MODULE_V1, + MAGNETIC_MODULE_V2, + ModuleModel, + ModuleType, + OT2_ROBOT_TYPE, + SPAN7_8_10_11_SLOT, + TEMPERATURE_MODULE_TYPE, + TEMPERATURE_MODULE_V1, + TEMPERATURE_MODULE_V2, + THERMOCYCLER_MODULE_TYPE, + THERMOCYCLER_MODULE_V1, + THERMOCYCLER_MODULE_V2, + getModuleDisplayName, + getModuleType, +} from '@opentrons/shared-data' +import { uuid } from '../../../utils' import { ModuleDiagram } from '../../modules' -import styles from './FilePipettesModal.module.css' -import { MAGNETIC_BLOCK_TYPE, ModuleType } from '@opentrons/shared-data' -import { useTranslation } from 'react-i18next' -import type { FormState } from '../CreateFileWizard/types' +import { EquipmentOption } from '../CreateFileWizard/EquipmentOption' +import type { WizardTileProps } from '../CreateFileWizard/types' -export interface ModuleFieldsProps { - errors: - | null - | string - | { - magneticModuleType?: { - model: string - } - temperatureModuleType?: { - model: string - } - thermocyclerModuleType?: { - model: string - } - heaterShakerModuleType?: { - model: string - } - magneticBlockType?: { - model: string - } - } - touched: - | null - | boolean - | { - magneticModuleType?: { - model: boolean - } - temperatureModuleType?: { - model: boolean - } - thermocyclerModuleType?: { - model: boolean - } - heaterShakerModuleType?: { - model: boolean - } - magneticBlockType?: { - model: boolean - } - } - values: FormModulesByType - control: Control - trigger: UseFormTrigger +export const DEFAULT_SLOT_MAP: { [moduleType in ModuleType]?: string } = { + [THERMOCYCLER_MODULE_TYPE]: SPAN7_8_10_11_SLOT, + [HEATERSHAKER_MODULE_TYPE]: '1', + [MAGNETIC_MODULE_TYPE]: '1', + [TEMPERATURE_MODULE_TYPE]: '3', } +export const OT2_SUPPORTED_MODULE_MODELS: ModuleModel[] = [ + THERMOCYCLER_MODULE_V2, + THERMOCYCLER_MODULE_V1, + HEATERSHAKER_MODULE_V1, + TEMPERATURE_MODULE_V1, + TEMPERATURE_MODULE_V2, + MAGNETIC_MODULE_V1, + MAGNETIC_MODULE_V2, +] -export function ModuleFields(props: ModuleFieldsProps): JSX.Element { - const { t } = useTranslation('modules') - const { values, errors, touched, control, trigger } = props - // TODO(BC, 2023-05-11): REMOVE THIS MAG BLOCK FILTER BEFORE LAUNCH TO INCLUDE IT AMONG MODULE OPTIONS - // @ts-expect-error(sa, 2021-6-21): Object.keys not smart enough to take the keys of FormModulesByType - const modules: ModuleType[] = Object.keys(values).filter( - k => k !== MAGNETIC_BLOCK_TYPE - ) - +export function ModuleFields(props: WizardTileProps): JSX.Element { + const { watch, setValue } = props + const modules = watch('modules') + const moduleModelsOnDeck = + modules != null ? Object.values(modules).map(module => module.model) : [] + const moduleTypesOnDeck = + modules != null ? Object.values(modules).map(module => module.type) : [] return ( -
- {modules.map((moduleType, i) => { - const label = t(`module_display_names.${moduleType}`) - const defaultModel = DEFAULT_MODEL_FOR_MODULE_TYPE[moduleType] - const selectedModel = values[moduleType].model + + {OT2_SUPPORTED_MODULE_MODELS.map(moduleModel => { + const moduleType = getModuleType(moduleModel) + const moduleOnDeck = moduleModelsOnDeck.includes(moduleModel) return ( -
- ( - ) => { - const type: ModuleType = e.target.value as ModuleType - field.onChange(e) - await trigger(`modulesByType.${type}.onDeck`) - }} - tabIndex={i} - /> - )} - /> - - - ( -
- {values[moduleType].onDeck && ( - - - - )} -
- )} - /> -
+ } + text={getModuleDisplayName(moduleModel)} + disabled={moduleTypesOnDeck.includes(moduleType) && !moduleOnDeck} + onClick={() => { + if (moduleOnDeck) { + const updatedModulesByModel = + modules != null + ? Object.fromEntries( + Object.entries(modules).filter( + ([key, value]) => value.model !== moduleModel + ) + ) + : {} + setValue('modules', updatedModulesByModel) + } else { + setValue('modules', { + ...modules, + [uuid()]: { + model: moduleModel, + type: moduleType, + slot: DEFAULT_SLOT_MAP[moduleType] ?? '', + }, + }) + } + }} + showCheckbox + /> ) })} -
+
) } diff --git a/protocol-designer/src/components/modals/FilePipettesModal/__tests__/ModuleFields.test.tsx b/protocol-designer/src/components/modals/FilePipettesModal/__tests__/ModuleFields.test.tsx index 2a4b94af92b..328cd439828 100644 --- a/protocol-designer/src/components/modals/FilePipettesModal/__tests__/ModuleFields.test.tsx +++ b/protocol-designer/src/components/modals/FilePipettesModal/__tests__/ModuleFields.test.tsx @@ -1,5 +1,65 @@ -import { describe, it } from 'vitest' +import * as React from 'react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import '@testing-library/jest-dom/vitest' +import { screen, cleanup } from '@testing-library/react' +import { OT2_ROBOT_TYPE } from '@opentrons/shared-data' +import { renderWithProviders } from '../../../../__testing-utils__' +import { i18n } from '../../../../localization' +import { EquipmentOption } from '../../CreateFileWizard/EquipmentOption' +import { ModuleFields } from '../../FilePipettesModal/ModuleFields' +import type { FormPipettesByMount } from '../../../../step-forms' +import type { FormState, WizardTileProps } from '../../CreateFileWizard/types' + +vi.mock('../../CreateFileWizard/EquipmentOption') + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +const values = { + fields: { + name: 'mockName', + description: 'mockDescription', + organizationOrAuthor: 'mockOrganizationOrAuthor', + robotType: OT2_ROBOT_TYPE, + }, + pipettesByMount: { + left: { pipetteName: 'p1000_single_flex', tiprackDefURI: 'mocktip' }, + right: { pipetteName: null, tiprackDefURI: null }, + } as FormPipettesByMount, + modules: {}, + additionalEquipment: ['trashBin'], +} as FormState + +const mockWizardTileProps: Partial = { + watch: vi.fn((name: keyof typeof values) => values[name]) as any, + trigger: vi.fn(), + goBack: vi.fn(), + proceed: vi.fn(), + setValue: vi.fn(), + getValues: vi.fn(() => values) as any, + formState: {} as any, +} describe('ModuleFields', () => { - it.todo('replace deprecated enzyme test') + let props: React.ComponentProps + + beforeEach(() => { + props = { + ...props, + ...mockWizardTileProps, + } as WizardTileProps + vi.mocked(EquipmentOption).mockReturnValue(
mock EquipmentOption
) + }) + + afterEach(() => { + cleanup() + }) + + it('renders correct module length for ot-2', () => { + render(props) + expect(screen.getAllByText('mock EquipmentOption')).toHaveLength(7) + }) }) diff --git a/protocol-designer/src/components/modals/FilePipettesModal/index.tsx b/protocol-designer/src/components/modals/FilePipettesModal/index.tsx index 474cbd59287..8e1b990bb08 100644 --- a/protocol-designer/src/components/modals/FilePipettesModal/index.tsx +++ b/protocol-designer/src/components/modals/FilePipettesModal/index.tsx @@ -14,23 +14,19 @@ import * as Yup from 'yup' import { Modal, OutlineButton } from '@opentrons/components' import { - HEATERSHAKER_MODULE_V1, MAGNETIC_MODULE_TYPE, TEMPERATURE_MODULE_TYPE, THERMOCYCLER_MODULE_TYPE, - THERMOCYCLER_MODULE_V1, HEATERSHAKER_MODULE_TYPE, ModuleType, ModuleModel, PipetteName, - MAGNETIC_BLOCK_V1, - MAGNETIC_BLOCK_TYPE, OT2_ROBOT_TYPE, getPipetteSpecsV2, } from '@opentrons/shared-data' import { StepChangesConfirmModal } from '../EditPipettesModal/StepChangesConfirmModal' import { PipetteFields } from './PipetteFields' -import { CrashInfoBox, isModuleWithCollisionIssue } from '../../modules' +import { CrashInfoBox } from '../../modules' import styles from './FilePipettesModal.module.css' import modalStyles from '../modal.module.css' import { @@ -39,18 +35,16 @@ import { getIsCrashablePipetteSelected, PipetteOnDeck, FormPipettesByMount, - FormModulesByType, + FormModules, FormPipette, } from '../../../step-forms' -import { - INITIAL_DECK_SETUP_STEP_ID, - SPAN7_8_10_11_SLOT, -} from '../../../constants' +import { INITIAL_DECK_SETUP_STEP_ID } from '../../../constants' import { NewProtocolFields } from '../../../load-file' import { getRobotType } from '../../../file-data/selectors' import { uuid } from '../../../utils' import { actions as steplistActions } from '../../../steplist' import { selectors as featureFlagSelectors } from '../../../feature-flags' +import { getCrashableModuleSelected } from '../CreateFileWizard/utils' import type { DeckSlot, ThunkDispatch } from '../../../types' import type { NormalizedPipette } from '@opentrons/step-generation' @@ -70,7 +64,7 @@ export interface ModuleCreationArgs { export interface FormState { fields: NewProtocolFields pipettesByMount: FormPipettesByMount - modulesByType: FormModulesByType + modules: FormModules } export interface Props { @@ -88,33 +82,7 @@ const initialFormState: FormState = { left: { pipetteName: '', tiprackDefURI: null }, right: { pipetteName: '', tiprackDefURI: null }, }, - modulesByType: { - [MAGNETIC_BLOCK_TYPE]: { - onDeck: false, - model: MAGNETIC_BLOCK_V1, - slot: '1', - }, - [HEATERSHAKER_MODULE_TYPE]: { - onDeck: false, - model: HEATERSHAKER_MODULE_V1, - slot: '1', - }, - [MAGNETIC_MODULE_TYPE]: { - onDeck: false, - model: null, - slot: '1', - }, - [TEMPERATURE_MODULE_TYPE]: { - onDeck: false, - model: null, - slot: '3', - }, - [THERMOCYCLER_MODULE_TYPE]: { - onDeck: false, - model: THERMOCYCLER_MODULE_V1, // Default to GEN1 for TC only - slot: SPAN7_8_10_11_SLOT, - }, - }, + modules: {}, } const pipetteValidationShape = Yup.object().shape({ @@ -130,14 +98,8 @@ const pipetteValidationShape = Yup.object().shape({ }) // any typing this because TS says there are too many possibilities of what this could be const moduleValidationShape: any = Yup.object().shape({ - onDeck: Yup.boolean().default(false), - model: Yup.string() - .nullable() - .when('onDeck', { - is: true, - then: schema => schema.required('Required'), - otherwise: schema => schema.nullable(), - }), + type: Yup.string(), + model: Yup.string(), slot: Yup.string(), }) @@ -339,19 +301,6 @@ export const FilePipettesModal = (props: Props): JSX.Element => { onCloseModal ) - const getCrashableModuleSelected: ( - modules: FormModulesByType, - moduleType: ModuleType - ) => boolean = (modules, moduleType) => { - const formModule = modules[moduleType] - const crashableModuleOnDeck = - formModule?.onDeck && formModule?.model - ? isModuleWithCollisionIssue(formModule.model) - : false - - return crashableModuleOnDeck - } - const handleFormSubmit: (values: FormState) => void = values => { if (!showEditPipetteConfirmation) { setShowEditPipetteConfirmation(true) @@ -382,25 +331,22 @@ export const FilePipettesModal = (props: Props): JSX.Element => { [] ) - // NOTE: this is extra-explicit for flow. Reduce fns won't cooperate - // with enum-typed key like `{[ModuleType]: ___}` - // @ts-expect-error(sa, 2021-6-21): TS not smart enough to take real type from Object.keys - const moduleTypes: ModuleType[] = Object.keys(values.modulesByType) - const modules: ModuleCreationArgs[] = moduleTypes.reduce< - ModuleCreationArgs[] - >((acc, moduleType) => { - const formModule = values.modulesByType[moduleType] - return formModule?.onDeck - ? [ - ...acc, - { - type: moduleType, - model: formModule.model || ('' as ModuleModel), // TODO: we need to validate that module models are of type ModuleModel - slot: formModule.slot, + const modules: ModuleCreationArgs[] = + values.modules != null + ? Object.entries(values.modules).reduce( + (acc, [number, formModule]) => { + return [ + ...acc, + { + type: formModule.type, + model: formModule.model || ('' as ModuleModel), + slot: formModule.slot, + }, + ] }, - ] - : acc - }, []) + [] + ) + : [] const heaterShakerIndex = modules.findIndex( hwModule => hwModule.type === HEATERSHAKER_MODULE_TYPE ) @@ -421,8 +367,8 @@ export const FilePipettesModal = (props: Props): JSX.Element => { ...initialFormState.pipettesByMount, ...initialPipettes, }, - modulesByType: { - ...initialFormState.modulesByType, + modules: { + ...initialFormState.modules, }, } } @@ -440,30 +386,41 @@ export const FilePipettesModal = (props: Props): JSX.Element => { resolver: yupResolver(validationSchema), }) const pipettesByMount = watch('pipettesByMount') - const { modulesByType } = getValues() + const { modules } = getValues() const { left, right } = pipettesByMount // at least one must not be none (empty string) const pipetteSelectionIsValid = left.pipetteName || right.pipetteName const hasCrashableMagnetModuleSelected = getCrashableModuleSelected( - modulesByType, + modules, MAGNETIC_MODULE_TYPE ) const hasCrashableTemperatureModuleSelected = getCrashableModuleSelected( - modulesByType, + modules, TEMPERATURE_MODULE_TYPE ) - const hasHeaterShakerSelected = Boolean( - modulesByType[HEATERSHAKER_MODULE_TYPE].onDeck - ) + const hasHeaterShakerSelected = + modules != null + ? Object.values(modules).find( + module => module.type === HEATERSHAKER_MODULE_TYPE + ) != null + : false + + const leftPipetteSpecs = + left.pipetteName != null && left.pipetteName !== '' + ? getPipetteSpecsV2(left.pipetteName as PipetteName) + : undefined + const rightPipetteSpecs = + right.pipetteName != null && right.pipetteName !== '' + ? getPipetteSpecsV2(right.pipetteName as PipetteName) + : undefined const showHeaterShakerPipetteCollisions = hasHeaterShakerSelected && - [ - getPipetteSpecsV2(left.pipetteName as PipetteName), - getPipetteSpecsV2(right.pipetteName as PipetteName), - ].some(pipetteSpecs => pipetteSpecs && pipetteSpecs.channels !== 1) + [leftPipetteSpecs, rightPipetteSpecs].some( + pipetteSpecs => pipetteSpecs && pipetteSpecs.channels !== 1 + ) const crashablePipetteSelected = getIsCrashablePipetteSelected( pipettesByMount diff --git a/protocol-designer/src/localization/en/tooltip.json b/protocol-designer/src/localization/en/tooltip.json index 9e1fbb908c0..59d2f32d1c9 100644 --- a/protocol-designer/src/localization/en/tooltip.json +++ b/protocol-designer/src/localization/en/tooltip.json @@ -5,6 +5,7 @@ "disabled_off_deck": "Off-deck labware cannot be modified unless on starting deck state.", "disabled_step_creation": "New steps cannot be added in Batch Edit mode.", "disabled_no_space_additional_items": "No space for this combination of staging area slots and modules.", + "disabled_you_can_add_one_type": "Only one module of each type is allowed on the deck at a time", "not_in_beta": "ⓘ Coming Soon", "step_description": {