diff --git a/components/src/assets/localization/en/protocol_command_text.json b/components/src/assets/localization/en/protocol_command_text.json index e34b2dc8449..479e3223038 100644 --- a/components/src/assets/localization/en/protocol_command_text.json +++ b/components/src/assets/localization/en/protocol_command_text.json @@ -93,6 +93,7 @@ "pickup_tip": "Picking up tip(s) from {{well_range}} of {{labware}} in {{labware_location}}", "prepare_to_aspirate": "Preparing {{pipette}} to aspirate", "pressurizing_to_dispense": "Pressurize pipette to dispense {{volume}} µL from resin tip at {{flow_rate}} µL/sec", + "quantity": "Quantity: {{count}}", "reloading_labware": "Reloading {{labware}}", "return_tip": "Returning tip to {{well_name}} of {{labware}} in {{labware_location}}", "right": "Right", diff --git a/components/src/organisms/LabwareDetailsWithCount/LabwareDetailsWithCount.module.css b/components/src/organisms/LabwareDetailsWithCount/LabwareDetailsWithCount.module.css new file mode 100644 index 00000000000..aa9c29529d7 --- /dev/null +++ b/components/src/organisms/LabwareDetailsWithCount/LabwareDetailsWithCount.module.css @@ -0,0 +1,24 @@ +div.container { + width: 318px; + padding: var(--spacing-16) var(--spacing-8); + border-radius: var(--border-radius-4); + background-color: var(--grey-20); +} + +div.sub_title { + display: flex; + width: 100%; + flex-direction: column; + color: var(--grey-60); + gap: var(--spacing-8); +} + +div.label { + display: flex; + width: 88px; + height: 24px; + align-items: center; + justify-content: center; + border-radius: var(--border-radius-4); + background-color: var(--transparent-black-80); +} diff --git a/components/src/organisms/LabwareDetailsWithCount/LabwareDetailsWithCount.stories.tsx b/components/src/organisms/LabwareDetailsWithCount/LabwareDetailsWithCount.stories.tsx new file mode 100644 index 00000000000..eada25341f8 --- /dev/null +++ b/components/src/organisms/LabwareDetailsWithCount/LabwareDetailsWithCount.stories.tsx @@ -0,0 +1,26 @@ +import { LabwareDetailsWithCount } from './index' + +import type { Meta, StoryObj } from '@storybook/react' + +const meta: Meta = { + title: 'Helix/Organisms/LabwareDetailsWithCount', + component: LabwareDetailsWithCount, + decorators: [ + Story => ( +
+ +
+ ), + ], +} +export default meta + +type Story = StoryObj + +export const LabwareDetailsWithCountStory: Story = { + args: { + title: 'Opentrons Flex 96 Tip Rack 1000 µL', + subTitle: 'With tip rack lid', + quantity: 1, + }, +} diff --git a/components/src/organisms/LabwareDetailsWithCount/__tests__/LabwareDetailsWithCount.test.tsx b/components/src/organisms/LabwareDetailsWithCount/__tests__/LabwareDetailsWithCount.test.tsx new file mode 100644 index 00000000000..73b074ada79 --- /dev/null +++ b/components/src/organisms/LabwareDetailsWithCount/__tests__/LabwareDetailsWithCount.test.tsx @@ -0,0 +1,59 @@ +import { useTranslation } from 'react-i18next' +import { screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { LabwareDetailsWithCount } from '..' +import { renderWithProviders } from '../../../testing/utils' + +import type { ComponentProps } from 'react' + +vi.mock('react-i18next', () => ({ + useTranslation: vi.fn(), + initReactI18next: vi.fn(), +})) + +vi.mock('i18next', () => { + return { + default: { + use: () => ({ init: vi.fn() }), + createInstance: () => ({ + use: () => ({ init: vi.fn() }), + init: vi.fn(), + t: (k: string) => k, + }), + init: vi.fn(), + t: (k: string) => k, + }, + } +}) +const render = (props: ComponentProps) => { + return renderWithProviders() +} +describe('LabwareDetailsWithCount', () => { + let props: ComponentProps + const t = vi.fn(key => key) + beforeEach(() => { + props = { + title: 'Title', + subTitle: 'SubTitle', + quantity: 1, + } + vi.mocked(useTranslation).mockReturnValue({ t } as any) + }) + + it('should render title, subTitle and label', () => { + render(props) + expect(screen.getByText('Title')).toBeInTheDocument() + expect(screen.getByText('SubTitle')).toBeInTheDocument() + expect(screen.getByText('quantity')).toBeInTheDocument() + }) + + it('should render title without subTitle and label', () => { + props.subTitle = undefined + props.quantity = undefined + render(props) + expect(screen.getByText('Title')).toBeInTheDocument() + expect(screen.queryByText('SubTitle')).not.toBeInTheDocument() + expect(screen.queryByText('quantity')).not.toBeInTheDocument() + }) +}) diff --git a/components/src/organisms/LabwareDetailsWithCount/index.tsx b/components/src/organisms/LabwareDetailsWithCount/index.tsx new file mode 100644 index 00000000000..8f53ad1ccb2 --- /dev/null +++ b/components/src/organisms/LabwareDetailsWithCount/index.tsx @@ -0,0 +1,31 @@ +import { useTranslation } from 'react-i18next' + +import { StyledText, Tag } from '../../atoms' +import styles from './LabwareDetailsWithCount.module.css' + +interface LabwareDetailsWithCountProps { + title: string + subTitle?: string + quantity?: number +} + +export function LabwareDetailsWithCount({ + title, + subTitle, + quantity: label, +}: LabwareDetailsWithCountProps): JSX.Element { + const { t } = useTranslation('protocol_command_text') + return ( +
+ {title} +
+ {subTitle} +
+ {label != null ? ( +
+ +
+ ) : null} +
+ ) +} diff --git a/components/src/organisms/index.ts b/components/src/organisms/index.ts index c06b079cf62..2d033a3c7ad 100644 --- a/components/src/organisms/index.ts +++ b/components/src/organisms/index.ts @@ -1,6 +1,7 @@ export * from './CommandText' export * from './DeckLabelSet' export * from './FixtureOption' +export * from './LabwareDetailsWithCount' export * from './LabwareInfoOverlay' export * from './ProtocolDeck' export * from './Toolbox' diff --git a/protocol-designer/src/assets/localization/en/application.json b/protocol-designer/src/assets/localization/en/application.json index 6c68eb2b178..3f2b76ce877 100644 --- a/protocol-designer/src/assets/localization/en/application.json +++ b/protocol-designer/src/assets/localization/en/application.json @@ -31,25 +31,26 @@ "pipettes": "Pipettes", "protocol_name": "Protocol Name", "save": "save", + "select": "Select", + "selected": "Selected", + "source": "Source", "stepType": { "absorbanceReader": "absorbance plate reader", "camera": "camera", "comment": "comment", "ending_hold": "ending hold", + "flexStacker": "flex stacker", "heaterShaker": "heater-shaker", "magnet": "magnet", "mix": "mix", "moveLabware": "move", "moveLiquid": "transfer", "pause": "pause", - "profile_steps": "profile steps", "profile": "Program a Thermocycler profile", + "profile_steps": "profile steps", "temperature": "temperature", "thermocycler": "thermocycler" }, - "select": "Select", - "selected": "Selected", - "source": "Source", "temperature": "Temperature (°C)", "time": "Time", "units": { @@ -60,8 +61,8 @@ "microliterPerSec": "µL/s", "millimeter": "mm", "millimeterPerSec": "mm/s", - "nanometer": "nm", "minutes": "m", + "nanometer": "nm", "rpm": "rpm", "seconds": "s", "seconds_long": "seconds", diff --git a/protocol-designer/src/assets/localization/en/protocol_steps.json b/protocol-designer/src/assets/localization/en/protocol_steps.json index 30c48527136..2e5e82e3978 100644 --- a/protocol-designer/src/assets/localization/en/protocol_steps.json +++ b/protocol-designer/src/assets/localization/en/protocol_steps.json @@ -25,6 +25,9 @@ "blowout_location": "Blowout location", "blowout_position": "Blowout position from top", "bottom_of_stack": "Bottom of stack", + "camera": { + "capture_image": "Capture image of the deck" + }, "captions_for_fields": { "blockTargetTemp": "Valid range between 4 and 99 °C", "blockTargetTempHold": "Valid range between 4 and 99 °C", @@ -36,9 +39,6 @@ "targetTemperature": "Valid range between 4 and 95 °C", "volume": "Recommended between 0.1 and {{max}}" }, - "camera": { - "capture_image": "Capture image of the deck" - }, "change_tips": "Change tips", "column": "Column", "comfirm_reset_settings": { @@ -67,6 +67,29 @@ "edit_step": "Edit step", "ending_deck": "Ending deck", "engage_height": "Engage height", + "flex_stacker": { + "label": "Flex Stacker", + "module_controls": { + "empty_label": "Empty", + "empty_sublabel": "Manually empty all labware from the stacker", + "label": "Module controls", + "refill_label": "Refill", + "refill_sublabel": "Refill the stacker with labware. Manually fill the stacker with more labware", + "retrieve_label": "Retrieve", + "retrieve_sublabel": "Retrieve labware from the stacker onto the shuttle" + }, + "shuttle": { + "label": "Shuttle", + "no_labware": "No labware on shuttle" + }, + "stacker": { + "label": "Stacker", + "labware_filled": "{{amount}}/{{total}} labware filled", + "no_labware": "No labware stored on stacker", + "quantity": "Quantity: {{count}}" + } + }, + "flexStacker": "Stacker", "flow_rate_builder": "Flow rate builder", "flow_type_title": "{{type}} flow rate", "from": "from", @@ -97,8 +120,8 @@ }, "heater_shaker_state": "Heater-Shaker state", "in": "in", - "into": "into", "individual_wells": "Individual wells", + "into": "into", "labware_in": "Labware in", "labware_to": "{{labware}} to", "liquids": "{{num}} liquids", @@ -127,12 +150,12 @@ "of": "of", "off_deck": "Off-Deck", "pause": { + "forDuration": "For {{duration}}", + "pausingForDuration": "Pausing for", "pausingUntilResume": "Pausing until manually told to resume", "pausingUntilTemperature": "Pausing until{{module}}reaches", - "pausingForDuration": "Pausing for", "untilResume": "Until told to resume", - "untilTemperature": "Until {{temperature}} °C reached", - "forDuration": "For {{duration}}" + "untilTemperature": "Until {{temperature}} °C reached" }, "pipette": "Pipette", "pipette_path": "Pipette path", @@ -198,11 +221,15 @@ "block_value": "{{value}} °C", "block_value_off": "Off", "lid_label": "Lid set to", - "lid_value": "{{value}} °C", - "lid_value_off": "Off", "lid_position_label": "Lid position", + "lid_position_value_closed": "Closed", "lid_position_value_open": "Open", - "lid_position_value_closed": "Closed" + "lid_value": "{{value}} °C", + "lid_value_off": "Off" + }, + "profile_timeline": { + "start": "Start profile", + "wait_for_complete": "Wait for profile to complete" }, "repeat": "Repeat {{repetitions}} times", "substep_settings": "Set block temperature tofor", @@ -218,10 +245,6 @@ "block": "Set thermocycler block to", "lid_position": "Lid position", "lid_temperature": "Set thermocycler lid to" - }, - "profile_timeline": { - "start": "Start profile", - "wait_for_complete": "Wait for profile to complete" } }, "time": "Time", diff --git a/protocol-designer/src/form-types.ts b/protocol-designer/src/form-types.ts index 6679855f227..be280032d2e 100644 --- a/protocol-designer/src/form-types.ts +++ b/protocol-designer/src/form-types.ts @@ -172,6 +172,7 @@ export type StepType = | 'pause' | 'temperature' | 'thermocycler' + | 'flexStacker' export const stepIconsByType: Record = { absorbanceReader: 'ot-absorbance', @@ -186,6 +187,7 @@ export const stepIconsByType: Record = { temperature: 'ot-temperature-v2', thermocycler: 'ot-thermocycler', heaterShaker: 'ot-heater-shaker', + flexStacker: 'ot-flex-stacker', } // ===== Unprocessed form types ===== export interface AnnotationFields { @@ -498,6 +500,13 @@ export interface HydratedAbsorbanceReaderFormData extends AnnotationFields { wavelengths: string[] } +// TODO(TZ, 2025-12-03): not fully flushed out, but this is the initial hydrated form data for the flex stacker form +export interface HydratedFlexStackerFormData extends AnnotationFields { + stepType: 'flexStacker' + id: string + moduleId: string +} + // fields used in TipPositionInput export type TipZOffsetFields = | 'aspirate_mmFromBottom' @@ -616,3 +625,4 @@ export type HydratedFormData = | HydratedPauseFormData | HydratedTemperatureFormData | HydratedThermocyclerFormData + | HydratedFlexStackerFormData diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx index 576c36fb5a8..cf81fa893a1 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx @@ -62,6 +62,7 @@ import { AbsorbanceReaderTools, CameraTools, CommentTools, + FlexStackerToolsContainer, HeaterShakerTools, MagnetTools, MixTools, @@ -107,6 +108,7 @@ const STEP_FORM_MAP: StepFormMap = { comment: CommentTools, camera: CameraTools, absorbanceReader: AbsorbanceReaderTools, + flexStacker: FlexStackerToolsContainer, } // used to inform StepFormToolbox when to prompt user confirmation for overriding advanced settings @@ -535,7 +537,10 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { desktopStyle="bodyLargeSemiBold" css={LINE_CLAMP_TEXT_STYLE(2, true)} > - {capitalizeFirstLetter(String(formData.stepName))} + {/* TODO: use module object from form.json instead */} + {formData.stepType === 'flexStacker' + ? t(`protocol_steps:${formData.stepType}`) + : capitalizeFirstLetter(String(formData.stepName))} } diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/FlexStackerTools/FlexStackerTools.module.css b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/FlexStackerTools/FlexStackerTools.module.css new file mode 100644 index 00000000000..e877dcc7c63 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/FlexStackerTools/FlexStackerTools.module.css @@ -0,0 +1,16 @@ +.space_between { + display: flex; + justify-content: space-between; +} + +div.container { + display: flex; + width: 100%; + flex-direction: column; + padding-top: var(--spacing-16); + gap: var(--spacing-8); +} + +.padding_x { + padding: 0 var(--spacing-16); +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/FlexStackerTools/__tests__/FlexStackerTools.test.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/FlexStackerTools/__tests__/FlexStackerTools.test.tsx new file mode 100644 index 00000000000..e5f0e09643a --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/FlexStackerTools/__tests__/FlexStackerTools.test.tsx @@ -0,0 +1,179 @@ +import { screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { FLEX_STACKER_MODULE_TYPE } from '@opentrons/shared-data' + +import { renderWithProviders } from '/protocol-designer/__testing-utils__' +import { i18n } from '/protocol-designer/assets/localization' + +import { FlexStackerTools } from '..' + +import type { ComponentProps } from 'react' + +const render = (props: ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('FlexStackerTools', () => { + let props: ComponentProps + beforeEach(() => { + props = { + propsForFields: {} as any, + formData: { moduleId: 'mockId' } as any, + toolboxStep: 0, + showFormErrors: false, + focusHandlers: {} as any, + tab: 'aspirate', + setTab: vi.fn(), + setShowFormErrors: vi.fn(), + robotState: { + pipettes: {}, + labware: {}, + tipState: { + tipracks: {}, + pipettes: {}, + }, + liquidState: { + pipettes: {}, + labware: {}, + trashBins: {}, + wasteChute: {}, + }, + modules: { + mockId: { + moduleState: { + type: FLEX_STACKER_MODULE_TYPE, + maxPoolCount: 6, + storedLabwareDetails: { + primaryLabware: { + loadName: 'mockLabwareId', + namespace: 'mockLabwareNamespace', + version: 1, + }, + lidLabware: { + loadName: 'mockLidLabwareId', + namespace: 'mockLidLabwareNamespace', + version: 1, + }, + initialCount: 1, + }, + labwareInHopper: ['mockLabwareId'], + labwareOnShuttle: null, + }, + }, + }, + } as any, + flexStackerOptions: [{ name: 'mock module', value: 'mockId' }], + } + }) + + it('should render view only', () => { + render(props) + expect(screen.getByText('Choose option')).toBeInTheDocument() + expect(screen.getByText('Shuttle')).toBeInTheDocument() + expect(screen.getByText('Stacker')).toBeInTheDocument() + expect(screen.getByText('Module controls')).toBeInTheDocument() + expect(screen.getByText('Retrieve')).toBeInTheDocument() + expect(screen.getByText('Refill')).toBeInTheDocument() + expect(screen.getByText('Empty')).toBeInTheDocument() + }) + + it('should render view with labware in hopper', () => { + props.robotState = { + ...props.robotState!, + modules: { + ...props.robotState?.modules, + mockId: { + moduleState: { + type: FLEX_STACKER_MODULE_TYPE, + maxPoolCount: 6, + storedLabwareDetails: { + primaryLabware: { + loadName: 'mockLabwareId', + namespace: 'mockLabwareNamespace', + version: 1, + }, + lidLabware: { + loadName: 'mockLidLabwareId', + namespace: 'mockLidLabwareNamespace', + version: 1, + }, + initialCount: 1, + }, + labwareInHopper: ['mockLabwareId'], + labwareOnShuttle: null, + }, + }, + } as any, + } + render(props) + expect(screen.getByText('1/6 labware filled')).toBeInTheDocument() + expect(screen.getByText('mockLabwareId')).toBeInTheDocument() + expect(screen.getByText('mockLidLabwareId')).toBeInTheDocument() + expect(screen.getByText('Quantity: 1')).toBeInTheDocument() + }) + + it('should render view with no labware in hopper', () => { + props.robotState = { + ...props.robotState!, + modules: { + ...props.robotState?.modules, + mockId: { + moduleState: { + ...props.robotState?.modules?.mockId?.moduleState, + labwareInHopper: [], + storedLabwareDetails: null, + labwareOnShuttle: null, + }, + }, + } as any, + } + render(props) + expect(screen.getByText('No labware stored on stacker')).toBeInTheDocument() + }) + + it('should render view with labware on shuttle', () => { + props.robotState = { + ...props.robotState!, + modules: { + ...props.robotState?.modules, + mockId: { + moduleState: { + ...props.robotState?.modules?.mockId?.moduleState, + labwareOnShuttle: [ + { + primaryLabwareId: 'mockLabwareId', + adapterLabwareId: null, + lidLabwareId: null, + }, + ], + }, + }, + } as any, + } + render(props) + expect(screen.getByText('Shuttle')).toBeInTheDocument() + expect(screen.getByText('mockLabwareId')).toBeInTheDocument() + expect(screen.getByText('mockLidLabwareId')).toBeInTheDocument() + expect(screen.queryByText('Quantity: 0')).not.toBeInTheDocument() + }) + + it('should render view with no labware on shuttle', () => { + props.robotState = { + ...props.robotState!, + modules: { + ...props.robotState?.modules, + mockId: { + moduleState: { + ...props.robotState?.modules?.mockId?.moduleState, + labwareOnShuttle: null, + }, + }, + } as any, + } + render(props) + expect(screen.getByText('No labware on shuttle')).toBeInTheDocument() + }) +}) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/FlexStackerTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/FlexStackerTools/index.tsx new file mode 100644 index 00000000000..c9a4bf84a44 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/FlexStackerTools/index.tsx @@ -0,0 +1,184 @@ +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' + +import { + Divider, + Icon, + InfoScreen, + LabwareDetailsWithCount, + RadioButton, + StyledText, +} from '@opentrons/components' + +import { DropdownStepFormField } from '/protocol-designer/components/molecules' +import { getRobotStateAtActiveItem } from '/protocol-designer/top-selectors/labware-locations' +import { getFlexStackerLabwareOptions } from '/protocol-designer/ui/modules/selectors' + +import styles from './FlexStackerTools.module.css' + +import type { DropdownOption } from '@opentrons/components' +import type { + FlexStackerModuleState, + TimelineFrame, +} from '@opentrons/step-generation' +import type { StepFormProps } from '../../types' + +export type FlexStackerToolsProps = StepFormProps & { + robotState: TimelineFrame | null + flexStackerOptions: DropdownOption[] +} + +export function FlexStackerTools(props: FlexStackerToolsProps): JSX.Element { + const { formData, propsForFields, robotState, flexStackerOptions } = props + const { moduleId } = formData + const { t } = useTranslation(['application', 'form', 'protocol_steps']) + + const { modules } = robotState ?? {} + + const flexStackerModuleState = modules?.[moduleId] + ?.moduleState as FlexStackerModuleState | null + + const labwareInHopperCount = + flexStackerModuleState?.labwareInHopper?.length ?? 0 + const maxPoolCount = flexStackerModuleState?.maxPoolCount ?? 0 + const labwareOnShuttle = flexStackerModuleState?.labwareOnShuttle ?? null + + console.log('labwareOnShuttle:', labwareOnShuttle) + const labwareFiledComponent = ( +
+ + {t('protocol_steps:flex_stacker.stacker.label')} + + + {labwareInHopperCount > 0 ? ( + + {t('protocol_steps:flex_stacker.stacker.labware_filled', { + amount: labwareInHopperCount, + total: maxPoolCount, + })} + + ) : null} +
+ ) + + return ( +
+ {}} + /> + +
+ {labwareFiledComponent} + {flexStackerModuleState?.storedLabwareDetails != null ? ( + + ) : ( + + )} +
+ +
+ + {t('protocol_steps:flex_stacker.shuttle.label')} + +
+ {labwareOnShuttle != null && + flexStackerModuleState?.storedLabwareDetails != null ? ( + + ) : ( + + )} +
+
+ +
+
+ + {t('protocol_steps:flex_stacker.module_controls.label')} + + +
+ Retrieve + } + buttonSubLabel={{ + align: 'vertical', + label: t( + 'protocol_steps:flex_stacker.module_controls.retrieve_sublabel' + ), + }} + onChange={() => {}} + largeDesktopBorderRadius + /> + { + console.log('e:', e) + }} + largeDesktopBorderRadius + /> + {}} + largeDesktopBorderRadius + /> +
+
+ ) +} + +export const FlexStackerToolsContainer = ( + props: StepFormProps +): JSX.Element => { + const robotState = useSelector(getRobotStateAtActiveItem) + const flexStackerOptions = useSelector(getFlexStackerLabwareOptions) + + return ( + + ) +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/index.ts b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/index.ts index a9ac37933e7..c1f80316782 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/index.ts +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/index.ts @@ -1,4 +1,5 @@ export { AbsorbanceReaderTools } from './AbsorbanceReaderTools' +export { FlexStackerToolsContainer } from './FlexStackerTools' export { CameraTools } from './CameraTools' export { CommentTools } from './CommentTools' export { HeaterShakerTools } from './HeaterShakerTools' diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/AddStepButton.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/AddStepButton.tsx index 0a67ec82ff7..a841842fde0 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/AddStepButton.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/Timeline/AddStepButton.tsx @@ -27,6 +27,7 @@ import { } from '@opentrons/components' import { ABSORBANCE_READER_TYPE, + FLEX_STACKER_MODULE_TYPE, getIsLid, getIsTiprack, HEATERSHAKER_MODULE_TYPE, @@ -133,6 +134,7 @@ export function AddStepButton({ 'magnet', 'temperature', 'thermocycler', + 'flexStacker', ] const isStepTypeEnabled: Record< Exclude, @@ -149,6 +151,7 @@ export function AddStepButton({ thermocycler: getIsModuleOnDeck(modules, THERMOCYCLER_MODULE_TYPE), heaterShaker: getIsModuleOnDeck(modules, HEATERSHAKER_MODULE_TYPE), absorbanceReader: getIsModuleOnDeck(modules, ABSORBANCE_READER_TYPE), + flexStacker: getIsModuleOnDeck(modules, FLEX_STACKER_MODULE_TYPE), } const addStep = (stepType: StepType): ReturnType => diff --git a/protocol-designer/src/step-forms/utils/createPresavedStepForm.ts b/protocol-designer/src/step-forms/utils/createPresavedStepForm.ts index 2de88ebcce3..ce8680446f5 100644 --- a/protocol-designer/src/step-forms/utils/createPresavedStepForm.ts +++ b/protocol-designer/src/step-forms/utils/createPresavedStepForm.ts @@ -3,6 +3,7 @@ import last from 'lodash/last' import { ABSORBANCE_READER_TYPE, ALL, + FLEX_STACKER_MODULE_TYPE, HEATERSHAKER_MODULE_TYPE, MAGNETIC_MODULE_TYPE, TEMPERATURE_MODULE_TYPE, @@ -319,6 +320,38 @@ const _patchHeaterShakerModuleId = return null } +const _patchFlexStackerModuleId = + (args: { + initialDeckSetup: InitialDeckSetup + orderedStepIds: OrderedStepIdsState + savedStepForms: SavedStepFormState + stepType: StepType + robotStateTimeline: Timeline + }): FormUpdater => + () => { + const { initialDeckSetup, stepType } = args + const numOfModules = + Object.values(initialDeckSetup.modules).filter( + module => module.type === FLEX_STACKER_MODULE_TYPE + )?.length ?? 1 + const hasFlexStackerModuleId = stepType === 'flexStacker' + // pre-select form type if module is set + if (hasFlexStackerModuleId && numOfModules === 1) { + const moduleId = + getModuleOnDeckByType(initialDeckSetup, FLEX_STACKER_MODULE_TYPE)?.id ?? + null + + if (moduleId == null) { + return null + } + // get labware details in hopper at moment + return { + moduleId, + } + } + return null + } + const _patchAbsorbanceReaderModuleId = (args: { initialDeckSetup: InitialDeckSetup @@ -494,6 +527,14 @@ export const createPresavedStepForm = ({ robotStateTimeline, }) + const updateFlexStackerModuleId = _patchFlexStackerModuleId({ + initialDeckSetup, + orderedStepIds, + savedStepForms, + stepType, + robotStateTimeline, + }) + const updateThermocyclerFields = _patchThermocyclerFields({ initialDeckSetup, stepType, @@ -515,6 +556,7 @@ export const createPresavedStepForm = ({ updateHeaterShakerModuleId, updateMagneticModuleId, updateAbsorbanceReaderModuleId, + updateFlexStackerModuleId, updateDefaultLabwareLocations, updateMoveLabwareFields, ].reduce( diff --git a/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts b/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts index 3c23fc0fad6..325ff63828b 100644 --- a/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts +++ b/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts @@ -251,6 +251,15 @@ export function getDefaultsForStepType( referenceWavelengthActive: false, wavelengths: [Object.keys(ABSORBANCE_READER_COLOR_BY_WAVELENGTH)[0]], // default to first known wavelength } + // TODO(TZ, 2025-12-03): not fully flushed out, but this is the initial state for the flex stacker form + case 'flexStacker': + return { + moduleId: null, + labwareInHopper: null, + labwareOnShuttle: null, + maxPoolCount: 0, + storedLabwareDetails: null, + } default: return {} } diff --git a/protocol-designer/src/steplist/formLevel/index.ts b/protocol-designer/src/steplist/formLevel/index.ts index 73f498ae790..779859fd192 100644 --- a/protocol-designer/src/steplist/formLevel/index.ts +++ b/protocol-designer/src/steplist/formLevel/index.ts @@ -97,6 +97,7 @@ import type { HydratedAbsorbanceReaderFormData, HydratedCameraFormData, HydratedCommentFormData, + HydratedFlexStackerFormData, HydratedFormData, HydratedHeaterShakerFormData, HydratedMagnetFormData, @@ -137,6 +138,7 @@ interface StepFormDataMap { thermocycler: HydratedThermocyclerFormData comment: HydratedCommentFormData camera: HydratedCameraFormData + flexStacker: HydratedFlexStackerFormData } interface FormHelpers { getErrors: ( @@ -290,6 +292,9 @@ const stepFormHelperMap: { camera: { getErrors: composeErrors(), }, + flexStacker: { + getErrors: composeErrors(), + }, } export const getFormErrors = ( @@ -380,6 +385,12 @@ export const getFormErrors = ( moduleEntities, labwareEntities ) + case 'flexStacker': + return stepFormHelperMap[stepType].getErrors( + formData as HydratedFlexStackerFormData, + moduleEntities, + labwareEntities + ) } } diff --git a/protocol-designer/src/ui/modules/selectors.ts b/protocol-designer/src/ui/modules/selectors.ts index 4bd964286f4..1b8a3537926 100644 --- a/protocol-designer/src/ui/modules/selectors.ts +++ b/protocol-designer/src/ui/modules/selectors.ts @@ -3,6 +3,7 @@ import { createSelector } from 'reselect' import { ABSORBANCE_READER_TYPE, + FLEX_STACKER_MODULE_TYPE, getLabwareDisplayName, HEATERSHAKER_MODULE_TYPE, MAGNETIC_MODULE_TYPE, @@ -99,6 +100,21 @@ export const getAbsorbanceReaderLabwareOptions: Selector = } ) +/** Returns dropdown option for labware placed on flex stacker module */ +export const getFlexStackerLabwareOptions: Selector = + createSelector( + getInitialDeckSetup, + getLabwareNicknamesById, + (initialDeckSetup, nicknamesById) => { + const flexStackerModuleOptions = getModuleLabwareOptions( + initialDeckSetup, + nicknamesById, + FLEX_STACKER_MODULE_TYPE + ) + return flexStackerModuleOptions + } + ) + /** Get single magnetic module (assumes no multiples) */ export const getSingleMagneticModuleId: Selector = createSelector( diff --git a/protocol-designer/src/ui/steps/actions/thunks/index.ts b/protocol-designer/src/ui/steps/actions/thunks/index.ts index 5f212dd2244..6aa57992a1d 100644 --- a/protocol-designer/src/ui/steps/actions/thunks/index.ts +++ b/protocol-designer/src/ui/steps/actions/thunks/index.ts @@ -2,6 +2,7 @@ import last from 'lodash/last' import { ABSORBANCE_READER_TYPE, + FLEX_STACKER_MODULE_TYPE, HEATERSHAKER_MODULE_TYPE, MAGNETIC_MODULE_TYPE, TEMPERATURE_MODULE_TYPE, @@ -151,6 +152,20 @@ export const addAndSelectStep: (arg: { }) ) } + } else if (payload.stepType === 'flexStacker') { + const flexStackerModules = Object.entries(modules).filter( + ([key, module]) => module.type === FLEX_STACKER_MODULE_TYPE + ) + const flexStackerId = + flexStackerModules.length === 1 ? flexStackerModules[0][0] : null + if (flexStackerId != null) { + dispatch( + selectDropdownItem({ + selection: { id: flexStackerId, text: 'Selected', field: '1' }, + mode: 'add', + }) + ) + } } else if (payload.stepType === 'mix' || payload.stepType === 'moveLiquid') { const labwares = Object.entries(labware).filter( ([key, lw]) => diff --git a/step-generation/src/types.ts b/step-generation/src/types.ts index 9bd9b2a2636..b5be196d33e 100644 --- a/step-generation/src/types.ts +++ b/step-generation/src/types.ts @@ -112,6 +112,7 @@ export interface FlexStackerModuleState { type: typeof FLEX_STACKER_MODULE_TYPE maxPoolCount: number storedLabwareDetails: FlexStackerSetStoredLabwareParams | null + // labware in hopper is the bottom up labwareInHopper: FlexStackerStoredLabwareGroup[] | null labwareOnShuttle: FlexStackerStoredLabwareGroup | null }