diff --git a/protocol-designer/src/organisms/SelectModules/AddModuleEmptySelectorButton.tsx b/protocol-designer/src/organisms/SelectModules/AddModuleEmptySelectorButton.tsx new file mode 100644 index 00000000000..8e07f72a538 --- /dev/null +++ b/protocol-designer/src/organisms/SelectModules/AddModuleEmptySelectorButton.tsx @@ -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 ( + <> + + { + handleAddModule(moduleModel, !areSlotsAvailable) + }} + /> + + {disableGripperRequired ? ( + {tooltipText} + ) : null} + + ) +} diff --git a/protocol-designer/src/organisms/SelectModules/__tests__/AddModuleEmptySelectorButton.test.tsx b/protocol-designer/src/organisms/SelectModules/__tests__/AddModuleEmptySelectorButton.test.tsx new file mode 100644 index 00000000000..e51ef27b1f5 --- /dev/null +++ b/protocol-designer/src/organisms/SelectModules/__tests__/AddModuleEmptySelectorButton.test.tsx @@ -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() + return { + ...actual, + EmptySelectorButton: ({ onClick }: { onClick: () => void }) => ( + + ), + Tooltip: vi.fn(({ children }) =>
{children}
), + } +}) +const mockHandleAddModule = vi.fn() + +const render = (props: ComponentProps) => { + return renderWithProviders() +} + +describe('AddModuleEmptySelectorButton', () => { + let props: ComponentProps + + 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') + }) + }) +}) diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/SelectModules.test.tsx b/protocol-designer/src/organisms/SelectModules/__tests__/SelectModules.test.tsx similarity index 78% rename from protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/SelectModules.test.tsx rename to protocol-designer/src/organisms/SelectModules/__tests__/SelectModules.test.tsx index 2d23f3a1fef..afe95b61234 100644 --- a/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/SelectModules.test.tsx +++ b/protocol-designer/src/organisms/SelectModules/__tests__/SelectModules.test.tsx @@ -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') @@ -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')) diff --git a/protocol-designer/src/organisms/SelectModules/index.tsx b/protocol-designer/src/organisms/SelectModules/index.tsx new file mode 100644 index 00000000000..c0d8a3c5426 --- /dev/null +++ b/protocol-designer/src/organisms/SelectModules/index.tsx @@ -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 ( + + { + goBack(1) + setValue('modules', null) + }} + proceed={() => { + proceed(1) + }} + > + + + {filteredSupportedModules.length > 0 || + !( + filteredSupportedModules.length === 1 && + filteredSupportedModules[0] === 'absorbanceReaderV1' + ) ? ( + + {t('which_modules')} + + ) : null} + + {filteredSupportedModules + .sort((moduleA, moduleB) => moduleA.localeCompare(moduleB)) + .map(moduleModel => ( + + ))} + + {modules != null && Object.keys(modules).length > 0 ? ( + + + {t('modules_added')} + + + {Object.entries(modules) + .sort(([, moduleA], [, moduleB]) => + moduleA.model.localeCompare(moduleB.model) + ) + .reduce>( + (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 => ( + + { + handleRemoveModule(module.type) + }} + header={getModuleDisplayName(module.model)} + leftHeaderItem={ + + + + } + /> + + ))} + + + ) : null} + + + + + ) +} diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/SelectModules.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectModules.tsx deleted file mode 100644 index fc09ad03517..00000000000 --- a/protocol-designer/src/pages/CreateNewProtocolWizard/SelectModules.tsx +++ /dev/null @@ -1,342 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { - ALIGN_CENTER, - BORDERS, - COLORS, - DIRECTION_COLUMN, - EmptySelectorButton, - FLEX_MAX_CONTENT, - Flex, - ListItem, - SPACING, - StyledText, - Tooltip, - TYPOGRAPHY, - useHoverTooltip, - WRAP, -} from '@opentrons/components' -import { - ABSORBANCE_READER_V1, - FLEX_ROBOT_TYPE, - getModuleDisplayName, - getModuleType, - HEATERSHAKER_MODULE_TYPE, - MAGNETIC_BLOCK_TYPE, - TEMPERATURE_MODULE_TYPE, -} from '@opentrons/shared-data' -import { uuid } from '../../utils' -import { useKitchen } from '../../organisms/Kitchen/hooks' -import { ModuleDiagram } from './ModuleDiagram' -import { WizardBody } from './WizardBody' -import { - DEFAULT_SLOT_MAP_FLEX, - DEFAULT_SLOT_MAP_OT2, - FLEX_SUPPORTED_MODULE_MODELS, - OT2_SUPPORTED_MODULE_MODELS, -} from './constants' -import { getNumOptions, getNumSlotsAvailable } from './utils' -import { HandleEnter } from '../../atoms/HandleEnter' -import { PDListItemCustomize as ListItemCustomize } from '../CreateNewProtocolWizard/PDListItemCustomize' - -import type { DropdownBorder } from '@opentrons/components' -import type { ModuleModel, ModuleType } from '@opentrons/shared-data' -import type { FormModule, FormModules } from '../../step-forms' -import type { WizardTileProps } from './types' - -export function SelectModules(props: WizardTileProps): JSX.Element | null { - const { goBack, proceed, watch, setValue } = props - const { t } = useTranslation(['create_new_protocol', 'shared']) - const { makeSnackbar } = useKitchen() - 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 MOAM_MODULE_TYPES: ModuleType[] = [ - TEMPERATURE_MODULE_TYPE, - HEATERSHAKER_MODULE_TYPE, - MAGNETIC_BLOCK_TYPE, - ] - const hasGripper = additionalEquipment.some(aE => aE === 'gripper') - - const handleAddModule = ( - moduleModel: ModuleModel, - hasNoAvailableSlots: boolean - ): void => { - if (hasNoAvailableSlots) { - makeSnackbar(t('slots_limit_reached') as string) - } else { - 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) - } - - const handleQuantityChange = ( - modules: FormModules, - module: FormModule, - newQuantity: number - ): void => { - if (!modules) return - - const modulesOfType = Object.entries(modules).filter( - ([, mod]) => mod.type === module.type - ) - const otherModules = Object.entries(modules).filter( - ([, mod]) => mod.type !== module.type - ) - - if (newQuantity > modulesOfType.length) { - const additionalModules: FormModules = {} - for (let i = 0; i < newQuantity - modulesOfType.length; i++) { - // @ts-expect-error: TS can't determine modules's type correctly - additionalModules[uuid()] = { - model: module.model, - type: module.type, - slot: null, - } - } - - const newModules = Object.fromEntries([ - ...otherModules, - ...modulesOfType, - ...Object.entries(additionalModules), - ]) - setValue('modules', newModules) - } else if (newQuantity < modulesOfType.length) { - const modulesToKeep = modulesOfType.slice(0, newQuantity) - const updatedModules = Object.fromEntries([ - ...otherModules, - ...modulesToKeep, - ]) - - setValue('modules', updatedModules) - } - } - - return ( - - { - goBack(1) - setValue('modules', null) - }} - proceed={() => { - proceed(1) - }} - > - - - {filteredSupportedModules.length > 0 || - !( - filteredSupportedModules.length === 1 && - filteredSupportedModules[0] === 'absorbanceReaderV1' - ) ? ( - - {t('which_modules')} - - ) : null} - - {filteredSupportedModules - .sort((moduleA, moduleB) => moduleA.localeCompare(moduleB)) - .map(moduleModel => { - const numSlotsAvailable = getNumSlotsAvailable( - modules, - additionalEquipment, - moduleModel - ) - return ( - 0} - hasGripper={hasGripper} - handleAddModule={handleAddModule} - /> - ) - })} - - {modules != null && Object.keys(modules).length > 0 ? ( - - - {t('modules_added')} - - - {Object.entries(modules) - .sort(([, moduleA], [, moduleB]) => - moduleA.model.localeCompare(moduleB.model) - ) - .reduce>( - (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 => { - const numSlotsAvailable = getNumSlotsAvailable( - modules, - additionalEquipment, - module.model - ) - const dropdownProps = { - currentOption: { - name: `${module.count}`, - value: `${module.count}`, - }, - onClick: (value: string) => { - handleQuantityChange( - modules, - module as FormModule, - parseInt(value) - ) - }, - dropdownType: 'neutral' as DropdownBorder, - filterOptions: getNumOptions( - module.model !== ABSORBANCE_READER_V1 - ? numSlotsAvailable + module.count - : numSlotsAvailable - ), - } - return ( - - { - handleRemoveModule(module.type) - }} - header={getModuleDisplayName(module.model)} - leftHeaderItem={ - - - - } - /> - - ) - })} - - - ) : null} - - - - - ) -} - -interface AddModuleEmptySelectorButtonProps { - moduleModel: ModuleModel - areSlotsAvailable: boolean - hasGripper: boolean - handleAddModule: (arg0: ModuleModel, arg1: boolean) => void -} - -function AddModuleEmptySelectorButton( - props: AddModuleEmptySelectorButtonProps -): JSX.Element { - const { moduleModel, areSlotsAvailable, hasGripper, handleAddModule } = props - const [targetProps, tooltipProps] = useHoverTooltip() - const { t } = useTranslation('create_new_protocol') - const disableGripperRequired = - !hasGripper && moduleModel === ABSORBANCE_READER_V1 - - return ( - <> - - { - handleAddModule(moduleModel, !areSlotsAvailable) - }} - /> - - {disableGripperRequired ? ( - - {t('add_gripper_for_absorbance_reader')} - - ) : null} - - ) -} diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/utils.test.ts b/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/utils.test.ts index 908aa24b1f1..3f9624e0008 100644 --- a/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/utils.test.ts +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/utils.test.ts @@ -7,13 +7,9 @@ import { HEATERSHAKER_MODULE_V1, MAGNETIC_BLOCK_TYPE, MAGNETIC_BLOCK_V1, - MAGNETIC_MODULE_V1, - MAGNETIC_MODULE_V2, TEMPERATURE_MODULE_TYPE, - TEMPERATURE_MODULE_V1, TEMPERATURE_MODULE_V2, THERMOCYCLER_MODULE_TYPE, - THERMOCYCLER_MODULE_V1, THERMOCYCLER_MODULE_V2, } from '@opentrons/shared-data' import { getNumSlotsAvailable, getTrashSlot } from '../utils' @@ -42,41 +38,6 @@ describe('getNumSlotsAvailable', () => { expect(result).toBe(0) }) - it('should return 1 for a non MoaM module - temperature module', () => { - const result = getNumSlotsAvailable(null, [], TEMPERATURE_MODULE_V1) - expect(result).toBe(1) - }) - - it('should return 1 for a non MoaM module - absorbance plate reader', () => { - const result = getNumSlotsAvailable(null, [], ABSORBANCE_READER_V1) - expect(result).toBe(1) - }) - - it('should return 1 for a non MoaM module - thermocycler v1', () => { - const result = getNumSlotsAvailable(null, [], THERMOCYCLER_MODULE_V1) - expect(result).toBe(1) - }) - - it('should return 1 for a non MoaM module - magnetic module v1', () => { - const result = getNumSlotsAvailable(null, [], MAGNETIC_MODULE_V1) - expect(result).toBe(1) - }) - - it('should return 1 for a non MoaM module - magnetic module v2', () => { - const result = getNumSlotsAvailable(null, [], MAGNETIC_MODULE_V2) - expect(result).toBe(1) - }) - - it('should return 2 for a thermocycler', () => { - const result = getNumSlotsAvailable(null, [], THERMOCYCLER_MODULE_V2) - expect(result).toBe(2) - }) - - it('should return 8 when there are no modules or additional equipment for a heater-shaker', () => { - const result = getNumSlotsAvailable(null, [], HEATERSHAKER_MODULE_V1) - expect(result).toBe(8) - }) - it('should return 3 when there a plate reader', () => { const mockModules = { 0: { diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/index.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/index.tsx index 2e29273b9ac..0a6e42b6ebe 100644 --- a/protocol-designer/src/pages/CreateNewProtocolWizard/index.tsx +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/index.tsx @@ -40,10 +40,10 @@ import { toggleIsGripperRequired, } from '../../step-forms/actions/additionalItems' import { getNewProtocolModal } from '../../navigation/selectors' +import { SelectModules } from '../../organisms/SelectModules' import { SelectRobot } from './SelectRobot' import { SelectPipettes } from './SelectPipettes' import { SelectGripper } from './SelectGripper' -import { SelectModules } from './SelectModules' import { SelectFixtures } from './SelectFixtures' import { AddMetadata } from './AddMetadata' import { getTrashSlot } from './utils' diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/utils.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/utils.tsx index 1c83ae09866..6f7d1c10db2 100644 --- a/protocol-designer/src/pages/CreateNewProtocolWizard/utils.tsx +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/utils.tsx @@ -8,14 +8,9 @@ import { HEATERSHAKER_MODULE_V1, MAGNETIC_BLOCK_TYPE, MAGNETIC_BLOCK_V1, - MAGNETIC_MODULE_V1, - MAGNETIC_MODULE_V2, TEMPERATURE_MODULE_TYPE, - TEMPERATURE_MODULE_V1, TEMPERATURE_MODULE_V2, THERMOCYCLER_MODULE_TYPE, - THERMOCYCLER_MODULE_V1, - THERMOCYCLER_MODULE_V2, WASTE_CHUTE_CUTOUT, } from '@opentrons/shared-data' import wasteChuteImage from '../../assets/images/waste_chute.png' @@ -129,23 +124,6 @@ export const getNumSlotsAvailable = ( return 0 } - // these modules don't support MoaM - case ABSORBANCE_READER_V1: - case THERMOCYCLER_MODULE_V1: - case TEMPERATURE_MODULE_V1: - case MAGNETIC_MODULE_V1: - case MAGNETIC_MODULE_V2: { - return 1 - } - - case THERMOCYCLER_MODULE_V2: { - if (filteredModuleLength + filteredAdditionalEquipmentLength > 7) { - return 0 - } else { - return 2 - } - } - case 'trashBin': case HEATERSHAKER_MODULE_V1: case TEMPERATURE_MODULE_V2: {