From 1ceba4348ab0889d33926cce6b7a4aa16df3bb28 Mon Sep 17 00:00:00 2001 From: koji Date: Fri, 10 Jan 2025 12:10:50 -0500 Subject: [PATCH] refactor(protocol-designer): refactor EditInstrumentsModal (#17226) * refactor(protocol-designer): refactor EditInstrumentsModal --- .../PipetteConfiguration.tsx | 296 ++++++++++ .../EditInstrumentsModal/PipetteOverview.tsx | 258 +++++++++ .../__tests__/EditInstrumentsModal.test.tsx | 150 +++++ .../__tests__/usePipetteConfig.test.ts | 93 +++ .../organisms/EditInstrumentsModal/index.tsx | 537 ++---------------- .../EditInstrumentsModal/usePipetteConfig.ts | 56 ++ 6 files changed, 900 insertions(+), 490 deletions(-) create mode 100644 protocol-designer/src/organisms/EditInstrumentsModal/PipetteConfiguration.tsx create mode 100644 protocol-designer/src/organisms/EditInstrumentsModal/PipetteOverview.tsx create mode 100644 protocol-designer/src/organisms/EditInstrumentsModal/__tests__/EditInstrumentsModal.test.tsx create mode 100644 protocol-designer/src/organisms/EditInstrumentsModal/__tests__/usePipetteConfig.test.ts create mode 100644 protocol-designer/src/organisms/EditInstrumentsModal/usePipetteConfig.ts diff --git a/protocol-designer/src/organisms/EditInstrumentsModal/PipetteConfiguration.tsx b/protocol-designer/src/organisms/EditInstrumentsModal/PipetteConfiguration.tsx new file mode 100644 index 00000000000..989fd317ada --- /dev/null +++ b/protocol-designer/src/organisms/EditInstrumentsModal/PipetteConfiguration.tsx @@ -0,0 +1,296 @@ +import { useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' +import styled from 'styled-components' + +import { + ALIGN_CENTER, + ALIGN_STRETCH, + Box, + Btn, + Checkbox, + COLORS, + CURSOR_POINTER, + DIRECTION_COLUMN, + DISPLAY_FLEX, + DISPLAY_INLINE_BLOCK, + FLEX_MAX_CONTENT, + Flex, + OVERFLOW_AUTO, + PRODUCT, + RadioButton, + SPACING, + StyledText, + TYPOGRAPHY, + WRAP, +} from '@opentrons/components' +import { + FLEX_ROBOT_TYPE, + getAllPipetteNames, + OT2_ROBOT_TYPE, +} from '@opentrons/shared-data' + +import { getLabwareDefsByURI } from '../../labware-defs/selectors' +import { getAllowAllTipracks } from '../../feature-flags/selectors' +import { setFeatureFlags } from '../../feature-flags/actions' +import { createCustomTiprackDef } from '../../labware-defs/actions' +import { getShouldShowPipetteType, getTiprackOptions } from './utils' +import { removeOpentronsPhrases } from '../../utils' +import { + PIPETTE_GENS, + PIPETTE_TYPES, + PIPETTE_VOLUMES, +} from '../../pages/CreateNewProtocolWizard/constants' + +import type { PipetteName, RobotType } from '@opentrons/shared-data' +import type { PipetteOnDeck } from '../../step-forms' +import type { + Gen, + PipetteInfoByGen, + PipetteInfoByType, + PipetteType, +} from '../../pages/CreateNewProtocolWizard/types' +import type { ThunkDispatch } from '../../types' +import type { PipetteConfig } from './usePipetteConfig' + +interface PipetteConfigurationProps { + has96Channel: boolean + robotType: RobotType + selectedPipette: string + pipetteConfig: PipetteConfig + leftPipette?: PipetteOnDeck + rightPipette?: PipetteOnDeck +} + +export function PipetteConfiguration({ + has96Channel, + robotType, + selectedPipette, + pipetteConfig, + leftPipette, + rightPipette, +}: PipetteConfigurationProps): JSX.Element { + const { t } = useTranslation(['create_new_protocol', 'shared']) + const dispatch = useDispatch>() + const allLabware = useSelector(getLabwareDefsByURI) + const allowAllTipracks = useSelector(getAllowAllTipracks) + const allPipetteOptions = getAllPipetteNames('maxVolume', 'channels') + const { + mount, + pipetteType, + setPipetteType, + pipetteGen, + setPipetteGen, + pipetteVolume, + setPipetteVolume, + selectedTips, + setSelectedTips, + } = pipetteConfig + + return ( + + + + {t('pipette_type')} + + + {PIPETTE_TYPES[robotType].map(type => { + return getShouldShowPipetteType( + type.value as PipetteType, + has96Channel, + leftPipette, + rightPipette, + mount + ) ? ( + { + setPipetteType(type.value) + setPipetteGen('flex') + setPipetteVolume(null) + setSelectedTips([]) + }} + buttonLabel={t(`shared:${type.label}`)} + buttonValue="single" + isSelected={pipetteType === type.value} + /> + ) : null + })} + + + {pipetteType != null && robotType === OT2_ROBOT_TYPE ? ( + + + {t('pipette_gen')} + + + {PIPETTE_GENS.map(gen => ( + { + setPipetteGen(gen) + setPipetteVolume(null) + setSelectedTips([]) + }} + buttonLabel={gen} + buttonValue={gen} + isSelected={pipetteGen === gen} + /> + ))} + + + ) : null} + {(pipetteType != null && robotType === FLEX_ROBOT_TYPE) || + (pipetteGen !== 'flex' && + pipetteType != null && + robotType === OT2_ROBOT_TYPE) ? ( + + + {t('pipette_vol')} + + + {PIPETTE_VOLUMES[robotType]?.map(volume => { + if (robotType === FLEX_ROBOT_TYPE && pipetteType != null) { + const flexVolume = volume as PipetteInfoByType + const flexPipetteInfo = flexVolume[pipetteType] + + return flexPipetteInfo?.map(type => ( + { + setPipetteVolume(type.value) + setSelectedTips([]) + }} + buttonLabel={t('vol_label', { volume: type.label })} + buttonValue={type.value} + isSelected={pipetteVolume === type.value} + /> + )) + } else { + const ot2Volume = volume as PipetteInfoByGen + const gen = pipetteGen as Gen + + return ot2Volume[gen].map(info => { + return info[pipetteType]?.map(type => ( + { + setPipetteVolume(type.value) + }} + buttonLabel={t('vol_label', { + volume: type.label, + })} + buttonValue={type.value} + isSelected={pipetteVolume === type.value} + /> + )) + }) + } + })} + + + ) : null} + {allPipetteOptions.includes(selectedPipette as PipetteName) + ? (() => { + const tiprackOptions = getTiprackOptions({ + allLabware, + allowAllTipracks, + selectedPipetteName: selectedPipette, + }) + return ( + + + {t('pipette_tips')} + + + {tiprackOptions.map(option => ( + { + const updatedTips = selectedTips.includes(option.value) + ? selectedTips.filter(v => v !== option.value) + : [...selectedTips, option.value] + setSelectedTips(updatedTips) + }} + /> + ))} + + + + {t('add_custom_tips')} + + dispatch(createCustomTiprackDef(e))} + /> + + {pipetteVolume === 'p1000' && + robotType === FLEX_ROBOT_TYPE ? null : ( + { + dispatch( + setFeatureFlags({ + OT_PD_ALLOW_ALL_TIPRACKS: !allowAllTipracks, + }) + ) + }} + textDecoration={TYPOGRAPHY.textDecorationUnderline} + > + + + {allowAllTipracks + ? t('show_default_tips') + : t('show_all_tips')} + + + + )} + + + + ) + })() + : null} + + ) +} + +const StyledBox = styled(Box)` + gap: ${SPACING.spacing4}; + display: ${DISPLAY_FLEX}; + flex-wrap: ${WRAP}; + align-items: ${ALIGN_CENTER}; + align-content: ${ALIGN_CENTER}; + align-self: ${ALIGN_STRETCH}; +` + +const StyledLabel = styled.label` + text-decoration: ${TYPOGRAPHY.textDecorationUnderline}; + font-size: ${PRODUCT.TYPOGRAPHY.fontSizeBodyDefaultSemiBold}; + display: ${DISPLAY_INLINE_BLOCK}; + cursor: ${CURSOR_POINTER}; + input[type='file'] { + display: none; + } + &:hover { + color: ${COLORS.blue50}; + } +` diff --git a/protocol-designer/src/organisms/EditInstrumentsModal/PipetteOverview.tsx b/protocol-designer/src/organisms/EditInstrumentsModal/PipetteOverview.tsx new file mode 100644 index 00000000000..328da8e7159 --- /dev/null +++ b/protocol-designer/src/organisms/EditInstrumentsModal/PipetteOverview.tsx @@ -0,0 +1,258 @@ +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import mapValues from 'lodash/mapValues' + +import { + ALIGN_CENTER, + Btn, + COLORS, + DIRECTION_COLUMN, + DIRECTION_ROW, + EmptySelectorButton, + Flex, + Icon, + JUSTIFY_SPACE_BETWEEN, + ListItem, + SPACING, + StyledText, + TYPOGRAPHY, +} from '@opentrons/components' +import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' + +import { PipetteInfoItem } from '../PipetteInfoItem' +import { changeSavedStepForm } from '../../steplist/actions' +import { deletePipettes } from '../../step-forms/actions' +import { deleteContainer } from '../../labware-ingred/actions' +import { toggleIsGripperRequired } from '../../step-forms/actions/additionalItems' +import { getSectionsFromPipetteName } from './utils' +import { INITIAL_DECK_SETUP_STEP_ID } from '../../constants' +import { LINK_BUTTON_STYLE } from '../../atoms' + +import type { AdditionalEquipmentName } from '@opentrons/step-generation' +import type { RobotType } from '@opentrons/shared-data' +import type { + AllTemporalPropertiesForTimelineFrame, + PipetteOnDeck, +} from '../../step-forms' +import type { ThunkDispatch } from '../../types' +import type { PipetteConfig } from './usePipetteConfig' + +interface Gripper { + name: AdditionalEquipmentName + id: string + location?: string +} + +interface PipetteOverviewProps { + has96Channel: boolean + pipettes: AllTemporalPropertiesForTimelineFrame['pipettes'] + labware: AllTemporalPropertiesForTimelineFrame['labware'] + robotType: RobotType + pipetteConfig: PipetteConfig + leftPipette?: PipetteOnDeck + rightPipette?: PipetteOnDeck + gripper?: Gripper +} + +export function PipetteOverview({ + has96Channel, + pipettes, + labware, + robotType, + pipetteConfig, + leftPipette, + rightPipette, + gripper, +}: PipetteOverviewProps): JSX.Element { + const { i18n, t } = useTranslation([ + 'create_new_protocol', + 'protocol_overview', + ]) + const dispatch = useDispatch>() + + const swapPipetteUpdate = mapValues(pipettes, pipette => { + if (!pipette.mount) return pipette.mount + return pipette.mount === 'left' ? 'right' : 'left' + }) + + const targetPipetteMount = leftPipette == null ? 'left' : 'right' + + const rightInfo = + rightPipette != null + ? getSectionsFromPipetteName(rightPipette.name, rightPipette.spec) + : null + const leftInfo = + leftPipette != null + ? getSectionsFromPipetteName(leftPipette.name, leftPipette.spec) + : null + + const previousLeftPipetteTipracks = Object.values(labware) + .filter(lw => lw.def.parameters.isTiprack) + .filter(tip => leftPipette?.tiprackDefURI.includes(tip.labwareDefURI)) + const previousRightPipetteTipracks = Object.values(labware) + .filter(lw => lw.def.parameters.isTiprack) + .filter(tip => rightPipette?.tiprackDefURI.includes(tip.labwareDefURI)) + + const { + setPage, + setMount, + setPipetteType, + setPipetteGen, + setPipetteVolume, + setSelectedTips, + } = pipetteConfig + + return ( + + + + + {t('your_pipettes')} + + {has96Channel || + (leftPipette == null && rightPipette == null) ? null : ( + + dispatch( + changeSavedStepForm({ + stepId: INITIAL_DECK_SETUP_STEP_ID, + update: { + pipetteLocationUpdate: swapPipetteUpdate, + }, + }) + ) + } + > + + + + {t('swap_pipette_mounts')} + + + + )} + + + {leftPipette?.tiprackDefURI != null && leftInfo != null ? ( + { + setPage('add') + setMount('left') + setPipetteType(leftInfo.type) + setPipetteGen(leftInfo.gen) + setPipetteVolume(leftInfo.volume) + setSelectedTips(leftPipette.tiprackDefURI as string[]) + }} + cleanForm={() => { + dispatch(deletePipettes([leftPipette.id as string])) + previousLeftPipetteTipracks.forEach(tip => + dispatch(deleteContainer({ labwareId: tip.id })) + ) + }} + /> + ) : null} + {rightPipette?.tiprackDefURI != null && rightInfo != null ? ( + { + setPage('add') + setMount('right') + setPipetteType(rightInfo.type) + setPipetteGen(rightInfo.gen) + setPipetteVolume(rightInfo.volume) + setSelectedTips(rightPipette.tiprackDefURI as string[]) + }} + cleanForm={() => { + dispatch(deletePipettes([rightPipette.id as string])) + previousRightPipetteTipracks.forEach(tip => + dispatch(deleteContainer({ labwareId: tip.id })) + ) + }} + /> + ) : null} + {has96Channel || + (leftPipette != null && rightPipette != null) ? null : ( + { + setPage('add') + setMount(targetPipetteMount) + }} + text={t('add_pipette')} + textAlignment="left" + iconName="plus" + /> + )} + + + {robotType === FLEX_ROBOT_TYPE ? ( + + + + {t('protocol_overview:your_gripper')} + + + + {gripper != null ? ( + + + + + {t('protocol_overview:extension')} + + + {i18n.format(t('gripper'), 'capitalize')} + + + { + dispatch(toggleIsGripperRequired()) + }} + > + + {t('remove')} + + + + + ) : ( + { + dispatch(toggleIsGripperRequired()) + }} + text={t('protocol_overview:add_gripper')} + textAlignment="left" + iconName="plus" + /> + )} + + + ) : null} + + ) +} diff --git a/protocol-designer/src/organisms/EditInstrumentsModal/__tests__/EditInstrumentsModal.test.tsx b/protocol-designer/src/organisms/EditInstrumentsModal/__tests__/EditInstrumentsModal.test.tsx new file mode 100644 index 00000000000..b4b06ee5bdf --- /dev/null +++ b/protocol-designer/src/organisms/EditInstrumentsModal/__tests__/EditInstrumentsModal.test.tsx @@ -0,0 +1,150 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { fireEvent, screen } from '@testing-library/react' + +import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' + +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../assets/localization' + +import { getRobotType } from '../../../file-data/selectors' +import { + getAdditionalEquipment, + getInitialDeckSetup, +} from '../../../step-forms/selectors' +import { getHas96Channel } from '../../../utils' +import { usePipetteConfig } from '../usePipetteConfig' +import { PipetteOverview } from '../PipetteOverview' +import { PipetteConfiguration } from '../PipetteConfiguration' + +import { EditInstrumentsModal } from '..' + +import type { ComponentProps } from 'react' + +vi.mock('../../../file-data/selectors') +vi.mock('../../../step-forms/selectors') +vi.mock('../../../utils') +vi.mock('../usePipetteConfig') +vi.mock('../PipetteOverview') +vi.mock('../PipetteConfiguration') + +const mockOnClose = vi.fn() + +const render = (props: ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('EditInstrumentsModal', () => { + let props: ComponentProps + + beforeEach(() => { + props = { + onClose: mockOnClose, + } + vi.mocked(getRobotType).mockReturnValue(FLEX_ROBOT_TYPE) + vi.mocked(usePipetteConfig).mockReturnValue({ + page: 'add', + mount: 'left', + pipetteType: 'single', + pipetteGen: 'flex', + pipetteVolume: 'p1000', + selectedTips: ['A1'], + setPage: vi.fn(), + setMount: vi.fn(), + setPipetteType: vi.fn(), + setPipetteGen: vi.fn(), + setPipetteVolume: vi.fn(), + setSelectedTips: vi.fn(), + resetFields: vi.fn(), + }) + vi.mocked(PipetteOverview).mockReturnValue(
mock PipetteOverview
) + vi.mocked(PipetteConfiguration).mockReturnValue( +
mock PipetteConfiguration
+ ) + vi.mocked(getInitialDeckSetup).mockReturnValue({ + pipettes: {}, + additionalEquipmentOnDeck: {}, + modules: {}, + labware: {}, + }) + vi.mocked(getHas96Channel).mockReturnValue(false) + vi.mocked(getAdditionalEquipment).mockReturnValue({}) + }) + + afterEach(() => { + vi.resetAllMocks() + }) + + it('should render text and buttons - pipette configuration', () => { + render(props) + screen.getByText('Edit Pipette') + screen.getByText('mock PipetteConfiguration') + screen.getByText('Save') + screen.getByText('Back') + }) + + it('should render text and buttons - pipette overview', () => { + vi.mocked(usePipetteConfig).mockReturnValue({ + page: 'overview', + mount: 'left', + pipetteType: 'single', + pipetteGen: 'flex', + pipetteVolume: 'p1000', + selectedTips: ['A1'], + setPage: vi.fn(), + setMount: vi.fn(), + setPipetteType: vi.fn(), + setPipetteGen: vi.fn(), + setPipetteVolume: vi.fn(), + setSelectedTips: vi.fn(), + resetFields: vi.fn(), + }) + render(props) + screen.getByText('Edit Instruments') + screen.getByText('mock PipetteOverview') + screen.getByText('Save') + screen.getByText('Cancel') + }) + + it('should render text and buttons - pipette overview', () => { + vi.mocked(usePipetteConfig).mockReturnValue({ + page: 'overview', + mount: 'left', + pipetteType: null, + pipetteGen: 'flex', + pipetteVolume: null, + selectedTips: [], + setPage: vi.fn(), + setMount: vi.fn(), + setPipetteType: vi.fn(), + setPipetteGen: vi.fn(), + setPipetteVolume: vi.fn(), + setSelectedTips: vi.fn(), + resetFields: vi.fn(), + }) + render(props) + expect(screen.getByText('Save')).toBeDisabled() + }) + + it('should render text and buttons - pipette overview', () => { + vi.mocked(usePipetteConfig).mockReturnValue({ + page: 'overview', + mount: 'left', + pipetteType: 'single', + pipetteGen: 'flex', + pipetteVolume: 'p1000', + selectedTips: ['A1'], + setPage: vi.fn(), + setMount: vi.fn(), + setPipetteType: vi.fn(), + setPipetteGen: vi.fn(), + setPipetteVolume: vi.fn(), + setSelectedTips: vi.fn(), + resetFields: vi.fn(), + }) + render(props) + fireEvent.click(screen.getByText('Cancel')) + expect(mockOnClose).toHaveBeenCalled() + }) +}) diff --git a/protocol-designer/src/organisms/EditInstrumentsModal/__tests__/usePipetteConfig.test.ts b/protocol-designer/src/organisms/EditInstrumentsModal/__tests__/usePipetteConfig.test.ts new file mode 100644 index 00000000000..9ddb18be1b9 --- /dev/null +++ b/protocol-designer/src/organisms/EditInstrumentsModal/__tests__/usePipetteConfig.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { usePipetteConfig } from '../usePipetteConfig' + +describe('usePipetteConfig', () => { + it('should initialize with default values', () => { + const { result } = renderHook(() => usePipetteConfig()) + + expect(result.current.page).toBe('overview') + expect(result.current.mount).toBe('left') + expect(result.current.pipetteType).toBeNull() + expect(result.current.pipetteGen).toBe('flex') + expect(result.current.pipetteVolume).toBeNull() + expect(result.current.selectedTips).toEqual([]) + }) + + it('should update page', () => { + const { result } = renderHook(() => usePipetteConfig()) + + act(() => { + result.current.setPage('add') + }) + + expect(result.current.page).toBe('add') + }) + + it('should update mount', () => { + const { result } = renderHook(() => usePipetteConfig()) + + act(() => { + result.current.setMount('right') + }) + + expect(result.current.mount).toBe('right') + }) + + it('should update pipetteType', () => { + const { result } = renderHook(() => usePipetteConfig()) + + act(() => { + result.current.setPipetteType('single') + }) + + expect(result.current.pipetteType).toBe('single') + }) + + it('should update pipetteGen', () => { + const { result } = renderHook(() => usePipetteConfig()) + + act(() => { + result.current.setPipetteGen('GEN2') + }) + + expect(result.current.pipetteGen).toBe('GEN2') + }) + + it('should update pipetteVolume', () => { + const { result } = renderHook(() => usePipetteConfig()) + + act(() => { + result.current.setPipetteVolume('1000') + }) + + expect(result.current.pipetteVolume).toBe('1000') + }) + + it('should update selectedTips', () => { + const { result } = renderHook(() => usePipetteConfig()) + + act(() => { + result.current.setSelectedTips(['tip1', 'tip2']) + }) + + expect(result.current.selectedTips).toEqual(['tip1', 'tip2']) + }) + + it('should reset fields', () => { + const { result } = renderHook(() => usePipetteConfig()) + + act(() => { + result.current.setPipetteType('single') + result.current.setPipetteGen('GEN2') + result.current.setPipetteVolume('1000') + result.current.setSelectedTips(['tip1', 'tip2']) + result.current.resetFields() + }) + + expect(result.current.pipetteType).toBeNull() + expect(result.current.pipetteGen).toBe('flex') + expect(result.current.pipetteVolume).toBeNull() + expect(result.current.selectedTips).toEqual([]) + }) +}) diff --git a/protocol-designer/src/organisms/EditInstrumentsModal/index.tsx b/protocol-designer/src/organisms/EditInstrumentsModal/index.tsx index 5de4843883c..853ac9b0619 100644 --- a/protocol-designer/src/organisms/EditInstrumentsModal/index.tsx +++ b/protocol-designer/src/organisms/EditInstrumentsModal/index.tsx @@ -1,86 +1,32 @@ -import { useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { useTranslation } from 'react-i18next' import { createPortal } from 'react-dom' -import styled, { css } from 'styled-components' -import mapValues from 'lodash/mapValues' import { - ALIGN_CENTER, - ALIGN_STRETCH, - Box, - Btn, - Checkbox, - COLORS, - CURSOR_POINTER, - DIRECTION_COLUMN, - DIRECTION_ROW, - DISPLAY_FLEX, - DISPLAY_INLINE_BLOCK, - EmptySelectorButton, - FLEX_MAX_CONTENT, Flex, - Icon, JUSTIFY_END, - JUSTIFY_SPACE_BETWEEN, - ListItem, Modal, - OVERFLOW_AUTO, PrimaryButton, - PRODUCT, - RadioButton, SecondaryButton, SPACING, - StyledText, - TYPOGRAPHY, - WRAP, } from '@opentrons/components' -import { - FLEX_ROBOT_TYPE, - getAllPipetteNames, - OT2_ROBOT_TYPE, -} from '@opentrons/shared-data' -import { getAllowAllTipracks } from '../../feature-flags/selectors' import { getAdditionalEquipment, getInitialDeckSetup, getPipetteEntities, } from '../../step-forms/selectors' -import { getHas96Channel, removeOpentronsPhrases } from '../../utils' -import { changeSavedStepForm } from '../../steplist/actions' -import { INITIAL_DECK_SETUP_STEP_ID } from '../../constants' -import { PipetteInfoItem } from '../PipetteInfoItem' -import { deletePipettes } from '../../step-forms/actions' -import { toggleIsGripperRequired } from '../../step-forms/actions/additionalItems' +import { getHas96Channel } from '../../utils' import { getRobotType } from '../../file-data/selectors' -import { - PIPETTE_GENS, - PIPETTE_TYPES, - PIPETTE_VOLUMES, -} from '../../pages/CreateNewProtocolWizard/constants' -import { getLabwareDefsByURI } from '../../labware-defs/selectors' -import { setFeatureFlags } from '../../feature-flags/actions' -import { createCustomTiprackDef } from '../../labware-defs/actions' -import { deleteContainer } from '../../labware-ingred/actions' import { selectors as stepFormSelectors } from '../../step-forms' -import { LINK_BUTTON_STYLE } from '../../atoms' import { getMainPagePortalEl } from '../Portal' -import { - getSectionsFromPipetteName, - getShouldShowPipetteType, - getTiprackOptions, -} from './utils' import { editPipettes } from './editPipettes' import { HandleEnter } from '../../atoms/HandleEnter' +import { PipetteOverview } from './PipetteOverview' +import { PipetteConfiguration } from './PipetteConfiguration' +import { usePipetteConfig } from './usePipetteConfig' -import type { PipetteMount, PipetteName } from '@opentrons/shared-data' -import type { - Gen, - PipetteInfoByGen, - PipetteInfoByType, - PipetteType, -} from '../../pages/CreateNewProtocolWizard/types' +import type { PipetteName } from '@opentrons/shared-data' import type { ThunkDispatch } from '../../types' interface EditInstrumentsModalProps { @@ -92,25 +38,13 @@ export function EditInstrumentsModal( ): JSX.Element { const { onClose } = props const dispatch = useDispatch>() - const { i18n, t } = useTranslation([ - 'create_new_protocol', - 'protocol_overview', - 'shared', - ]) - const [page, setPage] = useState<'add' | 'overview'>('overview') - const [mount, setMount] = useState('left') - const [pipetteType, setPipetteType] = useState(null) - const [pipetteGen, setPipetteGen] = useState('flex') - const [pipetteVolume, setPipetteVolume] = useState(null) - const [selectedTips, setSelectedTips] = useState([]) - const allowAllTipracks = useSelector(getAllowAllTipracks) + const { t } = useTranslation('shared') + const pipetteConfig = usePipetteConfig() const robotType = useSelector(getRobotType) const orderedStepIds = useSelector(stepFormSelectors.getOrderedStepIds) const initialDeckSetup = useSelector(getInitialDeckSetup) const additionalEquipment = useSelector(getAdditionalEquipment) const pipetteEntities = useSelector(getPipetteEntities) - const allLabware = useSelector(getLabwareDefsByURI) - const allPipetteOptions = getAllPipetteNames('maxVolume', 'channels') const { pipettes, labware } = initialDeckSetup const pipettesOnDeck = Object.values(pipettes) const has96Channel = getHas96Channel(pipetteEntities) @@ -119,42 +53,22 @@ export function EditInstrumentsModal( const gripper = Object.values(additionalEquipment).find( ae => ae.name === 'gripper' ) - const selectedPip = + const { + page, + mount, + pipetteType, + pipetteGen, + pipetteVolume, + selectedTips, + setPage, + resetFields, + } = pipetteConfig + + const selectedPipette = pipetteType === '96' || pipetteGen === 'GEN1' ? `${pipetteVolume}_${pipetteType}` : `${pipetteVolume}_${pipetteType}_${pipetteGen.toLowerCase()}` - const swapPipetteUpdate = mapValues(pipettes, pipette => { - if (!pipette.mount) return pipette.mount - return pipette.mount === 'left' ? 'right' : 'left' - }) - - const resetFields = (): void => { - setPipetteType(null) - setPipetteGen('flex') - setPipetteVolume(null) - } - - const previousLeftPipetteTipracks = Object.values(labware) - .filter(lw => lw.def.parameters.isTiprack) - .filter(tip => leftPipette?.tiprackDefURI.includes(tip.labwareDefURI)) - const previousRightPipetteTipracks = Object.values(labware) - .filter(lw => lw.def.parameters.isTiprack) - .filter(tip => rightPipette?.tiprackDefURI.includes(tip.labwareDefURI)) - - const rightInfo = - rightPipette != null - ? getSectionsFromPipetteName(rightPipette.name, rightPipette.spec) - : null - const leftInfo = - leftPipette != null - ? getSectionsFromPipetteName(leftPipette.name, leftPipette.spec) - : null - - // Note (kk:2024/10/09) - // if a user removes all pipettes, left mount is the first target. - const targetPipetteMount = leftPipette == null ? 'left' : 'right' - const handleOnSave = (): void => { if (page === 'overview') { onClose() @@ -166,7 +80,7 @@ export function EditInstrumentsModal( orderedStepIds, dispatch, mount, - selectedPip as PipetteName, + selectedPipette as PipetteName, selectedTips, leftPipette, rightPipette @@ -178,11 +92,7 @@ export function EditInstrumentsModal( - {page === 'overview' ? t('shared:cancel') : t('shared:back')} + {page === 'overview' ? t('cancel') : t('back')} - {t('shared:save')} + {t('save')} } > {page === 'overview' ? ( - - - - - {t('your_pipettes')} - - {has96Channel || - (leftPipette == null && rightPipette == null) ? null : ( - - dispatch( - changeSavedStepForm({ - stepId: INITIAL_DECK_SETUP_STEP_ID, - update: { - pipetteLocationUpdate: swapPipetteUpdate, - }, - }) - ) - } - > - - - - {t('swap_pipette_mounts')} - - - - )} - - - {leftPipette?.tiprackDefURI != null && leftInfo != null ? ( - { - setPage('add') - setMount('left') - setPipetteType(leftInfo.type) - setPipetteGen(leftInfo.gen) - setPipetteVolume(leftInfo.volume) - setSelectedTips(leftPipette.tiprackDefURI as string[]) - }} - cleanForm={() => { - dispatch(deletePipettes([leftPipette.id as string])) - previousLeftPipetteTipracks.forEach(tip => - dispatch(deleteContainer({ labwareId: tip.id })) - ) - }} - /> - ) : null} - {rightPipette?.tiprackDefURI != null && rightInfo != null ? ( - { - setPage('add') - setMount('right') - setPipetteType(rightInfo.type) - setPipetteGen(rightInfo.gen) - setPipetteVolume(rightInfo.volume) - setSelectedTips(rightPipette.tiprackDefURI as string[]) - }} - cleanForm={() => { - dispatch(deletePipettes([rightPipette.id as string])) - previousRightPipetteTipracks.forEach(tip => - dispatch(deleteContainer({ labwareId: tip.id })) - ) - }} - /> - ) : null} - {has96Channel || - (leftPipette != null && rightPipette != null) ? null : ( - { - setPage('add') - setMount(targetPipetteMount) - }} - text={t('add_pipette')} - textAlignment="left" - iconName="plus" - /> - )} - - - {robotType === FLEX_ROBOT_TYPE ? ( - - - - {t('protocol_overview:your_gripper')} - - - - {gripper != null ? ( - - - - - {t('protocol_overview:extension')} - - - {i18n.format(t('gripper'), 'capitalize')} - - - { - dispatch(toggleIsGripperRequired()) - }} - > - - {t('remove')} - - - - - ) : ( - { - dispatch(toggleIsGripperRequired()) - }} - text={t('protocol_overview:add_gripper')} - textAlignment="left" - iconName="plus" - /> - )} - - - ) : null} - + ) : ( - - - - {t('pipette_type')} - - - {PIPETTE_TYPES[robotType].map(type => { - return getShouldShowPipetteType( - type.value as PipetteType, - has96Channel, - leftPipette, - rightPipette, - mount - ) ? ( - { - setPipetteType(type.value) - setPipetteGen('flex') - setPipetteVolume(null) - setSelectedTips([]) - }} - buttonLabel={t(`shared:${type.label}`)} - buttonValue="single" - isSelected={pipetteType === type.value} - /> - ) : null - })} - - - {pipetteType != null && robotType === OT2_ROBOT_TYPE ? ( - - - {t('pipette_gen')} - - - {PIPETTE_GENS.map(gen => ( - { - setPipetteGen(gen) - setPipetteVolume(null) - setSelectedTips([]) - }} - buttonLabel={gen} - buttonValue={gen} - isSelected={pipetteGen === gen} - /> - ))} - - - ) : null} - {(pipetteType != null && robotType === FLEX_ROBOT_TYPE) || - (pipetteGen !== 'flex' && - pipetteType != null && - robotType === OT2_ROBOT_TYPE) ? ( - - - {t('pipette_vol')} - - - {PIPETTE_VOLUMES[robotType]?.map(volume => { - if (robotType === FLEX_ROBOT_TYPE && pipetteType != null) { - const flexVolume = volume as PipetteInfoByType - const flexPipetteInfo = flexVolume[pipetteType] - - return flexPipetteInfo?.map(type => ( - { - setPipetteVolume(type.value) - setSelectedTips([]) - }} - buttonLabel={t('vol_label', { volume: type.label })} - buttonValue={type.value} - isSelected={pipetteVolume === type.value} - /> - )) - } else { - const ot2Volume = volume as PipetteInfoByGen - const gen = pipetteGen as Gen - - return ot2Volume[gen].map(info => { - return info[pipetteType]?.map(type => ( - { - setPipetteVolume(type.value) - }} - buttonLabel={t('vol_label', { - volume: type.label, - })} - buttonValue={type.value} - isSelected={pipetteVolume === type.value} - /> - )) - }) - } - })} - - - ) : null} - {allPipetteOptions.includes(selectedPip as PipetteName) - ? (() => { - const tiprackOptions = getTiprackOptions({ - allLabware, - allowAllTipracks, - selectedPipetteName: selectedPip, - }) - return ( - - - {t('pipette_tips')} - - - {tiprackOptions.map(option => ( - { - const updatedTips = selectedTips.includes( - option.value - ) - ? selectedTips.filter(v => v !== option.value) - : [...selectedTips, option.value] - setSelectedTips(updatedTips) - }} - /> - ))} - - - - {t('add_custom_tips')} - - - dispatch(createCustomTiprackDef(e)) - } - /> - - {pipetteVolume === 'p1000' && - robotType === FLEX_ROBOT_TYPE ? null : ( - { - dispatch( - setFeatureFlags({ - OT_PD_ALLOW_ALL_TIPRACKS: !allowAllTipracks, - }) - ) - }} - textDecoration={ - TYPOGRAPHY.textDecorationUnderline - } - > - - - {allowAllTipracks - ? t('show_default_tips') - : t('show_all_tips')} - - {' '} - - )} - - - - ) - })() - : null} - + )} , getMainPagePortalEl() ) } - -const StyledLabel = styled.label` - text-decoration: ${TYPOGRAPHY.textDecorationUnderline}; - font-size: ${PRODUCT.TYPOGRAPHY.fontSizeBodyDefaultSemiBold}; - display: ${DISPLAY_INLINE_BLOCK}; - cursor: ${CURSOR_POINTER}; - input[type='file'] { - display: none; - } - &:hover { - color: ${COLORS.blue50}; - } -` diff --git a/protocol-designer/src/organisms/EditInstrumentsModal/usePipetteConfig.ts b/protocol-designer/src/organisms/EditInstrumentsModal/usePipetteConfig.ts new file mode 100644 index 00000000000..61f31f85bd4 --- /dev/null +++ b/protocol-designer/src/organisms/EditInstrumentsModal/usePipetteConfig.ts @@ -0,0 +1,56 @@ +import { useState } from 'react' + +import type { Dispatch, SetStateAction } from 'react' +import type { PipetteMount } from '@opentrons/shared-data' +import type { + Gen, + PipetteType, +} from '../../pages/CreateNewProtocolWizard/types' + +export interface PipetteConfig { + page: 'add' | 'overview' + mount: PipetteMount + pipetteType: PipetteType | null + pipetteGen: Gen | 'flex' + pipetteVolume: string | null + selectedTips: string[] + setPage: Dispatch> + setMount: Dispatch> + setPipetteType: Dispatch> + setPipetteGen: Dispatch> + setPipetteVolume: Dispatch> + setSelectedTips: Dispatch> + resetFields: () => void +} + +export const usePipetteConfig = (): PipetteConfig => { + const [page, setPage] = useState<'add' | 'overview'>('overview') + const [mount, setMount] = useState('left') + const [pipetteType, setPipetteType] = useState(null) + const [pipetteGen, setPipetteGen] = useState('flex') + const [pipetteVolume, setPipetteVolume] = useState(null) + const [selectedTips, setSelectedTips] = useState([]) + + const resetFields = (): void => { + setPipetteType(null) + setPipetteGen('flex') + setPipetteVolume(null) + setSelectedTips([]) + } + + return { + page, + setPage, + mount, + setMount, + pipetteType, + setPipetteType, + pipetteGen, + setPipetteGen, + pipetteVolume, + setPipetteVolume, + selectedTips, + setSelectedTips, + resetFields, + } +}