diff --git a/components/src/slotmap/SlotMap.tsx b/components/src/slotmap/SlotMap.tsx index 7f6ffd2738d..3c50f67bc91 100644 --- a/components/src/slotmap/SlotMap.tsx +++ b/components/src/slotmap/SlotMap.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import cx from 'classnames' import { Icon } from '../icons' import styles from './styles.css' +import { OT3_STANDARD_MODEL } from '@opentrons/shared-data' // TODO(bc, 2021-03-29): this component is only used in PD // reconsider whether it belongs in components library @@ -13,6 +14,8 @@ export interface SlotMapProps { collisionSlots?: string[] /** Optional error styling */ isError?: boolean + /** Optional robot type for OT-3 Robot */ + robotType?: string } const SLOT_MAP_SLOTS = [ @@ -22,6 +25,13 @@ const SLOT_MAP_SLOTS = [ ['1', '2', '3'], ] +const SLOT_MAP_FLEX_SLOTS = [ + ['10', '11', '12'], + ['7', '8', '9'], + ['4', '5', '6'], + ['1', '2', '3'], +] + const slotWidth = 33 const slotHeight = 23 const iconSize = 20 @@ -29,12 +39,14 @@ const numRows = 4 const numCols = 3 export function SlotMap(props: SlotMapProps): JSX.Element { - const { collisionSlots, occupiedSlots, isError } = props + const { collisionSlots, occupiedSlots, isError, robotType } = props + const slot_map = + robotType === OT3_STANDARD_MODEL ? SLOT_MAP_FLEX_SLOTS : SLOT_MAP_SLOTS return ( - {SLOT_MAP_SLOTS.flatMap((row, rowIndex) => + {slot_map.flatMap((row, rowIndex) => row.map((slot, colIndex) => { const isCollisionSlot = collisionSlots && collisionSlots.includes(slot) diff --git a/protocol-designer/cypress/integration/batchEdit.spec.js b/protocol-designer/cypress/integration/batchEdit.spec.js index 7343f7f5552..53d9b6ff708 100644 --- a/protocol-designer/cypress/integration/batchEdit.spec.js +++ b/protocol-designer/cypress/integration/batchEdit.spec.js @@ -1,6 +1,7 @@ describe('Batch Edit Transform', () => { beforeEach(() => { cy.visit('/') + cy.get('button').contains('Create new OT-2 protocol').click() cy.closeAnnouncementModal() }) diff --git a/protocol-designer/cypress/integration/customTipRacks.spec.js b/protocol-designer/cypress/integration/customTipRacks.spec.js index f202c867b4f..33ab2ae33e5 100644 --- a/protocol-designer/cypress/integration/customTipRacks.spec.js +++ b/protocol-designer/cypress/integration/customTipRacks.spec.js @@ -13,7 +13,6 @@ const slotThree = 'foreignObject[x="265"][y="0"]' describe('Custom Tip Racks', () => { before(() => { cy.visit('/') - cy.closeAnnouncementModal() }) const testCases = [ @@ -46,6 +45,8 @@ describe('Custom Tip Racks', () => { describe('build a new protocol with a custom tip rack', () => { it('sets up pipettes', () => { + cy.get('button').contains('Create new OT-2 protocol').click() + cy.closeAnnouncementModal() cy.get('button').contains('Create New').click() cy.get("input[placeholder='Untitled']").type(protocolTitle) cy.choosePipettes(pipette, pipette) diff --git a/protocol-designer/cypress/integration/migrations.spec.js b/protocol-designer/cypress/integration/migrations.spec.js index dd901a7a04f..e790b65a472 100644 --- a/protocol-designer/cypress/integration/migrations.spec.js +++ b/protocol-designer/cypress/integration/migrations.spec.js @@ -8,6 +8,7 @@ const semver = require('semver') describe('Protocol fixtures migrate and match snapshots', () => { beforeEach(() => { cy.visit('/') + cy.get('button').contains('Create new OT-2 protocol').click() cy.closeAnnouncementModal() }) diff --git a/protocol-designer/cypress/integration/mixSettings.spec.js b/protocol-designer/cypress/integration/mixSettings.spec.js index 2b9d5da1a0c..9dd9329df27 100644 --- a/protocol-designer/cypress/integration/mixSettings.spec.js +++ b/protocol-designer/cypress/integration/mixSettings.spec.js @@ -42,6 +42,7 @@ function enterBatchEdit() { describe('Advanced Settings for Mix Form', () => { before(() => { cy.visit('/') + cy.get('button').contains('Create new OT-2 protocol').click() cy.closeAnnouncementModal() importProtocol() openDesignTab() diff --git a/protocol-designer/cypress/integration/newProtocol.spec.js b/protocol-designer/cypress/integration/newProtocol.spec.js index 00bb0c163b7..46ecac01adb 100644 --- a/protocol-designer/cypress/integration/newProtocol.spec.js +++ b/protocol-designer/cypress/integration/newProtocol.spec.js @@ -1,6 +1,7 @@ describe('Desktop Navigation', () => { before(() => { cy.visit('/') + cy.get('button').contains('Create new OT-2 protocol').click() cy.closeAnnouncementModal() }) diff --git a/protocol-designer/cypress/integration/newProtocolWithModules.spec.js b/protocol-designer/cypress/integration/newProtocolWithModules.spec.js index 0b04baccf1e..18c48d0718f 100644 --- a/protocol-designer/cypress/integration/newProtocolWithModules.spec.js +++ b/protocol-designer/cypress/integration/newProtocolWithModules.spec.js @@ -20,6 +20,7 @@ const magenticDeckWellWithExtraSpace = 'Mag Deck Well ' describe('Protocols with Modules', () => { before(() => { cy.visit('/') + cy.get('button').contains('Create new OT-2 protocol').click() cy.closeAnnouncementModal() }) diff --git a/protocol-designer/cypress/integration/newProtocolWithTC.spec.js b/protocol-designer/cypress/integration/newProtocolWithTC.spec.js index a32e994bd7a..e7011a2efe6 100644 --- a/protocol-designer/cypress/integration/newProtocolWithTC.spec.js +++ b/protocol-designer/cypress/integration/newProtocolWithTC.spec.js @@ -31,6 +31,7 @@ const thermocyclerWellBlockWithExtraSpace = 'TC Well ' // Note: reason this is n describe('Protocols with Modules', () => { before(() => { cy.visit('/') + cy.get('button').contains('Create new OT-2 protocol').click() cy.closeAnnouncementModal() }) diff --git a/protocol-designer/cypress/integration/home.spec.js b/protocol-designer/cypress/integration/ot2Home.spec.js similarity index 87% rename from protocol-designer/cypress/integration/home.spec.js rename to protocol-designer/cypress/integration/ot2Home.spec.js index cdbe5911de7..d649896d878 100644 --- a/protocol-designer/cypress/integration/home.spec.js +++ b/protocol-designer/cypress/integration/ot2Home.spec.js @@ -1,6 +1,7 @@ -describe('The Home Page', () => { +describe('The OT-2 Home Page', () => { beforeEach(() => { cy.visit('/') + cy.get('button').contains('Create new OT-2 protocol').click() cy.closeAnnouncementModal() }) diff --git a/protocol-designer/cypress/integration/settings.spec.js b/protocol-designer/cypress/integration/settings.spec.js index 79be0dd400e..6c381d1f45f 100644 --- a/protocol-designer/cypress/integration/settings.spec.js +++ b/protocol-designer/cypress/integration/settings.spec.js @@ -3,6 +3,7 @@ describe('The Settings Page', () => { before(() => { cy.visit('/') + cy.get('button').contains('Create new OT-2 protocol').click() }) it('displays the announcement modal and clicks "GOT IT!" to close it', () => { diff --git a/protocol-designer/cypress/integration/sidebar.spec.js b/protocol-designer/cypress/integration/sidebar.spec.js index 75fc193f78f..1511dbc9941 100644 --- a/protocol-designer/cypress/integration/sidebar.spec.js +++ b/protocol-designer/cypress/integration/sidebar.spec.js @@ -1,6 +1,7 @@ describe('Desktop Navigation', () => { beforeEach(() => { cy.visit('/') + cy.get('button').contains('Create new OT-2 protocol').click() cy.closeAnnouncementModal() }) diff --git a/protocol-designer/cypress/integration/transferSettings.spec.js b/protocol-designer/cypress/integration/transferSettings.spec.js index 6d341888b7b..cf5c7d92dba 100644 --- a/protocol-designer/cypress/integration/transferSettings.spec.js +++ b/protocol-designer/cypress/integration/transferSettings.spec.js @@ -44,6 +44,7 @@ function enterBatchEdit() { describe('Advanced Settings for Transfer Form', () => { before(() => { cy.visit('/') + cy.get('button').contains('Create new OT-2 protocol').click() cy.closeAnnouncementModal() importProtocol() openDesignTab() diff --git a/protocol-designer/src/components/App.tsx b/protocol-designer/src/components/App.tsx index a72cd1162fd..8236770eb0e 100644 --- a/protocol-designer/src/components/App.tsx +++ b/protocol-designer/src/components/App.tsx @@ -1,7 +1,6 @@ import * as React from 'react' -import { ProtocolEditor } from './ProtocolEditor' - import '../css/reset.css' +import { ProtocolEditor } from './ProtocolEditor' export function App(): JSX.Element { return ( diff --git a/protocol-designer/src/components/FlexProtocolEditor/CreateFlexFileForm.tsx b/protocol-designer/src/components/FlexProtocolEditor/CreateFlexFileForm.tsx new file mode 100644 index 00000000000..17017c67f53 --- /dev/null +++ b/protocol-designer/src/components/FlexProtocolEditor/CreateFlexFileForm.tsx @@ -0,0 +1,73 @@ +import React, { useState } from 'react' +import { i18n } from '../../localization' +import { FlexProtocolEditorComponent } from './FlexProtocolEditor' +import { StyledText } from './StyledText' +import styles from './FlexComponents.css' +import { UpdateConfirmation } from './FlexUpdateConfirmation' +import { actions as navActions } from '../../navigation' +import { useDispatch } from 'react-redux' +import { FlexHeadingButtonGroup } from './FlexFileDetails' + +function FlexFormComponent(): JSX.Element { + const dispatch = useDispatch() + const [showConfirmation, setShowConfirmation] = useState(false) + + const handleCancelClick = (): void => { + setShowConfirmation(false) + } + + const handleConfirmClick = (): void => { + // handle the update action here + dispatch(navActions.navigateToPage('landing-page')) + setShowConfirmation(false) + } + + function protocolCancelClick(): void { + setShowConfirmation(true) + } + + return ( + <> + {Boolean(showConfirmation) && ( + <> + + + )} +
+
+ {i18n.t('flex.header.title')} + +
+ + {i18n.t('flex.header.required_fields')} + + +
+ + ) +} + +export const CreateFlexFileForm = FlexFormComponent diff --git a/protocol-designer/src/components/FlexProtocolEditor/FlexComponents.css b/protocol-designer/src/components/FlexProtocolEditor/FlexComponents.css new file mode 100644 index 00000000000..8a5718ed2a8 --- /dev/null +++ b/protocol-designer/src/components/FlexProtocolEditor/FlexComponents.css @@ -0,0 +1,238 @@ +@import '@opentrons/components'; + +.pd_landing_page { + width: 655px; + text-align: center; + margin: 114px auto auto; +} + +.ot_flex_logo { + width: 160px; + height: 160px; + margin: 50px auto; +} + +.flex_landing_buttons_wrapper { + width: 367px; + margin: auto; +} + +.flex_landing_button { + margin: 0.5rem 0; + height: 2rem; + width: 100%; +} + +.flex_landing_button input { + position: fixed; + clip: rect(1px 1px 1px 1px); +} + +.flex_header { + position: relative; + margin: 1.5rem auto auto; +} + +.flex_header > * { + margin: 1rem 2.5rem; +} + +.flex_title { + display: flex; + justify-content: space-between; +} + +.cancel_button { + width: 109px; + height: 25px; + padding: 0 !important; +} + +.right_end { + display: flex; + justify-content: flex-end; +} + +.editor_form { + margin: 1rem; +} + +.flex_round_tabs_button_wrapper { + display: flex; + justify-content: center; +} + +.flex_round_tabs_button_100p { + width: 100%; + margin: 1rem; +} + +.flex_round_tabs_button_50p { + width: 50%; + margin: 1rem; +} + +.flex_sub_heading { + margin-top: 27px; +} + +.textarea_input { + resize: none; + width: 100%; + overflow: hidden; + background-color: var(--c-light-gray); + border-radius: var(--bd-radius-form-field); + padding: 0.25rem 0.25rem 0.25rem 0.5rem; + border: none; +} + +.textarea_input:focus { + outline: none; +} + +.textarea_input::placeholder { + color: var(--c-med-gray); +} + +.textarea_input:focus-within { + background-color: var(--c-white); + box-shadow: 0 0.125rem 0.25rem 0 color-mod(var(--c-black) alpha(0.5)); +} + +.form_group { + width: 329px; + margin-top: 12px; + margin-bottom: 1rem; +} + +.form_group > * { + margin-bottom: 5px; +} + +/* Pipette Selection Module */ +.pipette_slection_mount { + display: flex; +} + +.custom_tiprack_upload_file { + margin: 5px 0; + width: auto; +} + +.custom_tiprack_upload_file input { + position: fixed; + clip: rect(1px 1px 1px 1px); +} + +.disable_mount_option { + opacity: 0.5; + pointer-events: none; + user-select: none; +} + +/* Custom Tip Rack Selection */ +.filter_data { + display: flex; + flex-direction: column; + margin-left: 17%; + margin-top: -3%; +} + +.custom_tiprack { + background: #d9e9fe; + width: fit-content; + padding: 5px; + margin: 2px 0; +} + +.remove_button { + display: flex; + align-items: center; + color: #5181ff; + padding-left: 10px; + cursor: pointer; +} + +.module_section { + display: flex; + flex-direction: row; + justify-content: flex-start; +} + +.module_section > * { + margin-right: 3rem; + height: 7rem; +} + +.mini_card { + width: 16.1rem; + min-width: 16.1rem; +} + +.model_options { + flex: 1; +} + +.line_separator { + border-bottom: 1px solid var(--c-light-gray); + margin: 1rem 0 1rem; + width: 100%; +} + +.error_text { + color: var(--c-red); +} + +.ptb_10 { + padding: 5px 0; +} + +.pb_10 { + padding-bottom: 10px; +} + +/* overlay styles */ +.confirmation_overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 100; +} + +/* confirmation model styles */ +.confirmation_model { + width: 400px; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: white; + padding: 20px; + border-radius: 10px; +} + +.confirmation_model_x_button { + margin: 0 !important; + padding: 0 !important; + color: #000 !important; +} + +.confirmation_model button { + margin: 0 10px; + padding: 10px 20px; + border-radius: 5px; + border: none; + cursor: pointer; + color: white; +} + +.confirmation_model_cancel_button { + background: #6c757d; +} + +.confirmation_model_proceed_button { + background: #0d6efd; +} diff --git a/protocol-designer/src/components/FlexProtocolEditor/FlexFileDetails/FlexFileDetails.css b/protocol-designer/src/components/FlexProtocolEditor/FlexFileDetails/FlexFileDetails.css new file mode 100644 index 00000000000..51331690915 --- /dev/null +++ b/protocol-designer/src/components/FlexProtocolEditor/FlexFileDetails/FlexFileDetails.css @@ -0,0 +1,84 @@ +@import '@opentrons/components'; + +.pd_fd_header { + position: relative; + font-family: 'Open Sans', sans-serif; + font-size: 1.3rem; + font-weight: 900; + line-height: 1rem; +} + +.pd_fd_sub_header { + font-size: 0.813rem; + font-weight: 400; + line-height: 1rem; +} + +.container { + margin: 1.5rem 3rem 1.5rem; +} + +.bold_text { + font-weight: bold !important; +} + +.heading_container { + display: flex; + justify-content: space-between; +} + +.left_button { + order: 1; +} + +.right_buttons { + order: 2; +} + +.close_protocol_button { + margin-left: 5px; + color: red !important; +} + +.line_separator { + border-bottom: 1px solid var(--c-light-gray); + margin: 30px 0 30px; + width: 100%; +} + +.margin_bottom { + margin-bottom: 10px !important; +} + +.margin_right { + margin-right: 5px; +} + +.name_margin { + margin-left: 64px; +} + +.desc_margin { + margin-left: 26px; +} + +.other_margin { + margin-left: 11px; +} + +.created_margin { + margin-left: 15px; +} + +.author_margin { + margin-left: 57px; +} + +.card_width { + width: 21rem; +} + +.card_content { + padding-top: 10px; + padding-bottom: 10px; +} diff --git a/protocol-designer/src/components/FlexProtocolEditor/FlexFileDetails/FlexFileDetails.tsx b/protocol-designer/src/components/FlexProtocolEditor/FlexFileDetails/FlexFileDetails.tsx new file mode 100644 index 00000000000..a06c31dc4ca --- /dev/null +++ b/protocol-designer/src/components/FlexProtocolEditor/FlexFileDetails/FlexFileDetails.tsx @@ -0,0 +1,431 @@ +import { + Card, + DIRECTION_COLUMN, + Flex, + SPACING, + SecondaryButton, +} from '@opentrons/components' +import cx from 'classnames' +import { format } from 'date-fns' +import { Formik, FormikProps } from 'formik' +import { mapValues } from 'lodash' +import React, { useState } from 'react' +import { connect, useDispatch } from 'react-redux' +import { INITIAL_DECK_SETUP_STEP_ID } from '../../../constants' +import { + FileMetadataFields, + selectors as fileSelectors, +} from '../../../file-data' +import { i18n } from '../../../localization' +import { + InitialDeckSetup, + ModulesForEditModulesCard, + selectors as stepFormSelectors, +} from '../../../step-forms' +import { actions as steplistActions } from '../../../steplist' +import { BaseState, ThunkDispatch } from '../../../types' +import { ModuleDiagram } from '../../modules' +import { StyledText } from '../StyledText' + +import flexStyles from '../FlexComponents.css' +import styles from '../FlexFileDetails/FlexFileDetails.css' +import { InstrumentGroup } from '../FlexInstrument/InstrumentGroup' +import { FlexProtocolEditorComponent } from '../FlexProtocolEditor' +import { actions as navActions } from '../../../navigation' +import { UpdateConfirmation } from '../FlexUpdateConfirmation' +export interface Props { + formValues: FileMetadataFields + instruments: React.ComponentProps + swapPipettes: () => unknown + modules: ModulesForEditModulesCard +} + +type PropsData = React.ComponentProps +interface SP { + instruments: PropsData['instruments'] + formValues: PropsData['formValues'] + _initialDeckSetup: InitialDeckSetup + modules: PropsData['modules'] +} + +// TODO(mc, 2020-02-28): explore l10n for these dates +const DATE_ONLY_FORMAT = 'MMM dd, yyyy' +const DATETIME_FORMAT = 'MMM dd, yyyy | h:mm a' + +export function FlexFileDetailsComponent(props: any): JSX.Element { + const [isEdit, setEdit] = useState(false) + const [selectedTabId, setTabId] = useState(0) + + if (isEdit) { + return ( + + ) + } else { + return ( +
+ {!Boolean(props.formValues) ? ( + + ) : ( +
+
+ + {({ + handleSubmit, + values, + }: FormikProps) => ( +
+
+ +
+
+ + + + +
+
+
+ + {i18n.t('flex.file_tab.pipette')} + + + {Object.keys(props.instruments).length !== 1 && ( + { + e.preventDefault() + props.swapPipettes() + }} + className={styles.margin_right} + > + {i18n.t('flex.file_tab.swap_pipette')} + + )} + + +
+
+ +
+
+ +
+ + {i18n.t('flex.file_tab.modules')} + + + + +
+ + +
+ + )} + +
+
+ )} +
+ ) + } +} + +const FileProtocolInformation = (): JSX.Element => { + const dispatch = useDispatch() + const [showConfirmation, setShowConfirmation] = useState(false) + + const handleCancelClick = (): any => { + setShowConfirmation(false) + } + const handleConfirmClick = (): any => { + // handle the update action here + dispatch(navActions.navigateToPage('landing-page')) + setShowConfirmation(false) + } + + function protocolCancelClick(e: { preventDefault: () => void }): any { + e.preventDefault() + setShowConfirmation(true) + } + return ( + <> + {Boolean(showConfirmation) && ( + <> + + + )} +
+
+ {i18n.t('flex.file_tab.heading')} + + {i18n.t('flex.file_tab.subheading')} + +
+ +
+ + ) +} + +export const FlexHeadingButtonGroup = (props: { + protocolCancelClickProps: any +}): JSX.Element => { + const { protocolCancelClickProps } = props + return ( +
+ {i18n.t('flex.file_tab.export')} + protocolCancelClickProps(e)} + > + {i18n.t('flex.file_tab.close_export')} + +
+ ) +} + +const NoFileSelection = (): JSX.Element => { + return ( +
+
+ {i18n.t('flex.file_tab.heading')} + + {i18n.t('flex.file_tab.subheading')} + +
+
+ + {i18n.t('flex.file_tab.invalid_json')} + +
+
+
+ ) +} + +// File related information name and description etc. +const FileProtocolNameAndDescription = (props: { + nameDescriptionData: any +}): JSX.Element => { + const { nameDescriptionData } = props + return ( +
+ + + {i18n.t('flex.file_tab.name_desc_title')} + + + + + {i18n.t('flex.file_tab.name')} + + + {nameDescriptionData.protocolName} + + + + + {i18n.t('flex.file_tab.author')} + + + {nameDescriptionData.author} + + + + + {i18n.t('flex.file_tab.description')} + + + {nameDescriptionData.description} + + + + + {i18n.t('flex.file_tab.date_created')} + + + {nameDescriptionData.created && + format(nameDescriptionData.created, DATE_ONLY_FORMAT)} + + + + + {i18n.t('flex.file_tab.last_exported')} + + + {nameDescriptionData.lastModified && + format(nameDescriptionData.lastModified, DATETIME_FORMAT)} + + +
+ ) +} + +const EditButton = ({ + editProps, + setTab, + setTabId, + addItems, +}: any): JSX.Element => { + return ( + { + e.preventDefault() + editProps(true) + setTabId(setTab) + }} + > + {addItems + ? i18n.t('flex.file_tab.add_items') + : i18n.t('flex.file_tab.edit')} + + ) +} + +const SelectedModules = (props: { propsData: any }): JSX.Element => { + const { propsData } = props + const existingModules = getModuleData(propsData.modules) + return ( + <> + {existingModules?.length > 0 ? ( + existingModules.map((moduleType: any, i: number) => ( +
+ +
+ + + + + {i18n.t( + `modules.module_display_names.${moduleType.type}` + )} + - Slot {moduleType.slot} + + + +
+
+
+ )) + ) : ( + + {i18n.t('flex.file_tab.no_modules_found')} + + )} + + ) +} + +export function getModuleData(modules: any): any { + const moduleData = [] + for (const obj in modules) { + if (modules[obj] != null) moduleData.push(modules[obj]) + } + return moduleData +} + +const mapStateToProps = (state: BaseState): SP => { + return { + formValues: fileSelectors.getFileMetadata(state), + instruments: stepFormSelectors.getPipettesForInstrumentGroup(state), + modules: stepFormSelectors.getModulesForEditModulesCard(state), + _initialDeckSetup: stepFormSelectors.getInitialDeckSetup(state), + } +} + +function mergeProps( + stateProps: SP, + dispatchProps: { + dispatch: ThunkDispatch + } +): Props { + const { _initialDeckSetup, ...passThruProps } = stateProps + const { dispatch } = dispatchProps + const swapPipetteUpdate = mapValues(_initialDeckSetup.pipettes, pipette => { + if (!pipette.mount) return pipette.mount + return pipette.mount === 'left' ? 'right' : 'left' + }) + return { + ...passThruProps, + swapPipettes: () => + dispatch( + steplistActions.changeSavedStepForm({ + stepId: INITIAL_DECK_SETUP_STEP_ID, + update: { + pipetteLocationUpdate: swapPipetteUpdate, + }, + }) + ), + } +} + +export const FlexFileDetails = connect( + mapStateToProps, + // @ts-expect-error(sa, 2021-6-21): TODO: refactor to use hooks api + null, + mergeProps +)(FlexFileDetailsComponent) diff --git a/protocol-designer/src/components/FlexProtocolEditor/FlexFileDetails/index.ts b/protocol-designer/src/components/FlexProtocolEditor/FlexFileDetails/index.ts new file mode 100644 index 00000000000..8006bf0f030 --- /dev/null +++ b/protocol-designer/src/components/FlexProtocolEditor/FlexFileDetails/index.ts @@ -0,0 +1 @@ +export * from './FlexFileDetails' diff --git a/protocol-designer/src/components/FlexProtocolEditor/FlexInstrument/InfoItem.tsx b/protocol-designer/src/components/FlexProtocolEditor/FlexInstrument/InfoItem.tsx new file mode 100644 index 00000000000..299372efc31 --- /dev/null +++ b/protocol-designer/src/components/FlexProtocolEditor/FlexInstrument/InfoItem.tsx @@ -0,0 +1,24 @@ +import * as React from 'react' + +import styles from './instrument.css' + +export interface InfoItemProps { + title: string + value: string + className?: string +} + +/** + * Used by `InstrumentInfo` for its titled values. + * But if you're using this, you probably want `LabeledValue` instead. + */ +export function InfoItem(props: InfoItemProps): JSX.Element { + const { title, value, className } = props + + return ( +
+

{title}

+ {value} +
+ ) +} diff --git a/protocol-designer/src/components/FlexProtocolEditor/FlexInstrument/InstrumentDiagram.tsx b/protocol-designer/src/components/FlexProtocolEditor/FlexInstrument/InstrumentDiagram.tsx new file mode 100644 index 00000000000..7251d49148a --- /dev/null +++ b/protocol-designer/src/components/FlexProtocolEditor/FlexInstrument/InstrumentDiagram.tsx @@ -0,0 +1,43 @@ +import * as React from 'react' +import cx from 'classnames' +import singleSrc from '@opentrons/components/src/instrument/single_channel_GEN1_800px.png' +import multiSrc from '@opentrons/components/src/instrument/multi-channel_GEN1_800px.png' +import singleGEN2Src from '@opentrons/components/src/instrument/single-channel_GEN2_800px.png' +import multiGEN2Src from '@opentrons/components/src/instrument/multi-channel_GEN2_800px.png' +import styles from './instrument.css' + +import type { PipetteNameSpecs } from '@opentrons/shared-data' + +import type { StyleProps } from '@opentrons/components' +import { Mount } from '@opentrons/components/src/robot-types' +import { Box } from '@opentrons/components' + +export interface InstrumentDiagramProps extends StyleProps { + pipetteSpecs?: Pick | null + className?: string + mount: Mount +} + +export function InstrumentDiagram(props: InstrumentDiagramProps): JSX.Element { + const { pipetteSpecs, mount, className, ...styleProps } = props + const { displayCategory, channels } = pipetteSpecs || {} + let imgSrc + switch (displayCategory) { + case 'GEN2': { + imgSrc = channels === 1 ? singleGEN2Src : multiGEN2Src + break + } + case 'GEN1': + default: { + imgSrc = channels === 1 ? singleSrc : multiSrc + } + } + return ( + + + + ) +} diff --git a/protocol-designer/src/components/FlexProtocolEditor/FlexInstrument/InstrumentGroup.tsx b/protocol-designer/src/components/FlexProtocolEditor/FlexInstrument/InstrumentGroup.tsx new file mode 100644 index 00000000000..a29a785213e --- /dev/null +++ b/protocol-designer/src/components/FlexProtocolEditor/FlexInstrument/InstrumentGroup.tsx @@ -0,0 +1,58 @@ +import * as React from 'react' + +import { InstrumentInfo } from './InstrumentInfo' +import styles from './instrument.css' + +import type { InstrumentInfoProps } from './InstrumentInfo' +import { SecondaryButton } from '@opentrons/components' +import { i18n } from '../../../localization' + +export interface InstrumentGroupProps { + showMountLabel?: boolean | null | undefined + left?: InstrumentInfoProps | null | undefined + right?: InstrumentInfoProps | null | undefined +} + +const EMPTY_INSTRUMENT_PROPS = { + description: 'None', + tiprackModel: 'N/A', + isDisabled: false, +} + +/** + * Renders a left and right pipette diagram & info. + * Takes child `InstrumentInfo` props in `right` and `left` props. + */ +export function InstrumentGroup(props: InstrumentGroupProps): JSX.Element { + const { left, right } = props + const Hide96CH = props.left?.description.includes('Flex 96-Channel 1000 μL') + + const leftProps = left || { ...EMPTY_INSTRUMENT_PROPS, mount: 'left' } + const rightProps = right || { ...EMPTY_INSTRUMENT_PROPS, mount: 'right' } + return ( +
+ {/* {props.left && ( */} + + {/* )} */} + {props.right ? ( + + ) : ( + Hide96CH && ( + { + e.preventDefault() + }} + > + {i18n.t('flex.file_tab.add_pipette')} + + ) + )} +
+ ) +} diff --git a/protocol-designer/src/components/FlexProtocolEditor/FlexInstrument/InstrumentInfo.tsx b/protocol-designer/src/components/FlexProtocolEditor/FlexInstrument/InstrumentInfo.tsx new file mode 100644 index 00000000000..3869631fa8d --- /dev/null +++ b/protocol-designer/src/components/FlexProtocolEditor/FlexInstrument/InstrumentInfo.tsx @@ -0,0 +1,66 @@ +import * as React from 'react' +import cx from 'classnames' + +import { InfoItem } from './InfoItem' +import { InstrumentDiagram } from './InstrumentDiagram' +import styles from './instrument.css' + +import type { InstrumentDiagramProps } from './InstrumentDiagram' +import { Mount } from '@opentrons/components/src/robot-types' +import { Card } from '@opentrons/components' + +export interface InstrumentInfoProps { + /** 'left' or 'right' */ + mount: Mount + /** if true, show labels 'LEFT PIPETTE' / 'RIGHT PIPETTE' */ + showMountLabel?: boolean | null + /** human-readable description, eg 'p300 Single-channel' */ + description: string + /** paired tiprack model */ + tiprackModel?: string + /** if disabled, pipette & its info are grayed out */ + isDisabled: boolean + /** specs of mounted pipette */ + pipetteSpecs?: InstrumentDiagramProps['pipetteSpecs'] | null + /** classes to apply */ + className?: string + /** classes to apply to the info group child */ + infoClassName?: string + /** children to display under the info */ + children?: React.ReactNode +} + +export function InstrumentInfo(props: InstrumentInfoProps): JSX.Element { + const className = cx( + styles.pipette, + styles[props.mount], + { [styles.disabled]: props.isDisabled }, + props.className + ) + return ( +
+ +
+ + +
+ + {props.tiprackModel && ( + + )} + {props.children} +
+
+
+
+ ) +} diff --git a/protocol-designer/src/components/FlexProtocolEditor/FlexInstrument/index.ts b/protocol-designer/src/components/FlexProtocolEditor/FlexInstrument/index.ts new file mode 100644 index 00000000000..73d553d04c4 --- /dev/null +++ b/protocol-designer/src/components/FlexProtocolEditor/FlexInstrument/index.ts @@ -0,0 +1,4 @@ +export * from './InfoItem' +export * from './InstrumentDiagram' +export * from './InstrumentGroup' +export * from './InstrumentInfo' diff --git a/protocol-designer/src/components/FlexProtocolEditor/FlexInstrument/instrument.css b/protocol-designer/src/components/FlexProtocolEditor/FlexInstrument/instrument.css new file mode 100644 index 00000000000..a3db4f2e0e0 --- /dev/null +++ b/protocol-designer/src/components/FlexProtocolEditor/FlexInstrument/instrument.css @@ -0,0 +1,57 @@ +@import '@opentrons/components'; + +.pipette_group { + padding-bottom: 2rem; + display: grid; + width: 21rem; +} + +.pipette { + display: flex; + vertical-align: top; +} + +.left { + grid-area: left; + grid-template-columns: 80% 20%; + grid-template-areas: 'info img'; + margin-right: 3rem; +} + +.right { + grid-area: right; + grid-template-columns: 40% 80%; + grid-template-areas: 'img info'; +} + +.disabled { + opacity: 0.3; + pointer-events: none; +} + +.pipette_info { + grid-area: info; + min-height: 8rem; +} + +.flipped_image { + transform: scaleX(-1); + filter: FlipH; +} + +.pipette_icon { + grid-area: img; + text-align: center; +} + +.title { + font-size: var(--fs-body-2); + font-weight: var(--fw-semibold); + color: var(--c-font-dark); + text-transform: capitalize; + margin: 2rem 0 0.25rem; +} + +.value { + @apply --font-body-2-dark; +} \ No newline at end of file diff --git a/protocol-designer/src/components/FlexProtocolEditor/FlexModules/FlexModuleData.ts b/protocol-designer/src/components/FlexProtocolEditor/FlexModules/FlexModuleData.ts new file mode 100644 index 00000000000..c37032c26aa --- /dev/null +++ b/protocol-designer/src/components/FlexProtocolEditor/FlexModules/FlexModuleData.ts @@ -0,0 +1,180 @@ +import { + MAGNETIC_MODULE_TYPE, + TEMPERATURE_MODULE_TYPE, + THERMOCYCLER_MODULE_TYPE, + HEATERSHAKER_MODULE_TYPE, + ModuleType, + MAGNETIC_BLOCK_TYPE, +} from '@opentrons/shared-data' +import { DropdownOption } from '@opentrons/components' +import { SPAN7_8_10_11_SLOT } from '../../../constants' +export const SUPPORTED_MODULE_TYPES: ModuleType[] = [ + HEATERSHAKER_MODULE_TYPE, + MAGNETIC_MODULE_TYPE, + TEMPERATURE_MODULE_TYPE, + THERMOCYCLER_MODULE_TYPE, +] + +type SupportedSlotMap = Record + +export const SUPPORTED_MODULE_SLOTS: SupportedSlotMap = { + [MAGNETIC_BLOCK_TYPE]: [ + { + name: 'Slot C1 (default)', + value: '4', + }, + ], + [TEMPERATURE_MODULE_TYPE]: [ + { + name: 'Slot D3 (default)', + value: '3', + }, + ], + [THERMOCYCLER_MODULE_TYPE]: [ + { + name: 'Thermocycler slots', + value: SPAN7_8_10_11_SLOT, + }, + ], + [HEATERSHAKER_MODULE_TYPE]: [ + { + name: 'Slot D1 (default)', + value: '1', + }, + ], + [MAGNETIC_MODULE_TYPE]: [], +} + +const ALL_MODULE_SLOTS: DropdownOption[] = [ + { + name: 'Slot A1', + value: '10', + }, + { + name: 'Slot A2', + value: '11', + }, + { + name: 'Slot A3', + value: '12', + }, + { + name: 'Slot B1', + value: '7', + }, + { + name: 'Slot B2', + value: '8', + }, + { + name: 'Slot B3', + value: '9', + }, + { + name: 'Slot C1', + value: '4', + }, + { + name: 'Slot C2', + value: '5', + }, + { + name: 'Slot C3', + value: '6', + }, + { + name: 'Slot D1', + value: '1', + }, + { + name: 'Slot D2', + value: '2', + }, +] + +const HEATER_SHAKER_SLOTS: DropdownOption[] = [ + { + name: 'Slot A1', + value: '10', + }, + { + name: 'Slot B1', + value: '7', + }, + { + name: 'Slot C1', + value: '4', + }, + { + name: 'Slot C3', + value: '6', + }, + { + name: 'Slot D1', + value: '1', + }, + { + name: 'Slot D3', + value: '3', + }, +] + +const TEMPERATURE_MODULE_SLOTS: DropdownOption[] = [ + { + name: 'Slot A1', + value: '10', + }, + { + name: 'Slot B1', + value: '7', + }, + { + name: 'Slot B3', + value: '9', + }, + { + name: 'Slot C1', + value: '4', + }, + { + name: 'Slot C3', + value: '6', + }, + { + name: 'Slot D1', + value: '1', + }, + { + name: 'Slot D3', + value: '3', + }, +] + +export function getAllFlexModuleSlotsByType( + moduleType: ModuleType +): DropdownOption[] { + const supportedSlotOption = SUPPORTED_MODULE_SLOTS[moduleType] + + if (moduleType === THERMOCYCLER_MODULE_TYPE) { + return supportedSlotOption + } + + if (moduleType === HEATERSHAKER_MODULE_TYPE) { + return supportedSlotOption.concat( + HEATER_SHAKER_SLOTS.filter(s => s.value !== supportedSlotOption[0].value) + ) + } + + if (moduleType === TEMPERATURE_MODULE_TYPE) { + return supportedSlotOption.concat( + TEMPERATURE_MODULE_SLOTS.filter( + s => s.value !== supportedSlotOption[0].value + ) + ) + } + + const allOtherSlots = ALL_MODULE_SLOTS.filter( + s => s.value !== supportedSlotOption[0].value + ) + return supportedSlotOption.concat(allOtherSlots) +} diff --git a/protocol-designer/src/components/FlexProtocolEditor/FlexModules/FlexModuleDiagram.tsx b/protocol-designer/src/components/FlexProtocolEditor/FlexModules/FlexModuleDiagram.tsx new file mode 100644 index 00000000000..8c5cdba09de --- /dev/null +++ b/protocol-designer/src/components/FlexProtocolEditor/FlexModules/FlexModuleDiagram.tsx @@ -0,0 +1,56 @@ +import * as React from 'react' +import styles from '../../modules/styles.css' +import { + MAGNETIC_MODULE_TYPE, + TEMPERATURE_MODULE_TYPE, + THERMOCYCLER_MODULE_TYPE, + ModuleType, + MAGNETIC_MODULE_V1, + MAGNETIC_MODULE_V2, + TEMPERATURE_MODULE_V1, + TEMPERATURE_MODULE_V2, + THERMOCYCLER_MODULE_V1, + ModuleModel, + HEATERSHAKER_MODULE_TYPE, + HEATERSHAKER_MODULE_V1, + THERMOCYCLER_MODULE_V2, + MAGNETIC_BLOCK_TYPE, + MAGNETIC_BLOCK_V1, +} from '@opentrons/shared-data' + +interface Props { + type: ModuleType + model: ModuleModel +} + +type ModuleImg = { + [type in ModuleType]: { + [model in ModuleModel]?: string + } +} + +const MODULE_IMG_BY_TYPE: ModuleImg = { + [MAGNETIC_MODULE_TYPE]: { + [MAGNETIC_MODULE_V1]: require('../../../images/modules/magdeck_gen1.png'), + [MAGNETIC_MODULE_V2]: require('../../../images/modules/magdeck_gen2.png'), + }, + [TEMPERATURE_MODULE_TYPE]: { + [TEMPERATURE_MODULE_V1]: require('../../../images/modules/tempdeck_gen1.png'), + [TEMPERATURE_MODULE_V2]: require('../../../images/modules/tempdeck_gen2.png'), + }, + [THERMOCYCLER_MODULE_TYPE]: { + [THERMOCYCLER_MODULE_V1]: require('../../../images/modules/thermocycler.jpg'), + [THERMOCYCLER_MODULE_V2]: require('../../../images/modules/thermocycler_gen2.png'), + }, + [HEATERSHAKER_MODULE_TYPE]: { + [HEATERSHAKER_MODULE_V1]: require('../../../images/modules/heatershaker.png'), + }, + [MAGNETIC_BLOCK_TYPE]: { + [MAGNETIC_BLOCK_V1]: require('../../../images/modules/mag_block.png'), + }, +} + +export function FlexSupportedModuleDiagram(props: Props): JSX.Element { + const model = MODULE_IMG_BY_TYPE[props.type][props.model] + return {props.type} +} diff --git a/protocol-designer/src/components/FlexProtocolEditor/FlexModules/MiniCard.tsx b/protocol-designer/src/components/FlexProtocolEditor/FlexModules/MiniCard.tsx new file mode 100644 index 00000000000..1458383fcce --- /dev/null +++ b/protocol-designer/src/components/FlexProtocolEditor/FlexModules/MiniCard.tsx @@ -0,0 +1,63 @@ +import * as React from 'react' +import { css } from 'styled-components' +import { SPACING, Flex, COLORS, BORDERS } from '@opentrons/components' + +import type { StyleProps } from '@opentrons/components' + +interface MiniCardProps extends StyleProps { + onClick: (event: React.MouseEvent) => void + isSelected: boolean + children: React.ReactNode + isError?: boolean + value: string +} +const unselectedOptionStyles = css` + background-color: ${COLORS.white}; + border: 1px solid ${COLORS.medGreyEnabled}; + border-radius: ${BORDERS.radiusSoftCorners}; + padding: ${SPACING.spacing4}; + width: 100%; + cursor: pointer; + + &:hover { + border: 1px solid ${COLORS.medGreyHover}; + } +` +const selectedOptionStyles = css` + ${unselectedOptionStyles} + border: 1px solid ${COLORS.blueEnabled}; + background-color: ${COLORS.lightBlue}; + + &:hover { + border: 1px solid ${COLORS.blueEnabled}; + background-color: ${COLORS.lightBlue}; + } +` + +const errorOptionStyles = css` + ${selectedOptionStyles} + border: 1px solid ${COLORS.errorEnabled}; + background-color: ${COLORS.errorBackgroundLight}; + + &:hover { + border: 1px solid ${COLORS.errorEnabled}; + background-color: ${COLORS.errorBackgroundLight}; + } +` + +export function MiniCard(props: MiniCardProps): JSX.Element { + const { children, onClick, isSelected, isError = false } = props + + const selectedWrapperStyles = isError + ? errorOptionStyles + : selectedOptionStyles + const wrapperStyles = isSelected + ? selectedWrapperStyles + : unselectedOptionStyles + + return ( + + {children} + + ) +} diff --git a/protocol-designer/src/components/FlexProtocolEditor/FlexModules/index.tsx b/protocol-designer/src/components/FlexProtocolEditor/FlexModules/index.tsx new file mode 100644 index 00000000000..5e3a91edd22 --- /dev/null +++ b/protocol-designer/src/components/FlexProtocolEditor/FlexModules/index.tsx @@ -0,0 +1,191 @@ +import React, { useState } from 'react' +import { + Flex, + DIRECTION_COLUMN, + SPACING, + FormGroup, + DropdownField, + useHoverTooltip, +} from '@opentrons/components' +import { i18n } from '../../../localization' +import { StyledText } from '../StyledText' +import styles from '../FlexComponents.css' +import { + ModuleType, + ModuleModel, + OT3_STANDARD_MODEL, + MAGNETIC_MODULE_TYPE, + TEMPERATURE_MODULE_TYPE, + THERMOCYCLER_MODULE_TYPE, + HEATERSHAKER_MODULE_TYPE, + MAGNETIC_BLOCK_TYPE, +} from '@opentrons/shared-data' +import { + DEFAULT_MODEL_FOR_MODULE_TYPE, + MODELS_FOR_MODULE_TYPE, +} from '../../../constants' +import { MiniCard } from './MiniCard' +import { ConnectedSlotMap } from '../../modals/EditModulesModal/ConnectedSlotMap' +import { getAllFlexModuleSlotsByType } from './FlexModuleData' +import { PDAlert } from '../../alerts/PDAlert' +import { ModuleOnDeck } from '../../../step-forms' +import { ModelModuleInfo } from '../../EditModules' +import { useFormikContext } from 'formik' +import { FlexSupportedModuleDiagram } from './FlexModuleDiagram' + +export interface EditModulesModalProps { + moduleType: ModuleType + moduleOnDeck: ModuleOnDeck | null + onCloseClick: () => unknown + editModuleModel: (model: ModuleModel) => unknown + editModuleSlot: (slot: string) => unknown + displayModuleWarning: (module: ModelModuleInfo) => unknown +} + +export interface EditModulesFormValues { + selectedModel: ModuleModel | null + selectedSlot: string +} + +interface FormValues { + modulesByType: any + selectedSlot: string +} + +interface SupportedSlots { + [key: string]: string +} + +function FlexModulesComponent(): JSX.Element { + const { + values: { modulesByType }, + handleChange, + handleBlur, + setFieldValue, + errors, + } = useFormikContext() + // @ts-expect-error(sa, 2021-6-21): Object.keys not smart enough to take the keys of FormModulesByType + const modules: ModuleType[] = Object.keys(modulesByType) + + const supportedSlots: SupportedSlots = { + [MAGNETIC_MODULE_TYPE]: 'GEN1', + [TEMPERATURE_MODULE_TYPE]: 'GEN2', + [THERMOCYCLER_MODULE_TYPE]: 'GEN2', + [HEATERSHAKER_MODULE_TYPE]: 'GEN1', + [MAGNETIC_BLOCK_TYPE]: 'GEN1', + } + + const [selectedModules, setSelectedModules] = useState([]) + const toggleModuleSelection = (moduleType: string): void => { + setSelectedModules([...selectedModules, moduleType]) + if (selectedModules.includes(moduleType)) { + setSelectedModules( + selectedModules.filter((name: string) => name !== moduleType) + ) + setFieldValue(`modulesByType.${moduleType}.onDeck`, false) + } else { + setSelectedModules([...selectedModules, moduleType]) + setFieldValue(`modulesByType.${moduleType}.onDeck`, true) + } + } + + const slotIssue = + errors?.selectedSlot && errors.selectedSlot.includes('occupied') + + const [targetProps] = useHoverTooltip({ + placement: 'top', + }) + + return ( + <> + + {i18n.t('flex.modules_selection.heading')} + + + {slotIssue && ( + + )} + +
+ <> + {modules.map((moduleType, i) => { + const label = i18n.t(`modules.module_display_names.${moduleType}`) + const defaultModel = DEFAULT_MODEL_FOR_MODULE_TYPE[moduleType] + const selectedModel = modulesByType[moduleType].model + const moduleTypeAccessor = `modulesByType.${moduleType}` + return ( +
+
+
+ toggleModuleSelection(moduleType)} + > + + + {label} + + +
+
+ + {/* Deck Map Selecetion */} + {modulesByType[moduleType].onDeck && ( + <> + + name === supportedSlots[moduleType] + )} + value={selectedModel} + onChange={handleChange} + onBlur={handleBlur} + /> + + + + + + + + )} +
+ ) + })} + +
+ + ) +} + +export const FlexModules = FlexModulesComponent diff --git a/protocol-designer/src/components/FlexProtocolEditor/FlexPillForm/FlexProtocolName.tsx b/protocol-designer/src/components/FlexProtocolEditor/FlexPillForm/FlexProtocolName.tsx new file mode 100644 index 00000000000..be40138b616 --- /dev/null +++ b/protocol-designer/src/components/FlexProtocolEditor/FlexPillForm/FlexProtocolName.tsx @@ -0,0 +1,92 @@ +import * as React from 'react' +import { FormGroup, InputField } from '@opentrons/components' +import { i18n } from '../../../localization' +import { StyledText } from '../StyledText' +import styles from '../FlexComponents.css' +import { useFormikContext } from 'formik' + +interface flexProtocolName { + fields: { name: string; author: string; description: string } + handleChange: () => void + handleBlur: () => void +} + +function FlexProtocolNameComponent(): JSX.Element { + const { + values, + errors, + touched, + handleChange, + handleBlur, + } = useFormikContext() + + return ( + <> + + {i18n.t('flex.name_and_description.heading')} + +
+ + {i18n.t('flex.name_and_description.choose_name')} + +
+ + + + {i18n.t('flex.name_and_description.protocol_name')} + + + {Boolean(errors?.fields?.name) && touched?.fields?.name && ( + + {errors?.fields?.name} + + )} + + +
+ + {i18n.t('flex.name_and_description.add_more_information')} + +
+ + + + {i18n.t('flex.name_and_description.organization_author')} + + + + + + + {i18n.t('flex.name_and_description.protocol_description')} + +