diff --git a/protocol-designer/src/components/modals/CreateFileWizard/EquipmentOption.tsx b/protocol-designer/src/components/modals/CreateFileWizard/EquipmentOption.tsx index 97266b07252..76b97572b47 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/EquipmentOption.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/EquipmentOption.tsx @@ -15,48 +15,39 @@ import { TYPOGRAPHY, useHoverTooltip, Tooltip, + DIRECTION_COLUMN, + Box, + StyledText, } 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}; - border-radius: ${BORDERS.borderRadius8}; - border: 1px ${BORDERS.styleSolid} ${COLORS.grey30}; - +const ARROW_STYLE = css` + color: ${COLORS.grey50}; + cursor: pointer; &:hover { - background-color: ${COLORS.grey10}; - border: 1px ${BORDERS.styleSolid} ${COLORS.grey35}; - } - - &:focus { - outline: 2px ${BORDERS.styleSolid} ${COLORS.blue50}; - outline-offset: 3px; + color: ${COLORS.black80}; } ` -const EQUIPMENT_OPTION_SELECTED_STYLE = css` - ${EQUIPMENT_OPTION_STYLE} - background-color: ${COLORS.blue10}; - border: 1px ${BORDERS.styleSolid} ${COLORS.blue50}; - +const ARROW_STYLE_ACTIVE = css` + color: ${COLORS.blue50}; + cursor: pointer; &:hover { - background-color: ${COLORS.blue10}; - border: 1px ${BORDERS.styleSolid} ${COLORS.blue50}; - box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.2); + color: ${COLORS.black80}; } ` -const EQUIPMENT_OPTION_DISABLED_STYLE = css` - ${EQUIPMENT_OPTION_STYLE} - background-color: ${COLORS.white}; - border: 1px ${BORDERS.styleSolid} ${COLORS.grey30}; - - &:hover { - background-color: ${COLORS.white}; - border: 1px ${BORDERS.styleSolid} ${COLORS.grey30}; - } +const ARROW_STYLE_DISABLED = css` + color: ${COLORS.grey50}; ` + +interface MultiplesProps { + numItems: number + maxItems: number + setValue: (num: number) => void + isDisabled: boolean +} interface EquipmentOptionProps extends StyleProps { onClick: React.MouseEventHandler isSelected: boolean @@ -65,6 +56,7 @@ interface EquipmentOptionProps extends StyleProps { image?: React.ReactNode showCheckbox?: boolean disabled?: boolean + multiples?: MultiplesProps } export function EquipmentOption(props: EquipmentOptionProps): JSX.Element { const { @@ -75,10 +67,51 @@ export function EquipmentOption(props: EquipmentOptionProps): JSX.Element { showCheckbox = false, disabled = false, robotType, + multiples, ...styleProps } = props - const { t } = useTranslation('tooltip') - const [targetProps, tooltipProps] = useHoverTooltip() + const { t } = useTranslation(['tooltip', 'shared']) + const [equipmentTargetProps, equipmentTooltipProps] = useHoverTooltip() + const [tempTargetProps, tempTooltipProps] = useHoverTooltip() + const [numMultiples, setNumMultiples] = React.useState(0) + + const EQUIPMENT_OPTION_STYLE = css` + background-color: ${COLORS.white}; + border-radius: ${BORDERS.borderRadius8}; + border: 1px ${BORDERS.styleSolid} ${COLORS.grey30}; + + &:hover { + background-color: ${multiples ? COLORS.white : COLORS.grey10}; + border: 1px ${BORDERS.styleSolid} + ${multiples ? COLORS.grey30 : COLORS.grey35}; + } + + &:focus { + outline: 2px ${BORDERS.styleSolid} ${COLORS.blue50}; + outline-offset: 3px; + } + ` + + const EQUIPMENT_OPTION_SELECTED_STYLE = css` + ${EQUIPMENT_OPTION_STYLE} + background-color: ${COLORS.blue10}; + border: 1px ${BORDERS.styleSolid} ${COLORS.blue50}; + + &:hover { + border: 1px ${BORDERS.styleSolid} ${COLORS.blue50}; + box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.2); + } + ` + + const EQUIPMENT_OPTION_DISABLED_STYLE = css` + ${EQUIPMENT_OPTION_STYLE} + background-color: ${COLORS.white}; + border: 1px ${BORDERS.styleSolid} ${COLORS.grey30}; + + &:hover { + border: 1px ${BORDERS.styleSolid} ${COLORS.grey30}; + } + ` let equipmentOptionStyle if (disabled) { @@ -102,6 +135,66 @@ export function EquipmentOption(props: EquipmentOptionProps): JSX.Element { ) } else if (showCheckbox && disabled) { iconInfo = + } else if (multiples != null) { + const { numItems, maxItems, isDisabled } = multiples + let upArrowStyle = ARROW_STYLE + if (isDisabled || numItems === maxItems) { + upArrowStyle = ARROW_STYLE_DISABLED + } else if (numItems > 0) { + upArrowStyle = ARROW_STYLE_ACTIVE + } + let downArrowStyle = ARROW_STYLE + if (numItems === 0) { + downArrowStyle = ARROW_STYLE_DISABLED + } else if (numItems > 0) { + downArrowStyle = ARROW_STYLE_ACTIVE + } + + iconInfo = ( + + { + multiples.setValue(numMultiples + 1) + setNumMultiples(prevNumMultiples => prevNumMultiples + 1) + } + } + > + + + { + multiples.setValue(numMultiples - 1) + setNumMultiples(prevNumMultiples => prevNumMultiples - 1) + } + } + > + + + {isDisabled || numMultiples === 7 ? ( + + {t('not_enough_space_for_temp')} + + ) : null} + + ) } return ( @@ -117,31 +210,52 @@ export function EquipmentOption(props: EquipmentOptionProps): JSX.Element { : BORDERS.lineBorder } borderRadius={BORDERS.borderRadius8} - cursor={disabled ? 'auto' : 'pointer'} + cursor={disabled || multiples != null ? 'auto' : 'pointer'} backgroundColor={disabled ? COLORS.grey30 : COLORS.transparent} onClick={disabled ? undefined : onClick} {...styleProps} - {...targetProps} + {...equipmentTargetProps} css={equipmentOptionStyle} > {iconInfo} {image} - - {text} - + + + {text} + + {multiples != null ? ( + <> + + + {t('shared:amount')} + {multiples.numItems} + + + ) : null} + {disabled ? ( - + {t( robotType === FLEX_ROBOT_TYPE ? 'disabled_no_space_additional_items' diff --git a/protocol-designer/src/components/modals/CreateFileWizard/InputField.tsx b/protocol-designer/src/components/modals/CreateFileWizard/InputField.tsx index 1140109b303..63a7903907e 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/InputField.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/InputField.tsx @@ -8,7 +8,6 @@ import { COLORS, DIRECTION_COLUMN, Flex, - RESPONSIVENESS, SPACING, TYPOGRAPHY, DISPLAY_INLINE_BLOCK, @@ -60,10 +59,6 @@ function Input(props: InputFieldProps): JSX.Element { border: 1px ${BORDERS.styleSolid} ${error ? COLORS.red50 : COLORS.grey30}; font-size: ${TYPOGRAPHY.fontSizeP}; - @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { - padding: 0; - } - &:active { border: 1px ${BORDERS.styleSolid} ${COLORS.grey50}; } diff --git a/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx b/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx index 492b408ae5f..bcebf6313c3 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx @@ -30,11 +30,14 @@ import { getModuleDisplayName, getModuleType, FLEX_ROBOT_TYPE, + THERMOCYCLER_MODULE_TYPE, + MAGNETIC_BLOCK_TYPE, } from '@opentrons/shared-data' 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 { getEnableMoam } from '../../../feature-flags/selectors' import { uuid } from '../../../utils' import { selectors as featureFlagSelectors } from '../../../feature-flags' import { CrashInfoBox, ModuleDiagram } from '../../modules' @@ -42,7 +45,8 @@ import { ModuleFields } from '../FilePipettesModal/ModuleFields' import { GoBack } from './GoBack' import { getCrashableModuleSelected, - getLastCheckedEquipment, + getDisabledEquipment, + getNextAvailableModuleSlot, getTrashBinOptionDisabled, } from './utils' import { EquipmentOption } from './EquipmentOption' @@ -50,6 +54,8 @@ import { HandleEnter } from './HandleEnter' import type { AdditionalEquipment, WizardTileProps } from './types' +const MAX_TEMPERATURE_MODULES = 7 + export const DEFAULT_SLOT_MAP: { [moduleModel in ModuleModel]?: string } = { [THERMOCYCLER_MODULE_V2]: 'B1', [HEATERSHAKER_MODULE_V1]: 'D1', @@ -186,13 +192,14 @@ export function ModulesAndOtherTile(props: WizardTileProps): JSX.Element { function FlexModuleFields(props: WizardTileProps): JSX.Element { const { watch, setValue } = props + const enableMoamFf = useSelector(getEnableMoam) const modules = watch('modules') const additionalEquipment = watch('additionalEquipment') const moduleTypesOnDeck = modules != null ? Object.values(modules).map(module => module.type) : [] const trashBinDisabled = getTrashBinOptionDisabled({ additionalEquipment, - moduleTypesOnDeck, + modules, }) const handleSetEquipmentOption = (equipment: AdditionalEquipment): void => { @@ -214,6 +221,82 @@ function FlexModuleFields(props: WizardTileProps): JSX.Element { {FLEX_SUPPORTED_MODULE_MODELS.map(moduleModel => { const moduleType = getModuleType(moduleModel) const moduleOnDeck = moduleTypesOnDeck.includes(moduleType) + + 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 + ? Object.entries(modules).filter( + ([key, module]) => module.type === TEMPERATURE_MODULE_TYPE + ) + : [] + + if (num > temperatureModules.length) { + for (let i = 0; i < num - temperatureModules.length; i++) { + setValue('modules', { + ...modules, + [uuid()]: { + model: moduleModel, + type: moduleType, + slot: getNextAvailableModuleSlot( + modules, + additionalEquipment + ), + }, + }) + } + } else if (num < temperatureModules.length) { + const modulesToRemove = temperatureModules.length - num + for (let i = 0; i < modulesToRemove; i++) { + const lastTempKey = + temperatureModules[temperatureModules.length - 1 - i][0] + // @ts-expect-error: TS can't determine modules's type correctly + const { [lastTempKey]: omit, ...rest } = modules + setValue('modules', rest) + } + } + } + + const handleOnClick = (): void => { + if ( + (moduleType !== TEMPERATURE_MODULE_TYPE && enableMoamFf) || + !enableMoamFf + ) { + if (moduleOnDeck) { + const updatedModules = + modules != null + ? Object.fromEntries( + Object.entries(modules).filter( + ([key, value]) => value.type !== moduleType + ) + ) + : {} + setValue('modules', updatedModules) + } else { + setValue('modules', { + ...modules, + [uuid()]: { + model: moduleModel, + type: moduleType, + slot: defaultSlot, + }, + }) + } + } + } + return ( } text={getModuleDisplayName(moduleModel)} - disabled={ - getLastCheckedEquipment({ - additionalEquipment, - moduleTypesOnDeck, - }) === moduleType + disabled={isDisabled && !moduleOnDeck} + onClick={handleOnClick} + multiples={ + moduleType === TEMPERATURE_MODULE_TYPE && enableMoamFf + ? { + maxItems: MAX_TEMPERATURE_MODULES, + setValue: handleMultiplesClick, + numItems: + modules != null + ? Object.values(modules).filter( + module => module.type === TEMPERATURE_MODULE_TYPE + ).length + : 0, + isDisabled: isDisabled ?? false, + } + : undefined + } + showCheckbox={ + enableMoamFf ? moduleType !== TEMPERATURE_MODULE_TYPE : true } - onClick={() => { - if (moduleOnDeck) { - const updatedModulesByType = - modules != null - ? Object.fromEntries( - Object.entries(modules).filter( - ([key, value]) => value.type !== moduleType - ) - ) - : {} - setValue('modules', updatedModulesByType) - } else { - setValue('modules', { - ...modules, - [uuid()]: { - model: moduleModel, - type: moduleType, - slot: DEFAULT_SLOT_MAP[moduleModel] ?? '', - }, - }) - } - }} - showCheckbox /> ) })} @@ -271,6 +345,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 + } image={ ({ + cutoutId, + cutoutFixtureId: SINGLE_RIGHT_SLOT_FIXTURE, + }) +) + export function StagingAreaTile(props: WizardTileProps): JSX.Element | null { const { getValues, goBack, proceed, setValue, watch } = props const { t } = useTranslation(['modal', 'application']) const { fields, pipettesByMount } = getValues() const additionalEquipment = watch('additionalEquipment') + const modules = watch('modules') const isOt2 = fields.robotType === OT2_ROBOT_TYPE const stagingAreaItems = additionalEquipment.filter(equipment => // TODO(bc, 11/14/2023): refactor the additional items field to include a cutoutId // and a cutoutFixtureId so that we don't have to string parse here to generate them equipment.includes('stagingArea') ) + const unoccupiedStagingAreaSlots = getUnoccupiedStagingAreaSlots(modules) const savedStagingAreaSlots: DeckConfiguration = stagingAreaItems.flatMap( item => { @@ -49,14 +59,7 @@ export function StagingAreaTile(props: WizardTileProps): JSX.Element | null { } ) - const STANDARD_EMPTY_SLOTS: DeckConfiguration = STAGING_AREA_CUTOUTS.map( - cutoutId => ({ - cutoutId, - cutoutFixtureId: SINGLE_RIGHT_SLOT_FIXTURE, - }) - ) - - STANDARD_EMPTY_SLOTS.forEach(emptySlot => { + unoccupiedStagingAreaSlots.forEach(emptySlot => { if ( !savedStagingAreaSlots.some( ({ cutoutId }) => cutoutId === emptySlot.cutoutId @@ -67,7 +70,9 @@ export function StagingAreaTile(props: WizardTileProps): JSX.Element | null { }) const initialSlots = - stagingAreaItems.length > 0 ? savedStagingAreaSlots : STANDARD_EMPTY_SLOTS + stagingAreaItems.length > 0 + ? savedStagingAreaSlots + : unoccupiedStagingAreaSlots const [updatedSlots, setUpdatedSlots] = React.useState( initialSlots diff --git a/protocol-designer/src/components/modals/CreateFileWizard/__tests__/EquipmentOption.test.tsx b/protocol-designer/src/components/modals/CreateFileWizard/__tests__/EquipmentOption.test.tsx index 09128361135..c83b1e99404 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/__tests__/EquipmentOption.test.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/__tests__/EquipmentOption.test.tsx @@ -1,7 +1,7 @@ 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 { screen, cleanup, fireEvent } from '@testing-library/react' import { BORDERS, COLORS } from '@opentrons/components' import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' import { i18n } from '../../../../localization' @@ -39,7 +39,7 @@ describe('EquipmentOption', () => { } render(props) expect(screen.getByLabelText('EquipmentOption_flex_mockText')).toHaveStyle( - `background-color: ${COLORS.white}` + `background-color: ${COLORS.grey10}` ) }) it('renders the equipment option without check not selected and image', () => { @@ -73,4 +73,21 @@ describe('EquipmentOption', () => { `border: ${BORDERS.activeLineBorder}` ) }) + it('renders the equipment option with multiples allowed', () => { + props = { + ...props, + multiples: { + numItems: 1, + maxItems: 4, + setValue: vi.fn(), + isDisabled: false, + }, + } + render(props) + screen.getByText('Amount:') + screen.getByText('1') + fireEvent.click(screen.getByTestId('EquipmentOption_upArrow')) + expect(props.multiples?.setValue).toHaveBeenCalled() + screen.getByTestId('EquipmentOption_downArrow') + }) }) 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 ba9924ee13e..63da7f3ed30 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/__tests__/ModulesAndOtherTile.test.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/__tests__/ModulesAndOtherTile.test.tsx @@ -5,7 +5,10 @@ import { fireEvent, screen, cleanup } from '@testing-library/react' import { FLEX_ROBOT_TYPE, OT2_ROBOT_TYPE } from '@opentrons/shared-data' import { renderWithProviders } from '../../../../__testing-utils__' import { i18n } from '../../../../localization' -import { getDisableModuleRestrictions } from '../../../../feature-flags/selectors' +import { + getDisableModuleRestrictions, + getEnableMoam, +} from '../../../../feature-flags/selectors' import { CrashInfoBox } from '../../../modules' import { ModuleFields } from '../../FilePipettesModal/ModuleFields' import { ModulesAndOtherTile } from '../ModulesAndOtherTile' @@ -58,6 +61,7 @@ describe('ModulesAndOtherTile', () => { ...props, ...mockWizardTileProps, } as WizardTileProps + vi.mocked(getEnableMoam).mockReturnValue(true) vi.mocked(CrashInfoBox).mockReturnValue(
mock CrashInfoBox
) vi.mocked(EquipmentOption).mockReturnValue(
mock EquipmentOption
) vi.mocked(getDisableModuleRestrictions).mockReturnValue(false) 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 213f3466c0e..240120c8b92 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/__tests__/utils.test.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/__tests__/utils.test.tsx @@ -1,17 +1,22 @@ +import { it, describe, expect } from 'vitest' import { FLEX_ROBOT_TYPE, HEATERSHAKER_MODULE_TYPE, + SINGLE_RIGHT_SLOT_FIXTURE, TEMPERATURE_MODULE_TYPE, THERMOCYCLER_MODULE_TYPE, } from '@opentrons/shared-data' -import { it, describe, expect } from 'vitest' import { FLEX_TRASH_DEFAULT_SLOT, - getLastCheckedEquipment, + getUnoccupiedStagingAreaSlots, getTrashSlot, + getNextAvailableModuleSlot, + getDisabledEquipment, + getTrashBinOptionDisabled, } from '../utils' +import { STANDARD_EMPTY_SLOTS } from '../StagingAreaTile' import type { FormPipettesByMount } from '../../../../step-forms' -import type { AdditionalEquipment, FormState } from '../types' +import type { FormState } from '../types' let MOCK_FORM_STATE = { fields: { @@ -28,43 +33,169 @@ let MOCK_FORM_STATE = { additionalEquipment: [], } as FormState -describe('getLastCheckedEquipment', () => { - it('should return null when there is no trash bin', () => { - const result = getLastCheckedEquipment({ - additionalEquipment: [], - moduleTypesOnDeck: [], +describe('getUnoccupiedStagingAreaSlots', () => { + it('should return all staging area slots when there are no modules', () => { + const result = getUnoccupiedStagingAreaSlots(null) + expect(result).toStrictEqual(STANDARD_EMPTY_SLOTS) + }) + it('should return one staging area slot when there are modules in the way of the other slots', () => { + const result = getUnoccupiedStagingAreaSlots({ + 0: { model: 'magneticBlockV1', type: 'magneticBlockType', slot: 'A3' }, + 1: { + model: 'temperatureModuleV2', + type: 'temperatureModuleType', + slot: 'B3', + }, + 2: { + model: 'temperatureModuleV2', + type: 'temperatureModuleType', + slot: 'C3', + }, }) - expect(result).toBe(null) + expect(result).toStrictEqual([ + { cutoutId: 'cutoutD3', cutoutFixtureId: SINGLE_RIGHT_SLOT_FIXTURE }, + ]) }) - it('should return null if not all the modules or staging areas are selected', () => { - const LastCheckedProps = { - additionalEquipment: [ + 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') + }) + }) + 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', + }, + }, + [ + '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', + }, + }, + [ + 'stagingArea_cutoutA3', + 'stagingArea_cutoutB3', + 'stagingArea_cutoutC3', 'stagingArea_cutoutD3', - ] as AdditionalEquipment[], - moduleTypesOnDeck: [THERMOCYCLER_MODULE_TYPE], - } - const result = getLastCheckedEquipment(LastCheckedProps) - expect(result).toBe(null) + 'trashBin', + ] + ) + expect(result).toStrictEqual('') + }) +}) +describe('getNextAvailableModuleSlot', () => { + it('should return nothing as disabled', () => { + const result = getDisabledEquipment({ + additionalEquipment: [], + modules: null, + }) + expect(result).toStrictEqual([]) }) - it('should return temperature module if other modules and staging areas are selected', () => { - const LastCheckedProps = { + it('should return the TC as disabled', () => { + const result = getDisabledEquipment({ + additionalEquipment: [], + modules: { + 0: { + model: 'heaterShakerModuleV1', + type: 'heaterShakerModuleType', + slot: 'A1', + }, + }, + }) + expect(result).toStrictEqual([THERMOCYCLER_MODULE_TYPE]) + }) + it('should return all module types if there is no available slot', () => { + const result = getDisabledEquipment({ additionalEquipment: [ - 'trashBin', 'stagingArea_cutoutA3', 'stagingArea_cutoutB3', 'stagingArea_cutoutC3', 'stagingArea_cutoutD3', - ] as AdditionalEquipment[], - moduleTypesOnDeck: [THERMOCYCLER_MODULE_TYPE, HEATERSHAKER_MODULE_TYPE], - } - const result = getLastCheckedEquipment(LastCheckedProps) - expect(result).toBe(TEMPERATURE_MODULE_TYPE) + 'trashBin', + ], + modules: { + 0: { + model: 'heaterShakerModuleV1', + type: 'heaterShakerModuleType', + slot: 'D1', + }, + 1: { + model: 'temperatureModuleV2', + type: 'temperatureModuleType', + slot: 'C1', + }, + 2: { + model: 'temperatureModuleV2', + 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 in that slot', () => { + 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'], @@ -72,7 +203,7 @@ describe('getTrashSlot', () => { const result = getTrashSlot(MOCK_FORM_STATE) expect(result).toBe(FLEX_TRASH_DEFAULT_SLOT) }) - it('should return cutoutB3 when there is a staging area in slot A3', () => { + it('should return cutoutA1 when there is a staging area in slot A3', () => { MOCK_FORM_STATE = { ...MOCK_FORM_STATE, additionalEquipment: ['stagingArea_cutoutA3'], @@ -80,4 +211,59 @@ describe('getTrashSlot', () => { 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', + }, + }, + }) + expect(result).toBe(true) + }) + }) }) diff --git a/protocol-designer/src/components/modals/CreateFileWizard/utils.ts b/protocol-designer/src/components/modals/CreateFileWizard/utils.ts index 989dabe2839..2e0e8d54a72 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/utils.ts +++ b/protocol-designer/src/components/modals/CreateFileWizard/utils.ts @@ -1,62 +1,52 @@ import { - getModuleType, HEATERSHAKER_MODULE_TYPE, TEMPERATURE_MODULE_TYPE, THERMOCYCLER_MODULE_TYPE, + WASTE_CHUTE_CUTOUT, } from '@opentrons/shared-data' import { isModuleWithCollisionIssue } from '../../modules' -import { - FLEX_SUPPORTED_MODULE_MODELS, - DEFAULT_SLOT_MAP, -} from './ModulesAndOtherTile' +import { STANDARD_EMPTY_SLOTS } from './StagingAreaTile' -import type { ModuleType } from '@opentrons/shared-data' +import type { DeckConfiguration, ModuleType } from '@opentrons/shared-data' import type { FormModules } from '../../../step-forms' import type { AdditionalEquipment, FormState } from './types' export const FLEX_TRASH_DEFAULT_SLOT = 'cutoutA3' -const ALL_STAGING_AREAS = 4 - -interface LastCheckedProps { - additionalEquipment: AdditionalEquipment[] - moduleTypesOnDeck: ModuleType[] -} - -export const getLastCheckedEquipment = ( - props: LastCheckedProps -): string | null => { - const { additionalEquipment, moduleTypesOnDeck } = props - const hasAllStagingAreas = - additionalEquipment.filter(equipment => equipment.includes('stagingArea')) - .length === ALL_STAGING_AREAS - const hasTrashBin = additionalEquipment.includes('trashBin') - if (!hasTrashBin || !hasAllStagingAreas) { - return null - } - - if ( - moduleTypesOnDeck.includes(THERMOCYCLER_MODULE_TYPE) && - moduleTypesOnDeck.includes(HEATERSHAKER_MODULE_TYPE) - ) { - return TEMPERATURE_MODULE_TYPE - } - - if ( - moduleTypesOnDeck.includes(HEATERSHAKER_MODULE_TYPE) && - moduleTypesOnDeck.includes(TEMPERATURE_MODULE_TYPE) - ) { - return THERMOCYCLER_MODULE_TYPE - } - - if ( - moduleTypesOnDeck.includes(THERMOCYCLER_MODULE_TYPE) && - moduleTypesOnDeck.includes(TEMPERATURE_MODULE_TYPE) - ) { - return HEATERSHAKER_MODULE_TYPE - } - return null -} +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, @@ -75,20 +65,6 @@ export const getCrashableModuleSelected = ( return crashableModuleOnDeck } -export const getTrashBinOptionDisabled = (props: LastCheckedProps): boolean => { - const { additionalEquipment, moduleTypesOnDeck } = props - const allStagingAreasInUse = - additionalEquipment.filter(equipment => equipment.includes('stagingArea')) - .length === ALL_STAGING_AREAS - - const allModulesInSideSlotsOnDeck = - moduleTypesOnDeck.includes(HEATERSHAKER_MODULE_TYPE) && - moduleTypesOnDeck.includes(TEMPERATURE_MODULE_TYPE) && - moduleTypesOnDeck.includes(THERMOCYCLER_MODULE_TYPE) - - return allStagingAreasInUse && allModulesInSideSlotsOnDeck -} - export const MOVABLE_TRASH_CUTOUTS = [ { value: 'cutoutA1', @@ -124,37 +100,159 @@ export const MOVABLE_TRASH_CUTOUTS = [ }, ] +export const getUnoccupiedStagingAreaSlots = ( + modules: FormState['modules'] +): DeckConfiguration => { + let unoccupiedSlots = STANDARD_EMPTY_SLOTS + const moduleCutoutIds = + modules != null + ? Object.values(modules).flatMap(module => + module.type === THERMOCYCLER_MODULE_TYPE + ? [`cutout${module.slot}`, 'cutoutA1'] + : `cutout${module.slot}` + ) + : [] + + unoccupiedSlots = unoccupiedSlots.filter(emptySlot => { + return !moduleCutoutIds.includes(emptySlot.cutoutId) + }) + + return unoccupiedSlots +} + +export const getNextAvailableModuleSlot = ( + 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') + ) + const stagingAreaCutouts = stagingAreas.map(cutout => cutout.split('_')[1]) + const hasWasteChute = additionalEquipment.find(equipment => + equipment.includes('wasteChute') + ) + const wasteChuteSlot = Boolean(hasWasteChute) + ? [WASTE_CHUTE_CUTOUT as string] + : [] + const trashBin = additionalEquipment.find(equipment => + equipment.includes('trashBin') + ) + 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) + } + const unoccupiedSlot = removeSlotForTrash.find( + cutout => + !stagingAreaCutouts.includes(cutout.value) && + !moduleSlots.includes(cutout.slot) && + !wasteChuteSlot.includes(cutout.value) + ) + if (unoccupiedSlot == null) { + return '' + } + + return unoccupiedSlot?.slot ?? '' +} + +interface DisabledEquipmentProps { + 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 +): boolean => { + const { additionalEquipment, modules } = props + const nextAvailableSlot = getNextAvailableModuleSlot( + modules, + additionalEquipment + ) + const hasTrashBinAlready = additionalEquipment.includes('trashBin') + return nextAvailableSlot === '' && !hasTrashBinAlready +} + export const getTrashSlot = (values: FormState): string => { const { additionalEquipment, modules } = values - const moduleTypesOnDeck = - modules != null ? Object.values(modules).map(module => module.type) : [] + 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') ) // TODO(Jr, 11/16/23): refactor additionalEquipment to store cutouts // so the split isn't needed const cutouts = stagingAreas.map(cutout => cutout.split('_')[1]) + const hasWasteChute = additionalEquipment.find(equipment => + equipment.includes('wasteChute') + ) + const wasteChuteSlot = Boolean(hasWasteChute) + ? [WASTE_CHUTE_CUTOUT as string] + : [] - if (!cutouts.includes(FLEX_TRASH_DEFAULT_SLOT)) { + if ( + !cutouts.includes(FLEX_TRASH_DEFAULT_SLOT) && + !moduleSlots.includes('A3') + ) { return FLEX_TRASH_DEFAULT_SLOT } - const moduleSlots: string[] = FLEX_SUPPORTED_MODULE_MODELS.reduce( - (slots: string[], model) => { - const moduleType = getModuleType(model) - if (moduleTypesOnDeck.includes(moduleType)) { - const slot = String(DEFAULT_SLOT_MAP[model]) - return moduleType === THERMOCYCLER_MODULE_TYPE - ? [...slots, 'A1', slot] - : [...slots, slot] - } - return slots - }, - [] - ) const unoccupiedSlot = MOVABLE_TRASH_CUTOUTS.find( cutout => - !cutouts.includes(cutout.value) && !moduleSlots.includes(cutout.slot) + !cutouts.includes(cutout.value) && + !moduleSlots.includes(cutout.slot) && + !wasteChuteSlot.includes(cutout.value) ) if (unoccupiedSlot == null) { console.error( diff --git a/protocol-designer/src/localization/en/shared.json b/protocol-designer/src/localization/en/shared.json index d69d55ffe32..89d916bce35 100644 --- a/protocol-designer/src/localization/en/shared.json +++ b/protocol-designer/src/localization/en/shared.json @@ -1,5 +1,6 @@ { "add": "add", + "amount": "Amount:", "confirm_reorder": "Are you sure you want to reorder these steps, it may cause errors?", "edit": "edit", "exit": "exit", diff --git a/protocol-designer/src/localization/en/tooltip.json b/protocol-designer/src/localization/en/tooltip.json index 8e41f7c1382..7ef580d81ce 100644 --- a/protocol-designer/src/localization/en/tooltip.json +++ b/protocol-designer/src/localization/en/tooltip.json @@ -4,8 +4,9 @@ "disabled_cannot_delete_trash": "A Trash Bin or Waste Chute is required", "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_no_space_additional_items": "No space for this combination of staging area slots, trash, and modules.", "disabled_you_can_add_one_type": "Only one module of each type is allowed on the deck at a time", + "not_enough_space_for_temp": "There is not enough space on the deck to add more temperature modules", "not_in_beta": "ⓘ Coming Soon", "step_description": {