diff --git a/opentrons-ai-client/src/assets/images/modules/MagneticBlock_GEN1_HERO.png b/opentrons-ai-client/src/assets/images/modules/MagneticBlock_GEN1_HERO.png
new file mode 100644
index 00000000000..180dd977498
Binary files /dev/null and b/opentrons-ai-client/src/assets/images/modules/MagneticBlock_GEN1_HERO.png differ
diff --git a/opentrons-ai-client/src/assets/images/modules/heater_shaker_module_transparent.png b/opentrons-ai-client/src/assets/images/modules/heater_shaker_module_transparent.png
new file mode 100644
index 00000000000..beefd651e45
Binary files /dev/null and b/opentrons-ai-client/src/assets/images/modules/heater_shaker_module_transparent.png differ
diff --git a/opentrons-ai-client/src/assets/images/modules/heatershaker.png b/opentrons-ai-client/src/assets/images/modules/heatershaker.png
new file mode 100644
index 00000000000..1df848f09e1
Binary files /dev/null and b/opentrons-ai-client/src/assets/images/modules/heatershaker.png differ
diff --git a/opentrons-ai-client/src/assets/images/modules/mag_block.png b/opentrons-ai-client/src/assets/images/modules/mag_block.png
new file mode 100644
index 00000000000..474f5775dd5
Binary files /dev/null and b/opentrons-ai-client/src/assets/images/modules/mag_block.png differ
diff --git a/opentrons-ai-client/src/assets/images/modules/magdeck_gen1.png b/opentrons-ai-client/src/assets/images/modules/magdeck_gen1.png
new file mode 100644
index 00000000000..7243441981a
Binary files /dev/null and b/opentrons-ai-client/src/assets/images/modules/magdeck_gen1.png differ
diff --git a/opentrons-ai-client/src/assets/images/modules/magdeck_gen2.png b/opentrons-ai-client/src/assets/images/modules/magdeck_gen2.png
new file mode 100644
index 00000000000..ec8bd0d0d79
Binary files /dev/null and b/opentrons-ai-client/src/assets/images/modules/magdeck_gen2.png differ
diff --git a/opentrons-ai-client/src/assets/images/modules/magdeck_tempdeck_combined.png b/opentrons-ai-client/src/assets/images/modules/magdeck_tempdeck_combined.png
new file mode 100644
index 00000000000..c2dbc55a869
Binary files /dev/null and b/opentrons-ai-client/src/assets/images/modules/magdeck_tempdeck_combined.png differ
diff --git a/opentrons-ai-client/src/assets/images/modules/module_pipette_collision_warning.png b/opentrons-ai-client/src/assets/images/modules/module_pipette_collision_warning.png
new file mode 100644
index 00000000000..788d4b5b932
Binary files /dev/null and b/opentrons-ai-client/src/assets/images/modules/module_pipette_collision_warning.png differ
diff --git a/opentrons-ai-client/src/assets/images/modules/temp_deck_gen_2_transparent.png b/opentrons-ai-client/src/assets/images/modules/temp_deck_gen_2_transparent.png
new file mode 100644
index 00000000000..6f8799ea74a
Binary files /dev/null and b/opentrons-ai-client/src/assets/images/modules/temp_deck_gen_2_transparent.png differ
diff --git a/opentrons-ai-client/src/assets/images/modules/tempdeck_gen1.png b/opentrons-ai-client/src/assets/images/modules/tempdeck_gen1.png
new file mode 100644
index 00000000000..668c1d7d911
Binary files /dev/null and b/opentrons-ai-client/src/assets/images/modules/tempdeck_gen1.png differ
diff --git a/opentrons-ai-client/src/assets/images/modules/tempdeck_gen2.png b/opentrons-ai-client/src/assets/images/modules/tempdeck_gen2.png
new file mode 100644
index 00000000000..aaf5948e2ee
Binary files /dev/null and b/opentrons-ai-client/src/assets/images/modules/tempdeck_gen2.png differ
diff --git a/opentrons-ai-client/src/assets/images/modules/thermocycler.png b/opentrons-ai-client/src/assets/images/modules/thermocycler.png
new file mode 100644
index 00000000000..fdae9b79c49
Binary files /dev/null and b/opentrons-ai-client/src/assets/images/modules/thermocycler.png differ
diff --git a/opentrons-ai-client/src/assets/images/modules/thermocycler_gen2.png b/opentrons-ai-client/src/assets/images/modules/thermocycler_gen2.png
new file mode 100644
index 00000000000..e17a723c5d9
Binary files /dev/null and b/opentrons-ai-client/src/assets/images/modules/thermocycler_gen2.png differ
diff --git a/opentrons-ai-client/src/assets/localization/en/create_protocol.json b/opentrons-ai-client/src/assets/localization/en/create_protocol.json
index 55c225ba35b..6c891525041 100644
--- a/opentrons-ai-client/src/assets/localization/en/create_protocol.json
+++ b/opentrons-ai-client/src/assets/localization/en/create_protocol.json
@@ -28,6 +28,13 @@
"flex_gripper": "Flex Gripper",
"flex_gripper_no_label": "No, do not use the Flex Gripper",
"modules_title": "Modules",
+ "no_modules_added_yet": "No modules added yet",
+ "modules_remove_label": "remove",
+ "modules_adapter_label": "Adapter",
+ "heater_shaker_module_v1": "Heater-Shaker Module GEN1",
+ "temperature_module_v2": "Temperature Module GEN2",
+ "thermocycler_module_v2": "Thermocycler Module GEN2",
+ "magnetic_module_v1": "Magnetic Block GEN1",
"labware_liquids_title": "Labware & Liquids",
"steps_title": "Steps"
}
diff --git a/opentrons-ai-client/src/molecules/ControlledEmptySelectorButtonGroup/__tests__/ControlledEmptySelectorButtonGroup.test.tsx b/opentrons-ai-client/src/molecules/ControlledEmptySelectorButtonGroup/__tests__/ControlledEmptySelectorButtonGroup.test.tsx
new file mode 100644
index 00000000000..efb82f1a482
--- /dev/null
+++ b/opentrons-ai-client/src/molecules/ControlledEmptySelectorButtonGroup/__tests__/ControlledEmptySelectorButtonGroup.test.tsx
@@ -0,0 +1,74 @@
+import { renderWithProviders } from '../../../__testing-utils__'
+import { i18n } from '../../../i18n'
+import { ControlledEmptySelectorButtonGroup } from '../index'
+import { describe, it, expect } from 'vitest'
+import { fireEvent, screen } from '@testing-library/react'
+import { FormProvider, useForm } from 'react-hook-form'
+import { MODULES_FIELD_NAME } from '../../../organisms/ModulesSection'
+import type { DisplayModules } from '../../../organisms/ModulesSection'
+
+const modulesMock: DisplayModules[] = [
+ {
+ type: 'heaterShakerModuleType',
+ model: 'heaterShakerModuleV1',
+ name: 'Heater-Shaker Module GEN1',
+ },
+ {
+ type: 'temperatureModuleType',
+ model: 'temperatureModuleV2',
+ name: 'Temperature Module GEN2',
+ },
+]
+
+const TestFormProviderComponent = () => {
+ const methods = useForm({})
+
+ const selectedValue = methods.watch(MODULES_FIELD_NAME) ?? []
+
+ return (
+
+
+
+ {'selected values: ' + selectedValue.map((m: DisplayModules) => m.name)}
+
+ )
+}
+
+const render = (): ReturnType => {
+ return renderWithProviders(, {
+ i18nInstance: i18n,
+ })
+}
+
+describe('ControlledEmptySelectorButtonGroup', () => {
+ it('should render ControlledEmptySelectorButtonGroup component', () => {
+ render()
+
+ screen.getByText('Heater-Shaker Module GEN1')
+ screen.getByText('Temperature Module GEN2')
+ })
+
+ it('should add the value when the button is clicked', async () => {
+ render()
+
+ const button1 = screen.getByText('Heater-Shaker Module GEN1')
+
+ expect(
+ screen.queryByText(
+ 'selected values: Heater-Shaker Module GEN1,Temperature Module GEN2'
+ )
+ ).not.toBeInTheDocument()
+
+ fireEvent.click(button1)
+
+ const button2 = screen.getByText('Temperature Module GEN2')
+
+ fireEvent.click(button2)
+
+ expect(
+ await screen.findByText(
+ 'selected values: Heater-Shaker Module GEN1,Temperature Module GEN2'
+ )
+ ).toBeInTheDocument()
+ })
+})
diff --git a/opentrons-ai-client/src/molecules/ControlledEmptySelectorButtonGroup/index.tsx b/opentrons-ai-client/src/molecules/ControlledEmptySelectorButtonGroup/index.tsx
new file mode 100644
index 00000000000..ad9791f1fcd
--- /dev/null
+++ b/opentrons-ai-client/src/molecules/ControlledEmptySelectorButtonGroup/index.tsx
@@ -0,0 +1,40 @@
+import { Flex, WRAP, SPACING, EmptySelectorButton } from '@opentrons/components'
+import { Controller, useFormContext } from 'react-hook-form'
+import type { DisplayModules } from '../../organisms/ModulesSection'
+import { MODULES_FIELD_NAME } from '../../organisms/ModulesSection'
+
+export function ControlledEmptySelectorButtonGroup({
+ modules,
+}: {
+ modules: DisplayModules[]
+}): JSX.Element | null {
+ const { watch } = useFormContext()
+ const modulesWatch: DisplayModules[] = watch(MODULES_FIELD_NAME) ?? []
+
+ return (
+ {
+ return (
+
+ {modules.map(module => (
+ {
+ if (modulesWatch.some(m => m.type === module.type)) {
+ return
+ }
+ field.onChange([...modulesWatch, module])
+ }}
+ text={module.name}
+ textAlignment="left"
+ />
+ ))}
+
+ )
+ }}
+ />
+ )
+}
diff --git a/opentrons-ai-client/src/molecules/ModelDiagram/index.tsx b/opentrons-ai-client/src/molecules/ModelDiagram/index.tsx
new file mode 100644
index 00000000000..e4211e7df98
--- /dev/null
+++ b/opentrons-ai-client/src/molecules/ModelDiagram/index.tsx
@@ -0,0 +1,80 @@
+import { css } from 'styled-components'
+import {
+ MAGNETIC_MODULE_TYPE,
+ TEMPERATURE_MODULE_TYPE,
+ THERMOCYCLER_MODULE_TYPE,
+ MAGNETIC_MODULE_V1,
+ MAGNETIC_MODULE_V2,
+ TEMPERATURE_MODULE_V1,
+ TEMPERATURE_MODULE_V2,
+ THERMOCYCLER_MODULE_V1,
+ HEATERSHAKER_MODULE_TYPE,
+ HEATERSHAKER_MODULE_V1,
+ THERMOCYCLER_MODULE_V2,
+ MAGNETIC_BLOCK_TYPE,
+ MAGNETIC_BLOCK_V1,
+ ABSORBANCE_READER_TYPE,
+ ABSORBANCE_READER_V1,
+} from '@opentrons/shared-data'
+
+import magdeck_gen1 from '../../assets/images/modules/magdeck_gen1.png'
+import magdeck_gen2 from '../../assets/images/modules/magdeck_gen2.png'
+import tempdeck_gen1 from '../../assets/images/modules/tempdeck_gen1.png'
+import temp_deck_gen_2_transparent from '../../assets/images/modules/temp_deck_gen_2_transparent.png'
+import thermocycler from '../../assets/images/modules/thermocycler.png'
+import thermocycler_gen2 from '../../assets/images/modules/thermocycler_gen2.png'
+import heater_shaker_module_transparent from '../../assets/images/modules/heater_shaker_module_transparent.png'
+import mag_block from '../../assets/images/modules/MagneticBlock_GEN1_HERO.png'
+import type { ModuleType, ModuleModel } from '@opentrons/shared-data'
+
+interface Props {
+ type: ModuleType
+ model: ModuleModel
+}
+
+type ModuleImg = {
+ [type in ModuleType]: {
+ [model in ModuleModel]?: string
+ }
+}
+
+const MODULE_IMG_BY_TYPE: ModuleImg = {
+ [MAGNETIC_MODULE_TYPE]: {
+ [MAGNETIC_MODULE_V1]: magdeck_gen1,
+ [MAGNETIC_MODULE_V2]: magdeck_gen2,
+ },
+ [TEMPERATURE_MODULE_TYPE]: {
+ [TEMPERATURE_MODULE_V1]: tempdeck_gen1,
+ [TEMPERATURE_MODULE_V2]: temp_deck_gen_2_transparent,
+ },
+ [THERMOCYCLER_MODULE_TYPE]: {
+ [THERMOCYCLER_MODULE_V1]: thermocycler,
+ [THERMOCYCLER_MODULE_V2]: thermocycler_gen2,
+ },
+ [HEATERSHAKER_MODULE_TYPE]: {
+ [HEATERSHAKER_MODULE_V1]: heater_shaker_module_transparent,
+ },
+ [MAGNETIC_BLOCK_TYPE]: {
+ [MAGNETIC_BLOCK_V1]: mag_block,
+ },
+ [ABSORBANCE_READER_TYPE]: {
+ // TODO (AA): update absorbance reader image
+ [ABSORBANCE_READER_V1]: heater_shaker_module_transparent,
+ },
+}
+
+const IMAGE_MAX_WIDTH = '96px'
+export function ModuleDiagram(props: Props): JSX.Element {
+ const model = MODULE_IMG_BY_TYPE[props.type][props.model]
+ return (
+
+ )
+}
diff --git a/opentrons-ai-client/src/molecules/ModuleListItemGroup/__tests__/ModuleListItemGroup.test.tsx b/opentrons-ai-client/src/molecules/ModuleListItemGroup/__tests__/ModuleListItemGroup.test.tsx
new file mode 100644
index 00000000000..5c04e3a6b44
--- /dev/null
+++ b/opentrons-ai-client/src/molecules/ModuleListItemGroup/__tests__/ModuleListItemGroup.test.tsx
@@ -0,0 +1,93 @@
+import { renderWithProviders } from '../../../__testing-utils__'
+import { i18n } from '../../../i18n'
+import { ModuleListItemGroup } from '../index'
+import { describe, it, expect } from 'vitest'
+import { fireEvent, screen } from '@testing-library/react'
+import { FormProvider, useForm } from 'react-hook-form'
+import type { DisplayModules } from '../../../organisms/ModulesSection'
+
+const modulesMock: DisplayModules[] = [
+ {
+ type: 'heaterShakerModuleType',
+ model: 'heaterShakerModuleV1',
+ name: 'Heater-Shaker Module GEN1',
+ },
+ {
+ type: 'temperatureModuleType',
+ model: 'temperatureModuleV2',
+ name: 'Temperature Module GEN2',
+ },
+]
+
+const TestFormProviderComponent = () => {
+ const methods = useForm({
+ defaultValues: {
+ modules: modulesMock,
+ },
+ })
+
+ return (
+
+
+
+ )
+}
+
+const render = (): ReturnType => {
+ return renderWithProviders(, {
+ i18nInstance: i18n,
+ })
+}
+
+describe('ModuleListItemGroup', () => {
+ it('should render ModuleListItemGroup component', () => {
+ render()
+
+ expect(screen.getAllByText('Adapter').length).toBe(2)
+ expect(screen.getAllByText('remove').length).toBe(2)
+
+ screen.getByAltText('heaterShakerModuleType')
+ screen.getByText('Heater-Shaker Module GEN1')
+
+ screen.getByAltText('temperatureModuleType')
+ screen.getByText('Temperature Module GEN2')
+ })
+
+ it('should remove the list item if remove is clicked', async () => {
+ render()
+
+ const removeListItemButton = screen.getAllByText('remove')[0]
+
+ fireEvent.click(removeListItemButton)
+
+ expect(
+ screen.queryByText('Heater-Shaker Module GEN1')
+ ).not.toBeInTheDocument()
+ })
+
+ it('should render the dropdown if adapters are available', () => {
+ render()
+
+ expect(screen.getAllByText('Choose an adapter').length).toBe(2)
+ })
+
+ it('should be able to select an adapter', () => {
+ render()
+
+ const dropdownButton = screen.getAllByText('Choose an adapter')[1]
+
+ fireEvent.click(dropdownButton)
+
+ const adapterOption = screen.getByText(
+ 'Opentrons 24 Well Aluminum Block with Generic 2 mL Screwcap'
+ )
+
+ fireEvent.click(adapterOption)
+
+ expect(
+ screen.getByText(
+ 'Opentrons 24 Well Aluminum Block with Generic 2 mL Screwcap'
+ )
+ ).toBeInTheDocument()
+ })
+})
diff --git a/opentrons-ai-client/src/molecules/ModuleListItemGroup/index.tsx b/opentrons-ai-client/src/molecules/ModuleListItemGroup/index.tsx
new file mode 100644
index 00000000000..878600fc97f
--- /dev/null
+++ b/opentrons-ai-client/src/molecules/ModuleListItemGroup/index.tsx
@@ -0,0 +1,173 @@
+import {
+ Flex,
+ SPACING,
+ ALIGN_CENTER,
+ BORDERS,
+ COLORS,
+ ListItem,
+ ListItemCustomize,
+} from '@opentrons/components'
+import type { DropdownBorder } from '@opentrons/components'
+import {
+ ABSORBANCE_READER_TYPE,
+ getAllDefinitions,
+ getModuleDisplayName,
+ HEATERSHAKER_MODULE_TYPE,
+ MAGNETIC_BLOCK_TYPE,
+ MAGNETIC_MODULE_TYPE,
+ TEMPERATURE_MODULE_TYPE,
+ THERMOCYCLER_MODULE_TYPE,
+} from '@opentrons/shared-data'
+import type { ModuleType } from '@opentrons/shared-data'
+import { Controller, useFormContext } from 'react-hook-form'
+import { ModuleDiagram } from '../ModelDiagram'
+import { MODULES_FIELD_NAME } from '../../organisms/ModulesSection'
+import type { DisplayModules } from '../../organisms/ModulesSection'
+import { useTranslation } from 'react-i18next'
+import { useMemo } from 'react'
+
+export const RECOMMENDED_LABWARE_BY_MODULE: { [K in ModuleType]: string[] } = {
+ [TEMPERATURE_MODULE_TYPE]: [
+ 'opentrons_24_aluminumblock_generic_2ml_screwcap',
+ 'opentrons_96_well_aluminum_block',
+ 'opentrons_96_aluminumblock_generic_pcr_strip_200ul',
+ 'opentrons_24_aluminumblock_nest_1.5ml_screwcap',
+ 'opentrons_24_aluminumblock_nest_1.5ml_snapcap',
+ 'opentrons_24_aluminumblock_nest_2ml_screwcap',
+ 'opentrons_24_aluminumblock_nest_2ml_snapcap',
+ 'opentrons_24_aluminumblock_nest_0.5ml_screwcap',
+ 'opentrons_aluminum_flat_bottom_plate',
+ 'opentrons_96_deep_well_temp_mod_adapter',
+ ],
+ [MAGNETIC_MODULE_TYPE]: [
+ 'nest_96_wellplate_100ul_pcr_full_skirt',
+ 'nest_96_wellplate_2ml_deep',
+ 'opentrons_96_wellplate_200ul_pcr_full_skirt',
+ ],
+ [THERMOCYCLER_MODULE_TYPE]: [
+ 'nest_96_wellplate_100ul_pcr_full_skirt',
+ 'opentrons_96_wellplate_200ul_pcr_full_skirt',
+ ],
+ [HEATERSHAKER_MODULE_TYPE]: [
+ 'opentrons_96_deep_well_adapter',
+ 'opentrons_96_flat_bottom_adapter',
+ 'opentrons_96_pcr_adapter',
+ 'opentrons_universal_flat_adapter',
+ ],
+ [MAGNETIC_BLOCK_TYPE]: [
+ 'nest_96_wellplate_100ul_pcr_full_skirt',
+ 'nest_96_wellplate_2ml_deep',
+ 'opentrons_96_wellplate_200ul_pcr_full_skirt',
+ ],
+ [ABSORBANCE_READER_TYPE]: [
+ 'opentrons_flex_lid_absorbance_plate_reader_module',
+ ],
+}
+
+export function ModuleListItemGroup(): JSX.Element | null {
+ const { watch, setValue } = useFormContext()
+ const { t } = useTranslation('create_protocol')
+ const modulesWatch: DisplayModules[] = watch(MODULES_FIELD_NAME) ?? []
+
+ const allDefinitionsValues = useMemo(
+ () => Object.values(getAllDefinitions()),
+ []
+ )
+
+ const getDefDisplayName = (value: string): string => {
+ return (
+ allDefinitionsValues.find(def => def.parameters.loadName === value)
+ ?.metadata.displayName ?? value
+ )
+ }
+
+ return (
+ <>
+ {modulesWatch?.map(module => {
+ const adapters = RECOMMENDED_LABWARE_BY_MODULE[module.type]
+
+ return (
+ {
+ const currentModule = field.value.find(
+ (m: DisplayModules) => m.type === module.type
+ )
+
+ return (
+
+ 0
+ ? t('modules_adapter_label')
+ : undefined
+ }
+ linkText={t('modules_remove_label')}
+ dropdown={
+ adapters != null && adapters.length > 0
+ ? {
+ title: (null as unknown) as string,
+ currentOption: {
+ name:
+ getDefDisplayName(
+ currentModule?.adapter?.value as string
+ ) ?? 'Choose an adapter',
+ value: currentModule?.adapter?.value,
+ },
+ onClick: (value: string) => {
+ field.onChange(
+ field.value.map((m: DisplayModules) =>
+ m.type === module.type
+ ? {
+ ...m,
+ adapter: {
+ name: getDefDisplayName(value),
+ value,
+ },
+ }
+ : m
+ )
+ )
+ },
+ dropdownType: 'neutral' as DropdownBorder,
+ filterOptions: adapters?.map(adapter => ({
+ name: getDefDisplayName(adapter),
+ value: adapter,
+ })),
+ }
+ : undefined
+ }
+ onClick={() => {
+ setValue(
+ MODULES_FIELD_NAME,
+ modulesWatch.filter(m => m.type !== module.type),
+ { shouldValidate: true }
+ )
+ }}
+ header={getModuleDisplayName(module.model)}
+ leftHeaderItem={
+
+
+
+ }
+ />
+
+ )
+ }}
+ />
+ )
+ })}
+ >
+ )
+}
diff --git a/opentrons-ai-client/src/molecules/PromptPreview/index.tsx b/opentrons-ai-client/src/molecules/PromptPreview/index.tsx
index d74884ad1ae..ce7687907a8 100644
--- a/opentrons-ai-client/src/molecules/PromptPreview/index.tsx
+++ b/opentrons-ai-client/src/molecules/PromptPreview/index.tsx
@@ -24,6 +24,7 @@ interface PromptPreviewProps {
const PromptPreviewContainer = styled(Flex)`
flex-direction: ${DIRECTION_COLUMN};
width: 100%;
+ max-width: 516px;
height: ${SIZE_AUTO};
padding-top: ${SPACING.spacing8};
background-color: ${COLORS.transparent};
@@ -78,7 +79,7 @@ export function PromptPreview({
key={`section-${index}`}
title={section.title}
items={section.items}
- itemMaxWidth={index <= 2 ? '33.33%' : '100%'}
+ itemMaxWidth={index <= 1 ? '33.33%' : '100%'}
/>
)
)}
diff --git a/opentrons-ai-client/src/organisms/ModulesSection/__tests__/ModulesSection.test.tsx b/opentrons-ai-client/src/organisms/ModulesSection/__tests__/ModulesSection.test.tsx
new file mode 100644
index 00000000000..0a556238930
--- /dev/null
+++ b/opentrons-ai-client/src/organisms/ModulesSection/__tests__/ModulesSection.test.tsx
@@ -0,0 +1,96 @@
+import { fireEvent, screen, waitFor } from '@testing-library/react'
+import { describe, it, expect } from 'vitest'
+import { renderWithProviders } from '../../../__testing-utils__'
+import { i18n } from '../../../i18n'
+import { FormProvider, useForm } from 'react-hook-form'
+import { ModulesSection } from '..'
+
+const TestFormProviderComponent = () => {
+ const methods = useForm({
+ defaultValues: {},
+ })
+
+ return (
+
+
+
+ )
+}
+
+const render = (): ReturnType => {
+ return renderWithProviders(, {
+ i18nInstance: i18n,
+ })
+}
+
+describe('ModulesSection', () => {
+ it('should render modules buttons, no modules added yet, and confirm button', async () => {
+ render()
+
+ expect(screen.getAllByRole('button').length).toBe(5)
+ expect(screen.getByText('No modules added yet')).toBeInTheDocument()
+ expect(screen.getByText('Confirm')).toBeInTheDocument()
+ })
+
+ it('should render a list item with the selected module if user clicks the module button', () => {
+ render()
+
+ const moduleButton = screen.getByText('Heater-Shaker Module GEN1')
+ fireEvent.click(moduleButton)
+
+ expect(screen.getAllByText('Heater-Shaker Module GEN1').length).toBe(2)
+ expect(screen.queryByText('No modules added yet')).not.toBeInTheDocument()
+ })
+
+ it('should render multiple list items with the selected modules if user clicks multiple module buttons', () => {
+ render()
+
+ const moduleButton1 = screen.getByText('Heater-Shaker Module GEN1')
+ fireEvent.click(moduleButton1)
+
+ const moduleButton2 = screen.getByText('Temperature Module GEN2')
+ fireEvent.click(moduleButton2)
+
+ expect(screen.getAllByText('Heater-Shaker Module GEN1').length).toBe(2)
+ expect(screen.getAllByText('Temperature Module GEN2').length).toBe(2)
+ })
+
+ it('should remove the module list item if user clicks the remove link', () => {
+ render()
+
+ const moduleButton = screen.getByText('Heater-Shaker Module GEN1')
+ fireEvent.click(moduleButton)
+
+ expect(screen.getAllByText('Heater-Shaker Module GEN1').length).toBe(2)
+
+ const removeLink = screen.getByText('remove')
+ fireEvent.click(removeLink)
+
+ expect(screen.getAllByText('Heater-Shaker Module GEN1').length).toBe(1)
+ })
+
+ it('should disable confirm button when all fields are not filled', async () => {
+ render()
+
+ const confirmButton = screen.getByRole('button', { name: 'Confirm' })
+ await waitFor(() => {
+ expect(confirmButton).not.toBeEnabled()
+ })
+ })
+
+ it('should enable confirm button when all fields are filled', async () => {
+ render()
+
+ const confirmButton = screen.getByRole('button', { name: 'Confirm' })
+ await waitFor(() => {
+ expect(confirmButton).not.toBeEnabled()
+ })
+
+ const moduleButton = screen.getByText('Heater-Shaker Module GEN1')
+ fireEvent.click(moduleButton)
+
+ await waitFor(() => {
+ expect(confirmButton).toBeEnabled()
+ })
+ })
+})
diff --git a/opentrons-ai-client/src/organisms/ModulesSection/index.tsx b/opentrons-ai-client/src/organisms/ModulesSection/index.tsx
new file mode 100644
index 00000000000..85f068bc226
--- /dev/null
+++ b/opentrons-ai-client/src/organisms/ModulesSection/index.tsx
@@ -0,0 +1,102 @@
+import {
+ DIRECTION_COLUMN,
+ DISPLAY_FLEX,
+ Flex,
+ InfoScreen,
+ JUSTIFY_FLEX_END,
+ LargeButton,
+ SPACING,
+} from '@opentrons/components'
+import { useFormContext } from 'react-hook-form'
+import { useTranslation } from 'react-i18next'
+import styled from 'styled-components'
+import { useAtom } from 'jotai'
+import { createProtocolAtom } from '../../resources/atoms'
+import { MODULES_STEP } from '../ProtocolSectionsContainer'
+import { ControlledEmptySelectorButtonGroup } from '../../molecules/ControlledEmptySelectorButtonGroup'
+import { ModuleListItemGroup } from '../../molecules/ModuleListItemGroup'
+import type { ModuleType, ModuleModel } from '@opentrons/shared-data'
+
+export interface DisplayModules {
+ type: ModuleType
+ model: ModuleModel
+ name: string
+ adapter?: {
+ name: string
+ value: string
+ }
+}
+
+export const MODULES_FIELD_NAME = 'modules'
+
+export function ModulesSection(): JSX.Element | null {
+ const { t } = useTranslation('create_protocol')
+ const {
+ formState: { isValid },
+ watch,
+ } = useFormContext()
+ const [{ currentStep }, setCreateProtocolAtom] = useAtom(createProtocolAtom)
+
+ const modules: DisplayModules[] = [
+ {
+ type: 'heaterShakerModuleType',
+ model: 'heaterShakerModuleV1',
+ name: t('heater_shaker_module_v1'),
+ },
+ {
+ type: 'temperatureModuleType',
+ model: 'temperatureModuleV2',
+ name: t('temperature_module_v2'),
+ },
+ {
+ type: 'thermocyclerModuleType',
+ model: 'thermocyclerModuleV2',
+ name: t('thermocycler_module_v2'),
+ },
+ {
+ type: 'magneticModuleType',
+ model: 'magneticModuleV1',
+ name: t('magnetic_module_v1'),
+ },
+ ]
+
+ function handleConfirmButtonClick(): void {
+ const step = currentStep > MODULES_STEP ? currentStep : MODULES_STEP + 1
+
+ setCreateProtocolAtom({
+ currentStep: step,
+ focusStep: step,
+ })
+ }
+
+ const modulesWatch: DisplayModules[] = watch(MODULES_FIELD_NAME) ?? []
+
+ return (
+
+
+
+ {modulesWatch.length === 0 && (
+
+ )}
+
+
+
+
+
+
+
+ )
+}
+
+const ButtonContainer = styled.div`
+ display: ${DISPLAY_FLEX};
+ justify-content: ${JUSTIFY_FLEX_END};
+`
diff --git a/opentrons-ai-client/src/organisms/ProtocolSectionsContainer/index.tsx b/opentrons-ai-client/src/organisms/ProtocolSectionsContainer/index.tsx
index bd54a4201ad..4bbd370c00f 100644
--- a/opentrons-ai-client/src/organisms/ProtocolSectionsContainer/index.tsx
+++ b/opentrons-ai-client/src/organisms/ProtocolSectionsContainer/index.tsx
@@ -7,6 +7,7 @@ import { createProtocolAtom } from '../../resources/atoms'
import { useAtom } from 'jotai'
import { useFormContext } from 'react-hook-form'
import { InstrumentsSection } from '../InstrumentsSection'
+import { ModulesSection } from '../ModulesSection'
export const APPLICATION_STEP = 0
export const INSTRUMENTS_STEP = 1
@@ -47,12 +48,12 @@ export function ProtocolSectionsContainer(): JSX.Element | null {
{
stepNumber: INSTRUMENTS_STEP,
title: 'instruments_title',
- Component: () => ,
+ Component: InstrumentsSection,
},
{
stepNumber: MODULES_STEP,
title: 'modules_title',
- Component: () => Content,
+ Component: ModulesSection,
},
{
stepNumber: LABWARE_LIQUIDS_STEP,
diff --git a/opentrons-ai-client/src/pages/CreateProtocol/__tests__/CreateProtocol.test.tsx b/opentrons-ai-client/src/pages/CreateProtocol/__tests__/CreateProtocol.test.tsx
index d71130d6f1a..9182f1778ca 100644
--- a/opentrons-ai-client/src/pages/CreateProtocol/__tests__/CreateProtocol.test.tsx
+++ b/opentrons-ai-client/src/pages/CreateProtocol/__tests__/CreateProtocol.test.tsx
@@ -7,6 +7,7 @@ import { Provider } from 'jotai'
import {
fillApplicationSectionAndClickConfirm,
fillInstrumentsSectionAndClickConfirm,
+ fillModulesSectionAndClickConfirm,
} from '../../../resources/utils/createProtocolTestUtils'
const render = (): ReturnType => {
@@ -119,4 +120,19 @@ describe('CreateProtocol', () => {
)
})
})
+
+ it('should display the Prompt preview correctly for Modules section', async () => {
+ render()
+
+ await fillApplicationSectionAndClickConfirm()
+ await fillInstrumentsSectionAndClickConfirm()
+ await fillModulesSectionAndClickConfirm()
+
+ const previewItems = screen.getAllByTestId('Tag_default')
+
+ expect(previewItems).toHaveLength(7)
+ expect(previewItems[6]).toHaveTextContent(
+ 'Heater-Shaker Module GEN1 with Opentrons 96 Deep Well Heater-Shaker Adapter'
+ )
+ })
})
diff --git a/opentrons-ai-client/src/pages/CreateProtocol/index.tsx b/opentrons-ai-client/src/pages/CreateProtocol/index.tsx
index 22b3cbcc338..b3bbd83169e 100644
--- a/opentrons-ai-client/src/pages/CreateProtocol/index.tsx
+++ b/opentrons-ai-client/src/pages/CreateProtocol/index.tsx
@@ -12,6 +12,7 @@ import { createProtocolAtom, headerWithMeterAtom } from '../../resources/atoms'
import { useAtom } from 'jotai'
import { ProtocolSectionsContainer } from '../../organisms/ProtocolSectionsContainer'
import { generatePromptPreviewData } from '../../resources/utils/createProtocolUtils'
+import type { DisplayModules } from '../../organisms/ModulesSection'
export interface CreateProtocolFormData {
application: {
@@ -26,6 +27,7 @@ export interface CreateProtocolFormData {
rightPipette: string
flexGripper: string
}
+ modules: DisplayModules[]
}
const TOTAL_STEPS = 5
@@ -33,7 +35,7 @@ const TOTAL_STEPS = 5
export function CreateProtocol(): JSX.Element | null {
const { t } = useTranslation('create_protocol')
const [, setHeaderWithMeterAtom] = useAtom(headerWithMeterAtom)
- const [{ currentStep }] = useAtom(createProtocolAtom)
+ const [{ currentStep }, setCreateProtocolAtom] = useAtom(createProtocolAtom)
const methods = useForm({
defaultValues: {
@@ -57,6 +59,21 @@ export function CreateProtocol(): JSX.Element | null {
})
}, [currentStep])
+ useEffect(() => {
+ return () => {
+ setHeaderWithMeterAtom({
+ displayHeaderWithMeter: false,
+ progress: 0,
+ })
+
+ methods.reset()
+ setCreateProtocolAtom({
+ currentStep: 0,
+ focusStep: 0,
+ })
+ }
+ }, [])
+
return (
{
})
fireEvent.click(confirmButton)
}
+
+export async function fillModulesSectionAndClickConfirm(): Promise {
+ const firstModuleButton = screen.getByText('Heater-Shaker Module GEN1')
+ fireEvent.click(firstModuleButton)
+
+ expect(
+ screen.getAllByText('Heater-Shaker Module GEN1')[1]
+ ).toBeInTheDocument()
+
+ const adapterDropdown = screen.getByText('Choose an adapter')
+ fireEvent.click(adapterDropdown)
+
+ const adapterOption = screen.getByText(
+ 'Opentrons 96 Deep Well Heater-Shaker Adapter'
+ )
+ fireEvent.click(adapterOption)
+
+ const confirmButton = screen.getByText('Confirm')
+ await waitFor(() => {
+ expect(confirmButton).toBeEnabled()
+ })
+ fireEvent.click(confirmButton)
+}
diff --git a/opentrons-ai-client/src/resources/utils/createProtocolUtils.tsx b/opentrons-ai-client/src/resources/utils/createProtocolUtils.tsx
index bb29912ae80..7e137fef854 100644
--- a/opentrons-ai-client/src/resources/utils/createProtocolUtils.tsx
+++ b/opentrons-ai-client/src/resources/utils/createProtocolUtils.tsx
@@ -59,6 +59,23 @@ export function generatePromptPreviewInstrumentItems(
return items.filter(Boolean)
}
+export function generatePromptPreviewModulesItems(
+ watch: UseFormWatch,
+ t: any
+): string[] {
+ const { modules } = watch()
+
+ if (modules === undefined || modules?.length === 0) return []
+
+ const items = modules?.map(module =>
+ module.adapter === undefined || module.adapter?.name === ''
+ ? module.name
+ : `${module.name} with ${module.adapter.name}`
+ )
+
+ return items.filter(Boolean)
+}
+
export function generatePromptPreviewData(
watch: UseFormWatch,
t: any
@@ -75,5 +92,9 @@ export function generatePromptPreviewData(
title: t('instruments_title'),
items: generatePromptPreviewInstrumentItems(watch, t),
},
+ {
+ title: t('modules_title'),
+ items: generatePromptPreviewModulesItems(watch, t),
+ },
]
}