diff --git a/components/src/atoms/buttons/LargeButton.tsx b/components/src/atoms/buttons/LargeButton.tsx index 90d6251d3a5..64a46888ee8 100644 --- a/components/src/atoms/buttons/LargeButton.tsx +++ b/components/src/atoms/buttons/LargeButton.tsx @@ -133,7 +133,7 @@ export function LargeButton(props: LargeButtonProps): JSX.Element { color: ${LARGE_BUTTON_PROPS_BY_TYPE[buttonType].defaultColor}; background-color: ${LARGE_BUTTON_PROPS_BY_TYPE[buttonType] .defaultBackgroundColor}; - cursor: default; + cursor: pointer; padding: ${SPACING.spacing16} ${SPACING.spacing24}; text-align: ${TYPOGRAPHY.textAlignCenter}; border-radius: ${BORDERS.borderRadiusFull}; @@ -148,7 +148,14 @@ export function LargeButton(props: LargeButtonProps): JSX.Element { ${activeIconStyle(buttonType)}; } + &:disabled { + color: ${LARGE_BUTTON_PROPS_BY_TYPE[buttonType].disabledColor}; + background-color: ${LARGE_BUTTON_PROPS_BY_TYPE[buttonType] + .disabledBackgroundColor}; + } + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + cursor: default; align-items: ${ALIGN_FLEX_START}; flex-direction: ${DIRECTION_COLUMN}; border-radius: ${BORDERS.borderRadius16}; @@ -184,12 +191,6 @@ export function LargeButton(props: LargeButtonProps): JSX.Element { background-clip: padding-box; box-shadow: none; } - - &:disabled { - color: ${LARGE_BUTTON_PROPS_BY_TYPE[buttonType].disabledColor}; - background-color: ${LARGE_BUTTON_PROPS_BY_TYPE[buttonType] - .disabledBackgroundColor}; - } } ` return ( diff --git a/components/src/atoms/buttons/RadioButton.tsx b/components/src/atoms/buttons/RadioButton.tsx index 225bfc20afb..fac944e03fb 100644 --- a/components/src/atoms/buttons/RadioButton.tsx +++ b/components/src/atoms/buttons/RadioButton.tsx @@ -88,7 +88,14 @@ export function RadioButton(props: RadioButtonProps): JSX.Element { ` return ( - + {defaultTiprackOptions.map(o => ( {customTiprackOptions.map(o => ( {pipetteOptions.map(o => ( { - handleProceedRobotType(formProps.getValues().fields.robotType) + handleProceedRobotType( + formProps.getValues().fields.robotType ?? OT2_ROBOT_TYPE + ) proceed() }} /> diff --git a/protocol-designer/src/images/placeholder_image_delete.png b/protocol-designer/src/images/placeholder_image_delete.png new file mode 100644 index 00000000000..a698ba4ffac Binary files /dev/null and b/protocol-designer/src/images/placeholder_image_delete.png differ diff --git a/protocol-designer/src/load-file/types.ts b/protocol-designer/src/load-file/types.ts index d3a25dfde74..06004856e31 100644 --- a/protocol-designer/src/load-file/types.ts +++ b/protocol-designer/src/load-file/types.ts @@ -18,7 +18,7 @@ export interface NewProtocolFields { name: string | null | undefined description: string | null | undefined organizationOrAuthor: string | null | undefined - robotType: RobotType + robotType: RobotType | undefined } export interface LoadFileAction { type: 'LOAD_FILE' diff --git a/protocol-designer/src/localization/en/create_new_protocol.json b/protocol-designer/src/localization/en/create_new_protocol.json new file mode 100644 index 00000000000..c9a8cb3ad23 --- /dev/null +++ b/protocol-designer/src/localization/en/create_new_protocol.json @@ -0,0 +1,19 @@ +{ + "add_fixtures": "Add your fixtures", + "add_gripper": "Add a gripper", + "add_modules": "Add your modules", + "add_pip": "Add a pipette", + "basics": "Let’s start with the basics", + "need_gripper": "Does your protocol need a Flex Gripper?", + "pip_type": "Pipette type", + "pip_vol": "Pipette volume", + "name": "Name", + "description": "Description", + "author_org": "Author/Organization", + "questions": "We’re going to ask a few questions to help you get started building your protocol.", + "robot_type": "Which robot would you like to use?", + "tell_us": "Tell us about your protocol", + "which_fixtures": "Which fixtures will you be using?", + "which_mods": "Which modules will you be using?", + "which_pip": "Tell us what pipette and tips you want to use." +} diff --git a/protocol-designer/src/localization/en/index.ts b/protocol-designer/src/localization/en/index.ts index 8d1c9ca4b79..68ed9877a8e 100644 --- a/protocol-designer/src/localization/en/index.ts +++ b/protocol-designer/src/localization/en/index.ts @@ -3,19 +3,20 @@ import application from './application.json' import button from './button.json' import card from './card.json' import context_menu from './context_menu.json' +import create_new_protocol from './create_new_protocol.json' import deck from './deck.json' import feature_flags from './feature_flags.json' import form from './form.json' +import liquids from './liquids.json' import modal from './modal.json' import modules from './modules.json' import nav from './nav.json' -import shared from './shared.json' -import tooltip from './tooltip.json' -import well_selection from './well_selection.json' -import liquids from './liquids.json' import protocol_overview from './protocol_overview.json' import protocol_steps from './protocol_steps.json' +import shared from './shared.json' import starting_deck_state from './starting_deck_state.json' +import tooltip from './tooltip.json' +import well_selection from './well_selection.json' export const en = { alert, @@ -23,17 +24,18 @@ export const en = { button, card, context_menu, + create_new_protocol, deck, feature_flags, form, + liquids, modal, modules, nav, - shared, - tooltip, - well_selection, - liquids, protocol_overview, protocol_steps, + shared, starting_deck_state, + tooltip, + well_selection, } diff --git a/protocol-designer/src/localization/en/shared.json b/protocol-designer/src/localization/en/shared.json index f8546592934..dad5540bb9f 100644 --- a/protocol-designer/src/localization/en/shared.json +++ b/protocol-designer/src/localization/en/shared.json @@ -2,19 +2,28 @@ "add": "add", "amount": "Amount:", "cancel": "Cancel", + "confirm_import": "Are you sure you want to upload this protocol?", "confirm_reorder": "Are you sure you want to reorder these steps, it may cause errors?", - "create_new_protocol": "Create new protocol", + "confirm": "Confirm", "create_a_protocol": "Create a protocol", - "confirm_import": "Are you sure you want to upload this protocol?", + "create_new_protocol": "Create new protocol", "done": "Done", "edit": "edit", + "eight_channel": "8-Channel", "exit": "exit", - "go_back": "go back", + "go_back": "Go back", "import_existing": "Import existing protocol", "import": "Import", "next": "next", + "ninety_six_channel": "96-Channel", "no-code-solution": "A no-code solution to create protocols that x, y and z meaning for your lab and workflow.", + "no": "No", + "one_channel": "1-Channel", + "opentrons_flex": "Opentrons Flex", + "ot2": "Opentrons OT-2", "remove": "remove", + "step_count": "Step {{current}}", "step": "Step {{current}} / {{max}}", - "welcome": "Welcome to Protocol Designer" + "welcome": "Welcome to Protocol Designer", + "yes": "Yes" } diff --git a/protocol-designer/src/pages/CreateNewProtocol/index.tsx b/protocol-designer/src/pages/CreateNewProtocol/index.tsx deleted file mode 100644 index 9a1cb694ff3..00000000000 --- a/protocol-designer/src/pages/CreateNewProtocol/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import * as React from 'react' - -export function CreateNewProtocol(): JSX.Element { - return
Create new protocol
-} diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/AddMetadata.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/AddMetadata.tsx new file mode 100644 index 00000000000..d78b02586ee --- /dev/null +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/AddMetadata.tsx @@ -0,0 +1,71 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' +import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' +import { + DIRECTION_COLUMN, + Flex, + SPACING, + StyledText, + BORDERS, + TYPOGRAPHY, +} from '@opentrons/components' +import { InputField } from '../../components/modals/CreateFileWizard/InputField' +import { WizardBody } from './WizardBody' + +import type { WizardTileProps } from './types' + +export function AddMetadata(props: WizardTileProps): JSX.Element | null { + const { goBack, proceed, watch, register } = props + const { t } = useTranslation(['create_new_protocol', 'shared']) + const fields = watch('fields') + const robotType = fields.robotType + + return ( + { + goBack(1) + }} + proceed={() => { + proceed(1) + }} + > + <> + + {t('name')} + {/* TODO(ja, 8/9/24): add new input field */} + + + + + {t('description')} + + + + + + {t('author_org')} + + {/* TODO(ja, 8/9/24): add new input field */} + + + + + ) +} + +const DescriptionField = styled.textarea` + min-height: 5rem; + width: 100%; + border: ${BORDERS.lineBorder}; + border-radius: ${BORDERS.borderRadius4}; + padding: ${SPACING.spacing8}; + font-size: ${TYPOGRAPHY.fontSizeP}; + resize: none; +` diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/SelectFixtures.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectFixtures.tsx new file mode 100644 index 00000000000..9cacf33248c --- /dev/null +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectFixtures.tsx @@ -0,0 +1,35 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { Flex, SPACING, StyledText } from '@opentrons/components' +import { WizardBody } from './WizardBody' + +import type { WizardTileProps } from './types' + +export function SelectFixtures(props: WizardTileProps): JSX.Element | null { + const { goBack, proceed } = props + const { t } = useTranslation(['create_new_protocol', 'shared']) + + return ( + { + goBack(1) + }} + proceed={() => { + proceed(1) + }} + > + <> + + {t('which_fixtures')} + + TODO: add fixture info + + + ) +} diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/SelectGripper.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectGripper.tsx new file mode 100644 index 00000000000..e83ed74e5b6 --- /dev/null +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectGripper.tsx @@ -0,0 +1,68 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import without from 'lodash/without' +import { Flex, SPACING, StyledText, RadioButton } from '@opentrons/components' +import { WizardBody } from './WizardBody' + +import type { WizardTileProps } from './types' + +export function SelectGripper(props: WizardTileProps): JSX.Element | null { + const { goBack, setValue, proceed, watch } = props + const { t } = useTranslation(['create_new_protocol', 'shared']) + const [gripperStatus, setGripperStatus] = React.useState<'yes' | 'no' | null>( + null + ) + const additionalEquipment = watch('additionalEquipment') + + const handleGripperSelection = (status: 'yes' | 'no'): void => { + setGripperStatus(status) + if (status === 'yes') { + if (!additionalEquipment.includes('gripper')) { + setValue('additionalEquipment', [...additionalEquipment, 'gripper']) + } + } else { + setValue('additionalEquipment', without(additionalEquipment, 'gripper')) + } + } + + return ( + { + goBack(1) + }} + proceed={() => { + proceed(1) + }} + > + <> + + {t('need_gripper')} + + + { + handleGripperSelection('yes') + }} + buttonLabel={t('shared:yes')} + buttonValue="yes" + isSelected={gripperStatus === 'yes'} + /> + { + handleGripperSelection('no') + }} + buttonLabel={t('shared:no')} + buttonValue="no" + isSelected={gripperStatus === 'no'} + /> + + + + ) +} diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/SelectModules.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectModules.tsx new file mode 100644 index 00000000000..82829203cca --- /dev/null +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectModules.tsx @@ -0,0 +1,38 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' + +import { Flex, SPACING, StyledText } from '@opentrons/components' +import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' +import { WizardBody } from './WizardBody' + +import type { WizardTileProps } from './types' + +export function SelectModules(props: WizardTileProps): JSX.Element | null { + const { goBack, proceed, watch } = props + const { t } = useTranslation(['create_new_protocol', 'shared']) + const fields = watch('fields') + const robotType = fields.robotType + return ( + { + goBack(1) + }} + proceed={() => { + proceed(1) + }} + > + <> + + {t('which_mods')} + + TODO: add module info + + + ) +} diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/SelectPipettes.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectPipettes.tsx new file mode 100644 index 00000000000..2810f35bc17 --- /dev/null +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectPipettes.tsx @@ -0,0 +1,90 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { OT2_ROBOT_TYPE } from '@opentrons/shared-data' +import { + DIRECTION_COLUMN, + Flex, + SPACING, + StyledText, + RadioButton, +} from '@opentrons/components' +import { WizardBody } from './WizardBody' + +import type { WizardTileProps } from './types' + +export function SelectPipettes(props: WizardTileProps): JSX.Element | null { + const { goBack, proceed, watch } = props + const { t } = useTranslation(['create_new_protocol', 'shared']) + // const pipettesByMount = watch('pipettesByMount') + const fields = watch('fields') + const [pipetteType, setPipetteType] = React.useState(null) + + return ( + { + proceed(1) + }} + goBack={() => { + goBack(1) + }} + disabled={false} + // disabled={ + // pipettesByMount.left.tiprackDefURI == null && + // pipettesByMount.right.tiprackDefURI == null + // } + > + <> + + {t('pip_type')} + + + { + setPipetteType('single') + }} + buttonLabel={t('shared:one_channel')} + buttonValue="single" + isSelected={pipetteType === 'single'} + /> + { + setPipetteType('multi') + }} + buttonLabel={t('shared:eight_channel')} + buttonValue="multi" + isSelected={pipetteType === 'multi'} + /> + {fields.robotType === OT2_ROBOT_TYPE ? null : ( + { + setPipetteType('96') + }} + buttonLabel={t('shared:ninety_six_channel')} + buttonValue="96" + isSelected={pipetteType === '96'} + /> + )} + + + {pipetteType != null ? ( + + + {t('pip_vol')} + + + {'TODO: finish up this component'} + + + ) : null} + + ) +} diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/SelectRobot.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectRobot.tsx new file mode 100644 index 00000000000..d9374d6c5f1 --- /dev/null +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/SelectRobot.tsx @@ -0,0 +1,53 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { Flex, RadioButton, SPACING, StyledText } from '@opentrons/components' +import { FLEX_ROBOT_TYPE, OT2_ROBOT_TYPE } from '@opentrons/shared-data' +import { WizardBody } from './WizardBody' +import type { WizardTileProps } from './types' + +export function SelectRobot(props: WizardTileProps): JSX.Element { + const { setValue, proceed, watch } = props + const { t } = useTranslation(['create_new_protocol', 'shared']) + const fields = watch('fields') + + const robotType = fields?.robotType + return ( + { + proceed(1) + }} + > + <> + + {t('robot_type')} + + + + { + setValue('fields.robotType', FLEX_ROBOT_TYPE) + }} + buttonLabel={t('shared:opentrons_flex')} + buttonValue={FLEX_ROBOT_TYPE} + isSelected={robotType === FLEX_ROBOT_TYPE} + /> + { + setValue('fields.robotType', OT2_ROBOT_TYPE) + }} + buttonLabel={t('shared:ot2')} + buttonValue={OT2_ROBOT_TYPE} + isSelected={robotType === OT2_ROBOT_TYPE} + /> + + + + ) +} diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/WizardBody.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/WizardBody.tsx new file mode 100644 index 00000000000..f9a7198ce31 --- /dev/null +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/WizardBody.tsx @@ -0,0 +1,113 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' +import { + DIRECTION_COLUMN, + Flex, + SPACING, + COLORS, + LargeButton, + StyledText, + ALIGN_END, + BORDERS, + TYPOGRAPHY, + Btn, + JUSTIFY_SPACE_BETWEEN, +} from '@opentrons/components' +import temporaryImg from '../../images/placeholder_image_delete.png' + +interface WizardBodyProps { + stepNumber: number + header: string + children: React.ReactNode + proceed: () => void + disabled: boolean + goBack?: () => void + subHeader?: string + imgSrc?: string +} +export function WizardBody(props: WizardBodyProps): JSX.Element { + const { + stepNumber, + header, + children, + goBack, + subHeader, + proceed, + disabled, + imgSrc, + } = props + const { t } = useTranslation('shared') + + return ( + + + + + {t('shared:step_count', { current: stepNumber })} + + + {header} + + {subHeader != null ? ( + + {subHeader} + + ) : null} + {children} + + + {goBack != null ? ( + + + {t('go_back')} + + + ) : null} + + + + + + ) +} + +const StyledImg = styled.img` + border-radius: ${BORDERS.borderRadius16}; + max-height: 844px; +` diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/AddMetadata.test.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/AddMetadata.test.tsx new file mode 100644 index 00000000000..45192d044d3 --- /dev/null +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/AddMetadata.test.tsx @@ -0,0 +1,61 @@ +import * as React from 'react' +import { describe, it, vi, beforeEach, expect } from 'vitest' +import '@testing-library/jest-dom/vitest' +import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' +import { fireEvent, screen } from '@testing-library/react' +import { i18n } from '../../../localization' +import { renderWithProviders } from '../../../__testing-utils__' +import { AddMetadata } from '../AddMetadata' + +import type { WizardFormState, WizardTileProps } from '../types' + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +const values = { + additionalEquipment: [], + fields: { + name: '', + description: '', + organizationOrAuthor: '', + robotType: FLEX_ROBOT_TYPE, + }, + pipettesByMount: {} as any, + modules: null, +} as WizardFormState + +const mockWizardTileProps: Partial = { + proceed: vi.fn(), + setValue: vi.fn(), + watch: vi.fn((name: keyof typeof values) => values[name]) as any, + register: vi.fn() as any, +} + +describe('AddMetadata', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + ...props, + ...mockWizardTileProps, + } as WizardTileProps + }) + + it('renders all the text and fields', () => { + render(props) + screen.getByText('Step 6') + screen.getByText('Tell us about your protocol') + screen.getByText('Name') + screen.getByText('Description') + screen.getByText('Author/Organization') + let input = screen.getAllByRole('textbox', { name: '' })[1] + fireEvent.change(input, { target: { value: 'mockProtocolName' } }) + expect(props.register).toHaveBeenCalled() + input = screen.getAllByRole('textbox', { name: '' })[2] + fireEvent.change(input, { target: { value: 'mock org' } }) + expect(props.register).toHaveBeenCalled() + }) +}) diff --git a/protocol-designer/src/pages/CreateNewProtocol/__tests__/CreateNewProtocol.test.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/CreateNewProtocol.test.tsx similarity index 100% rename from protocol-designer/src/pages/CreateNewProtocol/__tests__/CreateNewProtocol.test.tsx rename to protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/CreateNewProtocol.test.tsx diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/SelectGripper.test.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/SelectGripper.test.tsx new file mode 100644 index 00000000000..f848768417c --- /dev/null +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/SelectGripper.test.tsx @@ -0,0 +1,56 @@ +import * as React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import '@testing-library/jest-dom/vitest' +import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' +import { fireEvent, screen } from '@testing-library/react' +import { i18n } from '../../../localization' +import { renderWithProviders } from '../../../__testing-utils__' +import { SelectGripper } from '../SelectGripper' + +import type { WizardFormState, WizardTileProps } from '../types' + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +const values = { + additionalEquipment: [], + fields: { + name: '', + description: '', + organizationOrAuthor: '', + robotType: FLEX_ROBOT_TYPE, + }, + pipettesByMount: {} as any, + modules: null, +} as WizardFormState + +const mockWizardTileProps: Partial = { + proceed: vi.fn(), + setValue: vi.fn(), + watch: vi.fn((name: keyof typeof values) => values[name]) as any, +} + +describe('SelectGripper', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + ...props, + ...mockWizardTileProps, + } as WizardTileProps + }) + + it('renders all the text and buttons for adding gripper', () => { + render(props) + screen.getByText('Step 3') + screen.getByText('Add a gripper') + screen.getByText('Does your protocol need a Flex Gripper?') + fireEvent.click(screen.getByRole('label', { name: 'Yes' })) + expect(props.setValue).toHaveBeenCalled() + fireEvent.click(screen.getByRole('label', { name: 'No' })) + expect(props.setValue).toHaveBeenCalled() + }) +}) diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/SelectRobot.test.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/SelectRobot.test.tsx new file mode 100644 index 00000000000..a22c9bddf5d --- /dev/null +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/SelectRobot.test.tsx @@ -0,0 +1,57 @@ +import * as React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import '@testing-library/jest-dom/vitest' +import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' +import { fireEvent, screen } from '@testing-library/react' +import { i18n } from '../../../localization' +import { renderWithProviders } from '../../../__testing-utils__' +import { SelectRobot } from '../SelectRobot' +import type { WizardFormState, WizardTileProps } from '../types' + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +const values = { + fields: { + name: '', + description: '', + organizationOrAuthor: '', + robotType: FLEX_ROBOT_TYPE, + }, +} as WizardFormState + +const mockWizardTileProps: Partial = { + proceed: vi.fn(), + setValue: vi.fn(), + // @ts-expect-error: ts can't tell that its a nested key + // in values + watch: vi.fn(() => values['fields.robotType']), +} + +describe('SelectRobot', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + ...props, + ...mockWizardTileProps, + } as WizardTileProps + }) + + it('renders all the text and clicks on Flex and ot-2 buttons', () => { + render(props) + screen.getByText('Step 1') + screen.getByText('Let’s start with the basics') + screen.getByText( + 'We’re going to ask a few questions to help you get started building your protocol.' + ) + screen.getByText('Which robot would you like to use?') + fireEvent.click(screen.getByRole('label', { name: 'Opentrons Flex' })) + expect(props.setValue).toHaveBeenCalled() + fireEvent.click(screen.getByRole('label', { name: 'Opentrons OT-2' })) + expect(props.setValue).toHaveBeenCalled() + }) +}) diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/WizardBody.test.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/WizardBody.test.tsx new file mode 100644 index 00000000000..3b7e25096bd --- /dev/null +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/__tests__/WizardBody.test.tsx @@ -0,0 +1,42 @@ +import * as React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import '@testing-library/jest-dom/vitest' +import { fireEvent, screen } from '@testing-library/react' +import { i18n } from '../../../localization' +import { renderWithProviders } from '../../../__testing-utils__' +import { WizardBody } from '../WizardBody' + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('WizardBody', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + stepNumber: 1, + header: 'mockHeader', + children:
mock children
, + proceed: vi.fn(), + disabled: false, + goBack: vi.fn(), + subHeader: 'mockSubheader', + } + }) + + it('renders all the elements', () => { + render(props) + screen.getByText('Step 1') + screen.getByText('mockHeader') + screen.getByText('mock children') + screen.getByText('mockSubheader') + fireEvent.click(screen.getByRole('button', { name: 'Confirm' })) + expect(props.proceed).toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Go back' })) + expect(props.goBack).toHaveBeenCalled() + screen.getByRole('img', { name: '' }) + }) +}) diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/index.tsx b/protocol-designer/src/pages/CreateNewProtocolWizard/index.tsx new file mode 100644 index 00000000000..9ac80ff81c2 --- /dev/null +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/index.tsx @@ -0,0 +1,453 @@ +import * as React from 'react' +import * as Yup from 'yup' +import reduce from 'lodash/reduce' +import omit from 'lodash/omit' +import uniq from 'lodash/uniq' +import mapValues from 'lodash/mapValues' +import { yupResolver } from '@hookform/resolvers/yup' +import { useDispatch, useSelector } from 'react-redux' +import { useTranslation } from 'react-i18next' +import { useForm } from 'react-hook-form' +import { useNavigate } from 'react-router-dom' +import { + FLEX_ROBOT_TYPE, + HEATERSHAKER_MODULE_TYPE, + MAGNETIC_BLOCK_TYPE, + MAGNETIC_MODULE_TYPE, + OT2_ROBOT_TYPE, + TEMPERATURE_MODULE_TYPE, + THERMOCYCLER_MODULE_TYPE, + WASTE_CHUTE_CUTOUT, + getAreSlotsAdjacent, +} from '@opentrons/shared-data' +import { Box, COLORS } from '@opentrons/components' +import { + actions as fileActions, + selectors as loadFileSelectors, +} from '../../load-file' +import { uuid } from '../../utils' +import * as labwareDefSelectors from '../../labware-defs/selectors' +import * as labwareDefActions from '../../labware-defs/actions' +import * as labwareIngredActions from '../../labware-ingred/actions' +import { actions as steplistActions } from '../../steplist' +import { INITIAL_DECK_SETUP_STEP_ID } from '../../constants' +import { actions as stepFormActions } from '../../step-forms' +import { createModuleWithNoSlot } from '../../modules' +import { + createDeckFixture, + toggleIsGripperRequired, +} from '../../step-forms/actions/additionalItems' +import { SelectRobot } from './SelectRobot' +import { SelectPipettes } from './SelectPipettes' +import { SelectGripper } from './SelectGripper' +import { SelectModules } from './SelectModules' +import { SelectFixtures } from './SelectFixtures' +import { AddMetadata } from './AddMetadata' + +import type { ThunkDispatch } from 'redux-thunk' +import type { NormalizedPipette } from '@opentrons/step-generation' +import type { BaseState } from '../../types' +import type { + FormPipette, + FormPipettesByMount, + PipetteOnDeck, +} from '../../step-forms' +import type { + ModuleModel, + ModuleType, + PipetteName, +} from '@opentrons/shared-data' +import type { WizardFormState } from './types' + +type WizardStep = + | 'robot' + | 'pipette' + | 'gripper' + | 'modules' + | 'fixtures' + | 'metadata' +const WIZARD_STEPS: WizardStep[] = [ + 'robot', + 'pipette', + 'gripper', + 'modules', + 'fixtures', + 'metadata', +] +const WIZARD_STEPS_OT2: WizardStep[] = [ + 'robot', + 'pipette', + 'modules', + 'metadata', +] + +const adapter96ChannelDefUri = 'opentrons/opentrons_flex_96_tiprack_adapter/1' + +type PipetteFieldsData = Omit< + PipetteOnDeck, + 'id' | 'spec' | 'tiprackLabwareDef' +> + +interface ModuleCreationArgs { + type: ModuleType + model: ModuleModel + slot: string +} + +const initialFormState: WizardFormState = { + fields: { + name: undefined, + description: undefined, + organizationOrAuthor: undefined, + robotType: undefined, + }, + pipettesByMount: { + left: { pipetteName: undefined, tiprackDefURI: undefined }, + right: { pipetteName: undefined, tiprackDefURI: undefined }, + }, + modules: {}, + // defaulting to selecting trashBin already to avoid user having to + // click to add a trash bin/waste chute. Delete once we support returnTip() + additionalEquipment: ['trashBin'], +} + +const pipetteValidationShape = Yup.object().shape({ + pipetteName: Yup.string().nullable(), + tiprackDefURI: Yup.array() + .of(Yup.string()) + .nullable() + .when('pipetteName', { + is: (val: string | null): boolean => Boolean(val), + then: schema => schema.required('Required'), + otherwise: schema => schema.nullable(), + }), +}) +const moduleValidationShape: any = Yup.object().shape({ + type: Yup.string(), + model: Yup.string(), + slot: Yup.string(), +}) + +const validationSchema: any = Yup.object().shape({ + fields: Yup.object().shape({ + name: Yup.string().required('Required'), + }), + pipettesByMount: Yup.object() + .shape({ + left: pipetteValidationShape, + right: pipetteValidationShape, + }) + .test('pipette-is-required', 'a pipette is required', value => + // @ts-expect-error todo: fix this + Object.keys(value).some((val: string) => value[val].pipetteName) + ), + modulesByType: Yup.object().shape({ + [MAGNETIC_MODULE_TYPE]: moduleValidationShape, + [TEMPERATURE_MODULE_TYPE]: moduleValidationShape, + [THERMOCYCLER_MODULE_TYPE]: moduleValidationShape, + [HEATERSHAKER_MODULE_TYPE]: moduleValidationShape, + [MAGNETIC_BLOCK_TYPE]: moduleValidationShape, + }), +}) + +export function CreateNewProtocolWizard(): JSX.Element | null { + const { t } = useTranslation(['modal', 'alert']) + const hasUnsavedChanges = useSelector(loadFileSelectors.getHasUnsavedChanges) + const customLabware = useSelector( + labwareDefSelectors.getCustomLabwareDefsByURI + ) + const navigate = useNavigate() + const [currentStepIndex, setCurrentStepIndex] = React.useState(0) + const [wizardSteps, setWizardSteps] = React.useState( + WIZARD_STEPS + ) + + const dispatch = useDispatch>() + + const createProtocolFile = (values: WizardFormState): void => { + navigate('/overview') + + const pipettes = reduce( + values.pipettesByMount, + (acc, formPipette: FormPipette, mount): PipetteFieldsData[] => { + return formPipette?.pipetteName != null && + formPipette?.pipetteName !== '' && + formPipette.tiprackDefURI != null && + (mount === 'left' || mount === 'right') + ? [ + ...acc, + { + mount, + name: formPipette.pipetteName as PipetteName, + tiprackDefURI: formPipette.tiprackDefURI, + }, + ] + : acc + }, + [] + ) + + const modules: ModuleCreationArgs[] = + values.modules != null + ? Object.entries(values.modules).reduce( + (acc, [number, formModule]) => { + return [ + ...acc, + { + type: formModule.type, + model: formModule.model || ('' as ModuleModel), + slot: formModule.slot, + }, + ] + }, + [] + ) + : [] + const heaterShakerIndex = modules.findIndex( + mod => mod.type === HEATERSHAKER_MODULE_TYPE + ) + const magModIndex = modules.findIndex( + mod => mod.type === MAGNETIC_MODULE_TYPE + ) + if (heaterShakerIndex > -1 && magModIndex > -1) { + // if both are present, move the Mag mod to slot 9, since both can't be in slot 1 + modules[magModIndex].slot = '9' + } + const newProtocolFields = values.fields + + if ( + !hasUnsavedChanges || + window.confirm(t('alert:confirm_create_new') as string) + ) { + dispatch(fileActions.createNewProtocol(newProtocolFields)) + const pipettesById: Record = pipettes.reduce( + (acc, pipette) => ({ ...acc, [uuid()]: pipette }), + {} + ) + // create custom labware + mapValues(customLabware, labwareDef => + dispatch( + labwareDefActions.createCustomLabwareDefAction({ + def: labwareDef, + }) + ) + ) + // create new pipette entities + dispatch( + stepFormActions.createPipettes( + mapValues( + pipettesById, + (p: PipetteOnDeck, id: string): NormalizedPipette => ({ + // @ts-expect-error(sa, 2021-6-22): id will always get overwritten + id, + ...omit(p, 'mount'), + }) + ) + ) + ) + // update pipette locations in initial deck setup step + dispatch( + steplistActions.changeSavedStepForm({ + stepId: INITIAL_DECK_SETUP_STEP_ID, + update: { + pipetteLocationUpdate: mapValues( + pipettesById, + (p: typeof pipettesById[keyof typeof pipettesById]) => p.mount + ), + }, + }) + ) + + // add trash + if (values.additionalEquipment.includes('trashBin')) { + // defaulting trash to appropriate locations + dispatch( + createDeckFixture( + 'trashBin', + values.fields.robotType === FLEX_ROBOT_TYPE + ? // TODO(ja, 8/9/24): add logic for which trash location for flex to default to + 'cutoutA3' + : 'cutout12' + ) + ) + } + + // add waste chute + if (values.additionalEquipment.includes('wasteChute')) { + dispatch(createDeckFixture('wasteChute', WASTE_CHUTE_CUTOUT)) + } + // add staging areas + const stagingAreas = values.additionalEquipment.filter(equipment => + equipment.includes('stagingArea') + ) + if (stagingAreas.length > 0) { + stagingAreas.forEach(stagingArea => { + const [, location] = stagingArea.split('_') + dispatch(createDeckFixture('stagingArea', location)) + }) + } + + // create modules + // 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, + isMagneticBlock: moduleArgs.type === MAGNETIC_BLOCK_TYPE, + }) + ) + }) + + // add gripper + if (values.additionalEquipment.includes('gripper')) { + dispatch(toggleIsGripperRequired()) + } + // auto-generate tipracks for pipettes + const newTiprackModels: string[] = uniq( + pipettes.flatMap(pipette => pipette.tiprackDefURI) + ) + const hasMagneticBlock = modules.some( + module => module.type === MAGNETIC_BLOCK_TYPE + ) + const FLEX_MIDDLE_SLOTS = hasMagneticBlock ? [] : ['C2', 'B2', 'A2'] + const hasOt2TC = modules.find( + module => module.type === THERMOCYCLER_MODULE_TYPE + ) + const heaterShakerSlot = modules.find( + module => module.type === HEATERSHAKER_MODULE_TYPE + )?.slot + const OT2_MIDDLE_SLOTS = hasOt2TC ? ['2', '5'] : ['2', '5', '8', '11'] + const modifiedOt2Slots = OT2_MIDDLE_SLOTS.filter(slot => + heaterShakerSlot != null + ? !getAreSlotsAdjacent(heaterShakerSlot, slot) + : slot + ) + newTiprackModels.forEach((tiprackDefURI, index) => { + dispatch( + labwareIngredActions.createContainer({ + slot: + values.fields.robotType === FLEX_ROBOT_TYPE + ? FLEX_MIDDLE_SLOTS[index] + : modifiedOt2Slots[index], + labwareDefURI: tiprackDefURI, + adapterUnderLabwareDefURI: + values.pipettesByMount.left.pipetteName === 'p1000_96' + ? adapter96ChannelDefUri + : undefined, + }) + ) + }) + } + } + + const currentWizardStep = wizardSteps[currentStepIndex] + const goBack = (stepsBack: number = 1): void => { + if (currentStepIndex >= 0 + stepsBack) { + setCurrentStepIndex(currentStepIndex - stepsBack) + } + } + const proceed = (stepsForward: number = 1): void => { + if (currentStepIndex + stepsForward < wizardSteps.length) { + setCurrentStepIndex(currentStepIndex + stepsForward) + } + } + + return ( + + + + ) +} + +interface CreateFileFormProps { + currentWizardStep: WizardStep + createProtocolFile: (values: WizardFormState) => void + goBack: () => void + proceed: () => void + setWizardSteps: React.Dispatch> +} + +function CreateFileForm(props: CreateFileFormProps): JSX.Element { + const { + currentWizardStep, + createProtocolFile, + proceed, + goBack, + setWizardSteps, + } = props + const { ...formProps } = useForm({ + defaultValues: initialFormState, + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + resolver: yupResolver(validationSchema), + }) + + const handleProceedRobotType = (robotType: string): void => { + if (robotType === OT2_ROBOT_TYPE) { + setWizardSteps(WIZARD_STEPS_OT2) + } else { + setWizardSteps(WIZARD_STEPS) + } + } + + return ( +
{})}> + {(() => { + switch (currentWizardStep) { + case 'robot': + return ( + { + handleProceedRobotType( + formProps.getValues().fields.robotType ?? OT2_ROBOT_TYPE + ) + proceed() + }} + /> + ) + case 'pipette': + return + case 'gripper': + return + case 'modules': + return + case 'fixtures': + return + case 'metadata': + return ( + { + createProtocolFile(formProps.getValues()) + }} + goBack={goBack} + /> + ) + default: + return null + } + })()} + + ) +} diff --git a/protocol-designer/src/pages/CreateNewProtocolWizard/types.ts b/protocol-designer/src/pages/CreateNewProtocolWizard/types.ts new file mode 100644 index 00000000000..c5ae96ce8a2 --- /dev/null +++ b/protocol-designer/src/pages/CreateNewProtocolWizard/types.ts @@ -0,0 +1,23 @@ +import type { UseFormReturn } from 'react-hook-form' +import type { NewProtocolFields } from '../../load-file' +import type { FormModules, FormPipettesByMount } from '../../step-forms' +export type AdditionalEquipment = + | 'gripper' + | 'wasteChute' + | 'trashBin' + | 'stagingArea_cutoutA3' + | 'stagingArea_cutoutB3' + | 'stagingArea_cutoutC3' + | 'stagingArea_cutoutD3' + +export interface WizardFormState { + fields: NewProtocolFields + pipettesByMount: FormPipettesByMount + modules: FormModules | null + additionalEquipment: AdditionalEquipment[] +} + +export interface WizardTileProps extends UseFormReturn { + proceed: (stepsForward?: number) => void + goBack: (stepsBack?: number) => void +}