diff --git a/components/src/slotmap/SlotMap.tsx b/components/src/slotmap/SlotMap.tsx index 7f6ffd2738d..e3c08cd7e7f 100644 --- a/components/src/slotmap/SlotMap.tsx +++ b/components/src/slotmap/SlotMap.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import cx from 'classnames' +import { FLEX_ROBOT_TYPE, RobotType } from '@opentrons/shared-data' import { Icon } from '../icons' import styles from './styles.css' @@ -13,15 +14,23 @@ export interface SlotMapProps { collisionSlots?: string[] /** Optional error styling */ isError?: boolean + robotType?: RobotType } -const SLOT_MAP_SLOTS = [ +const OT2_SLOT_MAP_SLOTS = [ ['10', '11'], ['7', '8', '9'], ['4', '5', '6'], ['1', '2', '3'], ] +const FLEX_SLOT_MAP_SLOTS = [ + ['A1', 'A2', 'A3'], + ['B1', 'B2', 'B3'], + ['C1', 'C2', 'C3'], + ['D1', 'D2', 'D3'], +] + const slotWidth = 33 const slotHeight = 23 const iconSize = 20 @@ -29,12 +38,15 @@ const numRows = 4 const numCols = 3 export function SlotMap(props: SlotMapProps): JSX.Element { - const { collisionSlots, occupiedSlots, isError } = props + const { collisionSlots, occupiedSlots, isError, robotType } = props + const slots = + robotType === FLEX_ROBOT_TYPE ? FLEX_SLOT_MAP_SLOTS : OT2_SLOT_MAP_SLOTS + return ( - {SLOT_MAP_SLOTS.flatMap((row, rowIndex) => + {slots.flatMap((row, rowIndex) => row.map((slot, colIndex) => { const isCollisionSlot = collisionSlots && collisionSlots.includes(slot) diff --git a/components/src/slotmap/__tests__/SlotMap.test.tsx b/components/src/slotmap/__tests__/SlotMap.test.tsx index 0973d423365..a75725a6e86 100644 --- a/components/src/slotmap/__tests__/SlotMap.test.tsx +++ b/components/src/slotmap/__tests__/SlotMap.test.tsx @@ -1,17 +1,18 @@ import * as React from 'react' import { shallow } from 'enzyme' +import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' import { SlotMap } from '../SlotMap' import { Icon } from '../../icons' describe('SlotMap', () => { - it('component renders 11 slots', () => { + it('component renders 11 slots for ot-2', () => { const wrapper = shallow() expect(wrapper.find('rect')).toHaveLength(11) }) - it('component renders crash info icon when collision slots present', () => { + it('component renders crash info icon when collision slots present for ot-2', () => { const wrapper = shallow( ) @@ -29,4 +30,22 @@ describe('SlotMap', () => { expect(wrapperWithError.find('.slot_occupied')).toHaveLength(1) expect(wrapperWithError.find('.slot_occupied.slot_error')).toHaveLength(1) }) + + it('should render 12 slots for flex', () => { + const wrapper = shallow( + + ) + expect(wrapper.find('rect')).toHaveLength(12) + }) + + it('component renders crash info icon when collision slots present for flex', () => { + const wrapper = shallow( + + ) + expect(wrapper.find(Icon)).toHaveLength(1) + }) }) diff --git a/protocol-designer/src/components/DeckSetup/index.tsx b/protocol-designer/src/components/DeckSetup/index.tsx index e414fe2b65a..48fcb6fb5ed 100644 --- a/protocol-designer/src/components/DeckSetup/index.tsx +++ b/protocol-designer/src/components/DeckSetup/index.tsx @@ -432,33 +432,31 @@ export const DeckSetup = (): JSX.Element => { }) return ( - -
- {drilledDown && } -
- - {({ deckSlotsById, getRobotCoordsFromDOMCoords }) => ( - - )} - -
+
+ {drilledDown && } +
+ + {({ deckSlotsById, getRobotCoordsFromDOMCoords }) => ( + + )} +
- +
) } diff --git a/protocol-designer/src/components/FileSidebar/FileSidebar.tsx b/protocol-designer/src/components/FileSidebar/FileSidebar.tsx index 87da5e531cd..0495dd9a9e9 100644 --- a/protocol-designer/src/components/FileSidebar/FileSidebar.tsx +++ b/protocol-designer/src/components/FileSidebar/FileSidebar.tsx @@ -7,10 +7,10 @@ import { SidePanel, } from '@opentrons/components' import { i18n } from '../../localization' -import { useBlockingHint } from '../Hints/useBlockingHint' -import { KnowledgeBaseLink } from '../KnowledgeBaseLink' import { resetScrollElements } from '../../ui/steps/utils' import { Portal } from '../portals/MainPageModalPortal' +import { useBlockingHint } from '../Hints/useBlockingHint' +import { KnowledgeBaseLink } from '../KnowledgeBaseLink' import { getUnusedEntities } from './utils' import modalStyles from '../modals/modal.css' import styles from './FileSidebar.css' @@ -22,7 +22,11 @@ import type { ModuleOnDeck, PipetteOnDeck, } from '../../step-forms' -import type { CreateCommand, ProtocolFile } from '@opentrons/shared-data' +import type { + CreateCommand, + ProtocolFile, + RobotType, +} from '@opentrons/shared-data' export interface Props { loadFile: (event: React.ChangeEvent) => unknown @@ -33,6 +37,15 @@ export interface Props { pipettesOnDeck: InitialDeckSetup['pipettes'] modulesOnDeck: InitialDeckSetup['modules'] savedStepForms: SavedStepFormState + robotType: RobotType + additionalEquipment: AdditionalEquipment +} + +export interface AdditionalEquipment { + [additionalEquipmentId: string]: { + name: 'gripper' + id: string + } } interface WarningContent { @@ -44,6 +57,7 @@ interface MissingContent { noCommands: boolean pipettesWithoutStep: PipetteOnDeck[] modulesWithoutStep: ModuleOnDeck[] + gripperWithoutStep: boolean } const LOAD_COMMANDS: Array = [ @@ -57,6 +71,7 @@ function getWarningContent({ noCommands, pipettesWithoutStep, modulesWithoutStep, + gripperWithoutStep, }: MissingContent): WarningContent | null { if (noCommands) { return { @@ -73,6 +88,18 @@ function getWarningContent({ } } + if (gripperWithoutStep) { + return { + content: ( + <> +

{i18n.t('alert.export_warnings.unused_gripper.body1')}

+

{i18n.t('alert.export_warnings.unused_gripper.body2')}

+ + ), + heading: i18n.t('alert.export_warnings.unused_gripper.heading'), + } + } + const pipettesDetails = pipettesWithoutStep .map(pipette => `${pipette.mount} ${pipette.spec.displayName}`) .join(' and ') @@ -159,11 +186,16 @@ export function FileSidebar(props: Props): JSX.Element { modulesOnDeck, pipettesOnDeck, savedStepForms, + robotType, + additionalEquipment, } = props const [ showExportWarningModal, setShowExportWarningModal, ] = React.useState(false) + const isGripperAttached = Object.values(additionalEquipment).some( + equipment => equipment?.name === 'gripper' + ) const [showBlockingHint, setShowBlockingHint] = React.useState(false) @@ -174,20 +206,34 @@ export function FileSidebar(props: Props): JSX.Element { command => !LOAD_COMMANDS.includes(command.commandType) ) ?? [] + const gripperInUse = + fileData?.commands.find( + command => + command.commandType === 'moveLabware' && + command.params.strategy === 'usingGripper' + ) != null + const noCommands = fileData ? nonLoadCommands.length === 0 : true const pipettesWithoutStep = getUnusedEntities( pipettesOnDeck, savedStepForms, - 'pipette' + 'pipette', + robotType ) const modulesWithoutStep = getUnusedEntities( modulesOnDeck, savedStepForms, - 'moduleId' + 'moduleId', + robotType ) + const gripperWithoutStep = isGripperAttached && !gripperInUse + const hasWarning = - noCommands || modulesWithoutStep.length || pipettesWithoutStep.length + noCommands || + modulesWithoutStep.length || + pipettesWithoutStep.length || + gripperWithoutStep const warning = hasWarning && @@ -195,6 +241,7 @@ export function FileSidebar(props: Props): JSX.Element { noCommands, pipettesWithoutStep, modulesWithoutStep, + gripperWithoutStep, }) const getExportHintContent = (): { diff --git a/protocol-designer/src/components/FileSidebar/__tests__/FileSidebar.test.tsx b/protocol-designer/src/components/FileSidebar/__tests__/FileSidebar.test.tsx index 8c278e40e92..7a461299ea8 100644 --- a/protocol-designer/src/components/FileSidebar/__tests__/FileSidebar.test.tsx +++ b/protocol-designer/src/components/FileSidebar/__tests__/FileSidebar.test.tsx @@ -15,10 +15,12 @@ import { fixtureP300Single, } from '@opentrons/shared-data/pipette/fixtures/name' import fixture_tiprack_10_ul from '@opentrons/shared-data/labware/fixtures/2/fixture_tiprack_10_ul.json' -import { FileSidebar, v6WarningContent } from '../FileSidebar' import { useBlockingHint } from '../../Hints/useBlockingHint' +import { FileSidebar, v6WarningContent } from '../FileSidebar' jest.mock('../../Hints/useBlockingHint') +jest.mock('../../../file-data/selectors') +jest.mock('../../../step-forms/selectors') const mockUseBlockingHint = useBlockingHint as jest.MockedFunction< typeof useBlockingHint @@ -50,6 +52,8 @@ describe('FileSidebar', () => { pipettesOnDeck: {}, modulesOnDeck: {}, savedStepForms: {}, + robotType: 'OT-2 Standard', + additionalEquipment: {}, } commands = [ @@ -94,7 +98,6 @@ describe('FileSidebar', () => { afterEach(() => { jest.resetAllMocks() }) - it('create new button creates new protocol', () => { const wrapper = shallow() const createButton = wrapper.find(OutlineButton).at(0) @@ -154,6 +157,25 @@ describe('FileSidebar', () => { ) }) + it('warning modal is shown when export is clicked with unused gripper', () => { + const gripperId = 'gripperId' + props.modulesOnDeck = modulesOnDeck + props.savedStepForms = savedStepForms + // @ts-expect-error(sa, 2021-6-22): props.fileData might be null + props.fileData.commands = commands + props.additionalEquipment = { + [gripperId]: { name: 'gripper', id: gripperId }, + } + + const wrapper = shallow() + const downloadButton = wrapper.find(DeprecatedPrimaryButton).at(0) + downloadButton.simulate('click') + const alertModal = wrapper.find(AlertModal) + + expect(alertModal).toHaveLength(1) + expect(alertModal.prop('heading')).toEqual('Unused gripper') + }) + it('warning modal is shown when export is clicked with unused module', () => { props.modulesOnDeck = modulesOnDeck props.savedStepForms = savedStepForms diff --git a/protocol-designer/src/components/FileSidebar/index.ts b/protocol-designer/src/components/FileSidebar/index.ts index 70d0b0bb36c..85390d1cf49 100644 --- a/protocol-designer/src/components/FileSidebar/index.ts +++ b/protocol-designer/src/components/FileSidebar/index.ts @@ -3,11 +3,18 @@ import { i18n } from '../../localization' import { actions, selectors } from '../../navigation' import { selectors as fileDataSelectors } from '../../file-data' import { selectors as stepFormSelectors } from '../../step-forms' +import { getRobotType } from '../../file-data/selectors' +import { getAdditionalEquipment } from '../../step-forms/selectors' import { actions as loadFileActions, selectors as loadFileSelectors, } from '../../load-file' -import { FileSidebar as FileSidebarComponent, Props } from './FileSidebar' +import { + AdditionalEquipment, + FileSidebar as FileSidebarComponent, + Props, +} from './FileSidebar' +import type { RobotType } from '@opentrons/shared-data' import type { BaseState, ThunkDispatch } from '../../types' import type { SavedStepFormState, InitialDeckSetup } from '../../step-forms' @@ -19,6 +26,8 @@ interface SP { pipettesOnDeck: InitialDeckSetup['pipettes'] modulesOnDeck: InitialDeckSetup['modules'] savedStepForms: SavedStepFormState + robotType: RobotType + additionalEquipment: AdditionalEquipment } export const FileSidebar = connect( mapStateToProps, @@ -31,12 +40,17 @@ function mapStateToProps(state: BaseState): SP { const fileData = fileDataSelectors.createFile(state) const canDownload = selectors.getCurrentPage(state) !== 'file-splash' const initialDeckSetup = stepFormSelectors.getInitialDeckSetup(state) + const robotType = getRobotType(state) + const additionalEquipment = getAdditionalEquipment(state) + return { canDownload, fileData, pipettesOnDeck: initialDeckSetup.pipettes, modulesOnDeck: initialDeckSetup.modules, savedStepForms: stepFormSelectors.getSavedStepForms(state), + robotType: robotType, + additionalEquipment: additionalEquipment, // Ignore clicking 'CREATE NEW' button in these cases _canCreateNew: !selectors.getNewProtocolModal(state), _hasUnsavedChanges: loadFileSelectors.getHasUnsavedChanges(state), @@ -57,6 +71,8 @@ function mergeProps( pipettesOnDeck, modulesOnDeck, savedStepForms, + robotType, + additionalEquipment, } = stateProps const { dispatch } = dispatchProps return { @@ -77,5 +93,7 @@ function mergeProps( pipettesOnDeck, modulesOnDeck, savedStepForms, + robotType, + additionalEquipment, } } diff --git a/protocol-designer/src/components/FileSidebar/utils/__tests__/getUnusedEntities.test.ts b/protocol-designer/src/components/FileSidebar/utils/__tests__/getUnusedEntities.test.ts index 97f9051650e..0b9b2763a92 100644 --- a/protocol-designer/src/components/FileSidebar/utils/__tests__/getUnusedEntities.test.ts +++ b/protocol-designer/src/components/FileSidebar/utils/__tests__/getUnusedEntities.test.ts @@ -8,6 +8,8 @@ import { TEMPERATURE_MODULE_TYPE, MAGNETIC_MODULE_V1, TEMPERATURE_MODULE_V1, + MAGNETIC_BLOCK_TYPE, + MAGNETIC_BLOCK_V1, } from '@opentrons/shared-data' import { TEMPERATURE_DEACTIVATED } from '@opentrons/step-generation' import { SavedStepFormState } from '../../../../step-forms' @@ -41,7 +43,12 @@ describe('getUnusedEntities', () => { }, } - const result = getUnusedEntities(pipettesOnDeck, stepForms, 'pipette') + const result = getUnusedEntities( + pipettesOnDeck, + stepForms, + 'pipette', + 'OT-2 Standard' + ) expect(result).toEqual([pipettesOnDeck.pipette456]) }) @@ -82,7 +89,58 @@ describe('getUnusedEntities', () => { }, } - const result = getUnusedEntities(modulesOnDeck, stepForms, 'moduleId') + const result = getUnusedEntities( + modulesOnDeck, + stepForms, + 'moduleId', + 'OT-2 Standard' + ) + + expect(result).toEqual([modulesOnDeck.temperature456]) + }) + + it('filters out magnetic block and shows module entities not used in steps are returned for Flex', () => { + const stepForms: SavedStepFormState = { + step123: { + moduleId: 'magnet123', + id: 'step123', + magnetAction: 'engage', + engageHeight: '10', + stepType: 'magnet', + stepName: 'magnet', + stepDetails: '', + }, + } + const modulesOnDeck = { + magnet123: { + id: 'magnet123', + type: MAGNETIC_BLOCK_TYPE, + model: MAGNETIC_BLOCK_V1, + slot: '3', + moduleState: { + type: MAGNETIC_BLOCK_TYPE, + engaged: false, + }, + }, + temperature456: { + id: 'temperature456', + type: TEMPERATURE_MODULE_TYPE, + model: TEMPERATURE_MODULE_V1, + moduleState: { + type: TEMPERATURE_MODULE_TYPE, + status: TEMPERATURE_DEACTIVATED, + targetTemperature: null, + }, + slot: '9', + }, + } + + const result = getUnusedEntities( + modulesOnDeck, + stepForms, + 'moduleId', + 'OT-3 Standard' + ) expect(result).toEqual([modulesOnDeck.temperature456]) }) diff --git a/protocol-designer/src/components/FileSidebar/utils/getUnusedEntities.ts b/protocol-designer/src/components/FileSidebar/utils/getUnusedEntities.ts index 8e5a6867a2d..7968ef72ba2 100644 --- a/protocol-designer/src/components/FileSidebar/utils/getUnusedEntities.ts +++ b/protocol-designer/src/components/FileSidebar/utils/getUnusedEntities.ts @@ -1,23 +1,35 @@ import some from 'lodash/some' import reduce from 'lodash/reduce' +import { FLEX_ROBOT_TYPE, RobotType } from '@opentrons/shared-data' import type { SavedStepFormState } from '../../../step-forms' /** Pull out all entities never specified by step forms. Assumes that all forms share the entityKey */ export function getUnusedEntities( entities: Record, stepForms: SavedStepFormState, - entityKey: 'pipette' | 'moduleId' + entityKey: 'pipette' | 'moduleId', + robotType: RobotType ): T[] { - const a = reduce( + const unusedEntities = reduce( entities, (acc, entity: T, entityId): T[] => { const stepContainsEntity = some( stepForms, form => form[entityKey] === entityId ) + + if ( + robotType === FLEX_ROBOT_TYPE && + entityKey === 'moduleId' && + (entity as any).type === 'magneticBlockType' + ) { + return acc + } + return stepContainsEntity ? acc : [...acc, entity] }, [] as T[] ) - return a + + return unusedEntities } diff --git a/protocol-designer/src/components/StepEditForm/forms/MoveLabwareForm/index.tsx b/protocol-designer/src/components/StepEditForm/forms/MoveLabwareForm/index.tsx index 2f69c273174..cc474fa89dc 100644 --- a/protocol-designer/src/components/StepEditForm/forms/MoveLabwareForm/index.tsx +++ b/protocol-designer/src/components/StepEditForm/forms/MoveLabwareForm/index.tsx @@ -1,5 +1,12 @@ import * as React from 'react' -import { FormGroup } from '@opentrons/components' +import { useSelector } from 'react-redux' +import { + FormGroup, + Tooltip, + TOOLTIP_BOTTOM, + TOOLTIP_FIXED, + useHoverTooltip, +} from '@opentrons/components' import { i18n } from '../../../../localization' import { LabwareField, @@ -7,10 +14,22 @@ import { LabwareLocationField, } from '../../fields' import styles from '../../StepEditForm.css' -import type { StepFormProps } from '../../types' +import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' +import { getRobotType } from '../../../../file-data/selectors' +import { getAdditionalEquipment } from '../../../../step-forms/selectors' +import { StepFormProps } from '../../types' export const MoveLabwareForm = (props: StepFormProps): JSX.Element => { const { propsForFields } = props + const robotType = useSelector(getRobotType) + const additionalEquipment = useSelector(getAdditionalEquipment) + const isGripperAttached = Object.values(additionalEquipment).some( + equipment => equipment?.name === 'gripper' + ) + const [targetProps, tooltipProps] = useHoverTooltip({ + placement: TOOLTIP_BOTTOM, + strategy: TOOLTIP_FIXED, + }) return (
@@ -26,16 +45,34 @@ export const MoveLabwareForm = (props: StepFormProps): JSX.Element => { > - - - + {robotType === FLEX_ROBOT_TYPE ? ( + <> + {!isGripperAttached ? ( + + {i18n.t( + 'tooltip.step_fields.moveLabware.disabled.gripper_not_used' + )} + + ) : null} +
+ + + +
+ + ) : null}
= EditModules +const editGetAdditionalEquipment: jest.MockedFunction = getAdditionalEquipment describe('File Page', () => { let props: Props @@ -33,6 +35,7 @@ describe('File Page', () => { getState: () => ({ mock: 'this is a mocked out getState' }), } editModulesMock.mockImplementation(() =>
mock edit modules
) + editGetAdditionalEquipment.mockReturnValue({}) }) const render = (props: Props) => diff --git a/protocol-designer/src/components/modals/CreateFileWizard/MetadataTile.tsx b/protocol-designer/src/components/modals/CreateFileWizard/MetadataTile.tsx index 6aad2584ef0..74deacf3769 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/MetadataTile.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/MetadataTile.tsx @@ -14,9 +14,9 @@ import { } from '@opentrons/components' import { InputField } from './InputField' import { GoBackLink } from './GoBackLink' +import { HandleEnter } from './HandleEnter' import type { WizardTileProps } from './types' -import { HandleEnter } from './HandleEnter' export function MetadataTile(props: WizardTileProps): JSX.Element { const { i18n, t } = useTranslation() diff --git a/protocol-designer/src/components/modals/CreateFileWizard/index.tsx b/protocol-designer/src/components/modals/CreateFileWizard/index.tsx index b9d08a62fa2..ce6479472bc 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/index.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/index.tsx @@ -41,6 +41,7 @@ import * as labwareDefSelectors from '../../../labware-defs/selectors' import * as labwareDefActions from '../../../labware-defs/actions' import * as labwareIngredActions from '../../../labware-ingred/actions' import { actions as steplistActions } from '../../../steplist' +import { toggleIsGripperRequired } from '../../../step-forms/actions/additionalItems' import { RobotTypeTile } from './RobotTypeTile' import { MetadataTile } from './MetadataTile' import { FirstPipetteTypeTile, SecondPipetteTypeTile } from './PipetteTypeTile' @@ -183,6 +184,10 @@ export function CreateFileWizard(): JSX.Element | null { modules.forEach(moduleArgs => dispatch(stepFormActions.createModule(moduleArgs)) ) + // add gripper + if (values.additionalEquipment.includes('gripper')) { + dispatch(toggleIsGripperRequired()) + } // auto-generate tipracks for pipettes const newTiprackModels: string[] = uniq( pipettes.map(pipette => pipette.tiprackDefURI) diff --git a/protocol-designer/src/components/modals/CreateFileWizard/types.ts b/protocol-designer/src/components/modals/CreateFileWizard/types.ts index 7a833617724..0273da1126c 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/types.ts +++ b/protocol-designer/src/components/modals/CreateFileWizard/types.ts @@ -6,7 +6,7 @@ import type { import type { NewProtocolFields } from '../../../load-file' -type AdditionalEquipment = 'gripper' +export type AdditionalEquipment = 'gripper' export interface FormState { fields: NewProtocolFields diff --git a/protocol-designer/src/components/modals/EditModulesModal/ConnectedSlotMap.tsx b/protocol-designer/src/components/modals/EditModulesModal/ConnectedSlotMap.tsx index 9968ee36005..1280482675a 100644 --- a/protocol-designer/src/components/modals/EditModulesModal/ConnectedSlotMap.tsx +++ b/protocol-designer/src/components/modals/EditModulesModal/ConnectedSlotMap.tsx @@ -1,22 +1,25 @@ import * as React from 'react' import { useField } from 'formik' import { SlotMap } from '@opentrons/components' +import { RobotType } from '@opentrons/shared-data' import styles from './EditModules.css' interface ConnectedSlotMapProps { fieldName: string + robotType: RobotType } export const ConnectedSlotMap = ( props: ConnectedSlotMapProps ): JSX.Element | null => { - const { fieldName } = props + const { fieldName, robotType } = props const [field, meta] = useField(fieldName) return field.value ? (
) : null diff --git a/protocol-designer/src/components/modals/EditModulesModal/__tests__/EditModulesModal.test.tsx b/protocol-designer/src/components/modals/EditModulesModal/__tests__/EditModulesModal.test.tsx index 205ca846a58..83f2c646835 100644 --- a/protocol-designer/src/components/modals/EditModulesModal/__tests__/EditModulesModal.test.tsx +++ b/protocol-designer/src/components/modals/EditModulesModal/__tests__/EditModulesModal.test.tsx @@ -30,6 +30,7 @@ import { import * as moduleData from '../../../../modules/moduleData' import { MODELS_FOR_MODULE_TYPE } from '../../../../constants' import { selectors as featureSelectors } from '../../../../feature-flags' +import { getRobotType } from '../../../../file-data/selectors' import { getLabwareIsCompatible } from '../../../../utils/labwareModuleCompatibility' import { isModuleWithCollisionIssue } from '../../../modules/utils' import { PDAlert } from '../../../alerts/PDAlert' @@ -51,6 +52,7 @@ jest.mock('../../../../step-forms/selectors') jest.mock('../../../modules/utils') jest.mock('../../../../step-forms/utils') jest.mock('../form-state') +jest.mock('../../../../file-data/selectors') const MODEL_FIELD = 'selectedModel' const SLOT_FIELD = 'selectedSlot' @@ -73,10 +75,13 @@ const getLabwareOnSlotMock: jest.MockedFunction = getLabwareOnSlot const getIsLabwareAboveHeightMock: jest.MockedFunction = getIsLabwareAboveHeight +const getRobotTypeMock: jest.MockedFunction = getRobotType + describe('Edit Modules Modal', () => { let mockStore: any let props: EditModulesModalProps beforeEach(() => { + getRobotTypeMock.mockReturnValue('OT-2 Standard') getInitialDeckSetupMock.mockReturnValue(getMockDeckSetup()) getSlotIdsBlockedBySpanningMock.mockReturnValue([]) getLabwareOnSlotMock.mockReturnValue({}) diff --git a/protocol-designer/src/components/modals/EditModulesModal/index.tsx b/protocol-designer/src/components/modals/EditModulesModal/index.tsx index 8eb6c657fb1..2912ddbd3a4 100644 --- a/protocol-designer/src/components/modals/EditModulesModal/index.tsx +++ b/protocol-designer/src/components/modals/EditModulesModal/index.tsx @@ -18,6 +18,7 @@ import { HEATERSHAKER_MODULE_TYPE, ModuleType, ModuleModel, + OT2_STANDARD_MODEL, } from '@opentrons/shared-data' import { i18n } from '../../../localization' import { @@ -31,7 +32,8 @@ import { actions as stepFormActions, } from '../../../step-forms' import { - SUPPORTED_MODULE_SLOTS, + SUPPORTED_MODULE_SLOTS_OT2, + SUPPORTED_MODULE_SLOTS_FLEX, getAllModuleSlotsByType, } from '../../../modules/moduleData' import { selectors as featureFlagSelectors } from '../../../feature-flags' @@ -47,6 +49,7 @@ import { useResetSlotOnModelChange } from './form-state' import { ModuleOnDeck } from '../../../step-forms/types' import { ModelModuleInfo } from '../../EditModules' +import { getRobotType } from '../../../file-data/selectors' export interface EditModulesModalProps { moduleType: ModuleType @@ -75,7 +78,11 @@ export const EditModulesModal = (props: EditModulesModalProps): JSX.Element => { onCloseClick, moduleOnDeck, } = props - const supportedModuleSlot = SUPPORTED_MODULE_SLOTS[moduleType][0].value + const robotType = useSelector(getRobotType) + const supportedModuleSlot = + robotType === OT2_STANDARD_MODEL + ? SUPPORTED_MODULE_SLOTS_OT2[moduleType][0].value + : SUPPORTED_MODULE_SLOTS_FLEX[moduleType][0].value const initialDeckSetup = useSelector(stepFormSelectors.getInitialDeckSetup) const dispatch = useDispatch() @@ -222,6 +229,7 @@ const EditModulesModalComponent = ( const disabledModuleRestriction = useSelector( featureFlagSelectors.getDisableModuleRestrictions ) + const robotType = useSelector(getRobotType) const noCollisionIssue = selectedModel && !isModuleWithCollisionIssue(selectedModel) @@ -278,15 +286,18 @@ const EditModulesModalComponent = (
- + )}
diff --git a/protocol-designer/src/components/modules/EditModulesCard.tsx b/protocol-designer/src/components/modules/EditModulesCard.tsx index 4c4365c56da..d30b66b8624 100644 --- a/protocol-designer/src/components/modules/EditModulesCard.tsx +++ b/protocol-designer/src/components/modules/EditModulesCard.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { useSelector } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { Card } from '@opentrons/components' import { MAGNETIC_MODULE_TYPE, @@ -8,6 +8,7 @@ import { ModuleType, PipetteName, getPipetteNameSpecs, + FLEX_ROBOT_TYPE, } from '@opentrons/shared-data' import { selectors as stepFormSelectors, @@ -16,9 +17,12 @@ import { } from '../../step-forms' import { selectors as featureFlagSelectors } from '../../feature-flags' import { SUPPORTED_MODULE_TYPES } from '../../modules' +import { getAdditionalEquipment } from '../../step-forms/selectors' +import { toggleIsGripperRequired } from '../../step-forms/actions/additionalItems' import { getRobotType } from '../../file-data/selectors' import { CrashInfoBox } from './CrashInfoBox' import { ModuleRow } from './ModuleRow' +import { GripperRow } from './GripperRow' import { isModuleWithCollisionIssue } from './utils' import styles from './styles.css' @@ -29,10 +33,15 @@ export interface Props { export function EditModulesCard(props: Props): JSX.Element { const { modules, openEditModuleModal } = props - const pipettesByMount = useSelector( stepFormSelectors.getPipettesForEditPipetteForm ) + const additionalEquipment = useSelector(getAdditionalEquipment) + const isGripperAttached = Object.values(additionalEquipment).some( + equipment => equipment?.name === 'gripper' + ) + + const dispatch = useDispatch() const robotType = useSelector(getRobotType) const magneticModuleOnDeck = modules[MAGNETIC_MODULE_TYPE] @@ -67,18 +76,22 @@ export function EditModulesCard(props: Props): JSX.Element { ].some(pipetteSpecs => pipetteSpecs?.channels !== 1) const warningsEnabled = !moduleRestrictionsDisabled + const isFlex = robotType === FLEX_ROBOT_TYPE const SUPPORTED_MODULE_TYPES_FILTERED = SUPPORTED_MODULE_TYPES.filter( moduleType => - robotType === 'OT-3 Standard' + isFlex ? moduleType !== 'magneticModuleType' : moduleType !== 'magneticBlockType' ) + const handleGripperClick = (): void => { + dispatch(toggleIsGripperRequired()) + } return ( - +
- {warningsEnabled && ( + {warningsEnabled && !isFlex && ( ) } else { @@ -111,6 +125,12 @@ export function EditModulesCard(props: Props): JSX.Element { ) } })} + {isFlex ? ( + + ) : null}
) diff --git a/protocol-designer/src/components/modules/GripperRow.tsx b/protocol-designer/src/components/modules/GripperRow.tsx new file mode 100644 index 00000000000..35eefeec92c --- /dev/null +++ b/protocol-designer/src/components/modules/GripperRow.tsx @@ -0,0 +1,47 @@ +import * as React from 'react' +import styled from 'styled-components' +import { useTranslation } from 'react-i18next' +import { + OutlineButton, + Flex, + JUSTIFY_SPACE_BETWEEN, + ALIGN_CENTER, + DIRECTION_COLUMN, +} from '@opentrons/components' +import gripperImage from '../../images/flex_gripper.svg' +import styles from './styles.css' + +interface GripperRowProps { + handleGripper: () => void + isGripperAdded: boolean +} + +export function GripperRow(props: GripperRowProps): JSX.Element { + const { handleGripper, isGripperAdded } = props + const { i18n, t } = useTranslation() + + return ( + + +

Flex Gripper

+ +
+
+ + {isGripperAdded + ? i18n.format(t('shared.remove'), 'capitalize') + : i18n.format(t('shared.add'), 'capitalize')} + +
+
+ ) +} + +const AdditionalItemImage = styled.img` + width: 6rem; + max-height: 4rem; + display: block; +` diff --git a/protocol-designer/src/components/modules/ModuleRow.tsx b/protocol-designer/src/components/modules/ModuleRow.tsx index 435a2197e5e..1c87d93cfa7 100644 --- a/protocol-designer/src/components/modules/ModuleRow.tsx +++ b/protocol-designer/src/components/modules/ModuleRow.tsx @@ -12,6 +12,10 @@ import { SIZE_1, SPACING, } from '@opentrons/components' +import { + FLEX_ROBOT_TYPE, + THERMOCYCLER_MODULE_TYPE, +} from '@opentrons/shared-data' import { i18n } from '../../localization' import { actions as stepFormActions, ModuleOnDeck } from '../../step-forms' import { @@ -22,9 +26,10 @@ import { ModuleDiagram } from './ModuleDiagram' import { isModuleWithCollisionIssue } from './utils' import styles from './styles.css' -import { ModuleType } from '@opentrons/shared-data' +import type { ModuleType, RobotType } from '@opentrons/shared-data' interface Props { + robotType?: RobotType moduleOnDeck?: ModuleOnDeck showCollisionWarnings?: boolean type: ModuleType @@ -32,12 +37,17 @@ interface Props { } export function ModuleRow(props: Props): JSX.Element { - const { moduleOnDeck, openEditModuleModal, showCollisionWarnings } = props + const { + moduleOnDeck, + openEditModuleModal, + showCollisionWarnings, + robotType, + } = props const type: ModuleType = moduleOnDeck?.type || props.type + const isFlex = robotType === FLEX_ROBOT_TYPE const model = moduleOnDeck?.model const slot = moduleOnDeck?.slot - /* TODO (ka 2020-2-3): This logic is very specific to this individual implementation of SlotMap. Kept it here (for now?) because it spells out the different cases. @@ -66,8 +76,10 @@ export function ModuleRow(props: Props): JSX.Element { if (slot === SPAN7_8_10_11_SLOT) { slotDisplayName = 'Slot 7' occupiedSlotsForMap = ['7', '8', '10', '11'] + // TC on Flex + } else if (isFlex && type === THERMOCYCLER_MODULE_TYPE && slot === 'B1') { + occupiedSlotsForMap = ['A1', 'B1'] } - // If collisionSlots are populated, check which slot is occupied // and render module specific crash warning. This logic assumes // default module slot placement magnet = Slot1 temperature = Slot3 @@ -142,6 +154,7 @@ export function ModuleRow(props: Props): JSX.Element {
)} diff --git a/protocol-designer/src/components/modules/__tests__/EditModulesCard.test.tsx b/protocol-designer/src/components/modules/__tests__/EditModulesCard.test.tsx index c07cd5a34d3..b9876a26273 100644 --- a/protocol-designer/src/components/modules/__tests__/EditModulesCard.test.tsx +++ b/protocol-designer/src/components/modules/__tests__/EditModulesCard.test.tsx @@ -7,6 +7,8 @@ import { MAGNETIC_MODULE_V2, TEMPERATURE_MODULE_TYPE, TEMPERATURE_MODULE_V1, + OT2_ROBOT_TYPE, + FLEX_ROBOT_TYPE, } from '@opentrons/shared-data' import { TEMPERATURE_DEACTIVATED } from '@opentrons/step-generation' import { selectors as featureFlagSelectors } from '../../../feature-flags' @@ -16,10 +18,12 @@ import { } from '../../../step-forms' import { getRobotType } from '../../../file-data/selectors' import { FormPipette } from '../../../step-forms/types' +import { getAdditionalEquipment } from '../../../step-forms/selectors' import { SUPPORTED_MODULE_TYPES } from '../../../modules' import { EditModulesCard } from '../EditModulesCard' import { CrashInfoBox } from '../CrashInfoBox' import { ModuleRow } from '../ModuleRow' +import { GripperRow } from '../GripperRow' jest.mock('../../../feature-flags') jest.mock('../../../step-forms/selectors') @@ -34,7 +38,9 @@ const getPipettesForEditPipetteFormMock = stepFormSelectors.getPipettesForEditPi const mockGetRobotType = getRobotType as jest.MockedFunction< typeof getRobotType > - +const mockGetAdditionalEquipment = getAdditionalEquipment as jest.MockedFunction< + typeof getAdditionalEquipment +> describe('EditModulesCard', () => { let store: any let crashableMagneticModule: ModuleOnDeck | undefined @@ -72,8 +78,8 @@ describe('EditModulesCard', () => { pipetteName: 'p300_multi_test', tiprackDefURI: 'tiprack300', } - - mockGetRobotType.mockReturnValue('OT-2 Standard') + mockGetAdditionalEquipment.mockReturnValue({}) + mockGetRobotType.mockReturnValue(OT2_ROBOT_TYPE) getDisableModuleRestrictionsMock.mockReturnValue(false) getPipettesForEditPipetteFormMock.mockReturnValue({ left: crashablePipette, @@ -195,6 +201,7 @@ describe('EditModulesCard', () => { expect( wrapper.find(ModuleRow).filter({ type: MAGNETIC_MODULE_TYPE }).props() ).toEqual({ + robotType: OT2_ROBOT_TYPE, type: MAGNETIC_MODULE_TYPE, moduleOnDeck: crashableMagneticModule, showCollisionWarnings: true, @@ -218,7 +225,7 @@ describe('EditModulesCard', () => { }) }) it('displays module row with module to add when no moduleData for Flex', () => { - mockGetRobotType.mockReturnValue('OT-3 Standard') + mockGetRobotType.mockReturnValue(FLEX_ROBOT_TYPE) const wrapper = render(props) const SUPPORTED_MODULE_TYPES_FILTERED = SUPPORTED_MODULE_TYPES.filter( moduleType => moduleType !== 'magneticModuleType' @@ -233,4 +240,20 @@ describe('EditModulesCard', () => { }) }) }) + it('displays gripper row with no gripper', () => { + mockGetRobotType.mockReturnValue(FLEX_ROBOT_TYPE) + const wrapper = render(props) + expect(wrapper.find(GripperRow)).toHaveLength(1) + expect(wrapper.find(GripperRow).props().isGripperAdded).toEqual(false) + }) + it('displays gripper row with gripper attached', () => { + const mockGripperId = 'gripeprId' + mockGetRobotType.mockReturnValue(FLEX_ROBOT_TYPE) + mockGetAdditionalEquipment.mockReturnValue({ + [mockGripperId]: { name: 'gripper', id: mockGripperId }, + }) + const wrapper = render(props) + expect(wrapper.find(GripperRow)).toHaveLength(1) + expect(wrapper.find(GripperRow).props().isGripperAdded).toEqual(true) + }) }) diff --git a/protocol-designer/src/file-data/selectors/fileCreator.ts b/protocol-designer/src/file-data/selectors/fileCreator.ts index ee509377c2d..6373400f736 100644 --- a/protocol-designer/src/file-data/selectors/fileCreator.ts +++ b/protocol-designer/src/file-data/selectors/fileCreator.ts @@ -291,6 +291,8 @@ export const createFile: Selector = createSelector( [pipetteId: string]: { name: PipetteName } } + // TODO(jr 6/22/23): entire method should be replaced with the contents of robotType key + // on the protocol file instead of inferring it from pipette types. const getRobotModelFromPipettes = ( pipettes: RobotModel ): { @@ -299,7 +301,9 @@ export const createFile: Selector = createSelector( } => { const loadedPipettes = Object.values(pipettes) const pipetteGEN = loadedPipettes.some( - pipette => getPipetteNameSpecs(pipette.name)?.displayCategory === GEN3 + pipette => + getPipetteNameSpecs(pipette.name)?.displayCategory === GEN3 || + getPipetteNameSpecs(pipette.name)?.channels === 96 ) ? GEN3 : GEN2 diff --git a/protocol-designer/src/load-file/reducers.ts b/protocol-designer/src/load-file/reducers.ts index 4726ab0a6cc..129e40eed99 100644 --- a/protocol-designer/src/load-file/reducers.ts +++ b/protocol-designer/src/load-file/reducers.ts @@ -66,6 +66,7 @@ const unsavedChanges = ( case 'CREATE_MODULE': case 'DELETE_MODULE': case 'EDIT_MODULE': + case 'IS_GRIPPER_REQUIRED': return true default: diff --git a/protocol-designer/src/localization/en/alert.json b/protocol-designer/src/localization/en/alert.json index 0aecc51bbd4..fba5d2acb9c 100644 --- a/protocol-designer/src/localization/en/alert.json +++ b/protocol-designer/src/localization/en/alert.json @@ -201,6 +201,11 @@ "heading": "Unused modules", "body1": "The {{modulesDetails}} specified in your protocol are not currently used in any step. In order to run this protocol you will need to power up and connect the modules to your robot.", "body2": "If you don't intend to use these modules, please consider removing them from your protocol." + }, + "unused_gripper": { + "heading": "Unused gripper", + "body1": "The Flex Gripper is specified in your protocol is not currently used in any step. In order to run this protocol you will need to connect it to your robot.", + "body2": "If you don't intend to use the Flex Gripper, please consider removing it from your protocol." } }, "crash": { diff --git a/protocol-designer/src/localization/en/shared.json b/protocol-designer/src/localization/en/shared.json index c57c7aece34..5a2b4c32e8a 100644 --- a/protocol-designer/src/localization/en/shared.json +++ b/protocol-designer/src/localization/en/shared.json @@ -2,5 +2,7 @@ "exit": "exit", "go_back": "go back", "next": "next", - "step": "Step {{current}} / {{max}}" + "step": "Step {{current}} / {{max}}", + "remove": "remove", + "add": "add" } diff --git a/protocol-designer/src/localization/en/tooltip.json b/protocol-designer/src/localization/en/tooltip.json index 4fbdad37f45..b23c5312cf4 100644 --- a/protocol-designer/src/localization/en/tooltip.json +++ b/protocol-designer/src/localization/en/tooltip.json @@ -70,6 +70,11 @@ "blowout_checkbox": "Redundant with disposal volume" } }, + "moveLabware": { + "disabled": { + "gripper_not_used": "Attach Gripper to move labware" + } + }, "batch_edit": { "disabled": { "pipette-different": "Cannot edit unless selected steps share the same pipette.", diff --git a/protocol-designer/src/modules/moduleData.ts b/protocol-designer/src/modules/moduleData.ts index 9c8795828ef..874b0c63dc1 100644 --- a/protocol-designer/src/modules/moduleData.ts +++ b/protocol-designer/src/modules/moduleData.ts @@ -6,17 +6,18 @@ import { HEATERSHAKER_MODULE_TYPE, ModuleType, MAGNETIC_BLOCK_TYPE, + RobotType, } from '@opentrons/shared-data' import { DropdownOption } from '@opentrons/components' export const SUPPORTED_MODULE_TYPES: ModuleType[] = [ HEATERSHAKER_MODULE_TYPE, + MAGNETIC_BLOCK_TYPE, MAGNETIC_MODULE_TYPE, TEMPERATURE_MODULE_TYPE, THERMOCYCLER_MODULE_TYPE, - MAGNETIC_BLOCK_TYPE, ] type SupportedSlotMap = Record -export const SUPPORTED_MODULE_SLOTS: SupportedSlotMap = { +export const SUPPORTED_MODULE_SLOTS_OT2: SupportedSlotMap = { [MAGNETIC_MODULE_TYPE]: [ { name: 'Slot 1 (supported)', @@ -48,7 +49,39 @@ export const SUPPORTED_MODULE_SLOTS: SupportedSlotMap = { }, ], } -const ALL_MODULE_SLOTS: DropdownOption[] = [ +export const SUPPORTED_MODULE_SLOTS_FLEX: SupportedSlotMap = { + [MAGNETIC_MODULE_TYPE]: [ + { + name: 'Slot D1 (supported)', + value: 'D1', + }, + ], + [TEMPERATURE_MODULE_TYPE]: [ + { + name: 'Slot D3 (supported)', + value: 'D3', + }, + ], + [THERMOCYCLER_MODULE_TYPE]: [ + { + name: 'Thermocycler slots', + value: 'B1', + }, + ], + [HEATERSHAKER_MODULE_TYPE]: [ + { + name: 'Slot D1 (supported)', + value: 'D1', + }, + ], + [MAGNETIC_BLOCK_TYPE]: [ + { + name: 'Slot D2 (supported)', + value: 'D2', + }, + ], +} +const ALL_MODULE_SLOTS_OT2: DropdownOption[] = [ { name: 'Slot 1', value: '1', @@ -78,7 +111,8 @@ const ALL_MODULE_SLOTS: DropdownOption[] = [ value: '10', }, ] -const HEATER_SHAKER_SLOTS: DropdownOption[] = [ + +const HEATER_SHAKER_SLOTS_OT2: DropdownOption[] = [ { name: 'Slot 1', value: '1', @@ -104,22 +138,132 @@ const HEATER_SHAKER_SLOTS: DropdownOption[] = [ value: '10', }, ] +const HS_AND_TEMP_SLOTS_FLEX: DropdownOption[] = [ + { + name: 'Slot D1', + value: 'D1', + }, + { + name: 'Slot D3', + value: 'D3', + }, + { + name: 'Slot C1', + value: 'C1', + }, + { + name: 'Slot C3', + value: 'C3', + }, + { + name: 'Slot B1', + value: 'B1', + }, + { + name: 'Slot B3', + value: 'B3', + }, + { + name: 'Slot A1', + value: 'A1', + }, + { + name: 'Slot A3', + value: 'A3', + }, +] + +const MAG_BLOCK_SLOTS_FLEX: DropdownOption[] = [ + { + name: 'Slot D1', + value: 'D1', + }, + { + name: 'Slot D2', + value: 'D2', + }, + { + name: 'Slot D3', + value: 'D3', + }, + { + name: 'Slot C1', + value: 'C1', + }, + { + name: 'Slot C2', + value: 'C2', + }, + { + name: 'Slot C3', + value: 'C3', + }, + { + name: 'Slot B1', + value: 'B1', + }, + { + name: 'Slot B2', + value: 'B2', + }, + { + name: 'Slot B3', + value: 'B3', + }, + { + name: 'Slot A1', + value: 'A1', + }, + { + name: 'Slot A2', + value: 'A2', + }, + { + name: 'Slot A3', + value: 'A3', + }, +] export function getAllModuleSlotsByType( - moduleType: ModuleType + moduleType: ModuleType, + robotType: RobotType ): DropdownOption[] { - const supportedSlotOption = SUPPORTED_MODULE_SLOTS[moduleType] + const supportedSlotOption = + robotType === 'OT-2 Standard' + ? SUPPORTED_MODULE_SLOTS_OT2[moduleType] + : SUPPORTED_MODULE_SLOTS_FLEX[moduleType] - if (moduleType === THERMOCYCLER_MODULE_TYPE) { - return supportedSlotOption - } - if (moduleType === HEATERSHAKER_MODULE_TYPE) { - return supportedSlotOption.concat( - HEATER_SHAKER_SLOTS.filter(s => s.value !== supportedSlotOption[0].value) + let slot = supportedSlotOption + + if (robotType === 'OT-2 Standard') { + if (moduleType === THERMOCYCLER_MODULE_TYPE) { + slot = supportedSlotOption + } + if (moduleType === HEATERSHAKER_MODULE_TYPE) { + slot = supportedSlotOption.concat( + HEATER_SHAKER_SLOTS_OT2.filter( + s => s.value !== supportedSlotOption[0].value + ) + ) + } + const allOtherSlots = ALL_MODULE_SLOTS_OT2.filter( + s => s.value !== supportedSlotOption[0].value ) + slot = supportedSlotOption.concat(allOtherSlots) + } else { + if (moduleType === THERMOCYCLER_MODULE_TYPE) { + slot = supportedSlotOption + } else if ( + moduleType === HEATERSHAKER_MODULE_TYPE || + moduleType === TEMPERATURE_MODULE_TYPE + ) { + slot = HS_AND_TEMP_SLOTS_FLEX.filter( + s => s.value !== supportedSlotOption[0].value + ) + } else { + slot = MAG_BLOCK_SLOTS_FLEX.filter( + s => s.value !== supportedSlotOption[0].value + ) + } } - - const allOtherSlots = ALL_MODULE_SLOTS.filter( - s => s.value !== supportedSlotOption[0].value - ) - return supportedSlotOption.concat(allOtherSlots) + return slot } diff --git a/protocol-designer/src/step-forms/actions/additionalItems.ts b/protocol-designer/src/step-forms/actions/additionalItems.ts new file mode 100644 index 00000000000..b7e53ceb5ca --- /dev/null +++ b/protocol-designer/src/step-forms/actions/additionalItems.ts @@ -0,0 +1,7 @@ +export interface ToggleIsGripperRequiredAction { + type: 'TOGGLE_IS_GRIPPER_REQUIRED' +} + +export const toggleIsGripperRequired = (): ToggleIsGripperRequiredAction => ({ + type: 'TOGGLE_IS_GRIPPER_REQUIRED', +}) diff --git a/protocol-designer/src/step-forms/reducers/index.ts b/protocol-designer/src/step-forms/reducers/index.ts index a401ccbc5ec..2b3fb861daa 100644 --- a/protocol-designer/src/step-forms/reducers/index.ts +++ b/protocol-designer/src/step-forms/reducers/index.ts @@ -7,6 +7,7 @@ import omit from 'lodash/omit' import omitBy from 'lodash/omitBy' import reduce from 'lodash/reduce' import { + FLEX_ROBOT_TYPE, getLabwareDefaultEngageHeight, getLabwareDefURI, getModuleType, @@ -47,7 +48,10 @@ import { getLabwareOnModule } from '../../ui/modules/utils' import { nestedCombineReducers } from './nestedCombineReducers' import { PROFILE_CYCLE, PROFILE_STEP } from '../../form-types' import { Reducer } from 'redux' -import { NormalizedPipetteById } from '@opentrons/step-generation' +import { + NormalizedAdditionalEquipmentById, + NormalizedPipetteById, +} from '@opentrons/step-generation' import { LoadFileAction } from '../../load-file' import { CreateContainerAction, @@ -111,6 +115,7 @@ import { ResetBatchEditFieldChangesAction, SaveStepFormsMultiAction, } from '../actions' +import { ToggleIsGripperRequiredAction } from '../actions/additionalItems' type FormState = FormData | null const unsavedFormInitialState = null // the `unsavedForm` state holds temporary form info that is saved or thrown away with "cancel". @@ -134,6 +139,7 @@ export type UnsavedFormActions = | EditProfileCycleAction | EditProfileStepAction | SelectMultipleStepsAction + | ToggleIsGripperRequiredAction export const unsavedForm = ( rootState: RootState, action: UnsavedFormActions @@ -193,6 +199,7 @@ export const unsavedForm = ( case 'CANCEL_STEP_FORM': case 'CREATE_MODULE': case 'DELETE_MODULE': + case 'TOGGLE_IS_GRIPPER_REQUIRED': case 'DELETE_STEP': case 'DELETE_MULTIPLE_STEPS': case 'SELECT_MULTIPLE_STEPS': @@ -481,6 +488,7 @@ export type SavedStepFormsActions = | SwapSlotContentsAction | ReplaceCustomLabwareDef | EditModuleAction + | ToggleIsGripperRequiredAction export const _editModuleFormUpdate = ({ savedForm, moduleId, @@ -983,7 +991,6 @@ export const savedStepForms = ( { ...savedStepForms } ) } - case 'REPLACE_CUSTOM_LABWARE_DEF': { // no mismatch, it's safe to keep all steps as they are if (!action.payload.isOverwriteMismatched) return savedStepForms @@ -1259,6 +1266,67 @@ export const pipetteInvariantProperties: Reducer< }, initialPipetteState ) + +const initialAdditionalEquipmentState = {} + +export const additionalEquipmentInvariantProperties = handleActions( + { + // @ts-expect-error + LOAD_FILE: ( + state, + action: LoadFileAction + ): NormalizedAdditionalEquipmentById => { + const { file } = action.payload + const gripper = file.commands.filter( + command => + // @ts-expect-error (jr, 6/22/23): moveLabware doesn't exist in schemav6 + command.commandType === 'moveLabware' && + // @ts-expect-error (jr, 6/22/23): moveLabware doesn't exist in schemav6 + command.params.strategy === 'usingGripper' + ) + const hasGripper = gripper.length > 0 + // @ts-expect-error (jr, 6/22/23): OT-3 Standard doesn't exist on schemav6 + const isOt3 = file.robot.model === FLEX_ROBOT_TYPE + const additionalEquipmentId = uuid() + const updatedEquipment = { + [additionalEquipmentId]: { + name: 'gripper' as const, + id: additionalEquipmentId, + }, + } + if (hasGripper && isOt3) { + return { ...state, ...updatedEquipment } + } else { + return { ...state } + } + }, + TOGGLE_IS_GRIPPER_REQUIRED: ( + state: NormalizedAdditionalEquipmentById + ): NormalizedAdditionalEquipmentById => { + const additionalEquipmentId = Object.keys(state)[0] + const existingEquipment = state[additionalEquipmentId] + + let updatedEquipment + + if (existingEquipment && existingEquipment.name === 'gripper') { + updatedEquipment = {} + } else { + const newAdditionalEquipmentId = uuid() + updatedEquipment = { + [newAdditionalEquipmentId]: { + name: 'gripper' as const, + id: newAdditionalEquipmentId, + }, + } + } + + return updatedEquipment + }, + DEFAULT: (): NormalizedAdditionalEquipmentById => ({}), + }, + initialAdditionalEquipmentState +) + export type OrderedStepIdsState = StepIdType[] const initialOrderedStepIdsState: string[] = [] // @ts-expect-error(sa, 2021-6-10): cannot use string literals as action type @@ -1379,6 +1447,7 @@ export interface RootState { labwareInvariantProperties: NormalizedLabwareById pipetteInvariantProperties: NormalizedPipetteById moduleInvariantProperties: ModuleEntities + additionalEquipmentInvariantProperties: NormalizedAdditionalEquipmentById presavedStepForm: PresavedStepFormState savedStepForms: SavedStepFormState unsavedForm: FormState @@ -1402,6 +1471,10 @@ export const rootReducer: Reducer = nestedCombineReducers( prevStateFallback.moduleInvariantProperties, action ), + additionalEquipmentInvariantProperties: additionalEquipmentInvariantProperties( + prevStateFallback.additionalEquipmentInvariantProperties, + action + ), labwareDefs: labwareDefsRootReducer(prevStateFallback.labwareDefs, action), // 'forms' reducers get full rootReducer state savedStepForms: savedStepForms(state, action), diff --git a/protocol-designer/src/step-forms/selectors/index.ts b/protocol-designer/src/step-forms/selectors/index.ts index 11ff1e00e39..6b412bcf285 100644 --- a/protocol-designer/src/step-forms/selectors/index.ts +++ b/protocol-designer/src/step-forms/selectors/index.ts @@ -15,7 +15,10 @@ import { PipetteName, MAGNETIC_BLOCK_TYPE, } from '@opentrons/shared-data' -import { TEMPERATURE_DEACTIVATED } from '@opentrons/step-generation' +import { + NormalizedAdditionalEquipmentById, + TEMPERATURE_DEACTIVATED, +} from '@opentrons/step-generation' import { INITIAL_DECK_SETUP_STEP_ID } from '../../constants' import { getFormWarnings, @@ -147,11 +150,22 @@ export const _getPipetteEntitiesRootState: ( labwareDefSelectors._getLabwareDefsByIdRootState, denormalizePipetteEntities ) + export const getPipetteEntities: Selector< BaseState, PipetteEntities > = createSelector(rootSelector, _getPipetteEntitiesRootState) +export const _getAdditionalEquipmentRootState: ( + arg: RootState +) => NormalizedAdditionalEquipmentById = rs => + rs.additionalEquipmentInvariantProperties + +export const getAdditionalEquipment: Selector< + BaseState, + NormalizedAdditionalEquipmentById +> = createSelector(rootSelector, _getAdditionalEquipmentRootState) + const _getInitialDeckSetupStepFormRootState: ( arg: RootState ) => FormData = rs => rs.savedStepForms[INITIAL_DECK_SETUP_STEP_ID] diff --git a/protocol-designer/src/step-forms/test/reducers.test.ts b/protocol-designer/src/step-forms/test/reducers.test.ts index 33cca837c49..4c5c4ad8192 100644 --- a/protocol-designer/src/step-forms/test/reducers.test.ts +++ b/protocol-designer/src/step-forms/test/reducers.test.ts @@ -336,6 +336,7 @@ describe('labwareInvariantProperties reducer', () => { }) }) }) + describe('moduleInvariantProperties reducer', () => { let prevState: Record const existingModuleId = 'existingModuleId' @@ -977,42 +978,39 @@ describe('savedStepForms reducer: initial deck setup step', () => { }> = [ { testName: 'create mag mod -> override mag step module id', - // @ts-expect-error(sa, 2021-6-14): not a valid module model action: { type: 'CREATE_MODULE', payload: { id: 'newMagdeckId', slot: '1', type: MAGNETIC_MODULE_TYPE, - model: 'someMagModel', + model: 'magneticModuleV1', }, }, expectedModuleId: 'newMagdeckId', }, { testName: 'create temp mod -> DO NOT override mag step module id', - // @ts-expect-error(sa, 2021-6-14): not a valid module model action: { type: 'CREATE_MODULE', payload: { id: 'tempdeckId', slot: '1', type: TEMPERATURE_MODULE_TYPE, - model: 'someTempModel', + model: 'temperatureModuleV1', }, }, expectedModuleId: 'magdeckId', }, { testName: 'create TC -> DO NOT override mag step module id', - // @ts-expect-error(sa, 2021-6-14): not a valid module model action: { type: 'CREATE_MODULE', payload: { id: 'ThermocyclerId', slot: '1', type: THERMOCYCLER_MODULE_TYPE, - model: 'someThermoModel', + model: 'thermocyclerModuleV1', }, }, expectedModuleId: 'magdeckId', @@ -1025,42 +1023,39 @@ describe('savedStepForms reducer: initial deck setup step', () => { }> = [ { testName: 'create TC -> override TC step module id', - // @ts-expect-error(sa, 2021-6-14): not a valid module model action: { type: 'CREATE_MODULE', payload: { id: 'NewTCId', slot: SPAN7_8_10_11_SLOT, type: THERMOCYCLER_MODULE_TYPE, - model: 'someTCModel', + model: 'thermocyclerModuleV1', }, }, expectedModuleId: 'NewTCId', }, { testName: 'create temp mod -> DO NOT override TC step module id', - // @ts-expect-error(sa, 2021-6-14): not a valid module model action: { type: 'CREATE_MODULE', payload: { id: 'tempdeckId', slot: '1', type: TEMPERATURE_MODULE_TYPE, - model: 'someTempModel', + model: 'temperatureModuleV1', }, }, expectedModuleId: 'TCId', }, { testName: 'create magnetic mod -> DO NOT override TC step module id', - // @ts-expect-error(sa, 2021-6-14): not a valid module model action: { type: 'CREATE_MODULE', payload: { id: 'newMagdeckId', slot: '1', type: MAGNETIC_MODULE_TYPE, - model: 'someMagModel', + model: 'magneticModuleV2', }, }, expectedModuleId: 'TCId', @@ -1652,6 +1647,7 @@ describe('unsavedForm reducer', () => { 'SAVE_STEP_FORM', 'SELECT_TERMINAL_ITEM', 'SELECT_MULTIPLE_STEPS', + 'TOGGLE_IS_GRIPPER_REQUIRED', ] actionTypes.forEach(actionType => { it(`should clear the unsaved form when any ${actionType} action is dispatched`, () => { diff --git a/protocol-designer/src/top-selectors/labware-locations/index.ts b/protocol-designer/src/top-selectors/labware-locations/index.ts index 2940a00e986..c9701fd1c86 100644 --- a/protocol-designer/src/top-selectors/labware-locations/index.ts +++ b/protocol-designer/src/top-selectors/labware-locations/index.ts @@ -1,5 +1,4 @@ import { createSelector } from 'reselect' -import { useSelector } from 'react-redux' import mapValues from 'lodash/mapValues' import { THERMOCYCLER_MODULE_TYPE, @@ -91,8 +90,8 @@ export const getUnocuppiedLabwareLocationOptions: Selector< > = createSelector( getRobotStateAtActiveItem, getModuleEntities, - (robotState, moduleEntities) => { - const robotType = useSelector(getRobotType) + getRobotType, + (robotState, moduleEntities, robotType) => { const deckDef = getDeckDefFromRobotType(robotType) const allSlotIds = deckDef.locations.orderedSlots.map(slot => slot.id) if (robotState == null) return null diff --git a/shared-data/protocol/index.ts b/shared-data/protocol/index.ts index 6a6e2a5478d..d4908043480 100644 --- a/shared-data/protocol/index.ts +++ b/shared-data/protocol/index.ts @@ -16,4 +16,5 @@ export type JsonProtocolFile = | Readonly> | Readonly> +// TODO(jr, 6/21/23): update to schemaV7 export * from './types/schemaV6' diff --git a/step-generation/src/types.ts b/step-generation/src/types.ts index 7820639432d..3c7d0849223 100644 --- a/step-generation/src/types.ts +++ b/step-generation/src/types.ts @@ -110,6 +110,13 @@ export interface NormalizedPipetteById { } } +export interface NormalizedAdditionalEquipmentById { + [additionalEquipmentId: string]: { + name: 'gripper' + id: string + } +} + export type NormalizedPipette = NormalizedPipetteById[keyof NormalizedPipetteById] // "entities" have only properties that are time-invariant @@ -123,7 +130,6 @@ export type PipetteEntity = NormalizedPipette & { export interface PipetteEntities { [pipetteId: string]: PipetteEntity } - // ===== MIX-IN TYPES ===== export type ChangeTipOptions = | 'always'