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 ( + {props.type} + ) +} 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), + }, ] }