From de2b1eb803b948feaa77f160b67a18830a1ff47b Mon Sep 17 00:00:00 2001 From: koji Date: Mon, 22 Apr 2024 09:44:14 -0400 Subject: [PATCH 1/8] feat(opentrons-ai-client): introduce react-markdown to chat display component (#14965) * feat(opentrons-ai-client): introduce react-markdown to chat display component --- opentrons-ai-client/package.json | 1 + .../molecules/ChatDisplay/ChatDisplay.stories.tsx | 12 ++++++++---- .../ChatDisplay/__tests__/ChatDisplay.test.tsx | 4 ++-- .../src/molecules/ChatDisplay/index.tsx | 11 +++++++---- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/opentrons-ai-client/package.json b/opentrons-ai-client/package.json index f3dd1d2f2a1..39d4f6d275c 100644 --- a/opentrons-ai-client/package.json +++ b/opentrons-ai-client/package.json @@ -26,6 +26,7 @@ "react-dom": "18.2.0", "react-error-boundary": "^4.0.10", "react-i18next": "13.5.0", + "react-markdown": "9.0.1", "styled-components": "5.3.6" }, "engines": { diff --git a/opentrons-ai-client/src/molecules/ChatDisplay/ChatDisplay.stories.tsx b/opentrons-ai-client/src/molecules/ChatDisplay/ChatDisplay.stories.tsx index cd4d08a1701..ae03a25f754 100644 --- a/opentrons-ai-client/src/molecules/ChatDisplay/ChatDisplay.stories.tsx +++ b/opentrons-ai-client/src/molecules/ChatDisplay/ChatDisplay.stories.tsx @@ -21,12 +21,14 @@ const meta: Meta = { } export default meta type Story = StoryObj + export const OpentronsAI: Story = { args: { - text: ` - \`\`\`python -from opentrons import protocol_api + content: ` +## sample output from OpentronsAI +\`\`\`py +from opentrons import protocol_api # Metadata metadata = { 'protocolName': 'ThermoPrime Taq DNA Polymerase PCR Amplification', @@ -46,13 +48,15 @@ def run(protocol: protocol_api.ProtocolContext): TC_SAMPLE_MASTERMIX_MIX_VOLUME = SAMPLE_VOL + MASTERMIX_VOL MASTERMIX_BLOCK_TEMP = 10 # degree C TEMP_DECK_WAIT_TIME = 50 # seconds +\`\`\` `, isUserInput: false, }, } + export const User: Story = { args: { - text: ` + content: ` - Application: Reagent transfer - Robot: OT-2 - API: 2.13 diff --git a/opentrons-ai-client/src/molecules/ChatDisplay/__tests__/ChatDisplay.test.tsx b/opentrons-ai-client/src/molecules/ChatDisplay/__tests__/ChatDisplay.test.tsx index ad9bf527a0b..75b99717abb 100644 --- a/opentrons-ai-client/src/molecules/ChatDisplay/__tests__/ChatDisplay.test.tsx +++ b/opentrons-ai-client/src/molecules/ChatDisplay/__tests__/ChatDisplay.test.tsx @@ -15,7 +15,7 @@ describe('ChatDisplay', () => { beforeEach(() => { props = { - text: 'mock text from the backend', + content: 'mock text from the backend', isUserInput: false, } }) @@ -29,7 +29,7 @@ describe('ChatDisplay', () => { }) it('should display input from use and label', () => { props = { - text: 'mock text from user input', + content: 'mock text from user input', isUserInput: true, } render(props) diff --git a/opentrons-ai-client/src/molecules/ChatDisplay/index.tsx b/opentrons-ai-client/src/molecules/ChatDisplay/index.tsx index f18bc9f4998..c2d52e6a593 100644 --- a/opentrons-ai-client/src/molecules/ChatDisplay/index.tsx +++ b/opentrons-ai-client/src/molecules/ChatDisplay/index.tsx @@ -1,5 +1,6 @@ import React from 'react' import { useTranslation } from 'react-i18next' +import Markdown from 'react-markdown' import { BORDERS, COLORS, @@ -10,12 +11,12 @@ import { } from '@opentrons/components' interface ChatDisplayProps { - text: string + content: string isUserInput: boolean } export function ChatDisplay({ - text, + content, isUserInput, }: ChatDisplayProps): JSX.Element { const { t } = useTranslation('protocol_generator') @@ -25,7 +26,6 @@ export function ChatDisplay({ gridGap={SPACING.spacing12} paddingLeft={isUserInput ? SPACING.spacing40 : undefined} paddingRight={isUserInput ? undefined : SPACING.spacing40} - // max-width="58.125rem" > {isUserInput ? t('you') : t('opentronsai')} {/* text should be markdown so this component will have a package or function to parse markdown */} @@ -35,8 +35,11 @@ export function ChatDisplay({ data-testid={`ChatDisplay_from_${isUserInput ? 'user' : 'backend'}`} borderRadius={BORDERS.borderRadius12} width="100%" + flexDirection={DIRECTION_COLUMN} + gridGap={SPACING.spacing16} > - {text} + {/* ToDo (kk:04/19/2024) I will get feedback for additional styling from the design team. */} + {content} ) From 0f07f975e6795bffe09c4b624dc78cac63dd3c4c Mon Sep 17 00:00:00 2001 From: koji Date: Mon, 22 Apr 2024 09:44:41 -0400 Subject: [PATCH 2/8] fix(components): fix icon stories (#14969) * fix(components): fix icon stories --- components/src/icons/Icon.stories.tsx | 33 +++++++++++++++------------ 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/components/src/icons/Icon.stories.tsx b/components/src/icons/Icon.stories.tsx index 1be5df8581c..9d7b0f1141a 100644 --- a/components/src/icons/Icon.stories.tsx +++ b/components/src/icons/Icon.stories.tsx @@ -1,33 +1,38 @@ import * as React from 'react' - -import { Box, SIZE_3 } from '@opentrons/components' +import { Flex } from '../primitives' +import { SPACING } from '../ui-style-constants' import { ICON_DATA_BY_NAME } from './icon-data' import { Icon as IconComponent } from './Icon' +import type { Meta, StoryObj } from '@storybook/react' -import type { Story, Meta } from '@storybook/react' - -export default { +const meta: Meta = { title: 'Library/Atoms/Icon', + component: IconComponent, argTypes: { name: { + options: Object.keys(ICON_DATA_BY_NAME), control: { type: 'select', - options: Object.keys(ICON_DATA_BY_NAME), }, - defaultValue: 'alert', }, }, decorators: [ Story => ( - + - + ), ], -} as Meta +} -const Template: Story> = args => { - return +export default meta + +type Story = StoryObj + +export const Icon: Story = { + args: { + name: 'alert', + spin: false, + size: '4rem', + }, } -export const Icon = Template.bind({}) -Icon.args = { spin: false } From 8776ed9ce779702b8da3cc715ba10d1ffc41ed8f Mon Sep 17 00:00:00 2001 From: Shlok Amin Date: Mon, 22 Apr 2024 09:54:51 -0400 Subject: [PATCH 3/8] ci(shared-data): install dependencies in workflow (#14958) # Overview Follow up to https://github.com/Opentrons/opentrons/pull/14935 This PR adds make a make setup call that got deleted on accident. # Risk assessment Low --- .github/workflows/shared-data-test-lint-deploy.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/shared-data-test-lint-deploy.yaml b/.github/workflows/shared-data-test-lint-deploy.yaml index 94c56f16a56..57653337132 100644 --- a/.github/workflows/shared-data-test-lint-deploy.yaml +++ b/.github/workflows/shared-data-test-lint-deploy.yaml @@ -237,7 +237,8 @@ jobs: - name: 'js deps' run: | npm config set cache ./.npm-cache - yarn config set cache-folder ./.yarn-cache + yarn config set cache-folder ./.yarn-cache + make setup-js - name: 'build typescript' run: make build-ts - name: 'build library' From b42927a74661a0264dc33d4aa0d2ca6cc63f5d87 Mon Sep 17 00:00:00 2001 From: Sarah Breen Date: Mon, 22 Apr 2024 11:31:02 -0400 Subject: [PATCH 4/8] feat(app): add tiprack selection step to quick transfer flow (#14950) fix PLAT-290 --- .../QuickTransferFlow/SelectPipette.tsx | 7 +- .../QuickTransferFlow/SelectTipRack.tsx | 80 +++++++++++++++++ .../__tests__/SelectTipRack.test.tsx | 86 +++++++++++++++++++ app/src/organisms/QuickTransferFlow/index.tsx | 20 ++++- app/src/organisms/QuickTransferFlow/types.ts | 14 +-- components/src/atoms/StepMeter/index.tsx | 14 ++- 6 files changed, 207 insertions(+), 14 deletions(-) create mode 100644 app/src/organisms/QuickTransferFlow/SelectTipRack.tsx create mode 100644 app/src/organisms/QuickTransferFlow/__tests__/SelectTipRack.test.tsx diff --git a/app/src/organisms/QuickTransferFlow/SelectPipette.tsx b/app/src/organisms/QuickTransferFlow/SelectPipette.tsx index 0f92ca0d508..6ef31157fdf 100644 --- a/app/src/organisms/QuickTransferFlow/SelectPipette.tsx +++ b/app/src/organisms/QuickTransferFlow/SelectPipette.tsx @@ -79,9 +79,12 @@ export function SelectPipette(props: SelectPipetteProps): JSX.Element { marginTop={SPACING.spacing120} flexDirection={DIRECTION_COLUMN} padding={`${SPACING.spacing16} ${SPACING.spacing60} ${SPACING.spacing40} ${SPACING.spacing60}`} - gridGap={SPACING.spacing16} + gridGap={SPACING.spacing4} > - + {t('pipette_currently_attached')} {leftPipetteSpecs != null ? ( diff --git a/app/src/organisms/QuickTransferFlow/SelectTipRack.tsx b/app/src/organisms/QuickTransferFlow/SelectTipRack.tsx new file mode 100644 index 00000000000..bed59baa54b --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/SelectTipRack.tsx @@ -0,0 +1,80 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { Flex, SPACING, DIRECTION_COLUMN } from '@opentrons/components' +import { getAllDefinitions } from '@opentrons/shared-data' +import { SmallButton, LargeButton } from '../../atoms/buttons' +import { ChildNavigation } from '../ChildNavigation' + +import type { LabwareDefinition2 } from '@opentrons/shared-data' +import type { + QuickTransferSetupState, + QuickTransferWizardAction, +} from './types' + +interface SelectTipRackProps { + onNext: () => void + onBack: () => void + exitButtonProps: React.ComponentProps + state: QuickTransferSetupState + dispatch: React.Dispatch +} + +export function SelectTipRack(props: SelectTipRackProps): JSX.Element { + const { onNext, onBack, exitButtonProps, state, dispatch } = props + const { i18n, t } = useTranslation(['quick_transfer', 'shared']) + + const allLabwareDefinitionsByUri = getAllDefinitions() + const selectedPipetteDefaultTipracks = + state.pipette?.liquids.default.defaultTipracks ?? [] + + const [selectedTipRack, setSelectedTipRack] = React.useState< + LabwareDefinition2 | undefined + >(state.tipRack) + + const handleClickNext = (): void => { + // the button will be disabled if this values is null + if (selectedTipRack != null) { + dispatch({ + type: 'SELECT_TIP_RACK', + tipRack: selectedTipRack, + }) + onNext() + } + } + return ( + + + + {selectedPipetteDefaultTipracks.map(tipRack => { + const tipRackDef = allLabwareDefinitionsByUri[tipRack] + + return tipRackDef != null ? ( + { + setSelectedTipRack(tipRackDef) + }} + buttonText={tipRackDef.metadata.displayName} + /> + ) : null + })} + + + ) +} diff --git a/app/src/organisms/QuickTransferFlow/__tests__/SelectTipRack.test.tsx b/app/src/organisms/QuickTransferFlow/__tests__/SelectTipRack.test.tsx new file mode 100644 index 00000000000..b32b3188910 --- /dev/null +++ b/app/src/organisms/QuickTransferFlow/__tests__/SelectTipRack.test.tsx @@ -0,0 +1,86 @@ +import * as React from 'react' +import { fireEvent, screen } from '@testing-library/react' +import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest' + +import { renderWithProviders } from '../../../__testing-utils__' +import { i18n } from '../../../i18n' +import { SelectTipRack } from '../SelectTipRack' + +vi.mock('@opentrons/react-api-client') +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('SelectTipRack', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + onNext: vi.fn(), + onBack: vi.fn(), + exitButtonProps: { + buttonType: 'tertiaryLowLight', + buttonText: 'Exit', + onClick: vi.fn(), + }, + state: { + mount: 'left', + pipette: { + liquids: { + default: { + defaultTipracks: [ + 'opentrons/opentrons_flex_96_tiprack_1000ul/1', + 'opentrons/opentrons_flex_96_tiprack_200ul/1', + 'opentrons/opentrons_flex_96_tiprack_50ul/1', + 'opentrons/opentrons_flex_96_filtertiprack_1000ul/1', + 'opentrons/opentrons_flex_96_filtertiprack_200ul/1', + 'opentrons/opentrons_flex_96_filtertiprack_50ul/1', + ], + }, + }, + } as any, + }, + dispatch: vi.fn(), + } + }) + afterEach(() => { + vi.resetAllMocks() + }) + + it('renders the select tip rack screen, header, and exit button', () => { + render(props) + screen.getByText('Select tip rack') + const exitBtn = screen.getByText('Exit') + fireEvent.click(exitBtn) + expect(props.exitButtonProps.onClick).toHaveBeenCalled() + }) + + it('renders continue button and it is disabled if no tip rack is selected', () => { + render(props) + screen.getByText('Continue') + const continueBtn = screen.getByTestId('ChildNavigation_Primary_Button') + expect(continueBtn).toBeDisabled() + }) + + it('selects tip rack by default if there is one in state, button will be enabled', () => { + render({ ...props, state: { tipRack: { def: 'definition' } as any } }) + const continueBtn = screen.getByTestId('ChildNavigation_Primary_Button') + expect(continueBtn).toBeEnabled() + fireEvent.click(continueBtn) + expect(props.onNext).toHaveBeenCalled() + }) + + it('enables continue button if you click a tip rack', () => { + render(props) + const continueBtn = screen.getByTestId('ChildNavigation_Primary_Button') + expect(continueBtn).toBeDisabled() + const tipRackButton = screen.getByText('Opentrons Flex 96 Tip Rack 200 µL') + fireEvent.click(tipRackButton) + expect(continueBtn).toBeEnabled() + fireEvent.click(continueBtn) + expect(props.dispatch).toHaveBeenCalled() + expect(props.onNext).toHaveBeenCalled() + }) +}) diff --git a/app/src/organisms/QuickTransferFlow/index.tsx b/app/src/organisms/QuickTransferFlow/index.tsx index 36d0175b0db..cdfecc4fbe2 100644 --- a/app/src/organisms/QuickTransferFlow/index.tsx +++ b/app/src/organisms/QuickTransferFlow/index.tsx @@ -1,11 +1,17 @@ import * as React from 'react' import { useHistory } from 'react-router-dom' import { useTranslation } from 'react-i18next' -import { Flex, StepMeter, SPACING } from '@opentrons/components' +import { + Flex, + StepMeter, + SPACING, + POSITION_STICKY, +} from '@opentrons/components' import { SmallButton } from '../../atoms/buttons' import { ChildNavigation } from '../ChildNavigation' import { CreateNewTransfer } from './CreateNewTransfer' import { SelectPipette } from './SelectPipette' +import { SelectTipRack } from './SelectTipRack' import { quickTransferReducer } from './utils' import type { QuickTransferSetupState } from './types' @@ -66,6 +72,16 @@ export const QuickTransferFlow = (): JSX.Element => { exitButtonProps={exitButtonProps} /> ) + } else if (currentStep === 3) { + modalContent = ( + setCurrentStep(prevStep => prevStep - 1)} + onNext={() => setCurrentStep(prevStep => prevStep + 1)} + exitButtonProps={exitButtonProps} + /> + ) } else { modalContent = null } @@ -76,6 +92,8 @@ export const QuickTransferFlow = (): JSX.Element => { {modalContent == null ? ( diff --git a/app/src/organisms/QuickTransferFlow/types.ts b/app/src/organisms/QuickTransferFlow/types.ts index 814dae22a71..1d43017a58c 100644 --- a/app/src/organisms/QuickTransferFlow/types.ts +++ b/app/src/organisms/QuickTransferFlow/types.ts @@ -1,14 +1,14 @@ import { ACTIONS } from './constants' import type { Mount } from '@opentrons/api-client' -import type { LabwareDefinition1, PipetteV2Specs } from '@opentrons/shared-data' +import type { LabwareDefinition2, PipetteV2Specs } from '@opentrons/shared-data' export interface QuickTransferSetupState { pipette?: PipetteV2Specs mount?: Mount - tipRack?: LabwareDefinition1 - source?: LabwareDefinition1 + tipRack?: LabwareDefinition2 + source?: LabwareDefinition2 sourceWells?: string[] - destination?: LabwareDefinition1 + destination?: LabwareDefinition2 destinationWells?: string[] volume?: number } @@ -29,11 +29,11 @@ interface SelectPipetteAction { } interface SelectTipRackAction { type: typeof ACTIONS.SELECT_TIP_RACK - tipRack: LabwareDefinition1 + tipRack: LabwareDefinition2 } interface SetSourceLabwareAction { type: typeof ACTIONS.SET_SOURCE_LABWARE - labware: LabwareDefinition1 + labware: LabwareDefinition2 } interface SetSourceWellsAction { type: typeof ACTIONS.SET_SOURCE_WELLS @@ -41,7 +41,7 @@ interface SetSourceWellsAction { } interface SetDestLabwareAction { type: typeof ACTIONS.SET_DEST_LABWARE - labware: LabwareDefinition1 + labware: LabwareDefinition2 } interface SetDestWellsAction { type: typeof ACTIONS.SET_DEST_WELLS diff --git a/components/src/atoms/StepMeter/index.tsx b/components/src/atoms/StepMeter/index.tsx index 14bbf48c6ca..91f151fb5c9 100644 --- a/components/src/atoms/StepMeter/index.tsx +++ b/components/src/atoms/StepMeter/index.tsx @@ -5,13 +5,15 @@ import { RESPONSIVENESS, SPACING } from '../../ui-style-constants' import { COLORS } from '../../helix-design-system' import { POSITION_ABSOLUTE, POSITION_RELATIVE } from '../../styles' -interface StepMeterProps { +import type { StyleProps } from '../../primitives' + +interface StepMeterProps extends StyleProps { totalSteps: number currentStep: number | null } export const StepMeter = (props: StepMeterProps): JSX.Element => { - const { totalSteps, currentStep } = props + const { totalSteps, currentStep, ...styleProps } = props const progress = currentStep != null ? currentStep : 0 const percentComplete = `${ // this logic puts a cap at 100% percentComplete which we should never run into @@ -21,7 +23,7 @@ export const StepMeter = (props: StepMeterProps): JSX.Element => { }%` const StepMeterContainer = css` - position: ${POSITION_RELATIVE}; + position: ${styleProps.position ? styleProps.position : POSITION_RELATIVE}; height: ${SPACING.spacing4}; background-color: ${COLORS.grey30}; @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { @@ -41,7 +43,11 @@ export const StepMeter = (props: StepMeterProps): JSX.Element => { ` return ( - + ) From 58a1fc014e7dc4f7c710a23777c677a9546841c6 Mon Sep 17 00:00:00 2001 From: Jethary Rader <66035149+jerader@users.noreply.github.com> Date: Mon, 22 Apr 2024 11:48:24 -0400 Subject: [PATCH 5/8] refactor(protocol-designer): tip position modal max values round down (#14972) closes AUTH-352 --- .../TipPositionField/TipPositionModal.tsx | 14 +++++++------- .../TipPositionField/ZTipPositionModal.tsx | 8 ++++---- .../__tests__/TipPositionModal.test.tsx | 8 ++++---- .../fields/TipPositionField/utils.ts | 17 +++++++++++++++-- 4 files changed, 30 insertions(+), 17 deletions(-) diff --git a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/TipPositionModal.tsx b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/TipPositionModal.tsx index 2a303f92c2f..56a9148270f 100644 --- a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/TipPositionModal.tsx +++ b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/TipPositionModal.tsx @@ -93,12 +93,12 @@ export const TipPositionModal = ( } => { if (getIsTouchTipField(zSpec?.name ?? '')) { return { - maxMmFromBottom: utils.roundValue(wellDepthMm), - minMmFromBottom: utils.roundValue(wellDepthMm / 2), + maxMmFromBottom: utils.roundValue(wellDepthMm, 'up'), + minMmFromBottom: utils.roundValue(wellDepthMm / 2, 'up'), } } return { - maxMmFromBottom: utils.roundValue(wellDepthMm * 2), + maxMmFromBottom: utils.roundValue(wellDepthMm * 2, 'up'), minMmFromBottom: 0, } } @@ -138,10 +138,10 @@ export const TipPositionModal = ( return utils.getErrorText({ errors, minMm: min, maxMm: max, isPristine, t }) } - const roundedXMin = utils.roundValue(xMinWidth) - const roundedYMin = utils.roundValue(yMinWidth) - const roundedXMax = utils.roundValue(xMaxWidth) - const roundedYMax = utils.roundValue(yMaxWidth) + const roundedXMin = utils.roundValue(xMinWidth, 'up') + const roundedYMin = utils.roundValue(yMinWidth, 'up') + const roundedXMax = utils.roundValue(xMaxWidth, 'down') + const roundedYMax = utils.roundValue(yMaxWidth, 'down') const zErrorText = createErrorText(zErrors, minMmFromBottom, maxMmFromBottom) const xErrorText = createErrorText(xErrors, roundedXMin, roundedXMax) diff --git a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/ZTipPositionModal.tsx b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/ZTipPositionModal.tsx index b2812a35309..db2972c06e2 100644 --- a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/ZTipPositionModal.tsx +++ b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/ZTipPositionModal.tsx @@ -67,12 +67,12 @@ export function ZTipPositionModal(props: ZTipPositionModalProps): JSX.Element { } => { if (getIsTouchTipField(name)) { return { - maxMmFromBottom: utils.roundValue(wellDepthMm), - minMmFromBottom: utils.roundValue(wellDepthMm / 2), + maxMmFromBottom: utils.roundValue(wellDepthMm, 'up'), + minMmFromBottom: utils.roundValue(wellDepthMm / 2, 'up'), } } return { - maxMmFromBottom: utils.roundValue(wellDepthMm * 2), + maxMmFromBottom: utils.roundValue(wellDepthMm * 2, 'up'), minMmFromBottom: 0, } } @@ -148,7 +148,7 @@ export function ZTipPositionModal(props: ZTipPositionModalProps): JSX.Element { const handleIncrementDecrement = (delta: number): void => { const prevValue = value === null ? defaultMm : Number(value) setIsDefault(false) - handleChange(utils.roundValue(prevValue + delta)) + handleChange(utils.roundValue(prevValue + delta, 'up')) } const makeHandleIncrement = (step: number): (() => void) => () => { diff --git a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/__tests__/TipPositionModal.test.tsx b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/__tests__/TipPositionModal.test.tsx index 6054bd2eb2d..28b96c4c429 100644 --- a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/__tests__/TipPositionModal.test.tsx +++ b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/__tests__/TipPositionModal.test.tsx @@ -82,9 +82,9 @@ describe('TipPositionModal', () => { fireEvent.click(screen.getByRole('radio', { name: 'Custom' })) expect(screen.getAllByRole('textbox', { name: '' })).toHaveLength(3) screen.getByText('X position') - screen.getByText('between -5.1 and 5.2') + screen.getByText('between -5.1 and 5.1') screen.getByText('Y position') - screen.getByText('between -5.2 and 5.3') + screen.getByText('between -5.2 and 5.2') screen.getByText('Z position') screen.getByText('between 0 and 100') screen.getByText('mock TipPositionViz') @@ -129,8 +129,8 @@ describe('TipPositionModal', () => { fireEvent.click(screen.getByText('done')) // display out of bounds error screen.getByText('accepted range is 0 to 100') - screen.getByText('accepted range is -5.2 to 5.3') - screen.getByText('accepted range is -5.1 to 5.2') + screen.getByText('accepted range is -5.2 to 5.2') + screen.getByText('accepted range is -5.1 to 5.1') const xInputField = screen.getAllByRole('textbox', { name: '' })[0] fireEvent.change(xInputField, { target: { value: 3.55555 } }) fireEvent.click(screen.getByText('done')) diff --git a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/utils.ts b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/utils.ts index 96ed4729d49..4648aa78933 100644 --- a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/utils.ts +++ b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/utils.ts @@ -1,3 +1,4 @@ +import floor from 'lodash/floor' import round from 'lodash/round' import { getIsTouchTipField } from '../../../../form-types' import { @@ -46,8 +47,20 @@ export function getDefaultMmFromBottom(args: { } } -export const roundValue = (value: number | string | null): number => { - return value === null ? 0 : round(Number(value), DECIMALS_ALLOWED) +export const roundValue = ( + value: number | string | null, + direction: 'up' | 'down' +): number => { + if (value === null) return 0 + + switch (direction) { + case 'up': { + return round(Number(value), DECIMALS_ALLOWED) + } + case 'down': { + return floor(Number(value), DECIMALS_ALLOWED) + } + } } const OUT_OF_BOUNDS: 'OUT_OF_BOUNDS' = 'OUT_OF_BOUNDS' From 26d55ec7416080120b2dbe06ca6f39885db01ba4 Mon Sep 17 00:00:00 2001 From: koji Date: Mon, 22 Apr 2024 11:59:46 -0400 Subject: [PATCH 6/8] fix(app, api-client): fix choose protocol slideout issue (#14949) * fix(app, api-client): fix choose protocol slideout issue --- .../__fixtures__/simpleAnalysisFile.json | 3 +- .../__tests__/ChooseProtocolSlideout.test.tsx | 72 ++++++++++++------- .../ChooseProtocolSlideout/index.tsx | 10 +-- .../useStoredProtocolAnalysis.test.tsx | 14 ++-- .../protocol-storage/__fixtures__/index.ts | 11 +++ 5 files changed, 72 insertions(+), 38 deletions(-) diff --git a/api-client/src/protocols/__fixtures__/simpleAnalysisFile.json b/api-client/src/protocols/__fixtures__/simpleAnalysisFile.json index 74faa60fcb6..bb6aacccd6e 100644 --- a/api-client/src/protocols/__fixtures__/simpleAnalysisFile.json +++ b/api-client/src/protocols/__fixtures__/simpleAnalysisFile.json @@ -3989,5 +3989,6 @@ } ] } - ] + ], + "robotType": "OT-2 Standard" } diff --git a/app/src/organisms/ChooseProtocolSlideout/__tests__/ChooseProtocolSlideout.test.tsx b/app/src/organisms/ChooseProtocolSlideout/__tests__/ChooseProtocolSlideout.test.tsx index 11583264b3e..7973023d184 100644 --- a/app/src/organisms/ChooseProtocolSlideout/__tests__/ChooseProtocolSlideout.test.tsx +++ b/app/src/organisms/ChooseProtocolSlideout/__tests__/ChooseProtocolSlideout.test.tsx @@ -3,15 +3,21 @@ import { vi, it, describe, expect, beforeEach } from 'vitest' import { StaticRouter } from 'react-router-dom' import { fireEvent, screen } from '@testing-library/react' +import { simpleAnalysisFileFixture } from '@opentrons/api-client' +import { OT2_ROBOT_TYPE } from '@opentrons/shared-data' import { renderWithProviders } from '../../../__testing-utils__' import { i18n } from '../../../i18n' import { getStoredProtocols } from '../../../redux/protocol-storage' import { mockConnectableRobot } from '../../../redux/discovery/__fixtures__' -import { storedProtocolData as storedProtocolDataFixture } from '../../../redux/protocol-storage/__fixtures__' +import { + storedProtocolData as storedProtocolDataFixture, + storedProtocolDataWithoutRunTimeParameters, +} from '../../../redux/protocol-storage/__fixtures__' import { useTrackCreateProtocolRunEvent } from '../../../organisms/Devices/hooks' import { useCreateRunFromProtocol } from '../../ChooseRobotToRunProtocolSlideout/useCreateRunFromProtocol' import { ChooseProtocolSlideout } from '../' import { useNotifyService } from '../../../resources/useNotifyService' +import type { ProtocolAnalysisOutput } from '@opentrons/shared-data' vi.mock('../../ChooseRobotToRunProtocolSlideout/useCreateRunFromProtocol') vi.mock('../../../redux/protocol-storage') @@ -30,6 +36,20 @@ const render = (props: React.ComponentProps) => { ) } +const modifiedSimpleAnalysisFileFixture = { + ...simpleAnalysisFileFixture, + robotType: OT2_ROBOT_TYPE, +} +const mockStoredProtocolDataFixture = [ + { + ...storedProtocolDataFixture, + mostRecentAnalysis: ({ + ...modifiedSimpleAnalysisFileFixture, + runTimeParameters: [], + } as any) as ProtocolAnalysisOutput, + }, +] + describe('ChooseProtocolSlideout', () => { let mockCreateRunFromProtocol = vi.fn() let mockTrackCreateProtocolRunEvent = vi.fn() @@ -38,7 +58,7 @@ describe('ChooseProtocolSlideout', () => { mockTrackCreateProtocolRunEvent = vi.fn( () => new Promise(resolve => resolve({})) ) - vi.mocked(getStoredProtocols).mockReturnValue([storedProtocolDataFixture]) + vi.mocked(getStoredProtocols).mockReturnValue(mockStoredProtocolDataFixture) vi.mocked(useCreateRunFromProtocol).mockReturnValue({ createRunFromProtocolSource: mockCreateRunFromProtocol, reset: vi.fn(), @@ -86,34 +106,32 @@ describe('ChooseProtocolSlideout', () => { ).toBeInTheDocument() }) - // it('calls createRunFromProtocolSource if CTA clicked', () => { - // const protocolDataWithoutRunTimeParameter = { - // ...storedProtocolDataFixture, - // runTimeParameters: [], - // } - // vi.mocked(getStoredProtocols).mockReturnValue([ - // protocolDataWithoutRunTimeParameter, - // ]) - // render({ - // robot: mockConnectableRobot, - // onCloseClick: vi.fn(), - // showSlideout: true, - // }) - // const proceedButton = screen.getByRole('button', { - // name: 'Proceed to setup', - // }) - // fireEvent.click(proceedButton) - // expect(mockCreateRunFromProtocol).toHaveBeenCalledWith({ - // files: [expect.any(File)], - // protocolKey: storedProtocolDataFixture.protocolKey, - // }) - // expect(mockTrackCreateProtocolRunEvent).toHaveBeenCalled() - // }) + it('calls createRunFromProtocolSource if CTA clicked', () => { + const protocolDataWithoutRunTimeParameter = { + ...storedProtocolDataWithoutRunTimeParameters, + } + vi.mocked(getStoredProtocols).mockReturnValue([ + protocolDataWithoutRunTimeParameter, + ]) + render({ + robot: mockConnectableRobot, + onCloseClick: vi.fn(), + showSlideout: true, + }) + const proceedButton = screen.getByRole('button', { + name: 'Proceed to setup', + }) + fireEvent.click(proceedButton) + expect(mockCreateRunFromProtocol).toHaveBeenCalledWith({ + files: [expect.any(File)], + protocolKey: storedProtocolDataFixture.protocolKey, + }) + expect(mockTrackCreateProtocolRunEvent).toHaveBeenCalled() + }) it('move to the second slideout if CTA clicked', () => { const protocolDataWithoutRunTimeParameter = { ...storedProtocolDataFixture, - runTimeParameters: [], } vi.mocked(getStoredProtocols).mockReturnValue([ protocolDataWithoutRunTimeParameter, @@ -132,7 +150,7 @@ describe('ChooseProtocolSlideout', () => { screen.getByText('Restore default values') }) - // ToDo (kk:04/08) update test for RTP + // ToDo (kk:04/18/2024) I will update test for RTP /* it('renders error state when there is a run creation error', () => { vi.mocked(useCreateRunFromProtocol).mockReturnValue({ diff --git a/app/src/organisms/ChooseProtocolSlideout/index.tsx b/app/src/organisms/ChooseProtocolSlideout/index.tsx index 6f00082013a..dfd7b7c12a2 100644 --- a/app/src/organisms/ChooseProtocolSlideout/index.tsx +++ b/app/src/organisms/ChooseProtocolSlideout/index.tsx @@ -461,7 +461,7 @@ export function ChooseProtocolSlideoutComponent( setSelectedProtocol(storedProtocol) } }} - robotName={robot.name} + robot={robot} {...{ selectedProtocol, runCreationError, runCreationErrorCode }} /> ) : ( @@ -483,7 +483,7 @@ interface StoredProtocolListProps { handleSelectProtocol: (storedProtocol: StoredProtocolData | null) => void runCreationError: string | null runCreationErrorCode: number | null - robotName: string + robot: Robot } function StoredProtocolList(props: StoredProtocolListProps): JSX.Element { @@ -492,11 +492,13 @@ function StoredProtocolList(props: StoredProtocolListProps): JSX.Element { handleSelectProtocol, runCreationError, runCreationErrorCode, - robotName, + robot, } = props const { t } = useTranslation(['device_details', 'protocol_details', 'shared']) const storedProtocols = useSelector((state: State) => getStoredProtocols(state) + ).filter( + protocol => protocol.mostRecentAnalysis?.robotType === robot.robotModel ) React.useEffect(() => { handleSelectProtocol(first(storedProtocols) ?? null) @@ -585,7 +587,7 @@ function StoredProtocolList(props: StoredProtocolListProps): JSX.Element { color: ${COLORS.red60}; text-decoration: ${TYPOGRAPHY.textDecorationUnderline}; `} - to={`/devices/${robotName}`} + to={`/devices/${robot.name}`} /> ), }} diff --git a/app/src/organisms/Devices/hooks/__tests__/useStoredProtocolAnalysis.test.tsx b/app/src/organisms/Devices/hooks/__tests__/useStoredProtocolAnalysis.test.tsx index fa63db104c6..4a165f628c5 100644 --- a/app/src/organisms/Devices/hooks/__tests__/useStoredProtocolAnalysis.test.tsx +++ b/app/src/organisms/Devices/hooks/__tests__/useStoredProtocolAnalysis.test.tsx @@ -1,9 +1,9 @@ import * as React from 'react' import { vi, it, expect, describe, beforeEach, afterEach } from 'vitest' import { when } from 'vitest-when' -import { QueryClient, QueryClientProvider, UseQueryResult } from 'react-query' +import { QueryClient, QueryClientProvider } from 'react-query' import { Provider } from 'react-redux' -import { createStore, Store } from 'redux' +import { createStore } from 'redux' import { renderHook } from '@testing-library/react' import { @@ -12,12 +12,10 @@ import { parsePipetteEntity, } from '@opentrons/api-client' import { useProtocolQuery } from '@opentrons/react-api-client' +import { OT2_ROBOT_TYPE } from '@opentrons/shared-data' import { storedProtocolData } from '../../../../redux/protocol-storage/__fixtures__' -import { - getStoredProtocol, - StoredProtocolData, -} from '../../../../redux/protocol-storage' +import { getStoredProtocol } from '../../../../redux/protocol-storage' import { useStoredProtocolAnalysis } from '../useStoredProtocolAnalysis' import { LABWARE_ENTITY, @@ -27,7 +25,10 @@ import { } from '../__fixtures__/storedProtocolAnalysis' import { useNotifyRunQuery } from '../../../../resources/runs' +import type { Store } from 'redux' +import type { UseQueryResult } from 'react-query' import type { Protocol, Run } from '@opentrons/api-client' +import type { StoredProtocolData } from '../../../../redux/protocol-storage' vi.mock('@opentrons/api-client') vi.mock('@opentrons/react-api-client') @@ -44,6 +45,7 @@ const modifiedStoredProtocolData = { errors: storedProtocolData?.mostRecentAnalysis?.errors, runTimeParameters: storedProtocolData?.mostRecentAnalysis?.runTimeParameters, + robotType: OT2_ROBOT_TYPE, }, } diff --git a/app/src/redux/protocol-storage/__fixtures__/index.ts b/app/src/redux/protocol-storage/__fixtures__/index.ts index 56f7f4d021a..be5500203a2 100644 --- a/app/src/redux/protocol-storage/__fixtures__/index.ts +++ b/app/src/redux/protocol-storage/__fixtures__/index.ts @@ -11,6 +11,17 @@ export const storedProtocolData: StoredProtocolData = { modified: 123456789, } +export const storedProtocolDataWithoutRunTimeParameters: StoredProtocolData = { + protocolKey: 'protocolKeyStub', + mostRecentAnalysis: ({ + ...simpleAnalysisFileFixture, + runTimeParameters: [], + } as any) as ProtocolAnalysisOutput, + srcFileNames: ['fakeSrcFileName'], + srcFiles: ['fakeSrcFile' as any], + modified: 123456789, +} + export const storedProtocolDir: StoredProtocolDir = { dirPath: 'path/to/protocol/dir', modified: 1234556789, From 838e356ad3e934d7fe12a1f0e9aa7ad13e4ffc80 Mon Sep 17 00:00:00 2001 From: Jethary Rader <66035149+jerader@users.noreply.github.com> Date: Mon, 22 Apr 2024 12:28:41 -0400 Subject: [PATCH 7/8] refactor(protocol-designer): assign module slot in createFileWizard instead of modal (#14951) closes AUTH-355 AUTH-22 --- .../CreateFileWizard/ModulesAndOtherTile.tsx | 59 ++-- .../CreateFileWizard/__tests__/utils.test.tsx | 280 +++++++----------- .../modals/CreateFileWizard/index.tsx | 27 +- .../modals/CreateFileWizard/utils.ts | 148 +++------ .../components/modules/EditModulesCard.tsx | 4 +- ...leModuleRow.tsx => MultipleModulesRow.tsx} | 4 +- .../__tests__/MultipleModuleRow.test.tsx | 8 +- .../src/modules/__tests__/moduleData.test.tsx | 88 ++++++ protocol-designer/src/modules/index.ts | 1 + protocol-designer/src/modules/moduleData.ts | 48 ++- protocol-designer/src/modules/thunks.ts | 33 +++ 11 files changed, 359 insertions(+), 341 deletions(-) rename protocol-designer/src/components/modules/{MultipleModuleRow.tsx => MultipleModulesRow.tsx} (98%) create mode 100644 protocol-designer/src/modules/__tests__/moduleData.test.tsx create mode 100644 protocol-designer/src/modules/thunks.ts diff --git a/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx b/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx index bcebf6313c3..b1ad18b0752 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/ModulesAndOtherTile.tsx @@ -30,7 +30,6 @@ import { getModuleDisplayName, getModuleType, FLEX_ROBOT_TYPE, - THERMOCYCLER_MODULE_TYPE, MAGNETIC_BLOCK_TYPE, } from '@opentrons/shared-data' import { getIsCrashablePipetteSelected } from '../../../step-forms' @@ -45,9 +44,8 @@ import { ModuleFields } from '../FilePipettesModal/ModuleFields' import { GoBack } from './GoBack' import { getCrashableModuleSelected, - getDisabledEquipment, - getNextAvailableModuleSlot, - getTrashBinOptionDisabled, + getIsSlotAvailable, + getTrashOptionDisabled, } from './utils' import { EquipmentOption } from './EquipmentOption' import { HandleEnter } from './HandleEnter' @@ -197,10 +195,6 @@ function FlexModuleFields(props: WizardTileProps): JSX.Element { const additionalEquipment = watch('additionalEquipment') const moduleTypesOnDeck = modules != null ? Object.values(modules).map(module => module.type) : [] - const trashBinDisabled = getTrashBinOptionDisabled({ - additionalEquipment, - modules, - }) const handleSetEquipmentOption = (equipment: AdditionalEquipment): void => { if (additionalEquipment.includes(equipment)) { @@ -209,6 +203,11 @@ function FlexModuleFields(props: WizardTileProps): JSX.Element { setValue('additionalEquipment', [...additionalEquipment, equipment]) } } + const trashBinDisabled = getTrashOptionDisabled({ + additionalEquipment, + modules, + trashType: 'trashBin', + }) React.useEffect(() => { if (trashBinDisabled) { @@ -220,21 +219,10 @@ function FlexModuleFields(props: WizardTileProps): JSX.Element { {FLEX_SUPPORTED_MODULE_MODELS.map(moduleModel => { const moduleType = getModuleType(moduleModel) - const moduleOnDeck = moduleTypesOnDeck.includes(moduleType) + const isModuleOnDeck = moduleTypesOnDeck.includes(moduleType) + + const isDisabled = !getIsSlotAvailable(modules, additionalEquipment) - let defaultSlot = getNextAvailableModuleSlot( - modules, - additionalEquipment - ) - if (moduleType === THERMOCYCLER_MODULE_TYPE) { - defaultSlot = 'B1' - } else if (moduleType === MAGNETIC_BLOCK_TYPE) { - defaultSlot = 'D2' - } - const isDisabled = getDisabledEquipment({ - additionalEquipment, - modules, - })?.includes(moduleType) const handleMultiplesClick = (num: number): void => { const temperatureModules = modules != null @@ -250,10 +238,7 @@ function FlexModuleFields(props: WizardTileProps): JSX.Element { [uuid()]: { model: moduleModel, type: moduleType, - slot: getNextAvailableModuleSlot( - modules, - additionalEquipment - ), + slot: null, }, }) } @@ -274,7 +259,7 @@ function FlexModuleFields(props: WizardTileProps): JSX.Element { (moduleType !== TEMPERATURE_MODULE_TYPE && enableMoamFf) || !enableMoamFf ) { - if (moduleOnDeck) { + if (isModuleOnDeck) { const updatedModules = modules != null ? Object.fromEntries( @@ -290,7 +275,7 @@ function FlexModuleFields(props: WizardTileProps): JSX.Element { [uuid()]: { model: moduleModel, type: moduleType, - slot: defaultSlot, + slot: DEFAULT_SLOT_MAP[moduleModel], }, }) } @@ -301,10 +286,14 @@ function FlexModuleFields(props: WizardTileProps): JSX.Element { } text={getModuleDisplayName(moduleModel)} - disabled={isDisabled && !moduleOnDeck} + disabled={ + moduleType === MAGNETIC_BLOCK_TYPE + ? false + : isDisabled && !isModuleOnDeck + } onClick={handleOnClick} multiples={ moduleType === TEMPERATURE_MODULE_TYPE && enableMoamFf @@ -345,11 +334,11 @@ function FlexModuleFields(props: WizardTileProps): JSX.Element { robotType={FLEX_ROBOT_TYPE} onClick={() => handleSetEquipmentOption('wasteChute')} isSelected={additionalEquipment.includes('wasteChute')} - disabled={ - modules != null - ? Object.values(modules).some(module => module.slot === 'D3') - : false - } + disabled={getTrashOptionDisabled({ + additionalEquipment, + modules, + trashType: 'wasteChute', + })} image={ { { cutoutId: 'cutoutD3', cutoutFixtureId: SINGLE_RIGHT_SLOT_FIXTURE }, ]) }) - describe('getNextAvailableModuleSlot', () => { - it('should return D1 when there are no modules or staging areas', () => { - const result = getNextAvailableModuleSlot(null, []) - expect(result).toStrictEqual('D1') - }) - it('should return a C3 when all the modules are on the deck', () => { - const result = getNextAvailableModuleSlot( - { - 0: { - model: 'magneticBlockV1', - type: 'magneticBlockType', - slot: 'D1', - }, - 1: { - model: 'thermocyclerModuleV2', - type: 'thermocyclerModuleType', - slot: 'B1', - }, - 2: { - model: 'temperatureModuleV2', - type: 'temperatureModuleType', - slot: 'C1', - }, - }, - [] - ) - expect(result).toStrictEqual('C3') - }) +}) +describe('getIsSlotAvailable', () => { + it('should return true when there are no modules or additional equipment', () => { + const result = getIsSlotAvailable(null, []) + expect(result).toBe(true) }) - it('should return an empty string when all the modules and staging area slots are on the deck without TC', () => { - const result = getNextAvailableModuleSlot( - { - 0: { - model: 'heaterShakerModuleV1', - type: 'heaterShakerModuleType', - slot: 'D1', - }, - 1: { - model: 'temperatureModuleV2', - type: 'temperatureModuleType', - slot: 'C1', - }, - 2: { - model: 'temperatureModuleV2', - type: 'temperatureModuleType', - slot: 'B1', - }, + it('should return false when there is a TC and 7 modules', () => { + const mockModules = { + 0: { + model: 'heaterShakerModuleV1', + type: 'heaterShakerModuleType', + slot: 'D1', }, - [ - 'stagingArea_cutoutA3', - 'stagingArea_cutoutB3', - 'stagingArea_cutoutC3', - 'stagingArea_cutoutD3', - 'trashBin', - ] - ) - expect(result).toStrictEqual('') - }) - it('should return an empty string when all the modules and staging area slots are on the deck with TC', () => { - const result = getNextAvailableModuleSlot( - { - 0: { - model: 'heaterShakerModuleV1', - type: 'heaterShakerModuleType', - slot: 'D1', - }, - 1: { - model: 'thermocyclerModuleV2', - type: 'thermocyclerModuleType', - slot: 'B1', - }, + 1: { + model: 'temperatureModuleV2', + type: 'temperatureModuleType', + slot: 'D3', }, - [ - 'stagingArea_cutoutA3', - 'stagingArea_cutoutB3', - 'stagingArea_cutoutC3', - 'stagingArea_cutoutD3', - 'trashBin', - ] - ) - expect(result).toStrictEqual('') + 2: { + model: 'temperatureModuleV2', + type: 'temperatureModuleType', + slot: 'C1', + }, + 3: { + model: 'temperatureModuleV2', + type: 'temperatureModuleType', + slot: 'B3', + }, + 4: { + model: 'thermocyclerModuleV2', + type: 'thermocyclerModuleType', + slot: 'B1', + }, + 5: { + model: 'temperatureModuleV2', + type: 'temperatureModuleType', + slot: 'A3', + }, + 6: { + model: 'temperatureModuleV2', + type: 'temperatureModuleType', + slot: 'C3', + }, + } as any + const result = getIsSlotAvailable(mockModules, []) + expect(result).toBe(false) + }) + it('should return true when there are 9 additional equipment and 1 is a waste chute on the staging area and one is a gripper', () => { + const mockAdditionalEquipment: AdditionalEquipment[] = [ + 'trashBin', + 'stagingArea_cutoutA3', + 'stagingArea_cutoutB3', + 'stagingArea_cutoutC3', + 'stagingArea_cutoutD3', + 'wasteChute', + 'trashBin', + 'gripper', + 'trashBin', + ] + const result = getIsSlotAvailable(null, mockAdditionalEquipment) + expect(result).toBe(true) }) }) -describe('getNextAvailableModuleSlot', () => { - it('should return nothing as disabled', () => { - const result = getDisabledEquipment({ - additionalEquipment: [], - modules: null, - }) - expect(result).toStrictEqual([]) +describe('getTrashSlot', () => { + it('should return the default slot A3 when there is no staging area or module in that slot', () => { + MOCK_FORM_STATE = { + ...MOCK_FORM_STATE, + additionalEquipment: ['trashBin'], + } + const result = getTrashSlot(MOCK_FORM_STATE) + expect(result).toBe(FLEX_TRASH_DEFAULT_SLOT) + }) + it('should return cutoutA1 when there is a staging area in slot A3', () => { + MOCK_FORM_STATE = { + ...MOCK_FORM_STATE, + additionalEquipment: ['stagingArea_cutoutA3'], + } + const result = getTrashSlot(MOCK_FORM_STATE) + expect(result).toBe('cutoutA1') }) - it('should return the TC as disabled', () => { - const result = getDisabledEquipment({ - additionalEquipment: [], +}) +describe('getTrashOptionDisabled', () => { + it('returns false when there is a trash bin already', () => { + const result = getTrashOptionDisabled({ + trashType: 'trashBin', + additionalEquipment: ['trashBin'], modules: { 0: { model: 'heaterShakerModuleV1', type: 'heaterShakerModuleType', - slot: 'A1', + slot: 'D1', }, }, }) - expect(result).toStrictEqual([THERMOCYCLER_MODULE_TYPE]) + expect(result).toBe(false) }) - it('should return all module types if there is no available slot', () => { - const result = getDisabledEquipment({ + it('returns false when there is an available slot', () => { + const result = getTrashOptionDisabled({ + trashType: 'trashBin', + additionalEquipment: ['trashBin'], + modules: null, + }) + expect(result).toBe(false) + }) + it('returns true when there is no available slot and trash bin is not selected yet', () => { + const result = getTrashOptionDisabled({ + trashType: 'trashBin', additionalEquipment: [ 'stagingArea_cutoutA3', 'stagingArea_cutoutB3', 'stagingArea_cutoutC3', 'stagingArea_cutoutD3', - 'trashBin', ], modules: { 0: { @@ -185,85 +181,13 @@ describe('getNextAvailableModuleSlot', () => { type: 'temperatureModuleType', slot: 'B1', }, - }, - }) - expect(result).toStrictEqual([ - THERMOCYCLER_MODULE_TYPE, - TEMPERATURE_MODULE_TYPE, - HEATERSHAKER_MODULE_TYPE, - ]) - }) -}) -describe('getTrashSlot', () => { - it('should return the default slot A3 when there is no staging area or module in that slot', () => { - MOCK_FORM_STATE = { - ...MOCK_FORM_STATE, - additionalEquipment: ['trashBin'], - } - const result = getTrashSlot(MOCK_FORM_STATE) - expect(result).toBe(FLEX_TRASH_DEFAULT_SLOT) - }) - it('should return cutoutA1 when there is a staging area in slot A3', () => { - MOCK_FORM_STATE = { - ...MOCK_FORM_STATE, - additionalEquipment: ['stagingArea_cutoutA3'], - } - const result = getTrashSlot(MOCK_FORM_STATE) - expect(result).toBe('cutoutA1') - }) - describe('getTrashBinOptionDisabled', () => { - it('returns false when there is a trash bin already', () => { - const result = getTrashBinOptionDisabled({ - additionalEquipment: ['trashBin'], - modules: { - 0: { - model: 'heaterShakerModuleV1', - type: 'heaterShakerModuleType', - slot: 'D1', - }, - }, - }) - expect(result).toBe(false) - }) - it('returns false when there is an available slot', () => { - const result = getTrashBinOptionDisabled({ - additionalEquipment: ['trashBin'], - modules: null, - }) - expect(result).toBe(false) - }) - it('returns true when there is no available slot and trash bin is not selected yet', () => { - const result = getTrashBinOptionDisabled({ - additionalEquipment: [ - 'stagingArea_cutoutA3', - 'stagingArea_cutoutB3', - 'stagingArea_cutoutC3', - 'stagingArea_cutoutD3', - ], - modules: { - 0: { - model: 'heaterShakerModuleV1', - type: 'heaterShakerModuleType', - slot: 'D1', - }, - 1: { - model: 'temperatureModuleV2', - type: 'temperatureModuleType', - slot: 'C1', - }, - 2: { - model: 'temperatureModuleV2', - type: 'temperatureModuleType', - slot: 'B1', - }, - 3: { - model: 'temperatureModuleV2', - type: 'temperatureModuleType', - slot: 'A1', - }, + 3: { + model: 'temperatureModuleV2', + type: 'temperatureModuleType', + slot: 'A1', }, - }) - expect(result).toBe(true) + }, }) + expect(result).toBe(true) }) }) diff --git a/protocol-designer/src/components/modals/CreateFileWizard/index.tsx b/protocol-designer/src/components/modals/CreateFileWizard/index.tsx index b19ab426f65..53ae9a88f6a 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/index.tsx +++ b/protocol-designer/src/components/modals/CreateFileWizard/index.tsx @@ -43,6 +43,7 @@ import { createDeckFixture, toggleIsGripperRequired, } from '../../../step-forms/actions/additionalItems' +import { createModuleWithNoSlot } from '../../../modules' import { RobotTypeTile } from './RobotTypeTile' import { MetadataTile } from './MetadataTile' import { FirstPipetteTypeTile, SecondPipetteTypeTile } from './PipetteTypeTile' @@ -229,9 +230,29 @@ export function CreateFileWizard(): JSX.Element | null { } // create modules - modules.forEach(moduleArgs => - dispatch(stepFormActions.createModule(moduleArgs)) - ) + // sort so modules with slot are created first + // then modules without a slot are generated in remaining available slots + modules.sort((a, b) => { + if (a.slot == null && b.slot != null) { + return 1 + } + if (b.slot == null && a.slot != null) { + return -1 + } + return 0 + }) + + modules.forEach(moduleArgs => { + return moduleArgs.slot != null + ? dispatch(stepFormActions.createModule(moduleArgs)) + : dispatch( + createModuleWithNoSlot({ + model: moduleArgs.model, + type: moduleArgs.type, + }) + ) + }) + // add gripper if (values.additionalEquipment.includes('gripper')) { dispatch(toggleIsGripperRequired()) diff --git a/protocol-designer/src/components/modals/CreateFileWizard/utils.ts b/protocol-designer/src/components/modals/CreateFileWizard/utils.ts index 20abcf27cb3..7a23706a680 100644 --- a/protocol-designer/src/components/modals/CreateFileWizard/utils.ts +++ b/protocol-designer/src/components/modals/CreateFileWizard/utils.ts @@ -1,6 +1,4 @@ import { - HEATERSHAKER_MODULE_TYPE, - TEMPERATURE_MODULE_TYPE, THERMOCYCLER_MODULE_TYPE, WASTE_CHUTE_CUTOUT, } from '@opentrons/shared-data' @@ -13,41 +11,6 @@ import type { AdditionalEquipment, FormState } from './types' export const FLEX_TRASH_DEFAULT_SLOT = 'cutoutA3' -const MODULES_SLOTS_FLEX = [ - { - value: 'cutoutD1', - slot: 'D1', - }, - { - value: 'cutoutC3', - slot: 'C3', - }, - { - value: 'cutoutB1', - slot: 'B1', - }, - { - value: 'cutoutB3', - slot: 'B3', - }, - { - value: 'cutoutA3', - slot: 'A3', - }, - { - value: 'cutoutD3', - slot: 'D3', - }, - { - value: 'cutoutC1', - slot: 'C1', - }, - { - value: 'cutoutA1', - slot: 'A1', - }, -] - export const getCrashableModuleSelected = ( modules: FormModules | null, moduleType: ModuleType @@ -120,102 +83,57 @@ export const getUnoccupiedStagingAreaSlots = ( return unoccupiedSlots } -export const getNextAvailableModuleSlot = ( +const TOTAL_MODULE_SLOTS = 8 + +export const getIsSlotAvailable = ( modules: FormState['modules'], additionalEquipment: FormState['additionalEquipment'] -): string => { - const moduleSlots = - modules != null - ? Object.values(modules).flatMap(module => - module.type === THERMOCYCLER_MODULE_TYPE - ? [module.slot, 'A1'] - : module.slot - ) - : [] - const stagingAreas = additionalEquipment.filter(equipment => - equipment.includes('stagingArea') +): boolean => { + const moduleLength = modules != null ? Object.keys(modules).length : 0 + const additionalEquipmentLength = additionalEquipment.length + const hasTC = Object.values(modules || {}).some( + module => module.type === THERMOCYCLER_MODULE_TYPE ) - const stagingAreaCutouts = stagingAreas.map(cutout => cutout.split('_')[1]) - const hasWasteChute = additionalEquipment.find(equipment => + + const filteredModuleLength = hasTC ? moduleLength + 1 : moduleLength + const hasWasteChute = additionalEquipment.some(equipment => equipment.includes('wasteChute') ) - const wasteChuteSlot = Boolean(hasWasteChute) - ? [WASTE_CHUTE_CUTOUT as string] - : [] - const trashBin = additionalEquipment.find(equipment => - equipment.includes('trashBin') + const isStagingAreaInD3 = additionalEquipment + .filter(equipment => equipment.includes('stagingArea')) + .find(stagingArea => stagingArea.split('_')[1] === 'cutoutD3') + const hasGripper = additionalEquipment.some(equipment => + equipment.includes('gripper') ) - const hasTC = - modules != null - ? Object.values(modules).some( - module => module.type === THERMOCYCLER_MODULE_TYPE - ) - : false - // removing slot(s) for the trash if spaces are limited - let removeSlotForTrash = MODULES_SLOTS_FLEX - if (trashBin != null && hasTC) { - removeSlotForTrash = MODULES_SLOTS_FLEX.slice(0, -2) - } else if (trashBin != null && !hasTC) { - removeSlotForTrash = MODULES_SLOTS_FLEX.slice(0, -1) + let filteredAdditionalEquipmentLength = additionalEquipmentLength + if (hasWasteChute && isStagingAreaInD3) { + filteredAdditionalEquipmentLength = filteredAdditionalEquipmentLength - 1 } - const unoccupiedSlot = removeSlotForTrash.find( - cutout => - !stagingAreaCutouts.includes(cutout.value) && - !moduleSlots.includes(cutout.slot) && - !wasteChuteSlot.includes(cutout.value) - ) - if (unoccupiedSlot == null) { - return '' + if (hasGripper) { + filteredAdditionalEquipmentLength = filteredAdditionalEquipmentLength - 1 } - return unoccupiedSlot?.slot ?? '' + return ( + filteredModuleLength + filteredAdditionalEquipmentLength < + TOTAL_MODULE_SLOTS + ) } -interface DisabledEquipmentProps { +interface TrashOptionDisabledProps { + trashType: 'trashBin' | 'wasteChute' additionalEquipment: AdditionalEquipment[] modules: FormModules | null } -export const getDisabledEquipment = ( - props: DisabledEquipmentProps -): string[] => { - const { additionalEquipment, modules } = props - const nextAvailableSlot = getNextAvailableModuleSlot( - modules, - additionalEquipment - ) - const disabledEquipment: string[] = [] - - const moduleSlots = - modules != null - ? Object.values(modules).flatMap(module => - module.type === THERMOCYCLER_MODULE_TYPE - ? [module.slot, 'A1'] - : module.slot - ) - : [] - - if (moduleSlots.includes('A1') || moduleSlots.includes('B1')) { - disabledEquipment.push(THERMOCYCLER_MODULE_TYPE) - } - if (nextAvailableSlot === '') { - disabledEquipment.push(TEMPERATURE_MODULE_TYPE, HEATERSHAKER_MODULE_TYPE) - } - - return disabledEquipment -} - -export const getTrashBinOptionDisabled = ( - props: DisabledEquipmentProps +export const getTrashOptionDisabled = ( + props: TrashOptionDisabledProps ): boolean => { - const { additionalEquipment, modules } = props - const nextAvailableSlot = getNextAvailableModuleSlot( - modules, - additionalEquipment + const { additionalEquipment, modules, trashType } = props + return ( + !getIsSlotAvailable(modules, additionalEquipment) && + !additionalEquipment.includes(trashType) ) - const hasTrashBinAlready = additionalEquipment.includes('trashBin') - return nextAvailableSlot === '' && !hasTrashBinAlready } export const getTrashSlot = (values: FormState): string => { diff --git a/protocol-designer/src/components/modules/EditModulesCard.tsx b/protocol-designer/src/components/modules/EditModulesCard.tsx index 896463c295c..27dcc233ede 100644 --- a/protocol-designer/src/components/modules/EditModulesCard.tsx +++ b/protocol-designer/src/components/modules/EditModulesCard.tsx @@ -28,7 +28,7 @@ import { ModuleRow } from './ModuleRow' import { AdditionalItemsRow } from './AdditionalItemsRow' import { isModuleWithCollisionIssue } from './utils' import { StagingAreasRow } from './StagingAreasRow' -import { MultipleModuleRow } from './MultipleModuleRow' +import { MultipleModulesRow } from './MultipleModulesRow' import type { AdditionalEquipmentEntity } from '@opentrons/step-generation' @@ -146,7 +146,7 @@ export function EditModulesCard(props: Props): JSX.Element { ) } else if (moduleData != null && moduleData.length > 1) { return ( - ) => { - return renderWithProviders(, { +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { i18nInstance: i18n, })[0] } @@ -37,7 +37,7 @@ const mockTemp2: ModuleOnDeck = { } describe('MultipleModuleRow', () => { - let props: React.ComponentProps + let props: React.ComponentProps beforeEach(() => { props = { moduleType: TEMPERATURE_MODULE_TYPE, diff --git a/protocol-designer/src/modules/__tests__/moduleData.test.tsx b/protocol-designer/src/modules/__tests__/moduleData.test.tsx new file mode 100644 index 00000000000..9d27732bf56 --- /dev/null +++ b/protocol-designer/src/modules/__tests__/moduleData.test.tsx @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest' +import { getNextAvailableModuleSlot } from '../moduleData' +import type { InitialDeckSetup } from '../../step-forms' + +describe('getNextAvailableModuleSlot', () => { + it('renders slot D1 when no slots are occupied', () => { + const mockInitialDeckSetup: InitialDeckSetup = { + modules: {}, + labware: {}, + pipettes: {}, + additionalEquipmentOnDeck: {}, + } + const result = getNextAvailableModuleSlot(mockInitialDeckSetup) + expect(result).toBe('D1') + }) + it('renders slot C1 when other slots are occupied', () => { + const mockInitialDeckSetup: InitialDeckSetup = { + modules: {}, + labware: {}, + pipettes: {}, + additionalEquipmentOnDeck: { + wasteChuteId: { + name: 'wasteChute', + id: 'wasteChuteId', + location: 'D3', + }, + trashBinId: { + name: 'trashBin', + id: 'trashBinId', + location: 'D1', + }, + }, + } + const result = getNextAvailableModuleSlot(mockInitialDeckSetup) + expect(result).toBe('C1') + }) + it('renders undefined when all slots are occupied', () => { + const mockInitialDeckSetup: InitialDeckSetup = { + modules: { + thermocycler: { + model: 'thermocyclerModuleV2', + id: 'thermocycler', + type: 'thermocyclerModuleType', + slot: 'B1', + moduleState: {} as any, + }, + temperature: { + model: 'temperatureModuleV2', + id: 'temperature', + type: 'temperatureModuleType', + slot: 'C1', + moduleState: {} as any, + }, + }, + labware: {}, + pipettes: {}, + additionalEquipmentOnDeck: { + wasteChuteId: { + name: 'wasteChute', + id: 'wasteChuteId', + location: 'D3', + }, + trashBinId: { + name: 'trashBin', + id: 'trashBinId', + location: 'D1', + }, + stagingArea1: { + name: 'stagingArea', + id: 'stagingArea1', + location: 'A3', + }, + stagingArea2: { + name: 'stagingArea', + id: 'stagingArea2', + location: 'B3', + }, + stagingArea3: { + name: 'stagingArea', + id: 'stagingArea3', + location: 'C3', + }, + }, + } + const result = getNextAvailableModuleSlot(mockInitialDeckSetup) + expect(result).toBe(undefined) + }) +}) diff --git a/protocol-designer/src/modules/index.ts b/protocol-designer/src/modules/index.ts index 82b41275ed4..8ca029f4e14 100644 --- a/protocol-designer/src/modules/index.ts +++ b/protocol-designer/src/modules/index.ts @@ -1 +1,2 @@ export * from './moduleData' +export * from './thunks' diff --git a/protocol-designer/src/modules/moduleData.ts b/protocol-designer/src/modules/moduleData.ts index a2d05f33bc8..240a2e11eae 100644 --- a/protocol-designer/src/modules/moduleData.ts +++ b/protocol-designer/src/modules/moduleData.ts @@ -1,14 +1,27 @@ -import { SPAN7_8_10_11_SLOT } from '../constants' +import { COLUMN_4_SLOTS } from '@opentrons/step-generation' import { MAGNETIC_MODULE_TYPE, TEMPERATURE_MODULE_TYPE, THERMOCYCLER_MODULE_TYPE, HEATERSHAKER_MODULE_TYPE, - ModuleType, MAGNETIC_BLOCK_TYPE, + MOVABLE_TRASH_ADDRESSABLE_AREAS, + WASTE_CHUTE_ADDRESSABLE_AREAS, + FIXED_TRASH_ID, +} from '@opentrons/shared-data' +import { SPAN7_8_10_11_SLOT } from '../constants' +import { getStagingAreaAddressableAreas } from '../utils' +import { getSlotIsEmpty } from '../step-forms' +import type { + ModuleType, RobotType, + CutoutId, + AddressableAreaName, } from '@opentrons/shared-data' -import { DropdownOption } from '@opentrons/components' +import type { DropdownOption } from '@opentrons/components' +import type { InitialDeckSetup } from '../step-forms' +import type { DeckSlot } from '../types' + export const SUPPORTED_MODULE_TYPES: ModuleType[] = [ HEATERSHAKER_MODULE_TYPE, MAGNETIC_BLOCK_TYPE, @@ -270,3 +283,32 @@ export function getAllModuleSlotsByType( } return slot } + +const FLEX_MODULE_SLOTS = ['D1', 'D3', 'C1', 'C3', 'B1', 'B3', 'A1', 'A3'] + +export function getNextAvailableModuleSlot( + initialDeckSetup: InitialDeckSetup +): DeckSlot | undefined { + return FLEX_MODULE_SLOTS.find(slot => { + const cutoutIds = Object.values(initialDeckSetup.additionalEquipmentOnDeck) + .filter(ae => ae.name === 'stagingArea') + .map(ae => ae.location as CutoutId) + const stagingAreaAddressableAreaNames = getStagingAreaAddressableAreas( + cutoutIds + ) + const addressableAreaName = stagingAreaAddressableAreaNames.find( + aa => aa === slot + ) + let isSlotEmpty: boolean = getSlotIsEmpty(initialDeckSetup, slot, true) + if (addressableAreaName == null && COLUMN_4_SLOTS.includes(slot)) { + isSlotEmpty = false + } else if ( + MOVABLE_TRASH_ADDRESSABLE_AREAS.includes(slot as AddressableAreaName) || + WASTE_CHUTE_ADDRESSABLE_AREAS.includes(slot as AddressableAreaName) || + slot === FIXED_TRASH_ID + ) { + isSlotEmpty = false + } + return isSlotEmpty + }) +} diff --git a/protocol-designer/src/modules/thunks.ts b/protocol-designer/src/modules/thunks.ts new file mode 100644 index 00000000000..655eeb07a7c --- /dev/null +++ b/protocol-designer/src/modules/thunks.ts @@ -0,0 +1,33 @@ +import { selectors as stepFormSelectors } from '../step-forms' +import { uuid } from '../utils' +import { getNextAvailableModuleSlot } from './moduleData' +import type { ModuleModel, ModuleType } from '@opentrons/shared-data' +import type { CreateModuleAction } from '../step-forms/actions' +import type { ThunkAction } from '../types' + +interface CreateModuleWithNoSloArgs { + type: ModuleType + model: ModuleModel +} +export const createModuleWithNoSlot: ( + args: CreateModuleWithNoSloArgs +) => ThunkAction = args => (dispatch, getState) => { + const { model, type } = args + const state = getState() + const initialDeckSetup = stepFormSelectors.getInitialDeckSetup(state) + const slot = getNextAvailableModuleSlot(initialDeckSetup) + + if (slot == null) { + console.assert(slot, 'expected to find available slot but could not') + } + + dispatch({ + type: 'CREATE_MODULE', + payload: { + model, + type, + slot: slot ?? '', + id: `${uuid()}:${type}}`, + }, + }) +} From ec73d82853f31a56b94e73d095efa65ac350191d Mon Sep 17 00:00:00 2001 From: koji Date: Mon, 22 Apr 2024 13:01:43 -0400 Subject: [PATCH 8/8] refactor(components): refactor roundtab stories (#14956) * refactor(components): refactor roundtab stories --- components/src/molecules/RoundTab.stories.tsx | 109 +++++++++++------- 1 file changed, 67 insertions(+), 42 deletions(-) diff --git a/components/src/molecules/RoundTab.stories.tsx b/components/src/molecules/RoundTab.stories.tsx index be08c541743..fc0821c793d 100644 --- a/components/src/molecules/RoundTab.stories.tsx +++ b/components/src/molecules/RoundTab.stories.tsx @@ -1,55 +1,80 @@ import * as React from 'react' import { SPACING, TYPOGRAPHY } from '../ui-style-constants' -import { Flex, Text } from '../primitives' -import { DIRECTION_ROW } from '../styles' -import { RoundTab } from './RoundTab' -import type { Story, Meta } from '@storybook/react' +import { Flex } from '../primitives' +import { StyledText } from '../atoms/StyledText' +import { DIRECTION_COLUMN, DIRECTION_ROW } from '../styles' +import { RoundTab as RoundTabComponent } from './RoundTab' +import type { Meta, StoryObj } from '@storybook/react' -export default { +const meta: Meta = { title: 'Library/Molecules/RoundTab', - component: RoundTab, -} as Meta - -const Template: Story< - React.ComponentProps -> = (): JSX.Element => { - const [step, setStep] = React.useState<'details' | 'pipette' | 'module'>( - 'details' - ) + component: RoundTabComponent, + decorators: [Story => ], +} +export default meta + +const Tabs = (): JSX.Element => { + const [step, setStep] = React.useState< + 'setup' | 'parameters' | 'module controls' | 'run preview' + >('setup') return ( - setStep('details')} - > - - {'Protocol Name and Description'} - - - - setStep('pipette')} + + setStep('setup')} + tabName={'setup'} + > + + {'Setup'} + + + + setStep('parameters')} + > + + {'Parameters'} + + + + setStep('module controls')} + > + + {'Module Controls'} + + + + setStep('run preview')} + > + + {'Run Preview'} + + + + - - {'Pipette Selection'} - - - - setStep('module')}> - - {'Module Selection'} - - + {step} + ) } -export const Basic = Template.bind({}) -Basic.args = {} +type Story = StoryObj + +export const RoundTab: Story = { + args: {}, +}