From d3cc8b1d26713048bd8238b3825f0b398783f451 Mon Sep 17 00:00:00 2001 From: Jethary Rader <66035149+jerader@users.noreply.github.com> Date: Tue, 2 Jul 2024 13:16:24 -0400 Subject: [PATCH] feat(app, labware-library, app-shell): Labware creator in app prototype (#15485) This is the _InT3rNaL pRoTOtyP3_ of putting Labware Creator in the app and it is behind the feature flag --- app-shell/src/constants.ts | 4 + app-shell/src/labware/definitions.ts | 58 +- app-shell/src/labware/index.ts | 66 ++- app-shell/src/labware/validation.ts | 3 - app-shell/src/types.ts | 1 + app/package.json | 1 + .../assets/localization/en/app_settings.json | 1 + app/src/pages/Labware/index.tsx | 47 +- app/src/redux/config/constants.ts | 1 + app/src/redux/config/schema-types.ts | 1 + app/src/redux/custom-labware/actions.ts | 9 +- app/src/redux/custom-labware/reducer.ts | 1 + app/src/redux/custom-labware/types.ts | 6 + app/src/redux/types.ts | 2 + app/tsconfig.json | 5 +- app/vite.config.ts | 8 +- .../e2e/labware-creator/customTubeRack.cy.js | 2 +- .../e2e/labware-creator/reservoir.cy.js | 2 +- .../cypress/e2e/labware-creator/tipRack.cy.js | 2 +- .../e2e/labware-creator/tubesBlock.cy.js | 6 +- .../e2e/labware-creator/tubesRack.cy.js | 6 +- .../e2e/labware-creator/wellPlate.cy.js | 2 +- labware-library/src/index.tsx | 2 + .../src/labware-creator/WizardHeader.tsx | 67 +++ .../components/ImportLabware.tsx | 27 +- .../__tests__/sections/Export.test.tsx | 11 +- .../sections/CreateNewDefinition.tsx | 8 +- .../components/sections/Export.tsx | 25 +- .../components/sections/UploadExisting.tsx | 6 +- labware-library/src/labware-creator/index.tsx | 558 +++++++++++++++++- vitest.config.ts | 3 + yarn.lock | 20 + 32 files changed, 880 insertions(+), 81 deletions(-) create mode 100644 labware-library/src/labware-creator/WizardHeader.tsx diff --git a/app-shell/src/constants.ts b/app-shell/src/constants.ts index 3e86c503c839..e0a139a95f31 100644 --- a/app-shell/src/constants.ts +++ b/app-shell/src/constants.ts @@ -20,6 +20,7 @@ import type { ADD_CUSTOM_LABWARE_TYPE, ADD_CUSTOM_LABWARE_FILE_TYPE, ADD_CUSTOM_LABWARE_FAILURE_TYPE, + ADD_CUSTOM_LABWARE_FILE_FROM_CREATOR_TYPE, CLEAR_ADD_CUSTOM_LABWARE_FAILURE_TYPE, ADD_NEW_LABWARE_NAME_TYPE, CLEAR_NEW_LABWARE_NAME_TYPE, @@ -114,6 +115,9 @@ export const OPEN_CUSTOM_LABWARE_DIRECTORY: OPEN_CUSTOM_LABWARE_DIRECTORY_TYPE = export const DELETE_CUSTOM_LABWARE_FILE: DELETE_CUSTOM_LABWARE_FILE_TYPE = 'labware:DELETE_CUSTOM_LABWARE_FILE' +export const ADD_CUSTOM_LABWARE_FILE_FROM_CREATOR: ADD_CUSTOM_LABWARE_FILE_FROM_CREATOR_TYPE = + 'labware:ADD_CUSTOM_LABWARE_FILE_BLOB' + // action meta literals export const POLL: POLL_TYPE = 'poll' diff --git a/app-shell/src/labware/definitions.ts b/app-shell/src/labware/definitions.ts index 7c0ec235acc8..1e3f7f24bd0b 100644 --- a/app-shell/src/labware/definitions.ts +++ b/app-shell/src/labware/definitions.ts @@ -31,20 +31,40 @@ export function readLabwareDirectory(dir: string): Promise { } export function parseLabwareFiles( - files: string[] + filesOrContent: string | string[] ): Promise { - const tasks = files.map(f => { - const readTask = fs.readJson(f, { throws: false }) - const statTask = fs.stat(f) - - return Promise.all([readTask, statTask]).then(([data, stats]) => ({ - filename: f, - modified: stats.mtimeMs, - data, - })) - }) + if (typeof filesOrContent === 'string') { + return new Promise((resolve, reject) => { + try { + const data = JSON.parse(filesOrContent) + const modified = Date.now() + const filename = `${data.parameters?.loadName}.json` ?? 'unknown_file' + + resolve([{ filename, modified, data }]) + } catch (error) { + reject(error) + } + }) + } else if (Array.isArray(filesOrContent)) { + const tasks = filesOrContent.map(f => { + const readTask = fs.readJson(f, { throws: false }) + const statTask = fs.stat(f) - return Promise.all(tasks) + return Promise.all([readTask, statTask]).then(([data, stats]) => ({ + filename: f, + modified: stats.mtimeMs, + data, + })) + }) + + return Promise.all(tasks) + } else { + return Promise.reject( + new Error( + 'Invalid input: expected an inported file or data from App Labware Creator' + ) + ) + } } // get a filename, adding an incrementor to avoid collisions @@ -74,6 +94,20 @@ export function addLabwareFile(file: string, dir: string): Promise { ) } +export function addLabwareFileFromCreator( + fileContent: string, + dir: string, + fileName: string +): Promise { + const extname = path.extname(fileName) + const basename = path.basename(fileName, extname) + + return getFileName(dir, basename, extname).then(destName => { + const data = JSON.parse(fileContent) + return fs.outputJson(destName, data) + }) +} + export function removeLabwareFile(file: string): Promise { return shell.trashItem(file).catch(() => fs.unlink(file)) } diff --git a/app-shell/src/labware/index.ts b/app-shell/src/labware/index.ts index 2d2999414401..ed02de505513 100644 --- a/app-shell/src/labware/index.ts +++ b/app-shell/src/labware/index.ts @@ -5,6 +5,7 @@ import { showOpenDirectoryDialog, showOpenFileDialog } from '../dialogs' import { ADD_CUSTOM_LABWARE, ADD_CUSTOM_LABWARE_FILE, + ADD_CUSTOM_LABWARE_FILE_FROM_CREATOR, ADD_LABWARE, CHANGE_CUSTOM_LABWARE_DIRECTORY, CHANGE_DIRECTORY, @@ -42,23 +43,44 @@ import { const ensureDir: (dir: string) => Promise = fse.ensureDir -const fetchCustomLabware = (): Promise => { +const fetchCustomLabware = ( + inMemoryFile?: string +): Promise => { const { labware: config } = getFullConfig() return ensureDir(config.directory) .then(() => Definitions.readLabwareDirectory(config.directory)) - .then(Definitions.parseLabwareFiles) + .then(filePaths => { + const tasks = [] + + if (inMemoryFile) { + tasks.push(Definitions.parseLabwareFiles(inMemoryFile)) + } + tasks.push(Definitions.parseLabwareFiles(filePaths)) + + return Promise.all(tasks) + }) + .then(parsedFilesArrays => { + const parsedFiles = parsedFilesArrays.reduce( + (acc, curr) => acc.concat(curr), + [] + ) + return parsedFiles + }) } -const fetchValidatedCustomLabware = (): Promise => { - return fetchCustomLabware().then(validateLabwareFiles) +const fetchValidatedCustomLabware = ( + inMemoryFile?: string +): Promise => { + return fetchCustomLabware(inMemoryFile).then(validateLabwareFiles) } const fetchAndValidateCustomLabware = ( dispatch: Dispatch, - source: ListSource + source: ListSource, + inMemoryFile?: string ): Promise => { - return fetchValidatedCustomLabware() + return fetchValidatedCustomLabware(inMemoryFile) .then(payload => { dispatch(customLabwareList(payload, source)) }) @@ -112,6 +134,30 @@ const copyLabware = ( }) } +const copyLabwareFromCreator = ( + dispatch: Dispatch, + file: string +): Promise => { + return Promise.all([ + fetchCustomLabware(), + Definitions.parseLabwareFiles(file), + ]).then(([existingFiles, [newFile]]) => { + const existing = validateLabwareFiles(existingFiles) + const next = validateNewLabwareFile(existing, newFile) + const dir = getFullConfig().labware.directory + + if (next.type !== VALID_LABWARE_FILE) { + dispatch(addCustomLabwareFailure(next)) + return + } + return Definitions.addLabwareFileFromCreator(file, dir, next.filename) + .then(() => fetchAndValidateCustomLabware(dispatch, ADD_LABWARE, file)) + .then(() => { + dispatch(addNewLabwareName(newFile.filename)) + }) + }) +} + const deleteLabware = (dispatch: Dispatch, filePath: string): Promise => { return Definitions.removeLabwareFile(filePath).then(() => fetchAndValidateCustomLabware(dispatch, DELETE_LABWARE) @@ -193,6 +239,14 @@ export function registerLabware( break } + case ADD_CUSTOM_LABWARE_FILE_FROM_CREATOR: { + const file = action.payload.file + copyLabwareFromCreator(dispatch, file).catch((error: Error) => { + dispatch(addCustomLabwareFailure(null, error.message)) + }) + break + } + case ADD_CUSTOM_LABWARE_FILE: { const filePath = action.payload.filePath copyLabware(dispatch, [filePath]).catch((error: Error) => { diff --git a/app-shell/src/labware/validation.ts b/app-shell/src/labware/validation.ts index c46a93ae598c..0d8d51ee76ae 100644 --- a/app-shell/src/labware/validation.ts +++ b/app-shell/src/labware/validation.ts @@ -31,11 +31,9 @@ export function validateLabwareFiles( ): CheckedLabwareFile[] { const validated = files.map(file => { const { filename, data, modified } = file - // check file against the schema // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions const definition = data && validateLabwareDefinition(data) - if (definition === null) { return { filename, modified, type: INVALID_LABWARE_FILE } } @@ -60,7 +58,6 @@ export function validateLabwareFiles( return { type: DUPLICATE_LABWARE_FILE, ...props } } } - return v }) } diff --git a/app-shell/src/types.ts b/app-shell/src/types.ts index 494549f8c3d2..8a1bea51a201 100644 --- a/app-shell/src/types.ts +++ b/app-shell/src/types.ts @@ -38,6 +38,7 @@ export type CHANGE_CUSTOM_LABWARE_DIRECTORY_TYPE = 'labware:CHANGE_CUSTOM_LABWAR export type ADD_CUSTOM_LABWARE_TYPE = 'labware:ADD_CUSTOM_LABWARE' export type ADD_CUSTOM_LABWARE_FILE_TYPE = 'labware:ADD_CUSTOM_LABWARE_FILE' export type ADD_CUSTOM_LABWARE_FAILURE_TYPE = 'labware:ADD_CUSTOM_LABWARE_FAILURE' +export type ADD_CUSTOM_LABWARE_FILE_FROM_CREATOR_TYPE = 'labware:ADD_CUSTOM_LABWARE_FILE_BLOB' export type CLEAR_ADD_CUSTOM_LABWARE_FAILURE_TYPE = 'labware:CLEAR_ADD_CUSTOM_LABWARE_FAILURE' export type ADD_NEW_LABWARE_NAME_TYPE = 'labware:ADD_NEW_LABWARE_NAME' export type CLEAR_NEW_LABWARE_NAME_TYPE = 'labware:CLEAR_NEW_LABWARE_NAME' diff --git a/app/package.json b/app/package.json index c0dccd29a417..2108a9dff658 100644 --- a/app/package.json +++ b/app/package.json @@ -24,6 +24,7 @@ "@fontsource/public-sans": "5.0.3", "@opentrons/api-client": "link:../api-client", "@opentrons/components": "link:../components", + "@opentrons/labware-library": "link:../labware-library", "@opentrons/react-api-client": "link:../react-api-client", "@opentrons/shared-data": "link:../shared-data", "@opentrons/step-generation": "link:../step-generation", diff --git a/app/src/assets/localization/en/app_settings.json b/app/src/assets/localization/en/app_settings.json index eb2dfbc2225d..ddd63bf74870 100644 --- a/app/src/assets/localization/en/app_settings.json +++ b/app/src/assets/localization/en/app_settings.json @@ -5,6 +5,7 @@ "__dev_internal__enableRunNotes": "Display Notes During a Protocol Run", "__dev_internal__enableQuickTransfer": "Enable Quick Transfer", "__dev_internal__enableCsvFile": "Enable CSV File", + "__dev_internal__enableLabwareCreator": "Enable App Labware Creator", "add_folder_button": "Add labware source folder", "add_ip_button": "Add", "add_ip_error": "Enter an IP Address or Hostname", diff --git a/app/src/pages/Labware/index.tsx b/app/src/pages/Labware/index.tsx index 21473c7255e6..c017e6fdead8 100644 --- a/app/src/pages/Labware/index.tsx +++ b/app/src/pages/Labware/index.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import startCase from 'lodash/startCase' import { css } from 'styled-components' +import { useDispatch } from 'react-redux' import { ALIGN_CENTER, @@ -16,24 +17,27 @@ import { JUSTIFY_SPACE_BETWEEN, Link, POSITION_ABSOLUTE, + PrimaryButton, SecondaryButton, SPACING, LegacyStyledText, TYPOGRAPHY, useOnClickOutside, } from '@opentrons/components' - +import { LabwareCreator } from '@opentrons/labware-library' import { ERROR_TOAST, SUCCESS_TOAST } from '../../atoms/Toast' import { MenuItem } from '../../atoms/MenuList/MenuItem' import { useTrackEvent, ANALYTICS_OPEN_LABWARE_CREATOR_FROM_BOTTOM_OF_LABWARE_LIBRARY_LIST, } from '../../redux/analytics' +import { addCustomLabwareFileFromCreator } from '../../redux/custom-labware' import { DropdownMenu } from '../../atoms/MenuList/DropdownMenu' import { LabwareCard } from '../../organisms/LabwareCard' import { AddCustomLabwareSlideout } from '../../organisms/AddCustomLabwareSlideout' import { LabwareDetails } from '../../organisms/LabwareDetails' import { useToaster } from '../../organisms/ToasterOven' +import { useFeatureFlag } from '../../redux/config' import { useAllLabware, useLabwareFailure, useNewLabwareName } from './hooks' import type { DropdownOption } from '../../atoms/MenuList/DropdownMenu' @@ -73,12 +77,14 @@ const SORT_BY_BUTTON_STYLE = css` export function Labware(): JSX.Element { const { t } = useTranslation(['labware_landing', 'shared']) - + const enableLabwareCreator = useFeatureFlag('enableLabwareCreator') const [sortBy, setSortBy] = React.useState('alphabetical') const [showSortByMenu, setShowSortByMenu] = React.useState(false) const toggleSetShowSortByMenu = (): void => { setShowSortByMenu(!showSortByMenu) } + const dispatch = useDispatch() + const [showLC, setShowLC] = React.useState(false) const trackEvent = useTrackEvent() const [filterBy, setFilterBy] = React.useState('all') const { makeToast } = useToaster() @@ -89,6 +95,7 @@ export function Labware(): JSX.Element { const [showAddLabwareSlideout, setShowAddLabwareSlideout] = React.useState( false ) + const [ currentLabwareDef, setCurrentLabwareDef, @@ -122,6 +129,17 @@ export function Labware(): JSX.Element { return ( <> + {showLC ? ( + { + setShowLC(false) + }} + save={(file: string) => { + dispatch(addCustomLabwareFileFromCreator(file)) + }} + isOnRunApp + /> + ) : null} {t('labware')} - { - setShowAddLabwareSlideout(true) - }} - > - {t('import')} - + + { + setShowAddLabwareSlideout(true) + }} + > + {t('import')} + + {enableLabwareCreator ? ( + { + setShowLC(true) + }} + > + Open Labware Creator + + ) : null} + > diff --git a/app/src/redux/custom-labware/actions.ts b/app/src/redux/custom-labware/actions.ts index 705a4259aa4f..a5167b1f19ea 100644 --- a/app/src/redux/custom-labware/actions.ts +++ b/app/src/redux/custom-labware/actions.ts @@ -22,7 +22,8 @@ export const ADD_CUSTOM_LABWARE: 'labware:ADD_CUSTOM_LABWARE' = export const ADD_CUSTOM_LABWARE_FILE: 'labware:ADD_CUSTOM_LABWARE_FILE' = 'labware:ADD_CUSTOM_LABWARE_FILE' - +export const ADD_CUSTOM_LABWARE_FILE_FROM_CREATOR: 'labware:ADD_CUSTOM_LABWARE_FILE_BLOB' = + 'labware:ADD_CUSTOM_LABWARE_FILE_BLOB' export const ADD_CUSTOM_LABWARE_FAILURE: 'labware:ADD_CUSTOM_LABWARE_FAILURE' = 'labware:ADD_CUSTOM_LABWARE_FAILURE' @@ -99,6 +100,12 @@ export const addCustomLabwareFile = ( meta: { shell: true }, }) +export const addCustomLabwareFileFromCreator = (file: string): any => ({ + type: ADD_CUSTOM_LABWARE_FILE_FROM_CREATOR, + payload: { file }, + meta: { shell: true }, +}) + export const deleteCustomLabwareFile = ( filePath: string ): Types.DeleteCustomLabwareFileAction => ({ diff --git a/app/src/redux/custom-labware/reducer.ts b/app/src/redux/custom-labware/reducer.ts index 2d8d08368658..b3e10f247f27 100644 --- a/app/src/redux/custom-labware/reducer.ts +++ b/app/src/redux/custom-labware/reducer.ts @@ -35,6 +35,7 @@ export const customLabwareReducer: Reducer = ( case Actions.ADD_CUSTOM_LABWARE: case Actions.ADD_CUSTOM_LABWARE_FILE: + case Actions.ADD_CUSTOM_LABWARE_FILE_FROM_CREATOR: case Actions.CLEAR_ADD_CUSTOM_LABWARE_FAILURE: { return { ...state, addFailureFile: null, addFailureMessage: null } } diff --git a/app/src/redux/custom-labware/types.ts b/app/src/redux/custom-labware/types.ts index dd8c59d46092..ca6ef57154ae 100644 --- a/app/src/redux/custom-labware/types.ts +++ b/app/src/redux/custom-labware/types.ts @@ -97,6 +97,12 @@ export interface AddCustomLabwareFileAction { meta: { shell: true } } +export interface AddCustomLabwareFromCreatorAction { + type: 'labware:ADD_CUSTOM_LABWARE_FILE_BLOB' + payload: { file: string } + meta: { shell: true } +} + export interface DeleteCustomLabwareFileAction { type: 'labware:DELETE_CUSTOM_LABWARE_FILE' payload: { filePath: string } diff --git a/app/src/redux/types.ts b/app/src/redux/types.ts index 2082ee25f25b..a46c7f2dd964 100644 --- a/app/src/redux/types.ts +++ b/app/src/redux/types.ts @@ -25,6 +25,7 @@ import type { ProtocolAnalysisAction } from './protocol-analysis' import type { CustomLabwareState, CustomLabwareAction, + AddCustomLabwareFromCreatorAction, } from './custom-labware/types' import type { RobotSettingsState, @@ -77,6 +78,7 @@ export type Action = | SessionsAction | CalibrationAction | AnalyticsTriggerAction + | AddCustomLabwareFromCreatorAction export type GetState = () => State diff --git a/app/tsconfig.json b/app/tsconfig.json index 4ccea720873a..1eb903756519 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -24,7 +24,10 @@ }, { "path": "../react-api-client" - } + }, + { + "path": "../labware-library" + }, ], "compilerOptions": { "composite": true, diff --git a/app/vite.config.ts b/app/vite.config.ts index f88d492056af..5104827f50cb 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -10,7 +10,7 @@ import { versionForProject } from '../scripts/git-version.mjs' import type { UserConfig } from 'vite' export default defineConfig( - async(): Promise => { + async (): Promise => { const project = process.env.OPENTRONS_PROJECT ?? 'robot-stack' const version = await versionForProject(project) return { @@ -61,6 +61,9 @@ export default defineConfig( '@opentrons/step-generation': path.resolve( '../step-generation/src/index.ts' ), + '@opentrons/labware-library': path.resolve( + '../labware-library/src/labware-creator' + ), '@opentrons/api-client': path.resolve('../api-client/src/index.ts'), '@opentrons/react-api-client': path.resolve( '../react-api-client/src/index.ts' @@ -68,4 +71,5 @@ export default defineConfig( }, }, } - }) + } +) diff --git a/labware-library/cypress/e2e/labware-creator/customTubeRack.cy.js b/labware-library/cypress/e2e/labware-creator/customTubeRack.cy.js index 40b02ddbb4ca..f3c195030be3 100644 --- a/labware-library/cypress/e2e/labware-creator/customTubeRack.cy.js +++ b/labware-library/cypress/e2e/labware-creator/customTubeRack.cy.js @@ -28,7 +28,7 @@ context('Tubes and Rack', () => { .contains('Non-Opentrons tube rack') .click() - cy.contains('start creating labware').click({ force: true }) + cy.contains('Start creating labware').click({ force: true }) // no preview image yet cy.contains('Add missing info to see labware preview').should('exist') diff --git a/labware-library/cypress/e2e/labware-creator/reservoir.cy.js b/labware-library/cypress/e2e/labware-creator/reservoir.cy.js index 73564b053439..c88044d16785 100644 --- a/labware-library/cypress/e2e/labware-creator/reservoir.cy.js +++ b/labware-library/cypress/e2e/labware-creator/reservoir.cy.js @@ -16,7 +16,7 @@ context('Reservoirs', () => { .trigger('mousedown') cy.get('*[class^="_option_label"]').contains('Reservoir').click() cy.contains('Reservoir').click({ force: true }) - cy.contains('start creating labware').click({ force: true }) + cy.contains('Start creating labware').click({ force: true }) }) it('should create a resevoir', () => { diff --git a/labware-library/cypress/e2e/labware-creator/tipRack.cy.js b/labware-library/cypress/e2e/labware-creator/tipRack.cy.js index 116ce01825dd..4d633ffd5f6c 100644 --- a/labware-library/cypress/e2e/labware-creator/tipRack.cy.js +++ b/labware-library/cypress/e2e/labware-creator/tipRack.cy.js @@ -16,7 +16,7 @@ describe('Create a Tip Rack', () => { .first() .trigger('mousedown') cy.get('*[class^="_option_label"]').contains('Tip Rack').click() - cy.get('button').contains('start creating labware').click({ force: true }) + cy.get('button').contains('Start creating labware').click({ force: true }) // Custom Tip Racks Are Not Recommended cy.get('#CustomTiprackWarning p') diff --git a/labware-library/cypress/e2e/labware-creator/tubesBlock.cy.js b/labware-library/cypress/e2e/labware-creator/tubesBlock.cy.js index 4ea4f724bc76..b891aedafd21 100644 --- a/labware-library/cypress/e2e/labware-creator/tubesBlock.cy.js +++ b/labware-library/cypress/e2e/labware-creator/tubesBlock.cy.js @@ -32,7 +32,7 @@ context('Tubes and Block', () => { .contains(/^Tubes$/) .click() - cy.contains('start creating labware').click({ force: true }) + cy.contains('Start creating labware').click({ force: true }) }) describe('96 Well', () => { describe('Tubes', () => { @@ -310,7 +310,7 @@ context('Tubes and Block', () => { .trigger('mousedown') cy.get('*[class^="_option_label"]').contains('PCR Plate').click() - cy.contains('start creating labware').click({ force: true }) + cy.contains('Start creating labware').click({ force: true }) }) it('does not have a preview image', () => { cy.contains('Add missing info to see labware preview').should('exist') @@ -467,7 +467,7 @@ context('Tubes and Block', () => { .contains('What labware is on top of your aluminum block?') .should('not.exist') - cy.contains('start creating labware').click({ force: true }) + cy.contains('Start creating labware').click({ force: true }) cy.get("input[name='homogeneousWells'][value='false']").check({ force: true, }) diff --git a/labware-library/cypress/e2e/labware-creator/tubesRack.cy.js b/labware-library/cypress/e2e/labware-creator/tubesRack.cy.js index 424e74f386f3..738124ee2e85 100644 --- a/labware-library/cypress/e2e/labware-creator/tubesRack.cy.js +++ b/labware-library/cypress/e2e/labware-creator/tubesRack.cy.js @@ -21,7 +21,7 @@ context('Tubes and Rack', () => { .trigger('mousedown') cy.get('*[class^="_option_label"]').contains('6 tubes').click() - cy.contains('start creating labware').click({ force: true }) + cy.contains('Start creating labware').click({ force: true }) }) it('creates a tuberack with 16 tubes', () => { @@ -154,7 +154,7 @@ context('Tubes and Rack', () => { .trigger('mousedown') cy.get('*[class^="_option_label"]').contains('15 tubes').click() - cy.contains('start creating labware').click({ force: true }) + cy.contains('Start creating labware').click({ force: true }) }) it('creates a tuberack with 15 tubes', () => { @@ -285,7 +285,7 @@ context('Tubes and Rack', () => { .trigger('mousedown') cy.get('*[class^="_option_label"]').contains('24 tubes').click() - cy.contains('start creating labware').click({ force: true }) + cy.contains('Start creating labware').click({ force: true }) }) it('create a tuberack with 24 tubes', () => { diff --git a/labware-library/cypress/e2e/labware-creator/wellPlate.cy.js b/labware-library/cypress/e2e/labware-creator/wellPlate.cy.js index d558b7c174a3..d586f8040b67 100644 --- a/labware-library/cypress/e2e/labware-creator/wellPlate.cy.js +++ b/labware-library/cypress/e2e/labware-creator/wellPlate.cy.js @@ -20,7 +20,7 @@ context('Well Plates', () => { .first() .trigger('mousedown') cy.get('*[class^="_option_label"]').contains('Well Plate').click() - cy.get('button').contains('start creating labware').click({ force: true }) + cy.get('button').contains('Start creating labware').click({ force: true }) }) it('creates a wellplate', () => { cy.contains('Add missing info to see labware preview').should('exist') diff --git a/labware-library/src/index.tsx b/labware-library/src/index.tsx index d8a3f2f596bc..9bac13fe66ca 100644 --- a/labware-library/src/index.tsx +++ b/labware-library/src/index.tsx @@ -9,6 +9,8 @@ import { LabwareCreator } from './labware-creator' import { getPublicPath } from './public-path' import './styles.global.module.css' +export * from './labware-creator' + const $root = document.getElementById('root') if (!$root) { diff --git a/labware-library/src/labware-creator/WizardHeader.tsx b/labware-library/src/labware-creator/WizardHeader.tsx new file mode 100644 index 000000000000..fc26ba08858e --- /dev/null +++ b/labware-library/src/labware-creator/WizardHeader.tsx @@ -0,0 +1,67 @@ +import * as React from 'react' +import { css } from 'styled-components' +import { + Box, + Btn, + DIRECTION_ROW, + Flex, + JUSTIFY_SPACE_BETWEEN, + TYPOGRAPHY, + COLORS, + SPACING, + Text, + StepMeter, +} from '@opentrons/components' + +interface WizardHeaderProps { + title: string + onExit?: React.MouseEventHandler | null + totalSteps?: number + currentStep?: number | null + exitDisabled?: boolean +} + +const EXIT_BUTTON_STYLE = css` + ${TYPOGRAPHY.pSemiBold}; + text-transform: ${TYPOGRAPHY.textTransformCapitalize}; + color: ${COLORS.grey50}; + + &:hover { + opacity: 70%; + } +` +const HEADER_CONTAINER_STYLE = css` + flex-direction: ${DIRECTION_ROW}; + justify-content: ${JUSTIFY_SPACE_BETWEEN}; + padding: ${SPACING.spacing16} ${SPACING.spacing32}; +` +const TEXT_STYLE = css` + ${TYPOGRAPHY.pSemiBold} +` + +export const WizardHeader = (props: WizardHeaderProps): JSX.Element => { + const { totalSteps, currentStep, title, onExit, exitDisabled } = props + return ( + + + + + {title} + + + {currentStep != null && totalSteps != null && currentStep > 0 ? ( + + {`Steps: ${currentStep}/${totalSteps}`} + + ) : null} + + {onExit != null ? ( + + Exit + + ) : null} + + + + ) +} diff --git a/labware-library/src/labware-creator/components/ImportLabware.tsx b/labware-library/src/labware-creator/components/ImportLabware.tsx index 04ea841e3ea7..42afbef97088 100644 --- a/labware-library/src/labware-creator/components/ImportLabware.tsx +++ b/labware-library/src/labware-creator/components/ImportLabware.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { DeprecatedPrimaryButton, Icon } from '@opentrons/components' +import { Icon, PrimaryButton } from '@opentrons/components' import styles from './importLabware.module.css' interface Props { @@ -27,16 +27,23 @@ const stopEvent = (e: React.SyntheticEvent): void => { function UploadInput(props: UploadInputProps): JSX.Element { const { isButton, onUpload } = props + const fileInputRef = React.useRef(null) - const Label = isButton ? DeprecatedPrimaryButton : 'label' + const handleButtonClick = (): void => { + if (fileInputRef.current) { + fileInputRef.current.click() + } + } + + const Label = isButton ? PrimaryButton : 'label' const labelText = isButton - ? 'upload labware file' + ? 'Upload labware file' : 'Drag and drop labware file here' const labelProps = isButton ? { - Component: 'label' as const, + onClick: handleButtonClick, className: styles.upload_button, } : { onDrop: onUpload, className: styles.file_drop } @@ -44,9 +51,17 @@ function UploadInput(props: UploadInputProps): JSX.Element { return (
) diff --git a/labware-library/src/labware-creator/components/__tests__/sections/Export.test.tsx b/labware-library/src/labware-creator/components/__tests__/sections/Export.test.tsx index 9e83ddecc743..bf0ef3301e6e 100644 --- a/labware-library/src/labware-creator/components/__tests__/sections/Export.test.tsx +++ b/labware-library/src/labware-creator/components/__tests__/sections/Export.test.tsx @@ -35,7 +35,16 @@ describe('Export', () => { }) it('should render button when section is visible', () => { - render(wrapInFormik(, formikConfig)) + render( + wrapInFormik( + , + formikConfig + ) + ) screen.getByRole('button', { name: /export/i }) }) diff --git a/labware-library/src/labware-creator/components/sections/CreateNewDefinition.tsx b/labware-library/src/labware-creator/components/sections/CreateNewDefinition.tsx index e79e56133281..b50dc69693a9 100644 --- a/labware-library/src/labware-creator/components/sections/CreateNewDefinition.tsx +++ b/labware-library/src/labware-creator/components/sections/CreateNewDefinition.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import { useFormikContext } from 'formik' import cx from 'classnames' -import { PrimaryBtn } from '@opentrons/components' +import { PrimaryButton } from '@opentrons/components' import { Dropdown } from '../../components/Dropdown' import { isEveryFieldHidden, makeAutofillOnChange } from '../../utils' import { labwareTypeOptions, labwareTypeAutofills } from '../../fields' @@ -63,13 +63,13 @@ export const CreateNewDefinition = (props: Props): JSX.Element | null => { )} - - start creating labware - + Start creating labware + ) diff --git a/labware-library/src/labware-creator/components/sections/Export.tsx b/labware-library/src/labware-creator/components/sections/Export.tsx index e46b252a0089..e3d00b654099 100644 --- a/labware-library/src/labware-creator/components/sections/Export.tsx +++ b/labware-library/src/labware-creator/components/sections/Export.tsx @@ -4,19 +4,24 @@ import styles from '../../styles.module.css' interface ExportProps { onExportClick: (e: React.MouseEvent) => unknown + isOnRunApp: boolean + disabled: boolean } export const Export = (props: ExportProps): JSX.Element | null => { - return ( -
-
- - EXPORT FILE - -
+ return props.isOnRunApp ? ( + + {'Save'} + + ) : ( +
+ + {'EXPORT FILE'} +
) } diff --git a/labware-library/src/labware-creator/components/sections/UploadExisting.tsx b/labware-library/src/labware-creator/components/sections/UploadExisting.tsx index a0f96dbc398a..579d52c44d1d 100644 --- a/labware-library/src/labware-creator/components/sections/UploadExisting.tsx +++ b/labware-library/src/labware-creator/components/sections/UploadExisting.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { DeprecatedPrimaryButton } from '@opentrons/components' +import { PrimaryButton } from '@opentrons/components' import { ImportLabware } from '../ImportLabware' import styles from '../../styles.module.css' @@ -33,13 +33,13 @@ export const UploadExisting = (props: Props): JSX.Element => { ) : (
{labwareTypeChildFields} - start editing labware - +
)}
diff --git a/labware-library/src/labware-creator/index.tsx b/labware-library/src/labware-creator/index.tsx index 3e89f1e8ce71..6215545e2d28 100644 --- a/labware-library/src/labware-creator/index.tsx +++ b/labware-library/src/labware-creator/index.tsx @@ -5,7 +5,18 @@ import { Formik } from 'formik' import { saveAs } from 'file-saver' import { reportEvent } from '../analytics' import { reportErrors } from './analyticsUtils' -import { AlertModal } from '@opentrons/components' +import { + ALIGN_CENTER, + ALIGN_END, + AlertModal, + Box, + DIRECTION_COLUMN, + DIRECTION_ROW, + Flex, + JUSTIFY_SPACE_BETWEEN, + ModalShell, + PrimaryButton, +} from '@opentrons/components' import { getAllDefinitions, labwareSchemaV2 as labwareSchema, @@ -51,9 +62,9 @@ import { WellSpacing } from './components/sections/WellSpacing' import { getDefaultedDef } from './getDefaultedDef' import { getIsXYGeometryChanged } from './utils/getIsXYGeometryChanged' import { StackingOffsets } from './components/sections/StackingOffsets' +import { WizardHeader } from './WizardHeader' -import styles from './styles.module.css' - +import type { FormikErrors } from 'formik' import type { LabwareDefinition2 } from '@opentrons/shared-data' import type { LabwareCreatorErrors } from './formLevelValidation' import type { @@ -63,10 +74,49 @@ import type { ProcessedLabwareFields, } from './fields' +import styles from './styles.module.css' + const ajv = new Ajv() const validateLabwareSchema = ajv.compile(labwareSchema) +type WizardStep = + | 'intro' + | 'regularity' + | 'footprint' + | 'height' + | 'grid' + | 'volume' + | 'shape' + | 'depth' + | 'spacing' + | 'gridOffset' + | 'stackingOffset' + | 'preview' -export const LabwareCreator = (): JSX.Element => { +const WIZARD_STEPS: WizardStep[] = [ + 'intro', + 'regularity', + 'footprint', + 'height', + 'grid', + 'volume', + 'shape', + 'depth', + 'spacing', + 'gridOffset', + 'stackingOffset', + 'preview', +] + +interface LabwareCreatorProps { + isOnRunApp?: boolean + /** only for Run App usage */ + goBack?: () => void + /** only for Run App usage */ + save?: (fileContent: any) => void +} + +export const LabwareCreator = (props: LabwareCreatorProps): JSX.Element => { + const { save, goBack, isOnRunApp = false } = props const [ showExportErrorModal, _setShowExportErrorModal, @@ -75,6 +125,10 @@ export const LabwareCreator = (): JSX.Element => { const adapterDefinitions = Object.values( labwareDefinitions ).filter(definition => definition.allowedRoles?.includes('adapter')) + const [wizardSteps, setWizardSteps] = React.useState( + WIZARD_STEPS + ) + const [currentStepIndex, setCurrentStepIndex] = React.useState(0) const setShowExportErrorModal = React.useMemo( () => (v: boolean, fieldValues?: LabwareFields) => { @@ -170,6 +224,27 @@ export const LabwareCreator = (): JSX.Element => { } }, [showCreatorForm, scrollToForm]) + const wizardHeader = ( + + ) + + const currentWizardStep = wizardSteps[currentStepIndex] + const goBackWizard = (stepsBack: number = 1): void => { + if (currentStepIndex >= 0 + stepsBack) { + setCurrentStepIndex(currentStepIndex - stepsBack) + } + } + const proceed = (stepsForward: number = 1): void => { + if (currentStepIndex + stepsForward < wizardSteps.length) { + setCurrentStepIndex(currentStepIndex + stepsForward) + } + } + const onUpload = React.useCallback( ( event: @@ -245,13 +320,17 @@ export const LabwareCreator = (): JSX.Element => { fields.labwareType === 'tipRack' ) { // no additional required labware type child fields, we can scroll right away - scrollToForm() + if (isOnRunApp) { + proceed() + } else { + scrollToForm() + } } } reader.readAsText(file) } }, - [scrollToForm, setLastUploaded, setImportError] + [scrollToForm, proceed, setLastUploaded, setImportError] ) React.useEffect(() => { @@ -265,8 +344,8 @@ export const LabwareCreator = (): JSX.Element => { } }) - return ( - + const body = ( + <> {importError != null ? ( { @@ -311,7 +390,17 @@ export const LabwareCreator = (): JSX.Element => { const blob = new Blob([JSON.stringify(def, null, 4)], { type: 'text/plain;charset=utf-8', }) - saveAs(blob, `${loadName}.json`) + if (save != null) { + const fileReader = new FileReader() + fileReader.onload = function (event) { + const fileContent = + event.target != null ? event.target.result : null + save(fileContent) + } + fileReader.readAsText(blob) + } else { + saveAs(blob, `${loadName}.json`) + } reportEvent({ name: 'labwareCreatorFileExport', @@ -350,12 +439,14 @@ export const LabwareCreator = (): JSX.Element => { prevValues: values, }) } - const onExportClick = (): void => { if (!isValid && !showExportErrorModal) { setShowExportErrorModal(true, values) } handleSubmit() + if (goBack != null) { + goBack() + } } // @ts-expect-error(IL, 2021-03-24): values/errors/touched not typed for reportErrors to be happy @@ -415,7 +506,25 @@ export const LabwareCreator = (): JSX.Element => { ) - return ( + return isOnRunApp && goBack != null ? ( + + + + + + ) : (

Custom Labware Creator

@@ -452,13 +561,436 @@ export const LabwareCreator = (): JSX.Element => { - + )}
) }} -
+ + ) + + return goBack != null ? ( + body + ) : ( + {body} ) } + +interface CreateFileFormProps { + values: LabwareFields + errors: FormikErrors< + LabwareFields & { + FORM_LEVEL_ERRORS: Partial> + } + > + lastUploaded: LabwareFields | null + canProceedToForm: boolean + labwareTypeChildFields: any + currentWizardStep: WizardStep + onUpload: ( + event: + | React.DragEvent + | React.ChangeEvent + ) => void + goBack: (stepsBack: number) => void + onExportClick: () => void + proceed: (stepsForward: number) => void + setWizardSteps: React.Dispatch> +} + +function CreateForm(props: CreateFileFormProps): JSX.Element { + const { + currentWizardStep, + errors, + proceed, + goBack, + onUpload, + lastUploaded, + canProceedToForm, + onExportClick, + labwareTypeChildFields, + values, + } = props + + const skipStackingOffset = + values.labwareType === 'aluminumBlock' || values.labwareType === 'reservoir' + const skipSpacing = + values.gridRows != null && + values.gridRows === '1' && + values.gridColumns != null && + values.gridColumns === '1' + + switch (currentWizardStep) { + case 'intro': + return ( + <> +

Custom Labware Creator BETA

+ +
+ { + proceed(1) + }} + /> + { + proceed(1) + }} + onUpload={onUpload} + /> +
+ + ) + case 'regularity': + return ( + + + + + + + + { + goBack(1) + }} + > + Go back + + { + proceed(1) + }} + disabled={errors.homogeneousWells != null} + > + Next + + + + ) + case 'footprint': + return ( + <> + + + { + goBack(1) + }} + > + Go back + + { + proceed(1) + }} + disabled={ + !( + errors.footprintXDimension == null && + errors.footprintYDimension == null + ) + } + > + Next + + + + ) + case 'height': + return ( + <> + + + { + goBack(1) + }} + > + Go back + + { + proceed(1) + }} + disabled={errors.labwareZDimension != null} + > + Next + + + + ) + case 'grid': + return ( + <> + + + { + goBack(1) + }} + > + Go back + + { + proceed(1) + }} + disabled={ + !( + errors.gridColumns == null && + errors.gridRows == null && + errors.regularColumnSpacing == null && + errors.regularRowSpacing == null + ) + } + > + Next + + + + ) + case 'volume': + return ( + <> + + + { + goBack(1) + }} + > + Go back + + { + proceed(1) + }} + disabled={errors.wellVolume != null} + > + Next + + + + ) + case 'shape': + return ( + <> + + + { + goBack(1) + }} + > + Go back + + { + proceed(1) + }} + // TODO fix this tho + disabled={ + errors.wellDiameter != null || + !( + errors.wellXDimension == null && errors.wellYDimension == null + ) + } + > + Next + + + + ) + case 'depth': + return ( + <> + + + { + goBack(1) + }} + > + Go back + + { + if (skipSpacing) { + proceed(2) + } else { + proceed(1) + } + }} + disabled={errors.wellDepth != null} + > + Next + + + + ) + case 'spacing': + return ( + <> + + + { + if (skipSpacing) { + goBack(2) + } else { + goBack(1) + } + }} + > + Go back + + { + proceed(1) + }} + disabled={ + !(errors.gridSpacingX == null && errors.gridSpacingY == null) + } + > + Next + + + + ) + case 'gridOffset': + return ( + <> + + + { + goBack(1) + }} + > + Go back + + { + if (skipStackingOffset) { + proceed(2) + } else { + proceed(1) + } + }} + disabled={ + !(errors.gridOffsetX == null && errors.gridOffsetY == null) + } + > + Next + + + + ) + case 'stackingOffset': + return ( + <> + + + { + goBack(1) + }} + > + Go back + + { + proceed(1) + }} + > + Next + + + + ) + case 'preview': + return ( + <> + + + + + { + if (skipStackingOffset) { + goBack(2) + } else { + goBack(1) + } + }} + > + Go back + + 0} + /> + + + ) + default: + return
+ } +} diff --git a/vitest.config.ts b/vitest.config.ts index 34b6afca4f74..27ef068eba25 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -44,6 +44,9 @@ export default mergeConfig( '@opentrons/usb-bridge/node-client': path.resolve( './usb-bridge/node-client/src/index.ts' ), + '@opentrons/labware-library': path.resolve( + './labware-library/src/labware-creator/index.tsx' + ), }, }, }) diff --git a/yarn.lock b/yarn.lock index 100111635297..7d15ddae1344 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3240,6 +3240,7 @@ "@fontsource/public-sans" "5.0.3" "@opentrons/api-client" "link:api-client" "@opentrons/components" "link:components" + "@opentrons/labware-library" "link:labware-library" "@opentrons/react-api-client" "link:react-api-client" "@opentrons/shared-data" "link:shared-data" "@opentrons/step-generation" "link:step-generation" @@ -3321,6 +3322,25 @@ to-regex "3.0.2" yargs "15.4.0" +"@opentrons/labware-library@link:labware-library": + version "0.0.0-dev" + dependencies: + "@opentrons/components" "link:components" + ajv "6.12.3" + classnames "2.2.5" + cookie "0.4.0" + core-js "3.2.1" + file-saver "2.0.1" + formik "2.1.4" + jszip "3.2.2" + lodash "4.17.21" + mixpanel-browser "2.29.1" + query-string "6.2.0" + react "18.2.0" + react-dom "18.2.0" + react-router-dom "5.3.4" + yup "0.32.9" + "@opentrons/react-api-client@link:react-api-client": version "0.0.0-dev" dependencies: