From 586744c199b32ec580045658cb7ba9586088570d Mon Sep 17 00:00:00 2001 From: amitlissack Date: Tue, 25 May 2021 07:47:53 -0400 Subject: [PATCH 1/8] docs(robot-server): emulator and simulator notes. (#7830) --- robot-server/README.rst | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/robot-server/README.rst b/robot-server/README.rst index 42c20310da4..d68bc2a696e 100755 --- a/robot-server/README.rst +++ b/robot-server/README.rst @@ -52,3 +52,29 @@ Tests should be organized similarly to the organization of the module itself. We use `Flake8 `_ for lint checks, and `mypy `_ for type-checking annotations. Both of these tools are run in the ``lint`` makefile target, and is run in CI; PRs will not be merged with failing lint. Usage of ``noqa`` to temporarily disable lint is discouraged, but if you need to please disable only a specific rule and leave a comment explaining exactly why. The same goes with ``type: ignore``. New code should have appropriate type annotations, and refactors of old code should try to add type annotations. We’re flexible about the refactor part, though - if adding type annotations greatly expands the scope of a PR, it’s OK to not add them as long as you explain this in the PR message. + +Developer Modes +----------------- + +The robot server can be run on a PC in one of two development modes. + +These can be useful when an OT-2 and modules are not available. + +The **Opentrons** application will automatically discover a locally running robot server as **dev**. + +*************** +Simulators +*************** +Simulation mode will run the robot-server with simple software simulations of the Smoothie and magnetic, temperature, and thermocycler modules. This mode is ideal for rapid testing as the GCODE communication layer is bypassed. + +- `make -C robot-server dev` + +*************** +Emulators +*************** +Using the emulation mode will have the robot server send GCODE commands to a running emulation application. In this mode, the robot server is running exactly as it would on the OT-2. + +This requires two steps. Enter these commands from the opentrons directory: + +- `make -C api emulator` +- `make -C robot-server dev-with-emulator` From 46431d5b15dcd3f8590c971a80500851a59ffe57 Mon Sep 17 00:00:00 2001 From: Katie Adee Date: Wed, 26 May 2021 12:34:31 -0400 Subject: [PATCH 2/8] feat(labware-creator): Add hand placed tip fit section and alerts (#7832) closes #7713 --- .../labwareDefToFields.test.ts.snap | 1 + .../__tests__/labwareDefToFields.test.ts | 1 + .../labware-creator/components/FormAlerts.tsx | 19 +++- .../components/TipFitAlerts.tsx | 23 ++++ .../components/__tests__/FormAlerts.test.tsx | 25 ++++- .../sections/HandPlacedTipFit.test.tsx | 106 ++++++++++++++++++ .../components/sections/HandPlacedTipFit.tsx | 55 +++++++++ labware-library/src/labware-creator/fields.ts | 11 ++ labware-library/src/labware-creator/index.tsx | 2 + .../src/labware-creator/labwareDefToFields.ts | 1 + .../src/labware-creator/labwareFormSchema.ts | 9 ++ .../src/labware-creator/styles.css | 8 +- 12 files changed, 255 insertions(+), 6 deletions(-) create mode 100644 labware-library/src/labware-creator/components/TipFitAlerts.tsx create mode 100644 labware-library/src/labware-creator/components/__tests__/sections/HandPlacedTipFit.test.tsx create mode 100644 labware-library/src/labware-creator/components/sections/HandPlacedTipFit.tsx diff --git a/labware-library/src/labware-creator/__tests__/__snapshots__/labwareDefToFields.test.ts.snap b/labware-library/src/labware-creator/__tests__/__snapshots__/labwareDefToFields.test.ts.snap index 9f4b816f1d7..2af2cd2e48b 100644 --- a/labware-library/src/labware-creator/__tests__/__snapshots__/labwareDefToFields.test.ts.snap +++ b/labware-library/src/labware-creator/__tests__/__snapshots__/labwareDefToFields.test.ts.snap @@ -15,6 +15,7 @@ Object { "gridRows": "1", "gridSpacingX": "9.09", "gridSpacingY": null, + "handPlacedTipFit": null, "homogeneousWells": "true", "labwareType": "reservoir", "labwareZDimension": "44.45", diff --git a/labware-library/src/labware-creator/__tests__/labwareDefToFields.test.ts b/labware-library/src/labware-creator/__tests__/labwareDefToFields.test.ts index 7dea84eb8bb..71a2a5f19c6 100644 --- a/labware-library/src/labware-creator/__tests__/labwareDefToFields.test.ts +++ b/labware-library/src/labware-creator/__tests__/labwareDefToFields.test.ts @@ -19,6 +19,7 @@ describe('labwareDefToFields', () => { tubeRackInsertLoadName: null, aluminumBlockType: null, aluminumBlockChildType: null, + handPlacedTipFit: null, footprintXDimension: String(def.dimensions.xDimension), footprintYDimension: String(def.dimensions.yDimension), diff --git a/labware-library/src/labware-creator/components/FormAlerts.tsx b/labware-library/src/labware-creator/components/FormAlerts.tsx index 7717e9276a3..96eea7c796a 100644 --- a/labware-library/src/labware-creator/components/FormAlerts.tsx +++ b/labware-library/src/labware-creator/components/FormAlerts.tsx @@ -5,6 +5,7 @@ import { AlertItem } from '@opentrons/components' import { LabwareFields, IRREGULAR_LABWARE_ERROR, + LOOSE_TIP_FIT_ERROR, LINK_CUSTOM_LABWARE_FORM, } from '../fields' import { LinkOut } from './LinkOut' @@ -18,7 +19,6 @@ export interface Props { export const IrregularLabwareAlert = (): JSX.Element => ( @@ -30,6 +30,18 @@ export const IrregularLabwareAlert = (): JSX.Element => ( /> ) +export const LooseTipFitAlert = (): JSX.Element => ( + + If your tip does not fit when placed by hand then it is not a good + candidate for this pipette on the OT-2. + + } + /> +) + export const FormAlerts = (props: Props): JSX.Element | null => { const { fieldList, touched, errors } = props @@ -41,11 +53,14 @@ export const FormAlerts = (props: Props): JSX.Element | null => { return ( <> {allErrors.map(error => { + if (error === LOOSE_TIP_FIT_ERROR) { + return + } if (error === IRREGULAR_LABWARE_ERROR) { return } return - })}{' '} + })} ) } diff --git a/labware-library/src/labware-creator/components/TipFitAlerts.tsx b/labware-library/src/labware-creator/components/TipFitAlerts.tsx new file mode 100644 index 00000000000..862ff7a04c7 --- /dev/null +++ b/labware-library/src/labware-creator/components/TipFitAlerts.tsx @@ -0,0 +1,23 @@ +import * as React from 'react' +import { FormikTouched } from 'formik' +import { LabwareFields } from '../fields' +import { AlertItem } from '@opentrons/components' + +export interface Props { + values: LabwareFields + touched: FormikTouched +} + +// TODO: (ka 2021-5-25): Move this along with other form/section alerts to alerts/ as components +export const TipFitAlerts = (props: Props): JSX.Element | null => { + const { values, touched } = props + if (touched.handPlacedTipFit && values.handPlacedTipFit === 'snug') { + return ( + + ) + } + return null +} diff --git a/labware-library/src/labware-creator/components/__tests__/FormAlerts.test.tsx b/labware-library/src/labware-creator/components/__tests__/FormAlerts.test.tsx index adcc1a32fe8..fe705b4cdc4 100644 --- a/labware-library/src/labware-creator/components/__tests__/FormAlerts.test.tsx +++ b/labware-library/src/labware-creator/components/__tests__/FormAlerts.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import { render } from '@testing-library/react' import { getIsHidden } from '../../formSelectors' -import { IRREGULAR_LABWARE_ERROR } from '../../fields' +import { IRREGULAR_LABWARE_ERROR, LOOSE_TIP_FIT_ERROR } from '../../fields' import { FormAlerts, Props as FormAlertProps } from '../FormAlerts' import { when, resetAllWhenMocks } from 'jest-when' @@ -57,4 +57,27 @@ describe('FormAlerts', () => { 'Your labware is not compatible with the Labware Creator. Please fill out this form to request a custom labware definition.' ) }) + + it('should render an loose tip fit error when hand placed fit is loose', () => { + when(getIsHiddenMock) + .calledWith('labwareType', {} as any) + .mockReturnValue(false) + when(getIsHiddenMock) + .calledWith('tubeRackInsertLoadName', {} as any) + .mockReturnValue(false) + + const props: FormAlertProps = { + fieldList: ['labwareType', 'tubeRackInsertLoadName'], + touched: { labwareType: true, tubeRackInsertLoadName: true }, + errors: { + labwareType: LOOSE_TIP_FIT_ERROR, + }, + } + + const { container } = render() + const error = container.querySelector('[class="alert error"]') + expect(error?.textContent).toBe( + 'If your tip does not fit when placed by hand then it is not a good candidate for this pipette on the OT-2.' + ) + }) }) diff --git a/labware-library/src/labware-creator/components/__tests__/sections/HandPlacedTipFit.test.tsx b/labware-library/src/labware-creator/components/__tests__/sections/HandPlacedTipFit.test.tsx new file mode 100644 index 00000000000..427c2d78642 --- /dev/null +++ b/labware-library/src/labware-creator/components/__tests__/sections/HandPlacedTipFit.test.tsx @@ -0,0 +1,106 @@ +import React from 'react' +import { FormikConfig } from 'formik' +import isEqual from 'lodash/isEqual' +import { render, screen } from '@testing-library/react' +import { + getDefaultFormState, + LabwareFields, + snugLooseOptions, +} from '../../../fields' +import { HandPlacedTipFit } from '../../sections/HandPlacedTipFit' +import { FormAlerts } from '../../FormAlerts' +import { Dropdown } from '../../Dropdown' +import { wrapInFormik } from '../../utils/wrapInFormik' +import { TipFitAlerts } from '../../TipFitAlerts' + +jest.mock('../../Dropdown') +jest.mock('../../FormAlerts') +jest.mock('../../TipFitAlerts') + +const FormAlertsMock = FormAlerts as jest.MockedFunction +const dropdownMock = Dropdown as jest.MockedFunction + +const tipFitAlertsMock = TipFitAlerts as jest.MockedFunction< + typeof TipFitAlerts +> + +let formikConfig: FormikConfig + +describe('HandPlacedTipFit', () => { + beforeEach(() => { + formikConfig = { + initialValues: getDefaultFormState(), + onSubmit: jest.fn(), + } + + dropdownMock.mockImplementation(args => { + if ( + isEqual(args, { name: 'handPlacedTipFit', options: snugLooseOptions }) + ) { + return
handPlacedTipFit dropdown field
+ } else { + return
+ } + }) + + FormAlertsMock.mockImplementation(args => { + if ( + isEqual(args, { + touched: {}, + errors: {}, + fieldList: ['handPlacedTipFit'], + }) + ) { + return
mock alerts
+ } else { + return
+ } + }) + + tipFitAlertsMock.mockImplementation(args => { + if ( + isEqual(args, { + values: formikConfig.initialValues, + touched: {}, + }) + ) { + return
mock getTipFitAlertsMock alerts
+ } else { + return
+ } + }) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('should not render when no labware type selected', () => { + const { container } = render( + wrapInFormik(, formikConfig) + ) + expect(container.firstChild).toBe(null) + }) + + it('should render alerts and dropdown when tiprack is selected', () => { + formikConfig.initialValues.labwareType = 'tipRack' + render(wrapInFormik(, formikConfig)) + expect(screen.getByText('Hand-Placed Tip Fit')).toBeTruthy() + expect( + screen.getByText( + 'Place the tip you wish to use on the pipette you wish to use it on. Give the tip a wiggle to check the fit.' + ) + ).toBeTruthy() + expect(screen.getByText('mock alerts')).toBeTruthy() + expect(screen.getByText('handPlacedTipFit dropdown field')).toBeTruthy() + expect(screen.getByText('mock getTipFitAlertsMock alerts')).toBeTruthy() + }) + + it('should not render when non tiprack labware selected', () => { + formikConfig.initialValues.labwareType = 'wellPlate' + const { container } = render( + wrapInFormik(, formikConfig) + ) + expect(container.firstChild).toBe(null) + }) +}) diff --git a/labware-library/src/labware-creator/components/sections/HandPlacedTipFit.tsx b/labware-library/src/labware-creator/components/sections/HandPlacedTipFit.tsx new file mode 100644 index 00000000000..58ccbc950b6 --- /dev/null +++ b/labware-library/src/labware-creator/components/sections/HandPlacedTipFit.tsx @@ -0,0 +1,55 @@ +import * as React from 'react' +import { useFormikContext } from 'formik' +import { snugLooseOptions } from '../../fields' +import { FormAlerts } from '../FormAlerts' +import { TipFitAlerts } from '../TipFitAlerts' +import { Dropdown } from '../Dropdown' +import { SectionBody } from './SectionBody' + +import styles from '../../styles.css' + +import type { LabwareFields } from '../../fields' + +const Content = (): JSX.Element => ( +
+
+

+ Place the tip you wish to use on the pipette you wish to use it on. Give + the tip a wiggle to check the fit. +

+ +

+ Note that fit may vary between Single and 8 Channel pipettes, as well as + between generations of the same pipette. +

+
+
+ +
+
+) + +export const HandPlacedTipFit = (): JSX.Element | null => { + const fieldList: Array = ['handPlacedTipFit'] + const { values, errors, touched } = useFormikContext() + + if (values.labwareType === 'tipRack') { + return ( +
+ + <> + + + + + +
+ ) + } + + return null +} diff --git a/labware-library/src/labware-creator/fields.ts b/labware-library/src/labware-creator/fields.ts index b98db84ad15..ba2b3e16a71 100644 --- a/labware-library/src/labware-creator/fields.ts +++ b/labware-library/src/labware-creator/fields.ts @@ -18,6 +18,8 @@ export const DISPLAY_VOLUME_UNITS = 'µL' // magic string for all validation errors that direct user away to the labware request form export const IRREGULAR_LABWARE_ERROR = 'IRREGULAR_LABWARE_ERROR' +export const LOOSE_TIP_FIT_ERROR = 'LOOSE_TIP_FIT_ERROR' + export const LINK_CUSTOM_LABWARE_FORM = 'https://opentrons-ux.typeform.com/to/xi8h0W' @@ -72,12 +74,18 @@ export const yesNoOptions = [ { name: 'No', value: 'false' }, ] +export const snugLooseOptions = [ + { name: 'Snug', value: 'snug' }, + { name: 'Loose', value: 'loose' }, +] + export interface LabwareFields { labwareType: LabwareType | null | undefined tubeRackInsertLoadName: string | null | undefined aluminumBlockType: string | null | undefined // eg, '24well' or '96well' aluminumBlockChildType: string | null | undefined + handPlacedTipFit: string | null | undefined // tubeRackSides: string[], // eg, [] footprintXDimension: string | null | undefined footprintYDimension: string | null | undefined @@ -125,6 +133,7 @@ export interface ProcessedLabwareFields { tubeRackInsertLoadName: string aluminumBlockType: string aluminumBlockChildType: string | null + handPlacedTipFit: string | null // tubeRackSides: string[], // eg, [] footprintXDimension: number @@ -325,6 +334,7 @@ export const getDefaultFormState = (): LabwareFields => ({ aluminumBlockType: null, aluminumBlockChildType: null, + handPlacedTipFit: null, // tubeRackSides: [], footprintXDimension: null, footprintYDimension: null, @@ -368,6 +378,7 @@ export const LABELS: Record = { tubeRackInsertLoadName: 'Which tube rack insert?', aluminumBlockType: 'Which aluminum block?', aluminumBlockChildType: 'What labware is on top of your aluminum block?', + handPlacedTipFit: 'Fit', homogeneousWells: 'Are all your wells the same shape and size?', footprintXDimension: 'Length', footprintYDimension: 'Width', diff --git a/labware-library/src/labware-creator/index.tsx b/labware-library/src/labware-creator/index.tsx index e151534022d..017d440d049 100644 --- a/labware-library/src/labware-creator/index.tsx +++ b/labware-library/src/labware-creator/index.tsx @@ -39,6 +39,7 @@ import { CreateNewDefinition } from './components/sections/CreateNewDefinition' import { UploadExisting } from './components/sections/UploadExisting' import { CustomTiprackWarning } from './components/sections/CustomTiprackWarning' +import { HandPlacedTipFit } from './components/sections/HandPlacedTipFit' import { Regularity } from './components/sections/Regularity' import { Footprint } from './components/sections/Footprint' import { Height } from './components/sections/Height' @@ -408,6 +409,7 @@ export const LabwareCreator = (): JSX.Element => { <> {/* PAGE 1 - Labware */} + diff --git a/labware-library/src/labware-creator/labwareDefToFields.ts b/labware-library/src/labware-creator/labwareDefToFields.ts index 2216ebeec15..e4976cbc194 100644 --- a/labware-library/src/labware-creator/labwareDefToFields.ts +++ b/labware-library/src/labware-creator/labwareDefToFields.ts @@ -79,6 +79,7 @@ export function labwareDefToFields( tubeRackInsertLoadName: null, aluminumBlockType: null, aluminumBlockChildType: null, + handPlacedTipFit: null, labwareType, footprintXDimension: String(def.dimensions.xDimension), diff --git a/labware-library/src/labware-creator/labwareFormSchema.ts b/labware-library/src/labware-creator/labwareFormSchema.ts index 872f8c25ddb..b874d9f2a35 100644 --- a/labware-library/src/labware-creator/labwareFormSchema.ts +++ b/labware-library/src/labware-creator/labwareFormSchema.ts @@ -6,6 +6,7 @@ import { wellBottomShapeOptions, wellShapeOptions, IRREGULAR_LABWARE_ERROR, + LOOSE_TIP_FIT_ERROR, LABELS, MAX_X_DIMENSION, MIN_X_DIMENSION, @@ -53,6 +54,14 @@ export const labwareFormSchema: Yup.Schema = Yup.object( labwareType: requiredString(LABELS.labwareType).oneOf( labwareTypeOptions.map(o => o.value) ), + handPlacedTipFit: Yup.string().when('labwareType', { + is: 'tipRack', + then: requiredString(LABELS.handPlacedTipFit).oneOf( + ['snug'], + LOOSE_TIP_FIT_ERROR + ), + otherwise: Yup.string().nullable(), + }), tubeRackInsertLoadName: Yup.mixed().when('labwareType', { is: 'tubeRack', then: requiredString(LABELS.tubeRackInsertLoadName), diff --git a/labware-library/src/labware-creator/styles.css b/labware-library/src/labware-creator/styles.css index 424b821f6a0..b1c1b1b37e9 100644 --- a/labware-library/src/labware-creator/styles.css +++ b/labware-library/src/labware-creator/styles.css @@ -134,7 +134,8 @@ .brand_column, .brand_id_column, .volume_instructions_column, -.instructions_text { +.instructions_text, +.tip_fit_column { flex-basis: 100%; flex-shrink: 0; font-size: var(--fs-body-1); @@ -176,7 +177,7 @@ .brand_id_column, .help_text, .export_form_fields, - .volume_instructions_column { + .volume_instructions_column .tip_fit_column { flex-basis: var(--size-50p); flex-shrink: 1; } @@ -208,7 +209,8 @@ .instructions_column, .diagram_column, .form_fields_column, - .brand_column { + .brand_column, + .tip_fit_column { flex-basis: var(--size-third); flex-shrink: 1; } From d3fc09cd7a4e6553097f573e60d8fb473d6fc36f Mon Sep 17 00:00:00 2001 From: amitlissack Date: Thu, 27 May 2021 09:00:49 -0400 Subject: [PATCH 3/8] docs(api): Update pyenv in CONTRIBUTING.md (#7838) --- CONTRIBUTING.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 07a30fd97c8..d7b9388d0cd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,3 +1,4 @@ + # Contributing Guide Thanks for your interest in contributing to the Opentrons platform! This Contributing Guide is intended to ensure best practices for both internal Opentrons contributors as well as any external contributors. We want to make sure you’re set up to contribute effectively, no matter if you’re helping us out with bug reports, code, documentation, feature suggestions, or anything else. This guide covers: @@ -148,6 +149,11 @@ Your computer will need the following tools installed to be able to develop with pyenv install 3.7.6 ``` + **MacOS Big Sur Note:** due to this [known issue](https://github.com/pyenv/pyenv/issues/1737) we recommend using: + ```shell + pyenv install 3.7.10 + ``` + - Node v12 - [nvm][] is optional, but recommended ```shell From 9046037fac61ea4516585d216f32d32a7ae42ab9 Mon Sep 17 00:00:00 2001 From: Ethan Jones <39735022+ethanfjones@users.noreply.github.com> Date: Thu, 27 May 2021 10:01:43 -0400 Subject: [PATCH 4/8] Confusing variable name (#7829) --- api/docs/v2/new_atomic_commands.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/docs/v2/new_atomic_commands.rst b/api/docs/v2/new_atomic_commands.rst index 3afb7d7ec59..0cc8d8450e2 100644 --- a/api/docs/v2/new_atomic_commands.rst +++ b/api/docs/v2/new_atomic_commands.rst @@ -213,8 +213,8 @@ The examples in this section should be inserted in the following: metadata = {'apiLevel': '|apiLevel|'} def run(protocol): - tiprack = protocol.load_labware('corning_96_wellplate_360ul_flat', 2) - plate = protocol.load_labware('opentrons_96_tiprack_300ul', 3) + plate = protocol.load_labware('corning_96_wellplate_360ul_flat', 2) + tiprack = protocol.load_labware('opentrons_96_tiprack_300ul', 3) pipette = protocol.load_instrument('p300_single_gen2', mount='left', tip_racks=[tiprack]) pipette.pick_up_tip() # example code goes here From 47f33857a382b226e50e0ba15c8bc91014e9b6cf Mon Sep 17 00:00:00 2001 From: Ethan Jones <39735022+ethanfjones@users.noreply.github.com> Date: Thu, 27 May 2021 10:03:59 -0400 Subject: [PATCH 5/8] Update return_tip to drop_tip (#7834) --- api/docs/v2/new_examples.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/docs/v2/new_examples.rst b/api/docs/v2/new_examples.rst index 180a1e612dc..200e4a06bff 100644 --- a/api/docs/v2/new_examples.rst +++ b/api/docs/v2/new_examples.rst @@ -46,7 +46,7 @@ This accomplishes the same thing as the following basic commands: p300.pick_up_tip() p300.aspirate(100, plate.wells('A1')) p300.dispense(100, plate.wells('B1')) - p300.return_tip() + p300.drop_tip() ****************************** From f89a660bfa1c3162eeb44040a47eed181016ac58 Mon Sep 17 00:00:00 2001 From: amitlissack Date: Thu, 27 May 2021 11:38:21 -0400 Subject: [PATCH 6/8] feat(api,robot-server): Add Dockerfile and docker-compose file (#7836) * docker file. * docker-compose * Controller will build simulating gpio object so no reason for the check. * Update docker-compose.yml Co-authored-by: Sam! Bonfante <6620407+X-sam@users.noreply.github.com> * Update docker-compose.yml Co-authored-by: Sam! Bonfante <6620407+X-sam@users.noreply.github.com> * override config dir * only copy files needed for the build. * need manifest * added readme. * formatter Co-authored-by: Sam! Bonfante <6620407+X-sam@users.noreply.github.com> closes #7674 --- .dockerignore | 38 +++++++++++++++++++ .opentrons_config/config.json | 18 +++++++++ .opentrons_config/feature_flags.json | 17 +++++++++ .../pipettes/P20SV202020070101.json | 1 + .../pipettes/P3HMV202020041605.json | 1 + CONTRIBUTING.md | 2 +- DOCKER.md | 28 ++++++++++++++ Dockerfile | 29 ++++++++++++++ api/MANIFEST.in | 1 - .../opentrons/hardware_control/controller.py | 17 ++++----- .../hardware_control/emulation/app.py | 2 +- docker-compose.yml | 27 +++++++++++++ robot-server/setup.py | 4 ++ 13 files changed, 173 insertions(+), 12 deletions(-) create mode 100644 .dockerignore create mode 100644 .opentrons_config/config.json create mode 100644 .opentrons_config/feature_flags.json create mode 100644 .opentrons_config/pipettes/P20SV202020070101.json create mode 100644 .opentrons_config/pipettes/P3HMV202020041605.json create mode 100644 DOCKER.md create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000000..4073f078f70 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,38 @@ +./api-server +./app +./labware-library +./node_modules +./protocol-designer +./discovery-client +./coverage +./components +./app-shell +./update-server +./step-generation + +.pytest_cache +.flake8 +.idea +.pypy_cache +dist +build +yarn.lock +Pipfile.lock + +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +env +pip-log.txt +pip-delete-this-directory.txt +.tox +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +*.log +.git diff --git a/.opentrons_config/config.json b/.opentrons_config/config.json new file mode 100644 index 00000000000..51ba27f79c8 --- /dev/null +++ b/.opentrons_config/config.json @@ -0,0 +1,18 @@ +{ + "labware_database_file": "/config/opentrons.db", + "labware_calibration_offsets_dir_v2": "/config/labware/v2/offsets", + "labware_user_definitions_dir_v2": "/config/labware/v2/custom_definitions", + "feature_flags_file": "/config/feature_flags.json", + "robot_settings_file": "/config/robot_settings.json", + "deck_calibration_file": "/config/deck_calibration.json", + "log_dir": "/config/logs", + "api_log_file": "/config/logs/api.log", + "serial_log_file": "/config/logs/serial.log", + "wifi_keys_dir": "/config/user_storage/opentrons_data/network_keys", + "hardware_controller_lockfile": "/config/hardware.lock", + "pipette_config_overrides_dir": "/config/pipettes", + "tip_length_calibration_dir": "/config/tip_lengths", + "robot_calibration_dir": "/config/robot", + "pipette_calibration_dir": "/config/robot/pipettes", + "custom_tiprack_dir": "/config/tip_lengths/custom_tiprack_definitions" +} diff --git a/.opentrons_config/feature_flags.json b/.opentrons_config/feature_flags.json new file mode 100644 index 00000000000..f5790b92b4a --- /dev/null +++ b/.opentrons_config/feature_flags.json @@ -0,0 +1,17 @@ +{ + "shortFixedTrash": null, + "calibrateToBottom": null, + "deckCalibrationDots": null, + "useProtocolApi2": null, + "disableHomeOnBoot": null, + "useOldAspirationFunctions": null, + "enableDoorSafetySwitch": null, + "enableTipLengthCalibration": null, + "enableHttpProtocolSessions": null, + "enableFastProtocolUpload": null, + "enableProtocolEngine": null, + "disableLogAggregation": null, + "enableApi1BackCompat": null, + "useV1HttpApi": null, + "_version": 9 +} diff --git a/.opentrons_config/pipettes/P20SV202020070101.json b/.opentrons_config/pipettes/P20SV202020070101.json new file mode 100644 index 00000000000..8a711547564 --- /dev/null +++ b/.opentrons_config/pipettes/P20SV202020070101.json @@ -0,0 +1 @@ +{ "model": "p20_single_v2.0" } diff --git a/.opentrons_config/pipettes/P3HMV202020041605.json b/.opentrons_config/pipettes/P3HMV202020041605.json new file mode 100644 index 00000000000..0f7593543a0 --- /dev/null +++ b/.opentrons_config/pipettes/P3HMV202020041605.json @@ -0,0 +1 @@ +{ "model": "p20_multi_v2.0" } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d7b9388d0cd..e289a013e1c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,3 @@ - # Contributing Guide Thanks for your interest in contributing to the Opentrons platform! This Contributing Guide is intended to ensure best practices for both internal Opentrons contributors as well as any external contributors. We want to make sure you’re set up to contribute effectively, no matter if you’re helping us out with bug reports, code, documentation, feature suggestions, or anything else. This guide covers: @@ -150,6 +149,7 @@ Your computer will need the following tools installed to be able to develop with ``` **MacOS Big Sur Note:** due to this [known issue](https://github.com/pyenv/pyenv/issues/1737) we recommend using: + ```shell pyenv install 3.7.10 ``` diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 00000000000..aac2c436434 --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,28 @@ +Docker Guide +======================= +Included in this repo are the tools to run a containerized Opentrons robot stack in docker. + +This includes the `robot-server` connected to the hardware emulation application. The emulation application includes the Smoothie and magnetic, temperature, and thermocycler modules. + +## Requirements + +- A clone of this repo. +- An installation [docker](https://docs.docker.com/get-docker/) +- An installation of [docker-compose](https://docs.docker.com/compose/install/) + +## How to use + +Start a terminal and change directory to the root of this repo. + +1. Build + Enter `docker-compose build --force-rm` at the terminal. + +2. Run + Enter `docker-compose up` at the terminal. _The build and run stages can be combined `docker-compose up --build`._ + +3. Start the Opentrons application. The docker container will appear as `dev`. Connect and run just as you would on a robot. + +## Known Issues + +- Pipettes cannot be changed at run time. +- Pipettes are fixed as `p20_multi_v2.0` on the left mount and `p20_single_v2.0` on the right. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000000..5f2bd4a9443 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM ubuntu as base +RUN apt-get update && apt-get install -y python3 pip + +FROM base as builder +COPY scripts scripts +COPY LICENSE LICENSE + +COPY shared-data shared-data + +COPY api/MANIFEST.in api/MANIFEST.in +COPY api/setup.py api/setup.py +COPY api/pypi-readme.rst api/pypi-readme.rst +COPY api/src/opentrons api/src/opentrons + +COPY notify-server/setup.py notify-server/setup.py +COPY notify-server/README.rst notify-server/README.rst +COPY notify-server/notify_server notify-server/notify_server + +COPY robot-server/setup.py robot-server/setup.py +COPY robot-server/robot_server robot-server/robot_server + +RUN cd shared-data/python && python3 setup.py bdist_wheel -d /dist/ +RUN cd api && python3 setup.py bdist_wheel -d /dist/ +RUN cd notify-server && python3 setup.py bdist_wheel -d /dist/ +RUN cd robot-server && python3 setup.py bdist_wheel -d /dist/ + +FROM base +COPY --from=builder /dist /dist +RUN pip install /dist/* \ No newline at end of file diff --git a/api/MANIFEST.in b/api/MANIFEST.in index 6cfcd94fa52..55b69639860 100755 --- a/api/MANIFEST.in +++ b/api/MANIFEST.in @@ -1,5 +1,4 @@ include src/opentrons/package.json graft src/opentrons/config graft src/opentrons/resources -include src/opentrons/server/openapi.json exclude pyproject.toml diff --git a/api/src/opentrons/hardware_control/controller.py b/api/src/opentrons/hardware_control/controller.py index 2ebd0914156..a28bfa6230c 100644 --- a/api/src/opentrons/hardware_control/controller.py +++ b/api/src/opentrons/hardware_control/controller.py @@ -269,17 +269,16 @@ def disengage_axes(self, axes: List[str]): self._smoothie_driver.disengage_axis(''.join(axes)) def set_lights(self, button: Optional[bool], rails: Optional[bool]): - if opentrons.config.IS_ROBOT: - if button is not None: - self.gpio_chardev.set_button_light(blue=button) - if rails is not None: - self.gpio_chardev.set_rail_lights(rails) + if button is not None: + self.gpio_chardev.set_button_light(blue=button) + if rails is not None: + self.gpio_chardev.set_rail_lights(rails) def get_lights(self) -> Dict[str, bool]: - if not opentrons.config.IS_ROBOT: - return {} - return {'button': self.gpio_chardev.get_button_light()[2], - 'rails': self.gpio_chardev.get_rail_lights()} + return { + 'button': self.gpio_chardev.get_button_light()[2], + 'rails': self.gpio_chardev.get_rail_lights() + } def pause(self): self._smoothie_driver.pause() diff --git a/api/src/opentrons/hardware_control/emulation/app.py b/api/src/opentrons/hardware_control/emulation/app.py index 5dbaac2c707..378a23bb0e0 100644 --- a/api/src/opentrons/hardware_control/emulation/app.py +++ b/api/src/opentrons/hardware_control/emulation/app.py @@ -28,7 +28,7 @@ async def run_server(host: str, port: int, handler: ConnectionHandler) -> None: async def run() -> None: """Run the module emulators.""" - host = "127.0.0.1" + host = "0.0.0.0" await asyncio.gather( run_server(host=host, diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000000..f7bdba9962f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +version: '3' +services: + emulator: + build: . + command: python3 -m opentrons.hardware_control.emulation.app + ports: + - '9996:9996' + - '9997:9997' + - '9998:9998' + - '9999:9999' + robot-server: + build: . + command: uvicorn "robot_server:app" --host 0.0.0.0 --port 31950 --ws wsproto --reload + ports: + - '31950:31950' + environment: + - OT_API_CONFIG_DIR=/config + - OT_SMOOTHIE_EMULATOR_URI=socket://emulator:9996 + - OT_THERMOCYCLER_EMULATOR_URI=socket://emulator:9997 + - OT_TEMPERATURE_EMULATOR_URI=socket://emulator:9998 + - OT_MAGNETIC_EMULATOR_URI=socket://emulator:9999 + links: + - 'emulator' + depends_on: + - 'emulator' + volumes: + - .opentrons_config:/config:rw diff --git a/robot-server/setup.py b/robot-server/setup.py index 3279f7f9196..6a583284c93 100755 --- a/robot-server/setup.py +++ b/robot-server/setup.py @@ -52,7 +52,11 @@ def get_version(): INSTALL_REQUIRES = [ 'fastapi==0.54.1', 'python-multipart==0.0.5', + 'uvicorn==0.11.3', + 'python-dotenv', 'opentrons', + 'wsproto==0.15.0', + 'typing-extensions>=3.7.4.3', ] From 68ef57f45f78e5f2bd248131ce2fbcf544923e6f Mon Sep 17 00:00:00 2001 From: Katie Adee Date: Thu, 27 May 2021 12:12:42 -0400 Subject: [PATCH 7/8] refactor(labware-creator): Move all alert components to alert directory (#7842) --- .../components/__tests__/FormAlerts.test.tsx | 2 +- .../__tests__/sections/Footprint.test.tsx | 30 ++++++++++++------- .../__tests__/sections/Grid.test.tsx | 4 +-- .../__tests__/sections/GridOffset.test.tsx | 4 +-- .../sections/HandPlacedTipFit.test.tsx | 8 ++--- .../__tests__/sections/Height.test.tsx | 29 +++++++++++------- .../__tests__/sections/Regularity.test.tsx | 4 +-- .../__tests__/sections/Volume.test.tsx | 4 +-- .../sections/WellBottomAndDepth.test.tsx | 4 +-- .../sections/WellShapeAndSides.test.tsx | 4 +-- .../__tests__/sections/WellSpacing.test.tsx | 5 ++-- .../components/{ => alerts}/FormAlerts.tsx | 4 +-- .../HeightAlerts.tsx} | 12 ++++---- .../components/{ => alerts}/TipFitAlerts.tsx | 3 +- .../XYDimensionAlerts.tsx} | 9 ++++-- .../sections/CreateNewDefinition.tsx | 2 +- .../components/sections/Footprint.tsx | 6 ++-- .../components/sections/Grid.tsx | 2 +- .../components/sections/GridOffset.tsx | 2 +- .../components/sections/HandPlacedTipFit.tsx | 4 +-- .../components/sections/Height.tsx | 6 ++-- .../components/sections/Regularity.tsx | 2 +- .../components/sections/Volume.tsx | 2 +- .../sections/WellBottomAndDepth.tsx | 2 +- .../components/sections/WellShapeAndSides.tsx | 2 +- .../components/sections/WellSpacing.tsx | 2 +- .../src/labware-creator/styles.css | 3 +- 27 files changed, 92 insertions(+), 69 deletions(-) rename labware-library/src/labware-creator/components/{ => alerts}/FormAlerts.tsx (96%) rename labware-library/src/labware-creator/components/{utils/getHeightAlerts.tsx => alerts/HeightAlerts.tsx} (63%) rename labware-library/src/labware-creator/components/{ => alerts}/TipFitAlerts.tsx (83%) rename labware-library/src/labware-creator/components/{utils/getXYDimensionAlerts.tsx => alerts/XYDimensionAlerts.tsx} (87%) diff --git a/labware-library/src/labware-creator/components/__tests__/FormAlerts.test.tsx b/labware-library/src/labware-creator/components/__tests__/FormAlerts.test.tsx index fe705b4cdc4..de461a18c46 100644 --- a/labware-library/src/labware-creator/components/__tests__/FormAlerts.test.tsx +++ b/labware-library/src/labware-creator/components/__tests__/FormAlerts.test.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { render } from '@testing-library/react' import { getIsHidden } from '../../formSelectors' import { IRREGULAR_LABWARE_ERROR, LOOSE_TIP_FIT_ERROR } from '../../fields' -import { FormAlerts, Props as FormAlertProps } from '../FormAlerts' +import { FormAlerts, Props as FormAlertProps } from '../alerts/FormAlerts' import { when, resetAllWhenMocks } from 'jest-when' jest.mock('../../formSelectors') diff --git a/labware-library/src/labware-creator/components/__tests__/sections/Footprint.test.tsx b/labware-library/src/labware-creator/components/__tests__/sections/Footprint.test.tsx index 7ca9036422f..3ec08c5c2eb 100644 --- a/labware-library/src/labware-creator/components/__tests__/sections/Footprint.test.tsx +++ b/labware-library/src/labware-creator/components/__tests__/sections/Footprint.test.tsx @@ -5,15 +5,16 @@ import { when } from 'jest-when' import { render, screen } from '@testing-library/react' import { getDefaultFormState, LabwareFields } from '../../../fields' import { Footprint } from '../../sections/Footprint' -import { FormAlerts } from '../../FormAlerts' +import { FormAlerts } from '../../alerts/FormAlerts' +import { XYDimensionAlerts } from '../../alerts/XYDimensionAlerts' import { TextField } from '../../TextField' import { wrapInFormik } from '../../utils/wrapInFormik' -import { getXYDimensionAlerts } from '../../utils/getXYDimensionAlerts' + import { isEveryFieldHidden } from '../../../utils' jest.mock('../../TextField') -jest.mock('../../FormAlerts') -jest.mock('../../utils/getXYDimensionAlerts') +jest.mock('../../alerts/FormAlerts') +jest.mock('../../alerts/XYDimensionAlerts') jest.mock('../../../utils') const FormAlertsMock = FormAlerts as jest.MockedFunction @@ -22,8 +23,8 @@ const isEveryFieldHiddenMock = isEveryFieldHidden as jest.MockedFunction< typeof isEveryFieldHidden > -const getXYDimensionAlertsMock = getXYDimensionAlerts as jest.MockedFunction< - typeof getXYDimensionAlerts +const XYDimensionAlertsMock = XYDimensionAlerts as jest.MockedFunction< + typeof XYDimensionAlerts > const formikConfig: FormikConfig = { @@ -57,9 +58,18 @@ describe('Footprint', () => { } }) - when(getXYDimensionAlertsMock) - .expectCalledWith(getDefaultFormState(), {}) - .mockReturnValue(
mock getXYDimensionAlertsMock alerts
) + XYDimensionAlertsMock.mockImplementation(args => { + if ( + isEqual(args, { + values: formikConfig.initialValues, + touched: {}, + }) + ) { + return
mock XYDimensionAlertsMock alerts
+ } else { + return
+ } + }) when(isEveryFieldHiddenMock) .calledWith( @@ -83,7 +93,7 @@ describe('Footprint', () => { expect(screen.getByText('mock alerts')) expect(screen.getByText('footprintXDimension text field')) expect(screen.getByText('footprintYDimension text field')) - expect(screen.getByText('mock getXYDimensionAlertsMock alerts')) + expect(screen.getByText('mock XYDimensionAlertsMock alerts')) }) it('should not render when all fields are hidden', () => { when(isEveryFieldHiddenMock) diff --git a/labware-library/src/labware-creator/components/__tests__/sections/Grid.test.tsx b/labware-library/src/labware-creator/components/__tests__/sections/Grid.test.tsx index 2f834b149ef..ae8768f2680 100644 --- a/labware-library/src/labware-creator/components/__tests__/sections/Grid.test.tsx +++ b/labware-library/src/labware-creator/components/__tests__/sections/Grid.test.tsx @@ -10,7 +10,7 @@ import { } from '../../../fields' import { isEveryFieldHidden } from '../../../utils' import { Grid } from '../../sections/Grid' -import { FormAlerts } from '../../FormAlerts' +import { FormAlerts } from '../../alerts/FormAlerts' import { TextField } from '../../TextField' import { RadioField } from '../../RadioField' import { wrapInFormik } from '../../utils/wrapInFormik' @@ -18,7 +18,7 @@ import { wrapInFormik } from '../../utils/wrapInFormik' jest.mock('../../../utils') jest.mock('../../TextField') jest.mock('../../RadioField') -jest.mock('../../FormAlerts') +jest.mock('../../alerts/FormAlerts') const FormAlertsMock = FormAlerts as jest.MockedFunction diff --git a/labware-library/src/labware-creator/components/__tests__/sections/GridOffset.test.tsx b/labware-library/src/labware-creator/components/__tests__/sections/GridOffset.test.tsx index 06aea8ce0a5..aef8fccd58c 100644 --- a/labware-library/src/labware-creator/components/__tests__/sections/GridOffset.test.tsx +++ b/labware-library/src/labware-creator/components/__tests__/sections/GridOffset.test.tsx @@ -7,12 +7,12 @@ import { nestedTextMatcher } from '../../__testUtils__/nestedTextMatcher' import { getDefaultFormState, LabwareFields } from '../../../fields' import { isEveryFieldHidden } from '../../../utils' import { GridOffset } from '../../sections/GridOffset' -import { FormAlerts } from '../../FormAlerts' +import { FormAlerts } from '../../alerts/FormAlerts' import { TextField } from '../../TextField' import { wrapInFormik } from '../../utils/wrapInFormik' jest.mock('../../../utils') jest.mock('../../TextField') -jest.mock('../../FormAlerts') +jest.mock('../../alerts/FormAlerts') const FormAlertsMock = FormAlerts as jest.MockedFunction diff --git a/labware-library/src/labware-creator/components/__tests__/sections/HandPlacedTipFit.test.tsx b/labware-library/src/labware-creator/components/__tests__/sections/HandPlacedTipFit.test.tsx index 427c2d78642..c6c9202488b 100644 --- a/labware-library/src/labware-creator/components/__tests__/sections/HandPlacedTipFit.test.tsx +++ b/labware-library/src/labware-creator/components/__tests__/sections/HandPlacedTipFit.test.tsx @@ -8,14 +8,14 @@ import { snugLooseOptions, } from '../../../fields' import { HandPlacedTipFit } from '../../sections/HandPlacedTipFit' -import { FormAlerts } from '../../FormAlerts' +import { FormAlerts } from '../../alerts/FormAlerts' +import { TipFitAlerts } from '../../alerts/TipFitAlerts' import { Dropdown } from '../../Dropdown' import { wrapInFormik } from '../../utils/wrapInFormik' -import { TipFitAlerts } from '../../TipFitAlerts' jest.mock('../../Dropdown') -jest.mock('../../FormAlerts') -jest.mock('../../TipFitAlerts') +jest.mock('../../alerts/FormAlerts') +jest.mock('../../alerts/TipFitAlerts') const FormAlertsMock = FormAlerts as jest.MockedFunction const dropdownMock = Dropdown as jest.MockedFunction diff --git a/labware-library/src/labware-creator/components/__tests__/sections/Height.test.tsx b/labware-library/src/labware-creator/components/__tests__/sections/Height.test.tsx index 928c345e6a3..e72c3d37d67 100644 --- a/labware-library/src/labware-creator/components/__tests__/sections/Height.test.tsx +++ b/labware-library/src/labware-creator/components/__tests__/sections/Height.test.tsx @@ -6,21 +6,21 @@ import { render, screen } from '@testing-library/react' import { getDefaultFormState, LabwareFields } from '../../../fields' import { isEveryFieldHidden } from '../../../utils' import { Height } from '../../sections/Height' -import { FormAlerts } from '../../FormAlerts' +import { FormAlerts } from '../../alerts/FormAlerts' +import { HeightAlerts } from '../../alerts/HeightAlerts' import { TextField } from '../../TextField' import { wrapInFormik } from '../../utils/wrapInFormik' -import { getHeightAlerts } from '../../utils/getHeightAlerts' jest.mock('../../../utils') jest.mock('../../TextField') -jest.mock('../../FormAlerts') -jest.mock('../../utils/getHeightAlerts') +jest.mock('../../alerts/FormAlerts') +jest.mock('../../alerts/HeightAlerts') const FormAlertsMock = FormAlerts as jest.MockedFunction const textFieldMock = TextField as jest.MockedFunction -const getHeightAlertsMock = getHeightAlerts as jest.MockedFunction< - typeof getHeightAlerts +const HeightAlertsMock = HeightAlerts as jest.MockedFunction< + typeof HeightAlerts > const isEveryFieldHiddenMock = isEveryFieldHidden as jest.MockedFunction< @@ -52,9 +52,18 @@ describe('Height Section with Alerts', () => { } }) - when(getHeightAlertsMock) - .calledWith(getDefaultFormState(), {}) - .mockReturnValue(
mock getHeightAlertsMock alerts
) + HeightAlertsMock.mockImplementation(args => { + if ( + isEqual(args, { + values: formikConfig.initialValues, + touched: {}, + }) + ) { + return
mock heightAlertsMock alerts
+ } else { + return
+ } + }) when(isEveryFieldHiddenMock) .calledWith( @@ -78,7 +87,7 @@ describe('Height Section with Alerts', () => { ) expect(screen.getByText('mock alerts')) expect(screen.getByText('labwareZDimension text field')) - expect(screen.getByText('mock getHeightAlertsMock alerts')) + expect(screen.getByText('mock heightAlertsMock alerts')) }) it('should update title and instructions when tubeRack is selected', () => { diff --git a/labware-library/src/labware-creator/components/__tests__/sections/Regularity.test.tsx b/labware-library/src/labware-creator/components/__tests__/sections/Regularity.test.tsx index 8ba5a471b52..9e7e958a944 100644 --- a/labware-library/src/labware-creator/components/__tests__/sections/Regularity.test.tsx +++ b/labware-library/src/labware-creator/components/__tests__/sections/Regularity.test.tsx @@ -10,12 +10,12 @@ import { } from '../../../fields' import { isEveryFieldHidden } from '../../../utils' import { Regularity } from '../../sections/Regularity' -import { FormAlerts } from '../../FormAlerts' +import { FormAlerts } from '../../alerts/FormAlerts' import { RadioField } from '../../RadioField' import { wrapInFormik } from '../../utils/wrapInFormik' jest.mock('../../RadioField') -jest.mock('../../FormAlerts') +jest.mock('../../alerts/FormAlerts') jest.mock('../../../utils') const RadioFieldMock = RadioField as jest.MockedFunction diff --git a/labware-library/src/labware-creator/components/__tests__/sections/Volume.test.tsx b/labware-library/src/labware-creator/components/__tests__/sections/Volume.test.tsx index 3e1e2141fa5..5e37240a440 100644 --- a/labware-library/src/labware-creator/components/__tests__/sections/Volume.test.tsx +++ b/labware-library/src/labware-creator/components/__tests__/sections/Volume.test.tsx @@ -6,13 +6,13 @@ import { render, screen } from '@testing-library/react' import { getDefaultFormState, LabwareFields } from '../../../fields' import { isEveryFieldHidden } from '../../../utils' import { Volume } from '../../sections/Volume' -import { FormAlerts } from '../../FormAlerts' +import { FormAlerts } from '../../alerts/FormAlerts' import { TextField } from '../../TextField' import { wrapInFormik } from '../../utils/wrapInFormik' jest.mock('../../../utils') jest.mock('../../TextField') -jest.mock('../../FormAlerts') +jest.mock('../../alerts/FormAlerts') const FormAlertsMock = FormAlerts as jest.MockedFunction const textFieldMock = TextField as jest.MockedFunction diff --git a/labware-library/src/labware-creator/components/__tests__/sections/WellBottomAndDepth.test.tsx b/labware-library/src/labware-creator/components/__tests__/sections/WellBottomAndDepth.test.tsx index 2618ab21891..ea3a77f98a3 100644 --- a/labware-library/src/labware-creator/components/__tests__/sections/WellBottomAndDepth.test.tsx +++ b/labware-library/src/labware-creator/components/__tests__/sections/WellBottomAndDepth.test.tsx @@ -7,7 +7,7 @@ import { getDefaultFormState, LabwareFields } from '../../../fields' import { wellBottomShapeOptionsWithIcons } from '../../optionsWithImages' import { displayAsTube } from '../../../utils' import { WellBottomAndDepth } from '../../sections/WellBottomAndDepth' -import { FormAlerts } from '../../FormAlerts' +import { FormAlerts } from '../../alerts/FormAlerts' import { TextField } from '../../TextField' import { RadioField } from '../../RadioField' import { wrapInFormik } from '../../utils/wrapInFormik' @@ -15,7 +15,7 @@ import { wrapInFormik } from '../../utils/wrapInFormik' jest.mock('../../../utils/displayAsTube') jest.mock('../../TextField') jest.mock('../../RadioField') -jest.mock('../../FormAlerts') +jest.mock('../../alerts/FormAlerts') const FormAlertsMock = FormAlerts as jest.MockedFunction const textFieldMock = TextField as jest.MockedFunction diff --git a/labware-library/src/labware-creator/components/__tests__/sections/WellShapeAndSides.test.tsx b/labware-library/src/labware-creator/components/__tests__/sections/WellShapeAndSides.test.tsx index 32f16d1b72f..2e290d60440 100644 --- a/labware-library/src/labware-creator/components/__tests__/sections/WellShapeAndSides.test.tsx +++ b/labware-library/src/labware-creator/components/__tests__/sections/WellShapeAndSides.test.tsx @@ -7,7 +7,7 @@ import { getDefaultFormState, LabwareFields } from '../../../fields' import { wellShapeOptionsWithIcons } from '../../optionsWithImages' import { displayAsTube } from '../../../utils' import { WellShapeAndSides } from '../../sections/WellShapeAndSides' -import { FormAlerts } from '../../FormAlerts' +import { FormAlerts } from '../../alerts/FormAlerts' import { TextField } from '../../TextField' import { RadioField } from '../../RadioField' import { wrapInFormik } from '../../utils/wrapInFormik' @@ -15,7 +15,7 @@ import { wrapInFormik } from '../../utils/wrapInFormik' jest.mock('../../../utils/displayAsTube') jest.mock('../../TextField') jest.mock('../../RadioField') -jest.mock('../../FormAlerts') +jest.mock('../../alerts/FormAlerts') const FormAlertsMock = FormAlerts as jest.MockedFunction const textFieldMock = TextField as jest.MockedFunction diff --git a/labware-library/src/labware-creator/components/__tests__/sections/WellSpacing.test.tsx b/labware-library/src/labware-creator/components/__tests__/sections/WellSpacing.test.tsx index 1d1258a775f..07dbbcb6b2e 100644 --- a/labware-library/src/labware-creator/components/__tests__/sections/WellSpacing.test.tsx +++ b/labware-library/src/labware-creator/components/__tests__/sections/WellSpacing.test.tsx @@ -6,14 +6,13 @@ import { render, screen } from '@testing-library/react' import { getDefaultFormState, LabwareFields } from '../../../fields' import { isEveryFieldHidden } from '../../../utils' import { WellSpacing } from '../../sections/WellSpacing' -import { FormAlerts } from '../../FormAlerts' +import { FormAlerts } from '../../alerts/FormAlerts' import { TextField } from '../../TextField' - import { wrapInFormik } from '../../utils/wrapInFormik' jest.mock('../../../utils') jest.mock('../../TextField') -jest.mock('../../FormAlerts') +jest.mock('../../alerts/FormAlerts') const FormAlertsMock = FormAlerts as jest.MockedFunction diff --git a/labware-library/src/labware-creator/components/FormAlerts.tsx b/labware-library/src/labware-creator/components/alerts/FormAlerts.tsx similarity index 96% rename from labware-library/src/labware-creator/components/FormAlerts.tsx rename to labware-library/src/labware-creator/components/alerts/FormAlerts.tsx index 96eea7c796a..8e31dca53dc 100644 --- a/labware-library/src/labware-creator/components/FormAlerts.tsx +++ b/labware-library/src/labware-creator/components/alerts/FormAlerts.tsx @@ -7,8 +7,8 @@ import { IRREGULAR_LABWARE_ERROR, LOOSE_TIP_FIT_ERROR, LINK_CUSTOM_LABWARE_FORM, -} from '../fields' -import { LinkOut } from './LinkOut' +} from '../../fields' +import { LinkOut } from '../LinkOut' import type { FormikTouched, FormikErrors } from 'formik' export interface Props { diff --git a/labware-library/src/labware-creator/components/utils/getHeightAlerts.tsx b/labware-library/src/labware-creator/components/alerts/HeightAlerts.tsx similarity index 63% rename from labware-library/src/labware-creator/components/utils/getHeightAlerts.tsx rename to labware-library/src/labware-creator/components/alerts/HeightAlerts.tsx index f392bc8dd41..21b76362f9f 100644 --- a/labware-library/src/labware-creator/components/utils/getHeightAlerts.tsx +++ b/labware-library/src/labware-creator/components/alerts/HeightAlerts.tsx @@ -3,12 +3,14 @@ import { FormikTouched } from 'formik' import { LabwareFields, MAX_SUGGESTED_Z } from '../../fields' import { AlertItem } from '@opentrons/components' -export const getHeightAlerts = ( - values: LabwareFields, +export interface Props { + values: LabwareFields touched: FormikTouched -): JSX.Element | null => { - const { labwareZDimension } = values - const zAsNum = Number(labwareZDimension) // NOTE: if empty string or null, may be cast to 0, but that's fine for `>` +} + +export const HeightAlerts = (props: Props): JSX.Element | null => { + const { values, touched } = props + const zAsNum = Number(values.labwareZDimension) // NOTE: if empty string or null, may be cast to 0, but that's fine for `>` if (touched.labwareZDimension && zAsNum > MAX_SUGGESTED_Z) { return ( } -// TODO: (ka 2021-5-25): Move this along with other form/section alerts to alerts/ as components export const TipFitAlerts = (props: Props): JSX.Element | null => { const { values, touched } = props if (touched.handPlacedTipFit && values.handPlacedTipFit === 'snug') { diff --git a/labware-library/src/labware-creator/components/utils/getXYDimensionAlerts.tsx b/labware-library/src/labware-creator/components/alerts/XYDimensionAlerts.tsx similarity index 87% rename from labware-library/src/labware-creator/components/utils/getXYDimensionAlerts.tsx rename to labware-library/src/labware-creator/components/alerts/XYDimensionAlerts.tsx index c0c6421c31f..4f8149d4dd5 100644 --- a/labware-library/src/labware-creator/components/utils/getXYDimensionAlerts.tsx +++ b/labware-library/src/labware-creator/components/alerts/XYDimensionAlerts.tsx @@ -19,10 +19,13 @@ const xyMessage = ( ) -export const getXYDimensionAlerts = ( - values: LabwareFields, +export interface Props { + values: LabwareFields touched: FormikTouched -): JSX.Element | null => { +} + +export const XYDimensionAlerts = (props: Props): JSX.Element | null => { + const { values, touched } = props const xAsNum = Number(values.footprintXDimension) const yAsNum = Number(values.footprintYDimension) const showXInfo = diff --git a/labware-library/src/labware-creator/components/sections/CreateNewDefinition.tsx b/labware-library/src/labware-creator/components/sections/CreateNewDefinition.tsx index 6f094d8d5f6..75299f3275c 100644 --- a/labware-library/src/labware-creator/components/sections/CreateNewDefinition.tsx +++ b/labware-library/src/labware-creator/components/sections/CreateNewDefinition.tsx @@ -5,7 +5,7 @@ import { PrimaryBtn } from '@opentrons/components' import { Dropdown } from '../../components/Dropdown' import { isEveryFieldHidden, makeAutofillOnChange } from '../../utils' import { labwareTypeOptions, labwareTypeAutofills } from '../../fields' -import { FormAlerts } from '../FormAlerts' +import { FormAlerts } from '../alerts/FormAlerts' import { SectionBody } from './SectionBody' import styles from '../../styles.css' diff --git a/labware-library/src/labware-creator/components/sections/Footprint.tsx b/labware-library/src/labware-creator/components/sections/Footprint.tsx index e8607648ddd..c1bf3e9a990 100644 --- a/labware-library/src/labware-creator/components/sections/Footprint.tsx +++ b/labware-library/src/labware-creator/components/sections/Footprint.tsx @@ -3,8 +3,8 @@ import { useFormikContext } from 'formik' import { makeMaskToDecimal } from '../../fieldMasks' import { LabwareFields } from '../../fields' import { isEveryFieldHidden } from '../../utils' -import { FormAlerts } from '../FormAlerts' -import { getXYDimensionAlerts } from '../utils/getXYDimensionAlerts' +import { FormAlerts } from '../alerts/FormAlerts' +import { XYDimensionAlerts } from '../alerts/XYDimensionAlerts' import { TextField } from '../TextField' import { SectionBody } from './SectionBody' @@ -58,7 +58,7 @@ export const Footprint = (): JSX.Element | null => { <> - {getXYDimensionAlerts(values, touched)} + diff --git a/labware-library/src/labware-creator/components/sections/Grid.tsx b/labware-library/src/labware-creator/components/sections/Grid.tsx index c3db74bfc10..35102f2367f 100644 --- a/labware-library/src/labware-creator/components/sections/Grid.tsx +++ b/labware-library/src/labware-creator/components/sections/Grid.tsx @@ -3,7 +3,7 @@ import { useFormikContext } from 'formik' import { maskToInteger } from '../../fieldMasks' import { isEveryFieldHidden } from '../../utils' import { LabwareFields, yesNoOptions } from '../../fields' -import { FormAlerts } from '../FormAlerts' +import { FormAlerts } from '../alerts/FormAlerts' import { TextField } from '../TextField' import { RadioField } from '../RadioField' import { GridImg } from '../diagrams' diff --git a/labware-library/src/labware-creator/components/sections/GridOffset.tsx b/labware-library/src/labware-creator/components/sections/GridOffset.tsx index 4ab431920e7..4b7ca219749 100644 --- a/labware-library/src/labware-creator/components/sections/GridOffset.tsx +++ b/labware-library/src/labware-creator/components/sections/GridOffset.tsx @@ -3,7 +3,7 @@ import { useFormikContext } from 'formik' import { makeMaskToDecimal } from '../../fieldMasks' import { isEveryFieldHidden } from '../../utils' import { LabwareFields } from '../../fields' -import { FormAlerts } from '../FormAlerts' +import { FormAlerts } from '../alerts/FormAlerts' import { TextField } from '../TextField' import { XYOffsetImg } from '../diagrams' import { SectionBody } from './SectionBody' diff --git a/labware-library/src/labware-creator/components/sections/HandPlacedTipFit.tsx b/labware-library/src/labware-creator/components/sections/HandPlacedTipFit.tsx index 58ccbc950b6..d311f108159 100644 --- a/labware-library/src/labware-creator/components/sections/HandPlacedTipFit.tsx +++ b/labware-library/src/labware-creator/components/sections/HandPlacedTipFit.tsx @@ -1,8 +1,8 @@ import * as React from 'react' import { useFormikContext } from 'formik' import { snugLooseOptions } from '../../fields' -import { FormAlerts } from '../FormAlerts' -import { TipFitAlerts } from '../TipFitAlerts' +import { FormAlerts } from '../alerts/FormAlerts' +import { TipFitAlerts } from '../alerts/TipFitAlerts' import { Dropdown } from '../Dropdown' import { SectionBody } from './SectionBody' diff --git a/labware-library/src/labware-creator/components/sections/Height.tsx b/labware-library/src/labware-creator/components/sections/Height.tsx index ea5b747bbc1..a5b0447869d 100644 --- a/labware-library/src/labware-creator/components/sections/Height.tsx +++ b/labware-library/src/labware-creator/components/sections/Height.tsx @@ -3,8 +3,8 @@ import { useFormikContext } from 'formik' import { isEveryFieldHidden } from '../../utils' import { makeMaskToDecimal } from '../../fieldMasks' import { LabwareFields } from '../../fields' -import { getHeightAlerts } from '../utils/getHeightAlerts' -import { FormAlerts } from '../FormAlerts' +import { HeightAlerts } from '../alerts/HeightAlerts' +import { FormAlerts } from '../alerts/FormAlerts' import { TextField } from '../TextField' import { HeightImg } from '../diagrams' import { HeightGuidingText } from '../HeightGuidingText' @@ -65,7 +65,7 @@ export const Height = (): JSX.Element | null => { > <> - {getHeightAlerts(values, touched)} + diff --git a/labware-library/src/labware-creator/components/sections/Regularity.tsx b/labware-library/src/labware-creator/components/sections/Regularity.tsx index aa7b1a6a102..eb96e199163 100644 --- a/labware-library/src/labware-creator/components/sections/Regularity.tsx +++ b/labware-library/src/labware-creator/components/sections/Regularity.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { useFormikContext } from 'formik' import { isEveryFieldHidden } from '../../utils' import { yesNoOptions } from '../../fields' -import { FormAlerts } from '../FormAlerts' +import { FormAlerts } from '../alerts/FormAlerts' import { RadioField } from '../RadioField' import { SectionBody } from './SectionBody' diff --git a/labware-library/src/labware-creator/components/sections/Volume.tsx b/labware-library/src/labware-creator/components/sections/Volume.tsx index f84ff9a5c7c..1c25373e187 100644 --- a/labware-library/src/labware-creator/components/sections/Volume.tsx +++ b/labware-library/src/labware-creator/components/sections/Volume.tsx @@ -3,7 +3,7 @@ import { useFormikContext } from 'formik' import { isEveryFieldHidden } from '../../utils' import { makeMaskToDecimal } from '../../fieldMasks' import { LabwareFields } from '../../fields' -import { FormAlerts } from '../FormAlerts' +import { FormAlerts } from '../alerts/FormAlerts' import { TextField } from '../TextField' import { SectionBody } from './SectionBody' diff --git a/labware-library/src/labware-creator/components/sections/WellBottomAndDepth.tsx b/labware-library/src/labware-creator/components/sections/WellBottomAndDepth.tsx index dac061fb2fc..82ad6657154 100644 --- a/labware-library/src/labware-creator/components/sections/WellBottomAndDepth.tsx +++ b/labware-library/src/labware-creator/components/sections/WellBottomAndDepth.tsx @@ -3,7 +3,7 @@ import { useFormikContext } from 'formik' import { makeMaskToDecimal } from '../../fieldMasks' import { displayAsTube } from '../../utils' import { LabwareFields } from '../../fields' -import { FormAlerts } from '../FormAlerts' +import { FormAlerts } from '../alerts/FormAlerts' import { TextField } from '../TextField' import { RadioField } from '../RadioField' import { DepthImg } from '../diagrams' diff --git a/labware-library/src/labware-creator/components/sections/WellShapeAndSides.tsx b/labware-library/src/labware-creator/components/sections/WellShapeAndSides.tsx index 025ba9d10d4..7d57a4356ef 100644 --- a/labware-library/src/labware-creator/components/sections/WellShapeAndSides.tsx +++ b/labware-library/src/labware-creator/components/sections/WellShapeAndSides.tsx @@ -3,7 +3,7 @@ import { useFormikContext } from 'formik' import { makeMaskToDecimal } from '../../fieldMasks' import { displayAsTube } from '../../utils' import { LabwareFields } from '../../fields' -import { FormAlerts } from '../FormAlerts' +import { FormAlerts } from '../alerts/FormAlerts' import { wellShapeOptionsWithIcons } from '../optionsWithImages' import { TextField } from '../TextField' import { RadioField } from '../RadioField' diff --git a/labware-library/src/labware-creator/components/sections/WellSpacing.tsx b/labware-library/src/labware-creator/components/sections/WellSpacing.tsx index e480c4ae2cf..710c552b194 100644 --- a/labware-library/src/labware-creator/components/sections/WellSpacing.tsx +++ b/labware-library/src/labware-creator/components/sections/WellSpacing.tsx @@ -3,7 +3,7 @@ import { useFormikContext } from 'formik' import { makeMaskToDecimal } from '../../fieldMasks' import { isEveryFieldHidden } from '../../utils' import { LabwareFields } from '../../fields' -import { FormAlerts } from '../FormAlerts' +import { FormAlerts } from '../alerts/FormAlerts' import { TextField } from '../TextField' import { XYSpacingImg } from '../diagrams' import { SectionBody } from './SectionBody' diff --git a/labware-library/src/labware-creator/styles.css b/labware-library/src/labware-creator/styles.css index b1c1b1b37e9..3e2a8ff925d 100644 --- a/labware-library/src/labware-creator/styles.css +++ b/labware-library/src/labware-creator/styles.css @@ -177,7 +177,8 @@ .brand_id_column, .help_text, .export_form_fields, - .volume_instructions_column .tip_fit_column { + .volume_instructions_column, + .tip_fit_column { flex-basis: var(--size-50p); flex-shrink: 1; } From 973369730beb3c6469396c72ea57e63b7cb03ce7 Mon Sep 17 00:00:00 2001 From: Ian London Date: Thu, 27 May 2021 13:05:33 -0400 Subject: [PATCH 8/8] feat(labware-creator): new errors for wells outside of footprint (#7784) Closes #7165 --- .../labware-creator/reservoir.spec.js | 36 +- .../labware-creator/wellPlate.spec.js | 25 +- labware-library/package.json | 4 +- .../__tests__/formLevelValidation.test.ts | 122 +++++++ .../components/ConditionalLabwareRender.tsx | 3 + .../components/FormLevelErrorAlerts.tsx | 22 ++ labware-library/src/labware-creator/fields.ts | 2 +- .../labware-creator/formLevelValidation.ts | 248 +++++++++++++ labware-library/src/labware-creator/index.tsx | 32 +- .../src/labware-creator/labwareFormSchema.ts | 330 +++++++++--------- yarn.lock | 59 +++- 11 files changed, 657 insertions(+), 226 deletions(-) create mode 100644 labware-library/src/labware-creator/__tests__/formLevelValidation.test.ts create mode 100644 labware-library/src/labware-creator/components/FormLevelErrorAlerts.tsx create mode 100644 labware-library/src/labware-creator/formLevelValidation.ts diff --git a/labware-library/cypress/integration/labware-creator/reservoir.spec.js b/labware-library/cypress/integration/labware-creator/reservoir.spec.js index 7fa5323b47b..c499f80b38f 100644 --- a/labware-library/cypress/integration/labware-creator/reservoir.spec.js +++ b/labware-library/cypress/integration/labware-creator/reservoir.spec.js @@ -83,20 +83,14 @@ context('Reservoirs', () => { it('tests number of rows', () => { cy.get("input[name='gridRows']").focus().blur() cy.contains('Number of rows must be a number').should('exist') - cy.get("input[name='gridRows']").type('10').blur() + cy.get("input[name='gridRows']").type('1').blur() cy.contains('Number of rows must be a number').should('not.exist') }) - it('tests are all of your rows evenly spaced', () => { - cy.get("input[name='regularRowSpacing'][value='false']").check({ - force: true, - }) - cy.contains( - 'Your labware is not compatible with the Labware Creator' - ).should('exist') - cy.get("input[name='regularRowSpacing'][value='true']").check({ - force: true, - }) + it('should not ask if all of your rows evenly spaced, since we only have one row', () => { + cy.get("input[name='regularRowSpacing'][value='false']").should( + 'not.exist' + ) }) it('tests number of columns', () => { @@ -125,7 +119,7 @@ context('Reservoirs', () => { it('tests volume', () => { cy.get("input[name='wellVolume']").focus().blur() cy.contains('Max volume per well must be a number').should('exist') - cy.get("input[name='wellVolume']").type('10').blur() + cy.get("input[name='wellVolume']").type('250').blur() cy.contains('Max volume per well must be a number').should('not.exist') }) @@ -152,11 +146,11 @@ context('Reservoirs', () => { cy.get("input[name='wellYDimension']").should('exist') cy.get("input[name='wellXDimension']").focus().blur() cy.contains('Well X must be a number').should('exist') - cy.get("input[name='wellXDimension']").type('10').blur() + cy.get("input[name='wellXDimension']").type('8').blur() cy.contains('Well X must be a number').should('not.exist') cy.get("input[name='wellYDimension']").focus().blur() cy.contains('Well Y must be a number').should('exist') - cy.get("input[name='wellYDimension']").type('10').blur() + cy.get("input[name='wellYDimension']").type('60').blur() cy.contains('Well Y must be a number').should('not.exist') }) @@ -181,19 +175,15 @@ context('Reservoirs', () => { cy.get("img[src*='_v.']").should('exist') cy.get("input[name='wellDepth']").focus().blur() cy.contains('Depth must be a number').should('exist') - cy.get("input[name='wellDepth']").type('10').blur() + cy.get("input[name='wellDepth']").type('70').blur() cy.contains('Depth must be a number').should('not.exist') }) it('tests well spacing', () => { cy.get("input[name='gridSpacingX']").focus().blur() cy.contains('X Spacing (Xs) must be a number').should('exist') - cy.get("input[name='gridSpacingX']").type('10').blur() + cy.get("input[name='gridSpacingX']").type('12').blur() cy.contains('X Spacing (Xs) must be a number').should('not.exist') - cy.get("input[name='gridSpacingY']").focus().blur() - cy.contains('Y Spacing (Ys) must be a number').should('exist') - cy.get("input[name='gridSpacingY']").type('10').blur() - cy.contains('Y Spacing (Ys) must be a number').should('not.exist') }) it('tests grid offset', () => { @@ -203,7 +193,7 @@ context('Reservoirs', () => { cy.contains('X Offset (Xo) must be a number').should('not.exist') cy.get("input[name='gridOffsetY']").focus().blur() cy.contains('Y Offset (Yo) must be a number').should('exist') - cy.get("input[name='gridOffsetY']").type('10').blur() + cy.get("input[name='gridOffsetY']").type('45').blur() cy.contains('Y Offset (Yo) must be a number').should('not.exist') }) @@ -228,10 +218,10 @@ context('Reservoirs', () => { cy.get("input[name='brandId']").type('001') // File info - cy.get("input[placeholder='TestPro 100 Reservoir 10 µL']").should( + cy.get("input[placeholder='TestPro 10 Reservoir 250 µL']").should( 'exist' ) - cy.get("input[placeholder='testpro_100_reservoir_10ul']").should( + cy.get("input[placeholder='testpro_10_reservoir_250ul']").should( 'exist' ) diff --git a/labware-library/cypress/integration/labware-creator/wellPlate.spec.js b/labware-library/cypress/integration/labware-creator/wellPlate.spec.js index b793c73f109..926c0393dab 100644 --- a/labware-library/cypress/integration/labware-creator/wellPlate.spec.js +++ b/labware-library/cypress/integration/labware-creator/wellPlate.spec.js @@ -89,7 +89,7 @@ context('Well Plates', () => { it('tests number of rows', () => { cy.get("input[name='gridRows']").focus().blur() cy.contains('Number of rows must be a number').should('exist') - cy.get("input[name='gridRows']").type('10').blur() + cy.get("input[name='gridRows']").type('8').blur() cy.contains('Number of rows must be a number').should('not.exist') }) @@ -131,7 +131,7 @@ context('Well Plates', () => { it('tests volume', () => { cy.get("input[name='wellVolume']").focus().blur() cy.contains('Max volume per well must be a number').should('exist') - cy.get("input[name='wellVolume']").type('10').blur() + cy.get("input[name='wellVolume']").type('100').blur() cy.contains('Max volume per well must be a number').should('not.exist') }) @@ -158,11 +158,11 @@ context('Well Plates', () => { cy.get("input[name='wellYDimension']").should('exist') cy.get("input[name='wellXDimension']").focus().blur() cy.contains('Well X must be a number').should('exist') - cy.get("input[name='wellXDimension']").type('10').blur() + cy.get("input[name='wellXDimension']").type('8').blur() cy.contains('Well X must be a number').should('not.exist') cy.get("input[name='wellYDimension']").focus().blur() cy.contains('Well Y must be a number').should('exist') - cy.get("input[name='wellYDimension']").type('10').blur() + cy.get("input[name='wellYDimension']").type('8').blur() cy.contains('Well Y must be a number').should('not.exist') }) @@ -194,7 +194,7 @@ context('Well Plates', () => { it('tests well spacing', () => { cy.get("input[name='gridSpacingX']").focus().blur() cy.contains('X Spacing (Xs) must be a number').should('exist') - cy.get("input[name='gridSpacingX']").type('10').blur() + cy.get("input[name='gridSpacingX']").type('12').blur() cy.contains('X Spacing (Xs) must be a number').should('not.exist') cy.get("input[name='gridSpacingY']").focus().blur() cy.contains('Y Spacing (Ys) must be a number').should('exist') @@ -209,17 +209,20 @@ context('Well Plates', () => { cy.contains('X Offset (Xo) must be a number').should('not.exist') cy.get("input[name='gridOffsetY']").focus().blur() cy.contains('Y Offset (Yo) must be a number').should('exist') - cy.get("input[name='gridOffsetY']").type('10').blur() + cy.get("input[name='gridOffsetY']").type('8').blur() cy.contains('Y Offset (Yo) must be a number').should('not.exist') }) - it('does has a preview image', () => { + it('should have a preview image and no footprint errors', () => { cy.contains('Add missing info to see labware preview').should( 'not.exist' ) + cy.contains( + 'Please double-check well size, Y Spacing, and Y Offset.' + ).should('not.exist') }) - it('tests the file export', () => { + it('should export a file', () => { // Try with missing fields cy.get('button[class*="_export_button_"]').click({ force: true }) cy.contains( @@ -234,10 +237,10 @@ context('Well Plates', () => { cy.get("input[name='brandId']").type('001') // File info - cy.get("input[placeholder='TestPro 100 Well Plate 10 µL']").should( + cy.get("input[placeholder='TestPro 80 Well Plate 100 µL']").should( 'exist' ) - cy.get("input[placeholder='testpro_100_wellplate_10ul']").should( + cy.get("input[placeholder='testpro_80_wellplate_100ul']").should( 'exist' ) @@ -259,6 +262,8 @@ context('Well Plates', () => { cy.contains( 'Please resolve all invalid fields in order to export the labware definition' ).should('not.exist') + + // TODO IMMEDIATELY match against fixture ??? Is this not happening? }) }) }) diff --git a/labware-library/package.json b/labware-library/package.json index 7ac682c5497..e1388a6d445 100644 --- a/labware-library/package.json +++ b/labware-library/package.json @@ -25,7 +25,7 @@ "@types/query-string": "6.2.0", "@types/react-router-dom": "^5.1.7", "@types/webpack-env": "^1.16.0", - "@types/yup": "0.28.0" + "@types/yup": "0.29.11" }, "dependencies": { "@hot-loader/react-dom": "16.8.6", @@ -44,6 +44,6 @@ "react-dom": "16.8.6", "react-hot-loader": "4.12.19", "react-router-dom": "5.1.1", - "yup": "0.27.0" + "yup": "0.32.9" } } diff --git a/labware-library/src/labware-creator/__tests__/formLevelValidation.test.ts b/labware-library/src/labware-creator/__tests__/formLevelValidation.test.ts new file mode 100644 index 00000000000..e0d8c2d79ae --- /dev/null +++ b/labware-library/src/labware-creator/__tests__/formLevelValidation.test.ts @@ -0,0 +1,122 @@ +import { + FORM_LEVEL_ERRORS, + formLevelValidation, + getWellGridBoundingBox, + WELLS_OUT_OF_BOUNDS_X, + WELLS_OUT_OF_BOUNDS_Y, +} from '../formLevelValidation' +import { getDefaultFormState } from '../fields' +// NOTE(IL, 2021-05-18): eventual dependency on definitions.tsx which uses require.context +// would break this test (though it's not directly used) +jest.mock('../../definitions') + +describe('getWellGridBoundingBox', () => { + it('should get the bounding box for circular wells: single-well case', () => { + const result = getWellGridBoundingBox({ + gridColumns: 1, + gridRows: 1, + gridOffsetX: 0, + gridOffsetY: 0, + gridSpacingX: 0, + gridSpacingY: 0, + wellDiameter: 3, + }) + + expect(result).toEqual({ + topLeftCornerX: -1.5, + topLeftCornerY: -1.5, + bottomRightCornerX: 1.5, + bottomRightCornerY: 1.5, + }) + }) + + it('should get the bounding box for circular wells: multi-well case', () => { + const result = getWellGridBoundingBox({ + gridColumns: 2, + gridRows: 3, + gridOffsetX: 10, + gridOffsetY: 12, + gridSpacingX: 1, + gridSpacingY: 1, + wellDiameter: 3, + }) + + expect(result).toEqual({ + topLeftCornerX: 10 - 1.5, + topLeftCornerY: 12 - 1.5, + bottomRightCornerX: 12.5, + bottomRightCornerY: 15.5, + }) + }) + + it('should get the bounding box for rectangular wells: single-well case', () => { + const result = getWellGridBoundingBox({ + gridColumns: 1, + gridRows: 1, + gridOffsetX: 0, + gridOffsetY: 0, + gridSpacingX: 0, + gridSpacingY: 0, + wellXDimension: 3, + wellYDimension: 2, + }) + + expect(result).toEqual({ + topLeftCornerX: -1.5, + topLeftCornerY: -1, + bottomRightCornerX: 1.5, + bottomRightCornerY: 1, + }) + }) + + it('should get the bounding box for rectangular wells: multi-well case', () => { + const result = getWellGridBoundingBox({ + gridColumns: 4, + gridRows: 3, + gridOffsetX: 8, + gridOffsetY: 5, + gridSpacingX: 4.5, + gridSpacingY: 3.5, + wellXDimension: 6, + wellYDimension: 10, + }) + + expect(result).toEqual({ + topLeftCornerX: 8 - 6 / 2, + topLeftCornerY: 5 - 10 / 2, + bottomRightCornerX: 8 - 6 / 2 + 4.5 * (4 - 1) + 6, + bottomRightCornerY: 5 - 10 / 2 + 3.5 * (3 - 1) + 10, + }) + }) +}) + +describe('formLevelValidation', () => { + it('should return no errors with the initial values of the form', () => { + const errors = formLevelValidation({ ...getDefaultFormState() }) + expect(errors).toEqual({}) + }) + + it('should return errors when well outside bounding box', () => { + const errors = formLevelValidation({ + ...getDefaultFormState(), + footprintXDimension: '86', + footprintYDimension: '128', + gridColumns: '2', + gridOffsetX: '2', + gridOffsetY: '2', + gridRows: '2', + gridSpacingX: '2', + gridSpacingY: '2', + wellDiameter: '999', // big ol' well + labwareType: 'tipRack', + }) + expect(errors).toEqual({ + [FORM_LEVEL_ERRORS]: { + [WELLS_OUT_OF_BOUNDS_X]: + 'Grid of tips is larger than labware footprint in the X dimension. Please double check well size, X Spacing, and X Offset.', + [WELLS_OUT_OF_BOUNDS_Y]: + 'Grid of tips is larger than labware footprint in the Y dimension. Please double check well size, Y Spacing, and Y Offset.', + }, + }) + }) +}) diff --git a/labware-library/src/labware-creator/components/ConditionalLabwareRender.tsx b/labware-library/src/labware-creator/components/ConditionalLabwareRender.tsx index bb075c80c2b..af59d6240ca 100644 --- a/labware-library/src/labware-creator/components/ConditionalLabwareRender.tsx +++ b/labware-library/src/labware-creator/components/ConditionalLabwareRender.tsx @@ -47,6 +47,9 @@ export const ConditionalLabwareRender = (props: Props): JSX.Element => { let castValues: ProcessedLabwareFields | null = null try { castValues = labwareFormSchema.cast(values) + // TODO IMMEDIATELY: if we stick with this instead of single value casting, sniff this error to make sure it's + // really a Yup validation error (see how Formik does it in `Formik.tsx`). + // See #7824 and see pattern in formLevelValidation fn } catch (error) {} if (castValues === null) { diff --git a/labware-library/src/labware-creator/components/FormLevelErrorAlerts.tsx b/labware-library/src/labware-creator/components/FormLevelErrorAlerts.tsx new file mode 100644 index 00000000000..f274d656ed3 --- /dev/null +++ b/labware-library/src/labware-creator/components/FormLevelErrorAlerts.tsx @@ -0,0 +1,22 @@ +import * as React from 'react' +import { AlertItem } from '@opentrons/components' +import { LabwareCreatorErrors, FORM_LEVEL_ERRORS } from '../formLevelValidation' + +export const FormLevelErrorAlerts = (props: { + errors: LabwareCreatorErrors +}): JSX.Element | null => { + const { errors } = props + const formLevelErrors = errors[FORM_LEVEL_ERRORS] + if (formLevelErrors !== undefined) { + const errorTypesAndErrors = Object.entries(formLevelErrors) + return ( + <> + {errorTypesAndErrors.map(([errorType, errorMessage]) => ( + + ))} + + ) + } else { + return null + } +} diff --git a/labware-library/src/labware-creator/fields.ts b/labware-library/src/labware-creator/fields.ts index ba2b3e16a71..10bdc4dc1b5 100644 --- a/labware-library/src/labware-creator/fields.ts +++ b/labware-library/src/labware-creator/fields.ts @@ -67,7 +67,7 @@ export const wellBottomShapeOptions: Options = [ { name: 'V-Bottom', value: 'v' }, ] -export type BooleanString = 'true' | 'false' // TODO IMMEDIATELY revisit +export type BooleanString = 'true' | 'false' export const yesNoOptions = [ { name: 'Yes', value: 'true' }, diff --git a/labware-library/src/labware-creator/formLevelValidation.ts b/labware-library/src/labware-creator/formLevelValidation.ts new file mode 100644 index 00000000000..34eb1197c8a --- /dev/null +++ b/labware-library/src/labware-creator/formLevelValidation.ts @@ -0,0 +1,248 @@ +import { FormikErrors } from 'formik' +import { labwareFormSchemaBaseObject } from './labwareFormSchema' +import type { LabwareFields } from './fields' + +export const FORM_LEVEL_ERRORS = 'FORM_LEVEL_ERRORS' +export const WELLS_OUT_OF_BOUNDS_X = 'WELLS_OUT_OF_BOUNDS_X' +export const WELLS_OUT_OF_BOUNDS_Y = 'WELLS_OUT_OF_BOUNDS_Y' +type FormErrorType = typeof WELLS_OUT_OF_BOUNDS_X | typeof WELLS_OUT_OF_BOUNDS_Y +export type LabwareCreatorErrors = FormikErrors< + LabwareFields & { + [FORM_LEVEL_ERRORS]: Partial> + } +> + +type BoundingBoxFields = { + gridColumns: number + gridOffsetX: number + gridOffsetY: number + gridRows: number + gridSpacingX: number + gridSpacingY: number +} & ( + | { wellDiameter: number } + | { wellXDimension: number; wellYDimension: number } +) + +interface BoundingBoxResult { + topLeftCornerX: number + topLeftCornerY: number + bottomRightCornerX: number + bottomRightCornerY: number +} + +export const getWellGridBoundingBox = ( + args: BoundingBoxFields +): BoundingBoxResult => { + const { + gridColumns, + gridOffsetX, + gridOffsetY, + gridRows, + gridSpacingX, + gridSpacingY, + } = args + if ('wellDiameter' in args) { + const { wellDiameter } = args + const r = wellDiameter / 2 + const topLeftCornerX = gridOffsetX - r + const topLeftCornerY = gridOffsetY - r + const bottomRightCornerX = + topLeftCornerX + gridSpacingX * (gridColumns - 1) + wellDiameter + const bottomRightCornerY = + topLeftCornerY + gridSpacingY * (gridRows - 1) + wellDiameter + return { + topLeftCornerX, + topLeftCornerY, + bottomRightCornerX, + bottomRightCornerY, + } + } else { + const { wellXDimension, wellYDimension } = args + const topLeftCornerX = gridOffsetX - wellXDimension / 2 + const topLeftCornerY = gridOffsetY - wellYDimension / 2 + + const bottomRightCornerX = + topLeftCornerX + gridSpacingX * (gridColumns - 1) + wellXDimension + const bottomRightCornerY = + topLeftCornerY + gridSpacingY * (gridRows - 1) + wellYDimension + + return { + topLeftCornerX, + topLeftCornerY, + bottomRightCornerX, + bottomRightCornerY, + } + } +} + +const getLabwareName = (values: LabwareFields): string => { + const { labwareType } = values + switch (labwareType) { + case 'tipRack': + return 'tips' + case 'tubeRack': + return 'tubes' + case 'wellPlate': + case 'aluminumBlock': + case 'reservoir': + default: + return 'wells' + } +} + +const partialCast = ( + values: LabwareFields, + keys: TKey[] +): Pick< + ReturnType, + TKey +> | null => { + const partialSchema = labwareFormSchemaBaseObject.pick(keys) + let castFields: ReturnType | null = null + try { + castFields = partialSchema.cast(values) + } catch (error) { + // Yup will throw a validation error if validation fails. We catch those and + // ignore them. We can sniff if something is a Yup error by checking error.name. + // See https://github.com/jquense/yup#validationerrorerrors-string--arraystring-value-any-path-string + // and https://github.com/formium/formik/blob/2d613c11a67b1c1f5189e21b8d61a9dd8a2d0a2e/packages/formik/src/Formik.tsx + if (error.name !== 'ValidationError' && error.name !== 'TypeError') { + // TODO(IL, 2021-05-19): why are missing values for required fields giving TypeError instead of ValidationError? + // Is this partial schema (from `pick`) not handing requireds correctly?? + throw error + } + } + return castFields +} + +export const formLevelValidation = ( + values: LabwareFields +): LabwareCreatorErrors => { + // NOTE(IL, 2021-05-27): Casting will fail when any of the fields in a partial cast are missing values and don't have + // Yup casting defaults via `default()`. This happens very commonly, eg when users haven't gotten down to fill out + // any of these castFields yet in a new labware. When casting fails, formLevelValidation returns no errors (empty obj) + // and does not block save. This seems safe to do for now bc currently all these fields are required, at least in + // combination with each other (eg, wellDiameter is required only if wellShape is 'circular'). So we don't need + // form-level errors to block save in the case that some fields are missing, bc we can rely on the field-level + // errors from Yup validation. BUT - if we ever break this pattern and use purely-optional fields in this + // formLevelValidation fn in the future, we might need to return form-level errors when certain partial casting + // operations fail. We'll see! + + // Return value if there are missing fields and partial form casting fails. + const COULD_NOT_CAST = {} + + // Form-level errors are nested in the FormikErrors object under a special key, FORM_LEVEL_ERRORS. + const formLevelErrors: Partial> = {} + + const castFields = partialCast(values, [ + 'footprintXDimension', + 'footprintYDimension', + 'gridColumns', + 'gridOffsetX', + 'gridOffsetY', + 'gridRows', + 'gridSpacingX', + 'gridSpacingY', + 'wellShape', + ]) + + if ( + castFields == null || + castFields.footprintXDimension == null || + castFields.footprintYDimension == null || + castFields.gridColumns == null || + castFields.gridOffsetX == null || + castFields.gridOffsetY == null || + castFields.gridRows == null || + castFields.gridSpacingX == null || + castFields.gridSpacingY == null || + castFields.wellShape == null + ) { + return COULD_NOT_CAST + } + + const { + footprintXDimension, + footprintYDimension, + gridColumns, + gridOffsetX, + gridOffsetY, + gridRows, + gridSpacingX, + gridSpacingY, + wellShape, + } = castFields + + let boundingBox: BoundingBoxResult | undefined + + if (wellShape === 'circular') { + const castResult = partialCast(values, ['wellDiameter']) + if (castResult?.wellDiameter != null) { + const { wellDiameter } = castResult + boundingBox = getWellGridBoundingBox({ + gridColumns, + gridOffsetX, + gridOffsetY, + gridRows, + gridSpacingX, + gridSpacingY, + wellDiameter, + }) + } + } else if (wellShape === 'rectangular') { + const castResult = partialCast(values, ['wellXDimension', 'wellYDimension']) + if ( + castResult?.wellXDimension != null && + castResult?.wellYDimension != null + ) { + boundingBox = getWellGridBoundingBox({ + gridColumns, + gridOffsetX, + gridOffsetY, + gridRows, + gridSpacingX, + gridSpacingY, + wellXDimension: castResult.wellXDimension, + wellYDimension: castResult.wellYDimension, + }) + } + } + + if (boundingBox === undefined) { + return COULD_NOT_CAST + } + + const { + topLeftCornerX, + topLeftCornerY, + bottomRightCornerX, + bottomRightCornerY, + } = boundingBox + + const wellBoundsInsideFootprintX = + topLeftCornerX > 0 && bottomRightCornerX < footprintXDimension + const wellBoundsInsideFootprintY = + topLeftCornerY > 0 && bottomRightCornerY < footprintYDimension + + const labwareName = getLabwareName(values) + + if (!wellBoundsInsideFootprintX) { + formLevelErrors[WELLS_OUT_OF_BOUNDS_X] = + `Grid of ${labwareName} is larger than labware footprint in the X dimension. ` + + `Please double check well size, X Spacing, and X Offset.` + } + if (!wellBoundsInsideFootprintY) { + formLevelErrors[WELLS_OUT_OF_BOUNDS_Y] = + `Grid of ${labwareName} is larger than labware footprint in the Y dimension. ` + + `Please double check well size, Y Spacing, and Y Offset.` + } + + if (Object.keys(formLevelErrors).length > 0) { + return { + [FORM_LEVEL_ERRORS]: formLevelErrors, + } + } else { + return {} + } +} diff --git a/labware-library/src/labware-creator/index.tsx b/labware-library/src/labware-creator/index.tsx index 017d440d049..8a925decd09 100644 --- a/labware-library/src/labware-creator/index.tsx +++ b/labware-library/src/labware-creator/index.tsx @@ -12,22 +12,27 @@ import { AlertModal, PrimaryButton } from '@opentrons/components' import labwareSchema from '@opentrons/shared-data/labware/schemas/2.json' import { maskLoadName } from './fieldMasks' import { - tubeRackInsertOptions, aluminumBlockAutofills, - aluminumBlockTypeOptions, aluminumBlockChildTypeOptions, + aluminumBlockTypeOptions, getDefaultFormState, tubeRackAutofills, + tubeRackInsertOptions, } from './fields' import { makeAutofillOnChange } from './utils/makeAutofillOnChange' import { labwareDefToFields } from './labwareDefToFields' import { labwareFormSchema } from './labwareFormSchema' +import { + formLevelValidation, + LabwareCreatorErrors, +} from './formLevelValidation' import { getDefaultDisplayName, getDefaultLoadName } from './formSelectors' import { labwareTestProtocol, pipetteNameOptions } from './labwareTestProtocol' import { fieldsToLabware } from './fieldsToLabware' import { LabwareCreator as LabwareCreatorComponent } from './components/LabwareCreator' import { ConditionalLabwareRender } from './components/ConditionalLabwareRender' import { Dropdown } from './components/Dropdown' +import { FormLevelErrorAlerts } from './components/FormLevelErrorAlerts' import { IntroCopy } from './components/IntroCopy' import { LinkOut } from './components/LinkOut' @@ -52,7 +57,6 @@ import { GridOffset } from './components/sections/GridOffset' import styles from './styles.css' -import type { FormikProps } from 'formik' import type { LabwareDefinition2 } from '@opentrons/shared-data' import type { ImportError, @@ -283,6 +287,7 @@ export const LabwareCreator = (): JSX.Element => { initialValues={lastUploaded || getDefaultFormState()} enableReinitialize validationSchema={labwareFormSchema} + validate={formLevelValidation} onSubmit={(values: LabwareFields) => { const castValues: ProcessedLabwareFields = labwareFormSchema.cast( values @@ -319,15 +324,17 @@ export const LabwareCreator = (): JSX.Element => { }) }} > - {({ - handleSubmit, - values, - isValid, - errors, - touched, - setTouched, - setValues, - }: FormikProps) => { + {bag => { + const { + handleSubmit, + values, + isValid, + touched, + setTouched, + setValues, + } = bag + const errors: LabwareCreatorErrors = bag.errors + // @ts-expect-error(IL, 2021-03-24): values/errors/touched not typed for reportErrors to be happy reportErrors({ values, errors, touched }) // TODO (ka 2019-8-27): factor out this as sub-schema from Yup schema and use it to validate instead of repeating the logic @@ -421,6 +428,7 @@ export const LabwareCreator = (): JSX.Element => {
+

diff --git a/labware-library/src/labware-creator/labwareFormSchema.ts b/labware-library/src/labware-creator/labwareFormSchema.ts index b874d9f2a35..5d16d982276 100644 --- a/labware-library/src/labware-creator/labwareFormSchema.ts +++ b/labware-library/src/labware-creator/labwareFormSchema.ts @@ -6,13 +6,13 @@ import { wellBottomShapeOptions, wellShapeOptions, IRREGULAR_LABWARE_ERROR, - LOOSE_TIP_FIT_ERROR, LABELS, + LOOSE_TIP_FIT_ERROR, MAX_X_DIMENSION, - MIN_X_DIMENSION, MAX_Y_DIMENSION, - MIN_Y_DIMENSION, MAX_Z_DIMENSION, + MIN_X_DIMENSION, + MIN_Y_DIMENSION, } from './fields' import type { ProcessedLabwareFields } from './fields' @@ -30,6 +30,7 @@ const requiredPositiveNumber = (label: string): Yup.NumberSchema => const requiredPositiveInteger = (label: string): Yup.NumberSchema => Yup.number() + .default(0) .label(label) .typeError(MUST_BE_A_NUMBER) .moreThan(0) @@ -38,6 +39,7 @@ const requiredPositiveInteger = (label: string): Yup.NumberSchema => const unsupportedLabwareIfFalse = (label: string): Yup.BooleanSchema => Yup.boolean() + .default(false) .label(label) .typeError(REQUIRED_FIELD) .oneOf([true], IRREGULAR_LABWARE_ERROR) @@ -48,170 +50,177 @@ const nameExistsError = (nameName: string): string => // NOTE: all IRREGULAR_LABWARE_ERROR messages will be converted to a special 'error' Alert -// @ts-expect-error(IL, 2021-03-25): something(s) about this schema don't match the flow type (labwareType: string problem??) -export const labwareFormSchema: Yup.Schema = Yup.object() - .shape({ - labwareType: requiredString(LABELS.labwareType).oneOf( - labwareTypeOptions.map(o => o.value) - ), - handPlacedTipFit: Yup.string().when('labwareType', { - is: 'tipRack', - then: requiredString(LABELS.handPlacedTipFit).oneOf( - ['snug'], - LOOSE_TIP_FIT_ERROR - ), - otherwise: Yup.string().nullable(), - }), - tubeRackInsertLoadName: Yup.mixed().when('labwareType', { - is: 'tubeRack', - then: requiredString(LABELS.tubeRackInsertLoadName), - otherwise: Yup.mixed().nullable(), - }), - // TODO(mc, 2020-06-02): should this be Yup.string() instead of mixed? - aluminumBlockType: Yup.mixed().when('labwareType', { - is: 'aluminumBlock', - then: requiredString(LABELS.aluminumBlockType), +export const labwareFormSchemaBaseObject = Yup.object({ + labwareType: requiredString(LABELS.labwareType).oneOf( + labwareTypeOptions.map(o => o.value) + ), + tubeRackInsertLoadName: Yup.mixed().when('labwareType', { + is: 'tubeRack', + then: requiredString(LABELS.tubeRackInsertLoadName), + otherwise: Yup.mixed().nullable(), + }), + // TODO(mc, 2020-06-02): should this be Yup.string() instead of mixed? + aluminumBlockType: Yup.mixed().when('labwareType', { + is: 'aluminumBlock', + then: requiredString(LABELS.aluminumBlockType), + otherwise: Yup.mixed().nullable(), + }), + // TODO(mc, 2020-06-02): should this be Yup.string() instead of mixed? + aluminumBlockChildType: Yup.mixed().when( + ['labwareType', 'aluminumBlockType'], + { + // only required for 96-well aluminum block + is: (labwareType: string, aluminumBlockType: string): boolean => + labwareType === 'aluminumBlock' && aluminumBlockType === '96well', + then: requiredString(LABELS.aluminumBlockChildType), otherwise: Yup.mixed().nullable(), - }), - // TODO(mc, 2020-06-02): should this be Yup.string() instead of mixed? - aluminumBlockChildType: Yup.mixed().when( - ['labwareType', 'aluminumBlockType'], - { - // only required for 96-well aluminum block - is: (labwareType: string, aluminumBlockType: string): boolean => - labwareType === 'aluminumBlock' && aluminumBlockType === '96well', - then: requiredString(LABELS.aluminumBlockChildType), - otherwise: Yup.mixed().nullable(), - } + } + ), + + handPlacedTipFit: Yup.string().when('labwareType', { + is: 'tipRack', + then: requiredString(LABELS.handPlacedTipFit).oneOf( + ['snug'], + LOOSE_TIP_FIT_ERROR ), + otherwise: Yup.string().nullable(), + }), - // tubeRackSides: Array - footprintXDimension: Yup.number() - .label(LABELS.footprintXDimension) - .typeError(MUST_BE_A_NUMBER) - .min(MIN_X_DIMENSION, IRREGULAR_LABWARE_ERROR) - .max(MAX_X_DIMENSION, IRREGULAR_LABWARE_ERROR) - .required(), - footprintYDimension: Yup.number() - .label(LABELS.footprintYDimension) - .typeError(MUST_BE_A_NUMBER) - .min(MIN_Y_DIMENSION, IRREGULAR_LABWARE_ERROR) - .max(MAX_Y_DIMENSION, IRREGULAR_LABWARE_ERROR) - .required(), - labwareZDimension: requiredPositiveNumber(LABELS.labwareZDimension).max( - MAX_Z_DIMENSION, - IRREGULAR_LABWARE_ERROR + // tubeRackSides: Array + footprintXDimension: Yup.number() + .default(0) + .label(LABELS.footprintXDimension) + .typeError(MUST_BE_A_NUMBER) + .min(MIN_X_DIMENSION, IRREGULAR_LABWARE_ERROR) + .max(MAX_X_DIMENSION, IRREGULAR_LABWARE_ERROR) + .nullable() + .required(), + footprintYDimension: Yup.number() + .default(0) + .label(LABELS.footprintYDimension) + .typeError(MUST_BE_A_NUMBER) + .min(MIN_Y_DIMENSION, IRREGULAR_LABWARE_ERROR) + .max(MAX_Y_DIMENSION, IRREGULAR_LABWARE_ERROR) + .nullable() + .required(), + labwareZDimension: requiredPositiveNumber(LABELS.labwareZDimension).max( + MAX_Z_DIMENSION, + IRREGULAR_LABWARE_ERROR + ), + + gridRows: requiredPositiveInteger(LABELS.gridRows), + gridColumns: requiredPositiveInteger(LABELS.gridColumns), + // TODO(mc, 2020-06-02): should this be number() instead of mixed? + gridSpacingX: Yup.mixed().when('gridColumns', { + is: 1, + then: Yup.mixed().default(0), + otherwise: requiredPositiveNumber(LABELS.gridSpacingX), + }), + // TODO(mc, 2020-06-02): should this be number() instead of mixed()? + gridSpacingY: Yup.mixed().when('gridRows', { + is: 1, + then: Yup.mixed().default(0), + otherwise: requiredPositiveNumber(LABELS.gridSpacingY), + }), + gridOffsetX: requiredPositiveNumber(LABELS.gridOffsetX), + gridOffsetY: requiredPositiveNumber(LABELS.gridOffsetY), + homogeneousWells: unsupportedLabwareIfFalse(LABELS.homogeneousWells), + regularRowSpacing: Yup.mixed().when('gridRows', { + is: 1, + then: Yup.mixed().default(true), + otherwise: unsupportedLabwareIfFalse(LABELS.regularRowSpacing), + }), + regularColumnSpacing: Yup.mixed().when('gridColumns', { + is: 1, + then: Yup.mixed().default(true), + otherwise: unsupportedLabwareIfFalse(LABELS.regularColumnSpacing), + }), + + wellVolume: requiredPositiveNumber(LABELS.wellVolume), + wellBottomShape: requiredString(LABELS.wellBottomShape).oneOf( + wellBottomShapeOptions.map(o => o.value) + ), + wellDepth: Yup.number() + .default(0) + .label(LABELS.wellDepth) + .typeError(MUST_BE_A_NUMBER) + .moreThan(0) + .max( + Yup.ref('labwareZDimension'), + 'Well depth cannot exceed labware height' + ) + .required(), + wellShape: requiredString(LABELS.wellShape).oneOf( + wellShapeOptions.map(o => o.value) + ), + + // used with circular well shape only + wellDiameter: Yup.mixed().when('wellShape', { + is: 'circular', + then: requiredPositiveNumber(LABELS.wellDiameter), + otherwise: Yup.mixed().nullable(), + }), + + // used with rectangular well shape only + wellXDimension: Yup.mixed().when('wellShape', { + is: 'rectangular', + then: requiredPositiveNumber(LABELS.wellXDimension), + otherwise: Yup.mixed().nullable(), + }), + wellYDimension: Yup.mixed().when('wellShape', { + is: 'rectangular', + then: requiredPositiveNumber(LABELS.wellYDimension), + otherwise: Yup.mixed().nullable(), + }), + + brand: requiredString(LABELS.brand), + // TODO(mc, 2020-06-02): should this be Yup.array() instead of mixed? + brandId: Yup.mixed() + .nullable() + .transform( + ( + currentValue: string | null | undefined, + originalValue: string | null | undefined + ): ProcessedLabwareFields['brandId'] => + (currentValue || '') + .trim() + .split(',') + .map(s => s.trim()) + .filter(Boolean) ), - gridRows: requiredPositiveInteger(LABELS.gridRows), - gridColumns: requiredPositiveInteger(LABELS.gridColumns), - // TODO(mc, 2020-06-02): should this be number() instead of mixed? - gridSpacingX: Yup.mixed().when('gridColumns', { - is: 1, - then: Yup.mixed().default(0), - otherwise: requiredPositiveNumber(LABELS.gridSpacingX), - }), - // TODO(mc, 2020-06-02): should this be number() instead of mixed()? - gridSpacingY: Yup.mixed().when('gridRows', { - is: 1, - then: Yup.mixed().default(0), - otherwise: requiredPositiveNumber(LABELS.gridSpacingY), - }), - gridOffsetX: requiredPositiveNumber(LABELS.gridOffsetX), - gridOffsetY: requiredPositiveNumber(LABELS.gridOffsetY), - homogeneousWells: unsupportedLabwareIfFalse(LABELS.homogeneousWells), - regularRowSpacing: Yup.mixed().when('gridRows', { - is: 1, - then: Yup.mixed().default(true), - otherwise: unsupportedLabwareIfFalse(LABELS.regularRowSpacing), - }), - regularColumnSpacing: Yup.mixed().when('gridColumns', { - is: 1, - then: Yup.mixed().default(true), - otherwise: unsupportedLabwareIfFalse(LABELS.regularColumnSpacing), - }), - - wellVolume: requiredPositiveNumber(LABELS.wellVolume), - wellBottomShape: requiredString(LABELS.wellBottomShape).oneOf( - wellBottomShapeOptions.map(o => o.value) + loadName: Yup.string() + .nullable() + .label(LABELS.loadName) + .notOneOf(getAllLoadNames(), nameExistsError('load name')) + .matches( + /^[a-z0-9._]+$/, + '${label} can only contain lowercase letters, numbers, dot (.) and underscore (_). Spaces are not allowed.' // eslint-disable-line no-template-curly-in-string ), - wellDepth: Yup.number() - .label(LABELS.wellDepth) - .typeError(MUST_BE_A_NUMBER) - .moreThan(0) - .max( - Yup.ref('labwareZDimension'), - 'Well depth cannot exceed labware height' - ) - .required(), - wellShape: requiredString(LABELS.wellShape).oneOf( - wellShapeOptions.map(o => o.value) + // TODO(mc, 2020-06-02): should this be Yup.string() instead of mixed? + displayName: Yup.mixed() + .nullable() + .label(LABELS.displayName) + .test( + 'displayNameDoesNotAlreadyExist', + nameExistsError('display name'), + (value: string | null | undefined) => + !ALL_DISPLAY_NAMES.has( + (value == null ? '' : value).toLowerCase().trim() + ) // case-insensitive and trim-insensitive match + ) + .transform( + ( + currentValue: string | null | undefined, + originalValue: string | null | undefined + ) => (currentValue == null ? currentValue : currentValue.trim()) ), + pipetteName: requiredString(LABELS.pipetteName), +}) - // used with circular well shape only - wellDiameter: Yup.mixed().when('wellShape', { - is: 'circular', - then: requiredPositiveNumber(LABELS.wellDiameter), - otherwise: Yup.mixed().nullable(), - }), - - // used with rectangular well shape only - wellXDimension: Yup.mixed().when('wellShape', { - is: 'rectangular', - then: requiredPositiveNumber(LABELS.wellXDimension), - otherwise: Yup.mixed().nullable(), - }), - wellYDimension: Yup.mixed().when('wellShape', { - is: 'rectangular', - then: requiredPositiveNumber(LABELS.wellYDimension), - otherwise: Yup.mixed().nullable(), - }), - - brand: requiredString(LABELS.brand), - // TODO(mc, 2020-06-02): should this be Yup.array() instead of mixed? - brandId: Yup.mixed() - .nullable() - .transform( - ( - currentValue: string | null | undefined, - originalValue: string | null | undefined - ): ProcessedLabwareFields['brandId'] => - (currentValue || '') - .trim() - .split(',') - .map(s => s.trim()) - .filter(Boolean) - ), - - loadName: Yup.string() - .nullable() - .label(LABELS.loadName) - .notOneOf(getAllLoadNames(), nameExistsError('load name')) - .matches( - /^[a-z0-9._]+$/, - '${label} can only contain lowercase letters, numbers, dot (.) and underscore (_). Spaces are not allowed.' // eslint-disable-line no-template-curly-in-string - ), - // TODO(mc, 2020-06-02): should this be Yup.string() instead of mixed? - displayName: Yup.mixed() - .nullable() - .label(LABELS.displayName) - .test( - 'displayNameDoesNotAlreadyExist', - nameExistsError('display name'), - (value: string | null | undefined) => - !ALL_DISPLAY_NAMES.has( - (value == null ? '' : value).toLowerCase().trim() - ) // case-insensitive and trim-insensitive match - ) - .transform( - ( - currentValue: string | null | undefined, - originalValue: string | null | undefined - ) => (currentValue == null ? currentValue : currentValue.trim()) - ), - pipetteName: requiredString(LABELS.pipetteName), - }) - .transform((currentValue, originalValue) => { +// @ts-expect-error(IL, 2021-03-25): something(s) about this schema don't match the flow type (labwareType: string problem??) +export const labwareFormSchema: Yup.Schema = labwareFormSchemaBaseObject.transform( + (currentValue, originalValue) => { // "form-level" transforms // NOTE: the currentValue does NOT have transforms applied :( // TODO: these results are not validated, ideally I could do these transforms in the fields @@ -227,4 +236,5 @@ export const labwareFormSchema: Yup.Schema = Yup.object( loadName, displayName, } - }) + } +) diff --git a/yarn.lock b/yarn.lock index c1d9137f2e8..25d314eab45 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1471,6 +1471,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.10.5": + version "7.14.0" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.14.0.tgz#46794bc20b612c5f75e62dd071e24dfd95f1cbe6" + integrity sha512-JELkvo/DlpNdJ7dlyw/eY7E0suy5i5GQH+Vlxaq1nsNJ+H7f4Vtv3jMeCEgRhZZQFXTjldYfQgv2qmM6M1v5wA== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/runtime@^7.12.0", "@babel/runtime@^7.8.4": version "7.12.5" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.12.5.tgz#410e7e487441e1b360c29be715d870d9b985882e" @@ -4632,6 +4639,11 @@ dependencies: "@types/node" "*" +"@types/lodash@^4.14.165": + version "4.14.169" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.169.tgz#83c217688f07a4d9ef8f28a3ebd1d318f6ff4cbb" + integrity sha512-DvmZHoHTFJ8zhVYwCLWbQ7uAbYQEk52Ev2/ZiQ7Y7gQGeV9pjBqjnQpECMHfKS1rCYAhMI7LHVxwyZLZinJgdw== + "@types/lodash@^4.14.168": version "4.14.168" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.168.tgz#fe24632e79b7ade3f132891afff86caa5e5ce008" @@ -5026,10 +5038,10 @@ dependencies: "@types/yargs-parser" "*" -"@types/yup@0.28.0": - version "0.28.0" - resolved "https://registry.yarnpkg.com/@types/yup/-/yup-0.28.0.tgz#bddd4c7bc3cb23109d87b77d4607f983a9595768" - integrity sha512-M11FLKWuwvJo1HjL6oXXl7lXEjpvkW1vK+OA9qFQu1SGDY7Sh36FmvxFd2PoYkrUamKbluWQ+1ZMd+tbgQmwmg== +"@types/yup@0.29.11": + version "0.29.11" + resolved "https://registry.yarnpkg.com/@types/yup/-/yup-0.29.11.tgz#d654a112973f5e004bf8438122bd7e56a8e5cd7e" + integrity sha512-9cwk3c87qQKZrT251EDoibiYRILjCmxBvvcb4meofCmx1vdnNcR9gyildy5vOHASpOKMsn42CugxUvcwK5eu1g== "@typescript-eslint/eslint-plugin@^4.18.0": version "4.18.0" @@ -15366,6 +15378,11 @@ lodash-es@^4.17.14: resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.15.tgz#21bd96839354412f23d7a10340e5eac6ee455d78" integrity sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ== +lodash-es@^4.17.15: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" + integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== + lodash-es@^4.17.4: version "4.17.4" resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.4.tgz#dcc1d7552e150a0640073ba9cb31d70f032950e7" @@ -16554,6 +16571,11 @@ nanobench@^2.1.1: mutexify "^1.1.0" pretty-hrtime "^1.0.2" +nanoclone@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/nanoclone/-/nanoclone-0.2.1.tgz#dd4090f8f1a110d26bb32c49ed2f5b9235209ed4" + integrity sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA== + nanomatch@^1.2.5: version "1.2.7" resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.7.tgz#53cd4aa109ff68b7f869591fdc9d10daeeea3e79" @@ -19269,6 +19291,11 @@ property-expr@^1.5.0: version "1.5.1" resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-1.5.1.tgz#22e8706894a0c8e28d58735804f6ba3a3673314f" +property-expr@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.4.tgz#37b925478e58965031bb612ec5b3260f8241e910" + integrity sha512-sFPkHQjVKheDNnPvotjQmm3KD3uk1fWKUN7CrpdbwmUx3CrG3QiM8QpTSimvig5vTXmTvjz7+TDvXOI9+4rkcg== + property-information@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/property-information/-/property-information-3.2.0.tgz#fd1483c8fbac61808f5fe359e7693a1f48a58331" @@ -22727,11 +22754,6 @@ synchronous-promise@^2.0.5: version "2.0.6" resolved "https://registry.yarnpkg.com/synchronous-promise/-/synchronous-promise-2.0.6.tgz#de76e0ea2b3558c1e673942e47e714a930fa64aa" -synchronous-promise@^2.0.6: - version "2.0.9" - resolved "https://registry.yarnpkg.com/synchronous-promise/-/synchronous-promise-2.0.9.tgz#b83db98e9e7ae826bf9c8261fd8ac859126c780a" - integrity sha512-LO95GIW16x69LuND1nuuwM4pjgFGupg7pZ/4lU86AmchPKrhk0o2tpMU2unXRrqo81iAFe1YJ0nAGEVwsrZAgg== - table@^5.2.3: version "5.2.3" resolved "https://registry.yarnpkg.com/table/-/table-5.2.3.tgz#cde0cc6eb06751c009efab27e8c820ca5b67b7f2" @@ -25209,16 +25231,17 @@ yup@0.26.6: synchronous-promise "^2.0.5" toposort "^2.0.2" -yup@0.27.0: - version "0.27.0" - resolved "https://registry.yarnpkg.com/yup/-/yup-0.27.0.tgz#f8cb198c8e7dd2124beddc2457571329096b06e7" - integrity sha512-v1yFnE4+u9za42gG/b/081E7uNW9mUj3qtkmelLbW5YPROZzSH/KUUyJu9Wt8vxFJcT9otL/eZopS0YK1L5yPQ== +yup@0.32.9: + version "0.32.9" + resolved "https://registry.yarnpkg.com/yup/-/yup-0.32.9.tgz#9367bec6b1b0e39211ecbca598702e106019d872" + integrity sha512-Ci1qN+i2H0XpY7syDQ0k5zKQ/DoxO0LzPg8PAR/X4Mpj6DqaeCoIYEEjDJwhArh3Fa7GWbQQVDZKeXYlSH4JMg== dependencies: - "@babel/runtime" "^7.0.0" - fn-name "~2.0.1" - lodash "^4.17.11" - property-expr "^1.5.0" - synchronous-promise "^2.0.6" + "@babel/runtime" "^7.10.5" + "@types/lodash" "^4.14.165" + lodash "^4.17.20" + lodash-es "^4.17.15" + nanoclone "^0.2.1" + property-expr "^2.0.4" toposort "^2.0.2" zwitch@^1.0.0: