diff --git a/protocol-designer/src/components/modals/CreateFileWizard/EquipmentOption.tsx b/protocol-designer/src/components/modals/CreateFileWizard/EquipmentOption.tsx index 2805473ecd1..3d1bd093319 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/EquipmentOption.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/EquipmentOption.tsx @@ -18,6 +18,7 @@ interface EquipmentOptionProps extends StyleProps { text: React.ReactNode image?: React.ReactNode showCheckbox?: boolean + disabled?: boolean } export function EquipmentOption(props: EquipmentOptionProps): JSX.Element { const { @@ -26,6 +27,7 @@ export function EquipmentOption(props: EquipmentOptionProps): JSX.Element { isSelected, image = null, showCheckbox = false, + disabled = false, ...styleProps } = props return ( @@ -34,10 +36,13 @@ export function EquipmentOption(props: EquipmentOptionProps): JSX.Element { alignItems={ALIGN_CENTER} width="21.75rem" padding={SPACING.spacing8} - border={isSelected ? BORDERS.activeLineBorder : BORDERS.lineBorder} + border={ + isSelected && !disabled ? BORDERS.activeLineBorder : BORDERS.lineBorder + } borderRadius={BORDERS.borderRadiusSize2} - cursor="pointer" - onClick={onClick} + cursor={disabled ? 'auto' : 'pointer'} + backgroundColor={disabled ? COLORS.darkGreyDisabled : COLORS.transparent} + onClick={disabled ? undefined : onClick} {...styleProps} > {showCheckbox ? ( @@ -57,7 +62,11 @@ export function EquipmentOption(props: EquipmentOptionProps): JSX.Element { > {image} - + {text} diff --git a/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx b/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx index 881d36feb13..401648fcb2d 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx @@ -210,7 +210,7 @@ const DEFAULT_SLOT_MAP: { [moduleModel in ModuleModel]?: string } = { [THERMOCYCLER_MODULE_V2]: 'B1', [HEATERSHAKER_MODULE_V1]: 'D1', [MAGNETIC_BLOCK_V1]: 'D2', - [TEMPERATURE_MODULE_V2]: 'D3', + [TEMPERATURE_MODULE_V2]: 'C1', } interface FlexModuleFieldsProps extends WizardTileProps { @@ -220,6 +220,14 @@ function FlexModuleFields(props: FlexModuleFieldsProps): JSX.Element { const { values, setFieldValue, enableDeckModification } = props const isFlex = values.fields.robotType === FLEX_ROBOT_TYPE + const allStagingAreasInUse = + values.additionalEquipment.filter(equipment => + equipment.includes('stagingArea') + ).length === 4 + const allModulesInSideSlotsOnDeck = + values.modulesByType.heaterShakerModuleType.onDeck && + values.modulesByType.thermocyclerModuleType.onDeck && + values.modulesByType.temperatureModuleType.onDeck const handleSetEquipmentOption = (equipment: AdditionalEquipment): void => { if (values.additionalEquipment.includes(equipment)) { @@ -241,6 +249,7 @@ function FlexModuleFields(props: FlexModuleFieldsProps): JSX.Element { const moduleType = getModuleType(moduleModel) return ( } @@ -302,6 +311,7 @@ function FlexModuleFields(props: FlexModuleFieldsProps): JSX.Element { } text="Trash Bin" showCheckbox + disabled={allStagingAreasInUse && allModulesInSideSlotsOnDeck} /> ) : null} 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 ca977f015e4..93c3bed9563 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/__tests__/EquipmentOption.test.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/__tests__/EquipmentOption.test.tsx @@ -20,6 +20,16 @@ describe('EquipmentOption', () => { const { getByText } = render(props) getByText('mockText') }) + it('renders the equipment option that is disabled', () => { + props = { + ...props, + disabled: true, + } + const { getByLabelText } = render(props) + expect(getByLabelText('EquipmentOption_flex_mockText')).toHaveStyle( + `background-color: ${COLORS.darkGreyDisabled}` + ) + }) it('renders the equipment option without check not selected and image', () => { props = { ...props, diff --git a/protocol-designer/src/components/modals/CreateFileWizard/index.tsx b/protocol-designer/src/components/modals/CreateFileWizard/index.tsx index 03116323565..68fed6cfee2 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/index.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/index.tsx @@ -61,7 +61,10 @@ import { ModulesAndOtherTile } from './ModulesAndOtherTile' import { WizardHeader } from './WizardHeader' import { StagingAreaTile } from './StagingAreaTile' -import type { NormalizedPipette } from '@opentrons/step-generation' +import { + NormalizedPipette, + OT_2_TRASH_DEF_URI, +} from '@opentrons/step-generation' import type { FormState } from './types' type WizardStep = @@ -195,6 +198,8 @@ export function CreateFileWizard(): JSX.Element | null { }, }) ) + + // add trash if ( enableDeckModification && values.additionalEquipment.includes('trashBin') @@ -207,15 +212,39 @@ export function CreateFileWizard(): JSX.Element | null { }) ) } - - if (!enableDeckModification) { + if ( + !enableDeckModification || + (enableDeckModification && values.fields.robotType === OT2_ROBOT_TYPE) + ) { dispatch( labwareIngredActions.createContainer({ - labwareDefURI: FLEX_TRASH_DEF_URI, + labwareDefURI: + values.fields.robotType === FLEX_ROBOT_TYPE + ? FLEX_TRASH_DEF_URI + : OT_2_TRASH_DEF_URI, slot: values.fields.robotType === FLEX_ROBOT_TYPE ? 'A3' : '12', }) ) } + + // add waste chute + if ( + enableDeckModification && + values.additionalEquipment.includes('wasteChute') + ) { + dispatch(createDeckFixture('wasteChute', WASTE_CHUTE_SLOT)) + } + // add staging areas + const stagingAreas = values.additionalEquipment.filter(equipment => + equipment.includes('stagingArea') + ) + if (enableDeckModification && stagingAreas.length > 0) { + stagingAreas.forEach(stagingArea => { + const [, location] = stagingArea.split('_') + dispatch(createDeckFixture('stagingArea', location)) + }) + } + // create modules modules.forEach(moduleArgs => dispatch(stepFormActions.createModule(moduleArgs)) @@ -235,24 +264,6 @@ export function CreateFileWizard(): JSX.Element | null { }) ) }) - - // add waste chute - if ( - enableDeckModification && - values.additionalEquipment.includes('wasteChute') - ) { - dispatch(createDeckFixture('wasteChute', WASTE_CHUTE_SLOT)) - } - // add staging areas - const stagingAreas = values.additionalEquipment.filter(equipment => - equipment.includes('stagingArea') - ) - if (enableDeckModification && stagingAreas.length > 0) { - stagingAreas.forEach(stagingArea => { - const [, location] = stagingArea.split('_') - dispatch(createDeckFixture('stagingArea', location)) - }) - } } } const wizardHeader = ( diff --git a/protocol-designer/src/components/modals/EditModulesModal/index.tsx b/protocol-designer/src/components/modals/EditModulesModal/index.tsx index 71884d5a0ec..437f4501908 100644 --- a/protocol-designer/src/components/modals/EditModulesModal/index.tsx +++ b/protocol-designer/src/components/modals/EditModulesModal/index.tsx @@ -109,7 +109,7 @@ export const EditModulesModal = (props: EditModulesModalProps): JSX.Element => { const isSlotBlocked = getSlotIdsBlockedBySpanning( initialDeckSetup ).includes(selectedSlot) - const isSlotEmpty = getSlotIsEmpty(initialDeckSetup, selectedSlot) + const isSlotEmpty = getSlotIsEmpty(initialDeckSetup, selectedSlot, true) const labwareOnSlot = getLabwareOnSlot(initialDeckSetup, selectedSlot) const isLabwareCompatible = labwareOnSlot && getLabwareIsCompatible(labwareOnSlot.def, moduleType) diff --git a/protocol-designer/src/components/modules/AdditionalItemsRow.tsx b/protocol-designer/src/components/modules/AdditionalItemsRow.tsx index 266bcc40652..c65211ace68 100644 --- a/protocol-designer/src/components/modules/AdditionalItemsRow.tsx +++ b/protocol-designer/src/components/modules/AdditionalItemsRow.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import styled from 'styled-components' -import { FLEX_ROBOT_TYPE, WASTE_CHUTE_SLOT } from '@opentrons/shared-data' +import { WASTE_CHUTE_SLOT } from '@opentrons/shared-data' import { OutlineButton, Flex, @@ -8,7 +8,6 @@ import { DIRECTION_COLUMN, LabeledValue, SPACING, - SlotMap, Tooltip, useHoverTooltip, Box, @@ -19,6 +18,8 @@ import { i18n } from '../../localization' import gripperImage from '../../images/flex_gripper.png' import { Portal } from '../portals/TopPortal' import { TrashModal } from './TrashModal' +import { FlexSlotMap } from './FlexSlotMap' + import styles from './styles.css' interface AdditionalItemsRowProps { @@ -59,92 +60,94 @@ export function AdditionalItemsRow( /> ) : null} - - -

- {i18n.t(`modules.additional_equipment_display_names.${name}`)} -

+ +

+ {i18n.t(`modules.additional_equipment_display_names.${name}`)} +

+ + - -
- {isEquipmentAdded && name === 'gripper' ? ( - - ) : null} -
- {isEquipmentAdded && name !== 'gripper' ? ( - <> -
+
+ {isEquipmentAdded && name === 'gripper' ? ( -
-
- -
- - ) : null} + ) : null} +
- - {name === 'trashBin' && isEquipmentAdded ? ( - openTrashModal(true)} - className={styles.module_button} - > - {i18n.t('shared.edit')} - + {isEquipmentAdded && name !== 'gripper' ? ( + <> +
+ +
+
+ +
+ ) : null} + - openTrashModal(true) : handleAttachment} + {name === 'trashBin' && isEquipmentAdded ? ( + openTrashModal(true)} + className={styles.module_button} + > + {i18n.t('shared.edit')} + + ) : null} + - {isEquipmentAdded - ? i18n.t('shared.remove') - : i18n.t('shared.add')} - + openTrashModal(true) : handleAttachment + } + > + {isEquipmentAdded + ? i18n.t('shared.remove') + : i18n.t('shared.add')} + + + {disabledRemoveButton ? ( + + {i18n.t(`tooltip.disabled_cannot_delete_trash`)} + + ) : null}
- {disabledRemoveButton ? ( - - {i18n.t(`tooltip.disabled_cannot_delete_trash`)} - - ) : null} - +
) diff --git a/protocol-designer/src/components/modules/EditModulesCard.tsx b/protocol-designer/src/components/modules/EditModulesCard.tsx index 202e16fc458..11ca568c8c9 100644 --- a/protocol-designer/src/components/modules/EditModulesCard.tsx +++ b/protocol-designer/src/components/modules/EditModulesCard.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import { useDispatch, useSelector } from 'react-redux' -import { Card } from '@opentrons/components' +import { Box, Card, SPACING } from '@opentrons/components' import { MAGNETIC_MODULE_TYPE, TEMPERATURE_MODULE_TYPE, @@ -35,6 +35,8 @@ import { isModuleWithCollisionIssue } from './utils' import styles from './styles.css' import { FLEX_TRASH_DEF_URI } from '../../constants' import { deleteContainer } from '../../labware-ingred/actions' +import { AdditionalEquipmentEntity } from '@opentrons/step-generation' +import { StagingAreasRow } from './StagingAreasRow' export interface Props { modules: ModulesForEditModulesCard @@ -62,6 +64,9 @@ export function EditModulesCard(props: Props): JSX.Element { const wasteChute = Object.values(additionalEquipment).find( equipment => equipment?.name === 'wasteChute' ) + const stagingAreas: AdditionalEquipmentEntity[] = Object.values( + additionalEquipment + ).filter(equipment => equipment?.name === 'stagingArea') const dispatch = useDispatch() const robotType = useSelector(getRobotType) @@ -107,6 +112,12 @@ export function EditModulesCard(props: Props): JSX.Element { : moduleType !== 'magneticBlockType' ) + const handleDeleteStagingAreas = (): void => { + stagingAreas.forEach(stagingArea => { + dispatch(deleteDeckFixture(stagingArea.id)) + }) + } + return (
@@ -121,6 +132,15 @@ export function EditModulesCard(props: Props): JSX.Element { } /> )} + {isFlex ? ( + + dispatch(toggleIsGripperRequired())} + isEquipmentAdded={isGripperAttached} + name="gripper" + /> + + ) : null} {SUPPORTED_MODULE_TYPES_FILTERED.map((moduleType, i) => { const moduleData = modules[moduleType] if (moduleData) { @@ -146,6 +166,10 @@ export function EditModulesCard(props: Props): JSX.Element { })} {enableDeckModification && isFlex ? ( <> + trashBin != null @@ -171,13 +195,6 @@ export function EditModulesCard(props: Props): JSX.Element { /> ) : null} - {isFlex ? ( - dispatch(toggleIsGripperRequired())} - isEquipmentAdded={isGripperAttached} - name="gripper" - /> - ) : null}
) diff --git a/protocol-designer/src/components/modules/FlexSlotMap.tsx b/protocol-designer/src/components/modules/FlexSlotMap.tsx new file mode 100644 index 00000000000..90dc24e9ed3 --- /dev/null +++ b/protocol-designer/src/components/modules/FlexSlotMap.tsx @@ -0,0 +1,102 @@ +import * as React from 'react' +import { + FLEX_ROBOT_TYPE, + getDeckDefFromRobotType, +} from '@opentrons/shared-data' +import { RobotCoordinateSpace } from '@opentrons/components/src/hardware-sim/RobotCoordinateSpace' +import { DeckSlotLocation } from '@opentrons/components/src/hardware-sim/DeckSlotLocation' +import { + ALIGN_CENTER, + BORDERS, + COLORS, + Flex, + JUSTIFY_CENTER, + RobotCoordsForeignObject, + SPACING, +} from '@opentrons/components' + +const X_ADJUSTMENT_LEFT_SIDE = -101.5 +const X_ADJUSTMENT = -17 +const X_DIMENSION_MIDDLE_SLOTS = 160.3 +const X_DIMENSION_OUTER_SLOTS = 246.5 +const Y_DIMENSION = 106.0 + +interface FlexSlotMapProps { + selectedSlots: string[] +} +export function FlexSlotMap(props: FlexSlotMapProps): JSX.Element { + const { selectedSlots } = props + const deckDef = getDeckDefFromRobotType(FLEX_ROBOT_TYPE) + const slotFill = ( + + ) + + return ( + + {deckDef.locations.orderedSlots.map(slotDef => ( + <> + + + ))} + {selectedSlots.map(selectedSlot => { + const slot = deckDef.locations.orderedSlots.find( + slot => slot.id === selectedSlot + ) + const [xSlotPosition = 0, ySlotPosition = 0] = slot?.position ?? [] + + const isLeftSideofDeck = + selectedSlot === 'A1' || + selectedSlot === 'B1' || + selectedSlot === 'C1' || + selectedSlot === 'D1' + const xAdjustment = isLeftSideofDeck + ? X_ADJUSTMENT_LEFT_SIDE + : X_ADJUSTMENT + const x = xSlotPosition + xAdjustment + const yAdjustment = -10 + const y = ySlotPosition + yAdjustment + + const isMiddleOfDeck = + selectedSlot === 'A2' || + selectedSlot === 'B2' || + selectedSlot === 'C2' || + selectedSlot === 'D2' + + const xDimension = isMiddleOfDeck + ? X_DIMENSION_MIDDLE_SLOTS + : X_DIMENSION_OUTER_SLOTS + const yDimension = Y_DIMENSION + + return ( + + {slotFill} + + ) + })} + + ) +} diff --git a/protocol-designer/src/components/modules/ModuleRow.tsx b/protocol-designer/src/components/modules/ModuleRow.tsx index 2fdd4ef47b6..08c1c2ca9f3 100644 --- a/protocol-designer/src/components/modules/ModuleRow.tsx +++ b/protocol-designer/src/components/modules/ModuleRow.tsx @@ -27,6 +27,7 @@ import { isModuleWithCollisionIssue } from './utils' import styles from './styles.css' import type { ModuleType, RobotType } from '@opentrons/shared-data' +import { FlexSlotMap } from './FlexSlotMap' interface Props { robotType?: RobotType @@ -149,15 +150,22 @@ export function ModuleRow(props: Props): JSX.Element { {collisionSlots.length > 0 && ( {collisionTooltip} )} - {slot && ( -
- -
- )} + ) : ( +
+ +
+ ))}
{moduleOnDeck && ( diff --git a/protocol-designer/src/components/modules/StagingAreasModal.tsx b/protocol-designer/src/components/modules/StagingAreasModal.tsx new file mode 100644 index 00000000000..5d389db02a7 --- /dev/null +++ b/protocol-designer/src/components/modules/StagingAreasModal.tsx @@ -0,0 +1,217 @@ +import * as React from 'react' +import { useSelector, useDispatch } from 'react-redux' +import { Form, Formik, useFormikContext } from 'formik' +import { + BUTTON_TYPE_SUBMIT, + OutlineButton, + ModalShell, + Flex, + SPACING, + DIRECTION_ROW, + Box, + Text, + JUSTIFY_CENTER, + ALIGN_CENTER, + JUSTIFY_FLEX_END, + JUSTIFY_END, + DeckConfigurator, +} from '@opentrons/components' +import { + Cutout, + DeckConfiguration, + STAGING_AREA_LOAD_NAME, + STANDARD_SLOT_LOAD_NAME, +} from '@opentrons/shared-data' +import { i18n } from '../../localization' +import { + createDeckFixture, + deleteDeckFixture, +} from '../../step-forms/actions/additionalItems' +import { getSlotIsEmpty } from '../../step-forms' +import { getInitialDeckSetup } from '../../step-forms/selectors' +import { PDAlert } from '../alerts/PDAlert' +import { AdditionalEquipmentEntity } from '@opentrons/step-generation' +import { getStagingAreaSlots } from '../../utils' + +const STAGING_AREA_SLOTS: Cutout[] = ['A3', 'B3', 'C3', 'D3'] + +export interface StagingAreasValues { + selectedSlots: string[] +} + +const StagingAreasModalComponent = ( + props: StagingAreasModalProps +): JSX.Element => { + const { onCloseClick, stagingAreas } = props + const { values, setFieldValue } = useFormikContext() + const initialDeckSetup = useSelector(getInitialDeckSetup) + const areSlotsEmpty = values.selectedSlots.map(slot => + getSlotIsEmpty(initialDeckSetup, slot) + ) + const hasConflictedSlot = areSlotsEmpty.includes(false) + + const mappedStagingAreas = stagingAreas.flatMap(area => { + return [ + { + fixtureId: area.id, + fixtureLocation: area.location ?? '', + loadName: STAGING_AREA_LOAD_NAME, + }, + ] as DeckConfiguration + }) + const STANDARD_EMPTY_SLOTS: DeckConfiguration = STAGING_AREA_SLOTS.map( + fixtureLocation => ({ + fixtureId: `id_${fixtureLocation}`, + fixtureLocation: fixtureLocation as Cutout, + loadName: STANDARD_SLOT_LOAD_NAME, + }) + ) + + STANDARD_EMPTY_SLOTS.forEach(emptySlot => { + if ( + !mappedStagingAreas.some( + slot => slot.fixtureLocation === emptySlot.fixtureLocation + ) + ) { + mappedStagingAreas.push(emptySlot) + } + }) + + const selectableSlots = + mappedStagingAreas.length > 0 ? mappedStagingAreas : STANDARD_EMPTY_SLOTS + const [updatedSlots, setUpdatedSlots] = React.useState( + selectableSlots + ) + + const handleClickAdd = (fixtureLocation: string): void => { + const modifiedSlots: DeckConfiguration = updatedSlots.map(slot => { + if (slot.fixtureLocation === fixtureLocation) { + return { + ...slot, + loadName: STAGING_AREA_LOAD_NAME, + } + } + return slot + }) + setUpdatedSlots(modifiedSlots) + const updatedSelectedSlots = [...values.selectedSlots, fixtureLocation] + setFieldValue('selectedSlots', updatedSelectedSlots) + } + + const handleClickRemove = (fixtureLocation: string): void => { + const modifiedSlots: DeckConfiguration = updatedSlots.map(slot => { + if (slot.fixtureLocation === fixtureLocation) { + return { + ...slot, + loadName: STANDARD_SLOT_LOAD_NAME, + } + } + return slot + }) + setUpdatedSlots(modifiedSlots) + const updatedSelectedSlots = values.selectedSlots.filter( + item => item !== fixtureLocation + ) + setFieldValue('selectedSlots', updatedSelectedSlots) + } + + return ( +
+ + + + {hasConflictedSlot ? ( + + ) : null} + + + + + + + + + + {i18n.t('button.cancel')} + + + {i18n.t('button.save')} + + +
+ ) +} + +export interface StagingAreasModalProps { + onCloseClick: () => void + stagingAreas: AdditionalEquipmentEntity[] +} + +export const StagingAreasModal = ( + props: StagingAreasModalProps +): JSX.Element => { + const { onCloseClick, stagingAreas } = props + const dispatch = useDispatch() + const stagingAreaLocations = getStagingAreaSlots(stagingAreas) + + const onSaveClick = (values: StagingAreasValues): void => { + onCloseClick() + + values.selectedSlots.forEach(slot => { + if (!stagingAreaLocations?.includes(slot)) { + dispatch(createDeckFixture('stagingArea', slot)) + } + }) + Object.values(stagingAreas).forEach(area => { + if (!values.selectedSlots.includes(area.location as string)) { + dispatch(deleteDeckFixture(area.id)) + } + }) + } + + return ( + + + + + {i18n.t(`modules.additional_equipment_display_names.stagingAreas`)} + + + + + + ) +} diff --git a/protocol-designer/src/components/modules/StagingAreasRow.tsx b/protocol-designer/src/components/modules/StagingAreasRow.tsx new file mode 100644 index 00000000000..dd882953be1 --- /dev/null +++ b/protocol-designer/src/components/modules/StagingAreasRow.tsx @@ -0,0 +1,119 @@ +import * as React from 'react' +import styled from 'styled-components' +import { + OutlineButton, + Flex, + JUSTIFY_SPACE_BETWEEN, + DIRECTION_COLUMN, + LabeledValue, + SPACING, + Box, + TYPOGRAPHY, + DIRECTION_ROW, +} from '@opentrons/components' +import { i18n } from '../../localization' +import stagingAreaImage from '../../images/staging_area.png' +import { getStagingAreaSlots } from '../../utils' +import { Portal } from '../portals/TopPortal' +import { StagingAreasModal } from './StagingAreasModal' +import { FlexSlotMap } from './FlexSlotMap' + +import styles from './styles.css' +import type { AdditionalEquipmentEntity } from '@opentrons/step-generation' + +interface StagingAreasRowProps { + handleAttachment: () => void + stagingAreas: AdditionalEquipmentEntity[] +} + +export function StagingAreasRow(props: StagingAreasRowProps): JSX.Element { + const { handleAttachment, stagingAreas } = props + const hasStagingAreas = stagingAreas.length > 0 + const [stagingAreaModal, openStagingAreaModal] = React.useState( + false + ) + const stagingAreaLocations = getStagingAreaSlots(stagingAreas) + + return ( + <> + {stagingAreaModal ? ( + + openStagingAreaModal(false)} + stagingAreas={stagingAreas} + /> + + ) : null} + +

+ {i18n.t(`modules.additional_equipment_display_names.stagingAreas`)} +

+ + + +
+ {hasStagingAreas ? ( + <> +
+ +
+ +
+ +
+ + ) : null} + + {hasStagingAreas ? ( + openStagingAreaModal(true)} + className={styles.module_button} + > + {i18n.t('shared.edit')} + + ) : null} + + openStagingAreaModal(true) + } + > + {hasStagingAreas + ? i18n.t('shared.remove') + : i18n.t('shared.add')} + + + + + + + ) +} + +const StagingAreaImage = styled.img` + width: 6rem; + max-height: 4rem; + display: block; +` diff --git a/protocol-designer/src/components/modules/TrashModal.tsx b/protocol-designer/src/components/modules/TrashModal.tsx index 70593e6f103..fedb02bc905 100644 --- a/protocol-designer/src/components/modules/TrashModal.tsx +++ b/protocol-designer/src/components/modules/TrashModal.tsx @@ -41,7 +41,11 @@ const TrashModalComponent = (props: TrashModalProps): JSX.Element => { const { onCloseClick, trashName } = props const { values } = useFormikContext() const initialDeckSetup = useSelector(getInitialDeckSetup) - const isSlotEmpty = getSlotIsEmpty(initialDeckSetup, values.selectedSlot) + const isSlotEmpty = getSlotIsEmpty( + initialDeckSetup, + values.selectedSlot, + trashName === 'trashBin' + ) const flexDeck = getDeckDefFromRobotType(FLEX_ROBOT_TYPE) const [field] = useField('selectedSlot') @@ -74,7 +78,9 @@ const TrashModalComponent = (props: TrashModalProps): JSX.Element => { {!isSlotEmpty ? ( ) : null} diff --git a/protocol-designer/src/components/modules/__tests__/AdditionalItemsRow.test.tsx b/protocol-designer/src/components/modules/__tests__/AdditionalItemsRow.test.tsx index 3ba3afaa826..38221bf9321 100644 --- a/protocol-designer/src/components/modules/__tests__/AdditionalItemsRow.test.tsx +++ b/protocol-designer/src/components/modules/__tests__/AdditionalItemsRow.test.tsx @@ -1,15 +1,16 @@ import * as React from 'react' import i18n from 'i18next' -import { renderWithProviders, SlotMap } from '@opentrons/components' +import { renderWithProviders } from '@opentrons/components' import { WASTE_CHUTE_SLOT } from '@opentrons/shared-data' import { Portal } from '../../portals/TopPortal' import { AdditionalItemsRow } from '../AdditionalItemsRow' +import { FlexSlotMap } from '../FlexSlotMap' -jest.mock('@opentrons/components/src/slotmap/SlotMap') +jest.mock('../FlexSlotMap') jest.mock('../../portals/TopPortal') -const mockSlotMap = SlotMap as jest.MockedFunction +const mockFlexSlotMap = FlexSlotMap as jest.MockedFunction const mockPortal = Portal as jest.MockedFunction const render = (props: React.ComponentProps) => { @@ -26,7 +27,7 @@ describe('AdditionalItemsRow', () => { isEquipmentAdded: false, name: 'gripper', } - mockSlotMap.mockReturnValue(
mock slot map
) + mockFlexSlotMap.mockReturnValue(
mock slot map
) mockPortal.mockReturnValue(
mock portal
) }) it('renders no gripper', () => { diff --git a/protocol-designer/src/components/modules/__tests__/EditModulesCard.test.tsx b/protocol-designer/src/components/modules/__tests__/EditModulesCard.test.tsx index 0f6554b8b5e..e8c81f2dd12 100644 --- a/protocol-designer/src/components/modules/__tests__/EditModulesCard.test.tsx +++ b/protocol-designer/src/components/modules/__tests__/EditModulesCard.test.tsx @@ -30,6 +30,7 @@ import { CrashInfoBox } from '../CrashInfoBox' import { ModuleRow } from '../ModuleRow' import { AdditionalItemsRow } from '../AdditionalItemsRow' import { FLEX_TRASH_DEF_URI } from '../../../constants' +import { StagingAreasRow } from '../StagingAreasRow' jest.mock('../../../feature-flags') jest.mock('../../../step-forms/selectors') @@ -290,9 +291,10 @@ describe('EditModulesCard', () => { true ) }) - it('displays gripper waste chute and trash row with all are attached', () => { + it('displays gripper waste chute, staging area, and trash row with all are attached', () => { const mockGripperId = 'gripperId' const mockWasteChuteId = 'wasteChuteId' + const mockStagingAreaId = 'stagingAreaId' mockGetEnableDeckModification.mockReturnValue(true) mockGetRobotType.mockReturnValue(FLEX_ROBOT_TYPE) mockGetAdditionalEquipment.mockReturnValue({ @@ -302,6 +304,11 @@ describe('EditModulesCard', () => { id: mockWasteChuteId, location: 'D3', }, + mockStagingAreaId: { + name: 'stagingArea', + id: mockStagingAreaId, + location: 'B3', + }, }) mockGetLabwareEntities.mockReturnValue({ mockTrashId: { @@ -316,5 +323,6 @@ describe('EditModulesCard', () => { } const wrapper = render(props) expect(wrapper.find(AdditionalItemsRow)).toHaveLength(3) + expect(wrapper.find(StagingAreasRow)).toHaveLength(1) }) }) diff --git a/protocol-designer/src/components/modules/__tests__/StagingAreaModal.test.tsx b/protocol-designer/src/components/modules/__tests__/StagingAreaModal.test.tsx new file mode 100644 index 00000000000..a55a675b78e --- /dev/null +++ b/protocol-designer/src/components/modules/__tests__/StagingAreaModal.test.tsx @@ -0,0 +1,51 @@ +import * as React from 'react' +import i18n from 'i18next' +import { DeckConfigurator, renderWithProviders } from '@opentrons/components' +import { getInitialDeckSetup } from '../../../step-forms/selectors' +import { getSlotIsEmpty } from '../../../step-forms' +import { StagingAreasModal } from '../StagingAreasModal' + +jest.mock('../../../step-forms') +jest.mock('../../../step-forms/selectors') +jest.mock('../../../step-forms/actions/additionalItems') +jest.mock('@opentrons/components/src/hardware-sim/DeckConfigurator/index') + +const mockGetInitialDeckSetup = getInitialDeckSetup as jest.MockedFunction< + typeof getInitialDeckSetup +> +const mockGetSlotIsEmpty = getSlotIsEmpty as jest.MockedFunction< + typeof getSlotIsEmpty +> +const mockDeckConfigurator = DeckConfigurator as jest.MockedFunction< + typeof DeckConfigurator +> +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('StagingAreasModal', () => { + let props: React.ComponentProps + beforeEach(() => { + props = { + onCloseClick: jest.fn(), + stagingAreas: [], + } + mockGetInitialDeckSetup.mockReturnValue({ + pipettes: {}, + additionalEquipmentOnDeck: {}, + labware: {}, + modules: {}, + }) + mockGetSlotIsEmpty.mockReturnValue(true) + mockDeckConfigurator.mockReturnValue(
mock deck config
) + }) + it('renders the deck, header, and buttons work as expected', () => { + const { getByText, getByRole } = render(props) + getByText('mock deck config') + getByText('Staging Areas') + getByRole('button', { name: 'cancel' }).click() + expect(props.onCloseClick).toHaveBeenCalled() + }) +}) diff --git a/protocol-designer/src/components/modules/__tests__/StagingAreasRow.test.tsx b/protocol-designer/src/components/modules/__tests__/StagingAreasRow.test.tsx new file mode 100644 index 00000000000..08452c1f0c2 --- /dev/null +++ b/protocol-designer/src/components/modules/__tests__/StagingAreasRow.test.tsx @@ -0,0 +1,51 @@ +import * as React from 'react' +import i18n from 'i18next' +import { renderWithProviders } from '@opentrons/components' + +import { Portal } from '../../portals/TopPortal' +import { FlexSlotMap } from '../FlexSlotMap' +import { StagingAreasRow } from '../StagingAreasRow' + +jest.mock('../FlexSlotMap') +jest.mock('../../portals/TopPortal') + +const mockFlexSlotMap = FlexSlotMap as jest.MockedFunction +const mockPortal = Portal as jest.MockedFunction + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('StagingAreasRow', () => { + let props: React.ComponentProps + beforeEach(() => { + props = { + handleAttachment: jest.fn(), + stagingAreas: [], + } + mockFlexSlotMap.mockReturnValue(
mock slot map
) + mockPortal.mockReturnValue(
mock portal
) + }) + it('renders no staging areas', () => { + const { getByRole, getByText } = render(props) + getByText('Staging Areas') + getByRole('button', { name: 'add' }).click() + getByText('mock portal') + }) + it('renders a staging area', () => { + props = { + ...props, + stagingAreas: [{ name: 'stagingArea', location: 'B3', id: 'mockId' }], + } + const { getByRole, getByText } = render(props) + getByText('mock slot map') + getByText('Position:') + getByText('Slots B3') + getByRole('button', { name: 'remove' }).click() + expect(props.handleAttachment).toHaveBeenCalled() + getByRole('button', { name: 'edit' }).click() + getByText('mock portal') + }) +}) diff --git a/protocol-designer/src/components/modules/__tests__/TrashBinModal.test.tsx b/protocol-designer/src/components/modules/__tests__/TrashModal.test.tsx similarity index 100% rename from protocol-designer/src/components/modules/__tests__/TrashBinModal.test.tsx rename to protocol-designer/src/components/modules/__tests__/TrashModal.test.tsx diff --git a/protocol-designer/src/components/modules/styles.css b/protocol-designer/src/components/modules/styles.css index 7db6dbc456f..34b4921b570 100644 --- a/protocol-designer/src/components/modules/styles.css +++ b/protocol-designer/src/components/modules/styles.css @@ -34,6 +34,7 @@ .slot_map { flex: 1 0 12%; max-width: 6rem; + margin-top: -1rem; } .row_title { diff --git a/protocol-designer/src/images/staging_area.png b/protocol-designer/src/images/staging_area.png new file mode 100644 index 00000000000..cfd0138dad8 Binary files /dev/null and b/protocol-designer/src/images/staging_area.png differ diff --git a/protocol-designer/src/localization/en/alert.json b/protocol-designer/src/localization/en/alert.json index 48d0ff61d85..3de66e80e2d 100644 --- a/protocol-designer/src/localization/en/alert.json +++ b/protocol-designer/src/localization/en/alert.json @@ -200,6 +200,13 @@ "body": "Slot {{selectedSlot}} is occupied by a Heater-Shaker. Other modules cannot be placed in front of or behind a Heater-Shaker." } }, + "deck_config_placement": { + "SLOT_OCCUPIED": { + "staging_area": "Cannot place staging area", + "trashBin": "Cannot place trash bin", + "wasteChute": "Cannot place waste chute" + } + }, "export_warnings": { "no_commands": { "heading": "Your protocol has no steps", diff --git a/protocol-designer/src/localization/en/modules.json b/protocol-designer/src/localization/en/modules.json index cfbfe77933a..ee74aca70c1 100644 --- a/protocol-designer/src/localization/en/modules.json +++ b/protocol-designer/src/localization/en/modules.json @@ -1,6 +1,7 @@ { "additional_equipment_display_names": { "gripper": "Flex Gripper", + "stagingAreas": "Staging Areas", "trashBin": "Trash Bin", "wasteChute": "Waste Chute" }, diff --git a/protocol-designer/src/step-forms/selectors/index.ts b/protocol-designer/src/step-forms/selectors/index.ts index 8e1192971ef..9eec4adf825 100644 --- a/protocol-designer/src/step-forms/selectors/index.ts +++ b/protocol-designer/src/step-forms/selectors/index.ts @@ -230,11 +230,11 @@ const _getInitialDeckSetup = ( (initialSetupStep && initialSetupStep.pipetteLocationUpdate) || {} // filtering only the additionalEquipmentEntities that are rendered on the deck - // which for now is only the wasteChute + // which for now is wasteChute and stagingArea const additionalEquipmentEntitiesOnDeck = Object.values( additionalEquipmentEntities ).reduce((aeEntities: AdditionalEquipmentEntities, ae) => { - if (ae.name === 'wasteChute') { + if (ae.name === 'wasteChute' || ae.name === 'stagingArea') { aeEntities[ae.id] = ae } return aeEntities diff --git a/protocol-designer/src/step-forms/utils/index.ts b/protocol-designer/src/step-forms/utils/index.ts index f2379b61286..b6b09bdeea7 100644 --- a/protocol-designer/src/step-forms/utils/index.ts +++ b/protocol-designer/src/step-forms/utils/index.ts @@ -102,7 +102,10 @@ export const getSlotIdsBlockedBySpanning = ( } export const getSlotIsEmpty = ( initialDeckSetup: InitialDeckSetup, - slot: string + slot: string, + /* we don't always want to count the slot as full if there is a staging area present + since labware/wasteChute can still go on top of staging areas **/ + includeStagingAreas?: boolean ): boolean => { if ( slot === SPAN7_8_10_11_SLOT && @@ -118,7 +121,18 @@ export const getSlotIsEmpty = ( } else if (slot === '12') { return false } - // NOTE: should work for both deck slots and module slots + + const filteredAdditionalEquipmentOnDeck = includeStagingAreas + ? values(initialDeckSetup.additionalEquipmentOnDeck).filter( + (additionalEquipment: AdditionalEquipmentOnDeck) => + additionalEquipment.location === slot + ) + : values(initialDeckSetup.additionalEquipmentOnDeck).filter( + (additionalEquipment: AdditionalEquipmentOnDeck) => + additionalEquipment.location === slot && + additionalEquipment.name !== 'stagingArea' + ) + return ( [ ...values(initialDeckSetup.modules).filter( @@ -127,10 +141,7 @@ export const getSlotIsEmpty = ( ...values(initialDeckSetup.labware).filter( (labware: LabwareOnDeckType) => labware.slot === slot ), - ...values(initialDeckSetup.additionalEquipmentOnDeck).filter( - (additionalEquipment: AdditionalEquipmentOnDeck) => - additionalEquipment.location === slot - ), + ...filteredAdditionalEquipmentOnDeck, ].length === 0 ) } diff --git a/protocol-designer/src/utils/index.ts b/protocol-designer/src/utils/index.ts index 5598d7478f4..09cef6a9143 100644 --- a/protocol-designer/src/utils/index.ts +++ b/protocol-designer/src/utils/index.ts @@ -3,7 +3,10 @@ import { WellSetHelpers, makeWellSetHelpers } from '@opentrons/shared-data' import { i18n } from '../localization' import { WellGroup } from '@opentrons/components' import { BoundingRect, GenericRect } from '../collision-types' -import type { LabwareEntities } from '@opentrons/step-generation' +import type { + AdditionalEquipmentEntity, + LabwareEntities, +} from '@opentrons/step-generation' export const registerSelectors: (arg0: any) => void = process.env.NODE_ENV === 'development' @@ -116,3 +119,11 @@ export const getIsAdapter = ( labwareEntities[labwareId].def.allowedRoles?.includes('adapter') ?? false ) } + +export const getStagingAreaSlots = ( + stagingAreas?: AdditionalEquipmentEntity[] +): string[] | null => { + if (stagingAreas == null) return null + // we can assume that the location is always a string + return stagingAreas.map(area => area.location as string) +}