Skip to content

Commit

Permalink
Opentrons ai client modules (#16673)
Browse files Browse the repository at this point in the history
# Overview

This PR adds the Modules section to the create protocol flow.


![image](https://github.com/user-attachments/assets/38c69931-0de2-44d7-892a-b1c75ceb5133)

## Test Plan and Hands on Testing

- On the landing page click Create a new protocol button, you will be
redirected to the new page
- fill up the required information in the Application and Instruments
sections and click Confirm
- You now can add a module and an adapter (modules are not required)
- The Prompt Preview component is updated with the data entered.

## Changelog

 - Add Modules section to create protocol flow

## Review requests

- Verify new section.

## Risk assessment

- low
  • Loading branch information
fbelginetw authored Nov 6, 2024
1 parent a065ef4 commit ac00351
Show file tree
Hide file tree
Showing 27 changed files with 748 additions and 4 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Original file line number Diff line number Diff line change
@@ -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 (
<FormProvider {...methods}>
<ControlledEmptySelectorButtonGroup modules={modulesMock} />

{'selected values: ' + selectedValue.map((m: DisplayModules) => m.name)}
</FormProvider>
)
}

const render = (): ReturnType<typeof renderWithProviders> => {
return renderWithProviders(<TestFormProviderComponent />, {
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()
})
})
Original file line number Diff line number Diff line change
@@ -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 (
<Controller
defaultValue={[]}
name={MODULES_FIELD_NAME}
render={({ field }) => {
return (
<Flex flexWrap={WRAP} gap={SPACING.spacing8}>
{modules.map(module => (
<EmptySelectorButton
key={module.type}
iconName="plus"
onClick={() => {
if (modulesWatch.some(m => m.type === module.type)) {
return
}
field.onChange([...modulesWatch, module])
}}
text={module.name}
textAlignment="left"
/>
))}
</Flex>
)
}}
/>
)
}
80 changes: 80 additions & 0 deletions opentrons-ai-client/src/molecules/ModelDiagram/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<img
css={css`
max-width: ${IMAGE_MAX_WIDTH};
width: 100%;
height: auto;
`}
src={model}
alt={props.type}
/>
)
}
Original file line number Diff line number Diff line change
@@ -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 (
<FormProvider {...methods}>
<ModuleListItemGroup />
</FormProvider>
)
}

const render = (): ReturnType<typeof renderWithProviders> => {
return renderWithProviders(<TestFormProviderComponent />, {
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()
})
})
Loading

0 comments on commit ac00351

Please sign in to comment.