Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(protocol-designer): remove dropdown menu for module quantity #17531

Merged
merged 7 commits into from
Feb 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {
EmptySelectorButton,
FLEX_MAX_CONTENT,
Flex,
Tooltip,
TYPOGRAPHY,
useHoverTooltip,
} from '@opentrons/components'
import {
ABSORBANCE_READER_V1,
getModuleDisplayName,
} from '@opentrons/shared-data'

import type { ModuleModel } from '@opentrons/shared-data'

interface AddModuleEmptySelectorButtonProps {
moduleModel: ModuleModel
areSlotsAvailable: boolean
hasGripper: boolean
handleAddModule: (arg0: ModuleModel, arg1: boolean) => void
tooltipText: string
}

export function AddModuleEmptySelectorButton(
props: AddModuleEmptySelectorButtonProps
): JSX.Element {
const {
moduleModel,
areSlotsAvailable,
hasGripper,
handleAddModule,
tooltipText,
} = props
const [targetProps, tooltipProps] = useHoverTooltip()
const disableGripperRequired =
!hasGripper && moduleModel === ABSORBANCE_READER_V1

return (
<>
<Flex {...targetProps} width={FLEX_MAX_CONTENT}>
<EmptySelectorButton
disabled={!areSlotsAvailable || disableGripperRequired}
textAlignment={TYPOGRAPHY.textAlignLeft}
iconName="plus"
text={getModuleDisplayName(moduleModel)}
onClick={() => {
handleAddModule(moduleModel, !areSlotsAvailable)
}}
/>
</Flex>
{disableGripperRequired ? (
<Tooltip tooltipProps={tooltipProps}>{tooltipText}</Tooltip>
) : null}
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { ABSORBANCE_READER_V1 } from '@opentrons/shared-data'

import { renderWithProviders } from '../../../__testing-utils__'
import { AddModuleEmptySelectorButton } from '../AddModuleEmptySelectorButton'

import type { ComponentProps } from 'react'
import type { EmptySelectorButton } from '@opentrons/components'

vi.mock('@opentrons/components', async importOriginal => {
const actual = await importOriginal<typeof EmptySelectorButton>()
return {
...actual,
EmptySelectorButton: ({ onClick }: { onClick: () => void }) => (
<button onClick={onClick}>mock EmptySelectorButton</button>
),
Tooltip: vi.fn(({ children }) => <div>{children}</div>),
}
})
const mockHandleAddModule = vi.fn()

const render = (props: ComponentProps<typeof AddModuleEmptySelectorButton>) => {
return renderWithProviders(<AddModuleEmptySelectorButton {...props} />)
}

describe('AddModuleEmptySelectorButton', () => {
let props: ComponentProps<typeof AddModuleEmptySelectorButton>

beforeEach(() => {
props = {
moduleModel: ABSORBANCE_READER_V1,
areSlotsAvailable: true,
hasGripper: true,
handleAddModule: mockHandleAddModule,
tooltipText: 'tooltipText',
}
})

it('renders mock emptySelector button', () => {
render(props)
screen.getByText('mock EmptySelectorButton')
})

it('should call mock handleAddModule when clicked', () => {
render(props)
fireEvent.click(screen.getByText('mock EmptySelectorButton'))
expect(mockHandleAddModule).toHaveBeenCalled()
})

it('renders tooltip text', async () => {
props = { ...props, hasGripper: false }
render(props)
fireEvent.mouseOver(screen.getByText('mock EmptySelectorButton'))
await waitFor(() => {
screen.getByText('tooltipText')
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import { FLEX_ROBOT_TYPE, OT2_ROBOT_TYPE } from '@opentrons/shared-data'
import { fireEvent, screen } from '@testing-library/react'
import { i18n } from '../../../assets/localization'
import { renderWithProviders } from '../../../__testing-utils__'
import { SelectModules } from '../SelectModules'
import { SelectModules } from '..'

import type { ComponentProps } from 'react'
import type { WizardFormState, WizardTileProps } from '../types'
import type {
WizardFormState,
WizardTileProps,
} from '../../../pages/CreateNewProtocolWizard/types'

vi.mock('../../../feature-flags/selectors')

Expand Down Expand Up @@ -84,6 +87,30 @@ describe('SelectModules', () => {
screen.getByText('Thermocycler Module GEN1')
})

it('renders the Flex options', () => {
const values = {
fields: {
name: '',
description: '',
organizationOrAuthor: '',
robotType: FLEX_ROBOT_TYPE,
},
additionalEquipment: ['trashBin'],
modules: {},
pipettesByMount: {} as any,
} as WizardFormState
props = {
...props,
watch: vi.fn((name: keyof typeof values) => values[name]) as any,
}
render(props)
screen.getByText('Absorbance Plate Reader Module GEN1')
screen.getByText('Heater-Shaker Module GEN1')
screen.getByText('Magnetic Block GEN1')
screen.getByText('Temperature Module GEN2')
screen.getByText('Thermocycler Module GEN2')
})

it('calls setValue when clicking to add a Magnetic Block GEN1', () => {
render(props)
fireEvent.click(screen.getByText('Magnetic Block GEN1'))
Expand Down
198 changes: 198 additions & 0 deletions protocol-designer/src/organisms/SelectModules/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { useTranslation } from 'react-i18next'
import {
ALIGN_CENTER,
BORDERS,
COLORS,
DIRECTION_COLUMN,
Flex,
ListItem,
SPACING,
StyledText,
WRAP,
} from '@opentrons/components'
import {
FLEX_ROBOT_TYPE,
getModuleDisplayName,
getModuleType,
} from '@opentrons/shared-data'

import { uuid } from '../../utils'
import { ModuleDiagram } from '../../pages/CreateNewProtocolWizard/ModuleDiagram'
import { WizardBody } from '../../pages/CreateNewProtocolWizard/WizardBody'
import {
DEFAULT_SLOT_MAP_FLEX,
DEFAULT_SLOT_MAP_OT2,
FLEX_SUPPORTED_MODULE_MODELS,
OT2_SUPPORTED_MODULE_MODELS,
} from '../../pages/CreateNewProtocolWizard/constants'
import { HandleEnter } from '../../atoms/HandleEnter'
import { PDListItemCustomize as ListItemCustomize } from '../../pages/CreateNewProtocolWizard/PDListItemCustomize'
import { AddModuleEmptySelectorButton } from './AddModuleEmptySelectorButton'

import type { ModuleModel, ModuleType } from '@opentrons/shared-data'
import type { FormModule } from '../../step-forms'
import type { WizardTileProps } from '../../pages/CreateNewProtocolWizard/types'

export function SelectModules(props: WizardTileProps): JSX.Element | null {
const { goBack, proceed, watch, setValue } = props
const { t } = useTranslation(['create_new_protocol', 'shared'])
const fields = watch('fields')
const modules = watch('modules')
const additionalEquipment = watch('additionalEquipment')
const robotType = fields.robotType
const supportedModules =
robotType === FLEX_ROBOT_TYPE
? FLEX_SUPPORTED_MODULE_MODELS
: OT2_SUPPORTED_MODULE_MODELS
const filteredSupportedModules = supportedModules.filter(
moduleModel =>
!(
modules != null &&
Object.values(modules).some(module =>
robotType === FLEX_ROBOT_TYPE
? module.model === moduleModel
: module.type === getModuleType(moduleModel)
)
)
)
const hasGripper = additionalEquipment.some(aE => aE === 'gripper')

const handleAddModule = (
moduleModel: ModuleModel,
hasNoAvailableSlots: boolean
): void => {
setValue('modules', {
...modules,
[uuid()]: {
model: moduleModel,
type: getModuleType(moduleModel),
slot:
robotType === FLEX_ROBOT_TYPE
? DEFAULT_SLOT_MAP_FLEX[moduleModel]
: DEFAULT_SLOT_MAP_OT2[getModuleType(moduleModel)],
},
})
}

const handleRemoveModule = (moduleType: ModuleType): void => {
const updatedModules =
modules != null
? Object.fromEntries(
Object.entries(modules).filter(
([key, value]) => value.type !== moduleType
)
)
: {}
setValue('modules', updatedModules)
}

return (
<HandleEnter onEnter={proceed}>
<WizardBody
robotType={robotType}
stepNumber={robotType === FLEX_ROBOT_TYPE ? 4 : 3}
header={t('add_modules')}
goBack={() => {
goBack(1)
setValue('modules', null)
}}
proceed={() => {
proceed(1)
}}
>
<Flex flexDirection={DIRECTION_COLUMN}>
<Flex flexDirection={DIRECTION_COLUMN} gridGap={SPACING.spacing12}>
{filteredSupportedModules.length > 0 ||
!(
filteredSupportedModules.length === 1 &&
filteredSupportedModules[0] === 'absorbanceReaderV1'
) ? (
<StyledText desktopStyle="headingSmallBold">
{t('which_modules')}
</StyledText>
) : null}
<Flex gridGap={SPACING.spacing4} flexWrap={WRAP}>
{filteredSupportedModules
.sort((moduleA, moduleB) => moduleA.localeCompare(moduleB))
.map(moduleModel => (
<AddModuleEmptySelectorButton
key={moduleModel}
moduleModel={moduleModel}
areSlotsAvailable={true}
hasGripper={hasGripper}
handleAddModule={handleAddModule}
tooltipText={t('add_gripper_for_absorbance_reader')}
/>
))}
</Flex>
{modules != null && Object.keys(modules).length > 0 ? (
<Flex
flexDirection={DIRECTION_COLUMN}
gridGap={SPACING.spacing12}
paddingTop={
filteredSupportedModules.length === 1 &&
filteredSupportedModules[0] === 'absorbanceReaderV1'
? 0
: SPACING.spacing32
}
>
<StyledText desktopStyle="headingSmallBold">
{t('modules_added')}
</StyledText>
<Flex
flexDirection={DIRECTION_COLUMN}
gridGap={SPACING.spacing4}
>
{Object.entries(modules)
.sort(([, moduleA], [, moduleB]) =>
moduleA.model.localeCompare(moduleB.model)
)
.reduce<Array<FormModule & { count: number; key: string }>>(
(acc, [key, module]) => {
const existingModule = acc.find(
m => m.type === module.type
)
if (existingModule != null) {
existingModule.count++
} else {
acc.push({ ...module, count: 1, key })
}
return acc
},
[]
)
.map(module => (
<ListItem type="noActive" key={`${module.model}`}>
<ListItemCustomize
linkText={t('remove')}
onClick={() => {
handleRemoveModule(module.type)
}}
header={getModuleDisplayName(module.model)}
leftHeaderItem={
<Flex
padding={SPACING.spacing2}
backgroundColor={COLORS.white}
borderRadius={BORDERS.borderRadius8}
alignItems={ALIGN_CENTER}
width="3.75rem"
height="3.625rem"
>
<ModuleDiagram
type={module.type}
model={module.model}
/>
</Flex>
}
/>
</ListItem>
))}
</Flex>
</Flex>
) : null}
</Flex>
</Flex>
</WizardBody>
</HandleEnter>
)
}
Loading
Loading