diff --git a/protocol-designer/src/components/BatchEditForm/__tests__/makeBatchEditFieldProps.test.ts b/protocol-designer/src/components/BatchEditForm/__tests__/makeBatchEditFieldProps.test.ts index a3fa436e663..9cfd1475561 100644 --- a/protocol-designer/src/components/BatchEditForm/__tests__/makeBatchEditFieldProps.test.ts +++ b/protocol-designer/src/components/BatchEditForm/__tests__/makeBatchEditFieldProps.test.ts @@ -36,7 +36,8 @@ describe('makeBatchEditFieldProps', () => { const result = makeBatchEditFieldProps( fieldValues, disabledFields, - handleChangeFormInput + handleChangeFormInput, + [] ) expect(result).toEqual({ @@ -76,7 +77,8 @@ describe('makeBatchEditFieldProps', () => { const result = makeBatchEditFieldProps( fieldValues, disabledFields, - handleChangeFormInput + handleChangeFormInput, + [] ) expect(result.aspirate_flowRate.disabled).toBe(true) @@ -99,7 +101,8 @@ describe('makeBatchEditFieldProps', () => { const result = makeBatchEditFieldProps( fieldValues, disabledFields, - handleChangeFormInput + handleChangeFormInput, + [] ) expect(result.aspirate_flowRate.isIndeterminate).toBe(true) @@ -119,7 +122,8 @@ describe('makeBatchEditFieldProps', () => { const result = makeBatchEditFieldProps( fieldValues, disabledFields, - handleChangeFormInput + handleChangeFormInput, + [] ) expect(result.preWetTip.isIndeterminate).toBe(true) @@ -144,7 +148,8 @@ describe('makeBatchEditFieldProps', () => { const result = makeBatchEditFieldProps( fieldValues, disabledFields, - handleChangeFormInput + handleChangeFormInput, + [] ) expect(result.preWetTip.isIndeterminate).toBe(true) diff --git a/protocol-designer/src/components/DeckSetup/LabwareOverlays/AdapterControls.tsx b/protocol-designer/src/components/DeckSetup/LabwareOverlays/AdapterControls.tsx index 6d8577cfa23..e01e1b7dcd5 100644 --- a/protocol-designer/src/components/DeckSetup/LabwareOverlays/AdapterControls.tsx +++ b/protocol-designer/src/components/DeckSetup/LabwareOverlays/AdapterControls.tsx @@ -1,5 +1,4 @@ import assert from 'assert' -import { useTranslation } from 'react-i18next' import * as React from 'react' import { DropTarget, DropTargetConnector, DropTargetMonitor } from 'react-dnd' import cx from 'classnames' @@ -76,7 +75,6 @@ export const AdapterControlsComponents = ( onDeck, allLabware, } = props - const { t } = useTranslation('deck') if ( selectedTerminalItemId !== START_TERMINAL_ITEM_ID || (itemType !== DND_TYPES.LABWARE && itemType !== null) @@ -128,11 +126,11 @@ export const AdapterControlsComponents = ( > {!isOver && } - {t(`overlay.slot.${isOver ? 'place_here' : 'add_labware'}`)} + {isOver ? 'Place Here' : 'Add Labware'} {!isOver && } - {t('overlay.edit.delete')} + {'Delete'} )} @@ -150,16 +148,14 @@ const mapDispatchToProps = (dispatch: ThunkDispatch, ownProps: OP): DP => { const adapterName = ownProps.allLabware.find(labware => labware.id === ownProps.labwareId)?.def .metadata.displayName ?? '' - const { t } = useTranslation('deck') return { addLabware: () => dispatch(openAddLabwareModal({ slot: ownProps.labwareId })), moveDeckItem: (sourceSlot, destSlot) => dispatch(moveDeckItem(sourceSlot, destSlot)), deleteLabware: () => { - window.confirm( - t('warning.cancelForSure', { adapterName: adapterName }) - ) && dispatch(deleteContainer({ labwareId: ownProps.labwareId })) + window.confirm(`"Are you sure you want to remove this ${adapterName}?`) && + dispatch(deleteContainer({ labwareId: ownProps.labwareId })) }, } } diff --git a/protocol-designer/src/components/FilePage.tsx b/protocol-designer/src/components/FilePage.tsx index 4490a10a206..f7e3be023a3 100644 --- a/protocol-designer/src/components/FilePage.tsx +++ b/protocol-designer/src/components/FilePage.tsx @@ -1,4 +1,7 @@ import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { useSelector, useDispatch } from 'react-redux' +import mapValues from 'lodash/mapValues' import { Formik, FormikProps } from 'formik' import { format } from 'date-fns' import cx from 'classnames' @@ -13,10 +16,14 @@ import { } from '@opentrons/components' import { resetScrollElements } from '../ui/steps/utils' import { Portal } from './portals/MainPageModalPortal' -import { EditPipettesModal } from './modals/EditPipettesModal' import { EditModulesCard } from './modules' import { EditModules } from './EditModules' - +import { actions, selectors as fileSelectors } from '../file-data' +import { actions as navActions } from '../navigation' +import { actions as steplistActions } from '../steplist' +import { selectors as stepFormSelectors } from '../step-forms' +import { INITIAL_DECK_SETUP_STEP_ID } from '../constants' +import { FilePipettesModal } from './modals/FilePipettesModal' import styles from './FilePage.css' import modalStyles from '../components/modals/modal.css' import formStyles from '../components/forms/forms.css' @@ -35,199 +42,203 @@ export interface Props { t: any } -interface State { - isEditPipetteModalOpen: boolean - moduleToEdit: { - moduleType: ModuleType - moduleId?: string | null - } | null -} - // 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 class FilePage extends React.Component { - state: State = { - isEditPipetteModalOpen: false, - moduleToEdit: null, - } +export const FilePage = (): JSX.Element => { + const { t } = useTranslation('button') + const dispatch = useDispatch() + + const formValues = useSelector(fileSelectors.getFileMetadata) + const instruments = useSelector( + stepFormSelectors.getPipettesForInstrumentGroup + ) + const modules = useSelector(stepFormSelectors.getModulesForEditModulesCard) + const initialDeckSetup = useSelector(stepFormSelectors.getInitialDeckSetup) + const [ + isEditPipetteModalOpen, + setEditPipetteModalOpen, + ] = React.useState(false) + const [moduleToEdit, setModuleToEdit] = React.useState<{ + moduleType: ModuleType + moduleId?: string | null + } | null>(null) - openEditPipetteModal: () => void = () => { + const swapPipetteUpdate = mapValues(initialDeckSetup.pipettes, pipette => { + if (!pipette.mount) return pipette.mount + return pipette.mount === 'left' ? 'right' : 'left' + }) + + const openEditPipetteModal = (): void => { resetScrollElements() - this.setState({ isEditPipetteModalOpen: true }) + setEditPipetteModalOpen(true) } - closeEditPipetteModal: () => void = () => - this.setState({ isEditPipetteModalOpen: false }) - - handleEditModule: (moduleType: ModuleType, moduleId?: string) => void = ( - moduleType, - moduleId - ) => { + const closeEditPipetteModal = (): void => { + setEditPipetteModalOpen(false) + } + const handleEditModule = ( + moduleType: ModuleType, + moduleId?: string | null + ): void => { resetScrollElements() - this.setState({ - moduleToEdit: { moduleType: moduleType, moduleId: moduleId }, - }) + setModuleToEdit({ moduleType: moduleType, moduleId: moduleId }) } - closeEditModulesModal: () => void = () => { - this.setState({ - moduleToEdit: null, - }) + const closeEditModulesModal = (): void => { + setModuleToEdit(null) } - render(): JSX.Element { - const { - formValues, - instruments, - goToNextPage, - saveFileMetadata, - swapPipettes, - modules, - t, - } = this.props - - return ( -
- - - {({ - handleChange, - handleSubmit, - dirty, - touched, - values, - }: FormikProps) => ( -
-
{ + dispatch(actions.saveFileMetadata(nextFormValues)) + } + + return ( +
+ + + {({ + handleChange, + handleSubmit, + dirty, + touched, + values, + }: FormikProps) => ( + +
+ - - {values.created && format(values.created, DATE_ONLY_FORMAT)} - - - - {values.lastModified && - format(values.lastModified, DATETIME_FORMAT)} - -
- -
+ + - - - - - - - -
+ {values.lastModified && + format(values.lastModified, DATETIME_FORMAT)} + +
+
-
- - {dirty ? 'UPDATE' : 'UPDATED'} - -
- - )} - - - - -
- -
- - {t('edit')} - - - {t('swap')} - -
-
-
- - - -
- - {t('continue_to_liquids')} - -
- - {this.state.isEditPipetteModalOpen && ( - - )} - {this.state.moduleToEdit && ( - + + + +
+ + + + +
+ + {dirty ? 'UPDATE' : 'UPDATED'} + +
+ )} - + + + + +
+ +
+ + {t('edit')} + + + dispatch( + steplistActions.changeSavedStepForm({ + stepId: INITIAL_DECK_SETUP_STEP_ID, + update: { + pipetteLocationUpdate: swapPipetteUpdate, + }, + }) + ) + } + className={styles.swap_button} + iconName="swap-horizontal" + name={'swapPipettes'} + disabled={instruments?.left?.pipetteSpecs?.channels === 96} + > + {t('swap')} + +
+
+
+ + + +
+ dispatch(navActions.navigateToPage('liquids'))} + className={styles.continue_button} + iconName="arrow-right" + name={'continueToLiquids'} + > + {t('continue_to_liquids')} +
- ) - } + + + {isEditPipetteModalOpen && ( + + )} + {moduleToEdit != null && ( + + )} + +
+ ) } diff --git a/protocol-designer/src/components/FileSidebar/FileSidebar.tsx b/protocol-designer/src/components/FileSidebar/FileSidebar.tsx index 2f7076896f1..fc3a4ef4ed1 100644 --- a/protocol-designer/src/components/FileSidebar/FileSidebar.tsx +++ b/protocol-designer/src/components/FileSidebar/FileSidebar.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import cx from 'classnames' +import { useDispatch, useSelector } from 'react-redux' import { useTranslation } from 'react-i18next' import { DeprecatedPrimaryButton, @@ -7,6 +8,15 @@ import { OutlineButton, SidePanel, } from '@opentrons/components' +import { + actions as loadFileActions, + selectors as loadFileSelectors, +} from '../../load-file' +import { actions, selectors } from '../../navigation' +import { selectors as fileDataSelectors } from '../../file-data' +import { selectors as stepFormSelectors } from '../../step-forms' +import { getRobotType } from '../../file-data/selectors' +import { getAdditionalEquipment } from '../../step-forms/selectors' import { resetScrollElements } from '../../ui/steps/utils' import { Portal } from '../portals/MainPageModalPortal' import { useBlockingHint } from '../Hints/useBlockingHint' @@ -19,6 +29,11 @@ import { import modalStyles from '../modals/modal.css' import styles from './FileSidebar.css' +import type { + CreateCommand, + ProtocolFile, + RobotType, +} from '@opentrons/shared-data' import type { HintKey } from '../../tutorial' import type { InitialDeckSetup, @@ -26,11 +41,7 @@ import type { ModuleOnDeck, PipetteOnDeck, } from '../../step-forms' -import type { - CreateCommand, - ProtocolFile, - RobotType, -} from '@opentrons/shared-data' +import type { ThunkDispatch } from '../../types' export interface AdditionalEquipment { [additionalEquipmentId: string]: { @@ -69,6 +80,7 @@ interface MissingContent { modulesWithoutStep: ModuleOnDeck[] gripperWithoutStep: boolean fixtureWithoutStep: Fixture + t: any } const LOAD_COMMANDS: Array = [ @@ -84,9 +96,8 @@ function getWarningContent({ modulesWithoutStep, gripperWithoutStep, fixtureWithoutStep, + t, }: MissingContent): WarningContent | null { - const { t } = useTranslation(['alert', 'modules']) - if (noCommands) { return { content: ( @@ -218,8 +229,7 @@ function getWarningContent({ return null } -export function v8WarningContent(): JSX.Element { - const { t } = useTranslation('alert') +export function v8WarningContent(t: any): JSX.Element { return (

@@ -230,23 +240,25 @@ export function v8WarningContent(): JSX.Element {

) } -export function FileSidebar(props: Props): JSX.Element { - const { - canDownload, - fileData, - loadFile, - createNewFile, - onDownload, - modulesOnDeck, - pipettesOnDeck, - savedStepForms, - robotType, - additionalEquipment, - } = props +export function FileSidebar(): JSX.Element { + const fileData = useSelector(fileDataSelectors.createFile) + const canDownload = useSelector(selectors.getCurrentPage) + const initialDeckSetup = useSelector(stepFormSelectors.getInitialDeckSetup) + const modulesOnDeck = initialDeckSetup.modules + const pipettesOnDeck = initialDeckSetup.pipettes + const robotType = useSelector(getRobotType) + const additionalEquipment = useSelector(getAdditionalEquipment) + const savedStepForms = useSelector(stepFormSelectors.getSavedStepForms) + const newProtocolModal = useSelector(selectors.getNewProtocolModal) + const hasUnsavedChanges = useSelector(loadFileSelectors.getHasUnsavedChanges) + const canCreateNew = !newProtocolModal + const dispatch: ThunkDispatch = useDispatch() + const [ showExportWarningModal, setShowExportWarningModal, ] = React.useState(false) + const { t } = useTranslation(['alert', 'modules']) const isGripperAttached = Object.values(additionalEquipment).some( equipment => equipment?.name === 'gripper' ) @@ -267,6 +279,20 @@ export function FileSidebar(props: Props): JSX.Element { const cancelModal = (): void => setShowExportWarningModal(false) + const loadFile = ( + fileChangeEvent: React.ChangeEvent + ): void => { + if (!hasUnsavedChanges || window.confirm(t('window.confirm_import'))) { + dispatch(loadFileActions.loadProtocolFile(fileChangeEvent)) + } + } + + const createNewFile = (): void => { + if (canCreateNew) { + dispatch(actions.toggleNewProtocolModal(true)) + } + } + const nonLoadCommands = fileData?.commands.filter( command => !LOAD_COMMANDS.includes(command.commandType) @@ -312,6 +338,7 @@ export function FileSidebar(props: Props): JSX.Element { modulesWithoutStep, gripperWithoutStep, fixtureWithoutStep, + t, }) const getExportHintContent = (): { @@ -320,7 +347,7 @@ export function FileSidebar(props: Props): JSX.Element { } => { return { hintKey: 'export_v8_protocol_7_1', - content: v8WarningContent(), + content: v8WarningContent(t), } } @@ -333,7 +360,7 @@ export function FileSidebar(props: Props): JSX.Element { handleCancel: () => setShowBlockingHint(false), handleContinue: () => { setShowBlockingHint(false) - onDownload() + dispatch(loadFileActions.saveProtocolFile()) }, }) diff --git a/protocol-designer/src/components/FileSidebar/index.ts b/protocol-designer/src/components/FileSidebar/index.ts deleted file mode 100644 index 70d95785327..00000000000 --- a/protocol-designer/src/components/FileSidebar/index.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { connect } from 'react-redux' -import { useTranslation } from 'react-i18next' -import { actions, selectors } from '../../navigation' -import { selectors as fileDataSelectors } from '../../file-data' -import { selectors as stepFormSelectors } from '../../step-forms' -import { getRobotType } from '../../file-data/selectors' -import { getAdditionalEquipment } from '../../step-forms/selectors' -import { - actions as loadFileActions, - selectors as loadFileSelectors, -} from '../../load-file' -import { - AdditionalEquipment, - FileSidebar as FileSidebarComponent, - Props, -} from './FileSidebar' -import type { RobotType } from '@opentrons/shared-data' -import type { BaseState, ThunkDispatch } from '../../types' -import type { SavedStepFormState, InitialDeckSetup } from '../../step-forms' - -interface SP { - canDownload: boolean - fileData: Props['fileData'] - _canCreateNew?: boolean | null - _hasUnsavedChanges?: boolean | null - pipettesOnDeck: InitialDeckSetup['pipettes'] - modulesOnDeck: InitialDeckSetup['modules'] - savedStepForms: SavedStepFormState - robotType: RobotType - additionalEquipment: AdditionalEquipment - t: any -} -export const FileSidebar = connect( - mapStateToProps, - null, - mergeProps -)(FileSidebarComponent) - -function mapStateToProps(state: BaseState): SP { - const fileData = fileDataSelectors.createFile(state) - const canDownload = selectors.getCurrentPage(state) !== 'file-splash' - const initialDeckSetup = stepFormSelectors.getInitialDeckSetup(state) - const robotType = getRobotType(state) - const additionalEquipment = getAdditionalEquipment(state) - const { t } = useTranslation('alert') - - return { - canDownload, - fileData, - pipettesOnDeck: initialDeckSetup.pipettes, - modulesOnDeck: initialDeckSetup.modules, - savedStepForms: stepFormSelectors.getSavedStepForms(state), - robotType: robotType, - additionalEquipment: additionalEquipment, - // Ignore clicking 'CREATE NEW' button in these cases - _canCreateNew: !selectors.getNewProtocolModal(state), - _hasUnsavedChanges: loadFileSelectors.getHasUnsavedChanges(state), - t, - } -} - -function mergeProps( - stateProps: SP, - dispatchProps: { - dispatch: ThunkDispatch - } -): Props { - const { - _canCreateNew, - _hasUnsavedChanges, - canDownload, - fileData, - pipettesOnDeck, - modulesOnDeck, - savedStepForms, - robotType, - additionalEquipment, - t, - } = stateProps - const { dispatch } = dispatchProps - return { - loadFile: fileChangeEvent => { - if (!_hasUnsavedChanges || window.confirm(t('window.confirm_import'))) { - dispatch(loadFileActions.loadProtocolFile(fileChangeEvent)) - } - }, - canDownload, - createNewFile: _canCreateNew - ? () => dispatch(actions.toggleNewProtocolModal(true)) - : undefined, - onDownload: () => dispatch(loadFileActions.saveProtocolFile()), - fileData, - pipettesOnDeck, - modulesOnDeck, - savedStepForms, - robotType, - additionalEquipment, - } -} diff --git a/protocol-designer/src/components/Hints/index.tsx b/protocol-designer/src/components/Hints/index.tsx index cbf100d3358..1b360576e7f 100644 --- a/protocol-designer/src/components/Hints/index.tsx +++ b/protocol-designer/src/components/Hints/index.tsx @@ -1,5 +1,6 @@ import * as React from 'react' -import { connect } from 'react-redux' +import { useTranslation } from 'react-i18next' +import { useSelector, useDispatch } from 'react-redux' import { AlertModal, DeprecatedCheckboxField, @@ -7,51 +8,37 @@ import { OutlineButton, Text, } from '@opentrons/components' -import { actions as stepsActions } from '../../ui/steps' -import { TerminalItemId } from '../../steplist' +// import { actions as stepsActions } from '../../ui/steps' +// import { TerminalItemId } from '../../steplist' import { actions, selectors, HintKey } from '../../tutorial' import { Portal } from '../portals/MainPageModalPortal' import styles from './hints.css' import EXAMPLE_ADD_LIQUIDS_IMAGE from '../../images/example_add_liquids.png' import EXAMPLE_WATCH_LIQUIDS_MOVE_IMAGE from '../../images/example_watch_liquids_move.png' import EXAMPLE_BATCH_EDIT_IMAGE from '../../images/announcements/multi_select.gif' -import { BaseState, ThunkDispatch } from '../../types' -import { useTranslation } from 'react-i18next' - -interface SP { - t: any - hintKey?: HintKey | null -} -interface DP { - removeHint: (key: HintKey, rememberDismissal: boolean) => unknown - selectTerminalItem: (item: TerminalItemId) => unknown -} -type Props = SP & DP - -interface State { - rememberDismissal: boolean -} -// List of hints that should have /!\ gray AlertModal header -// (versus calmer non-alert header) const HINT_IS_ALERT: HintKey[] = ['add_liquids_and_labware'] -class HintsComponent extends React.Component { - constructor(props: Props) { - super(props) - this.state = { rememberDismissal: false } - } - - toggleRememberDismissal = (): void => { - this.setState({ rememberDismissal: !this.state.rememberDismissal }) +export const Hints = (): JSX.Element | null => { + const { t } = useTranslation(['alert', 'nav', 'button']) + const [rememberDismissal, toggleRememberDismissal] = React.useState( + false + ) + const hintKey = useSelector(selectors.getHint) + const dispatch = useDispatch() + const removeHint = (hintKey: HintKey): void => { + dispatch(actions.removeHint(hintKey, rememberDismissal)) } + // Is this needed? + // const selectTerminalItem = (terminalId: TerminalItemId): void => { + // dispatch(stepsActions.selectTerminalItem(terminalId)) + // } - makeHandleCloseClick = (hintKey: HintKey): (() => void) => { - const { rememberDismissal } = this.state - return () => this.props.removeHint(hintKey, rememberDismissal) + const makeHandleCloseClick = (hintKey: HintKey): (() => void) => { + return () => removeHint(hintKey) } - renderHintContents = (hintKey: HintKey): JSX.Element | null => { + const renderHintContents = (hintKey: HintKey): JSX.Element | null => { // Only hints that have no outside effects should go here. // For hints that have an effect, use BlockingHint. switch (hintKey) { @@ -59,19 +46,15 @@ class HintsComponent extends React.Component { return ( <>
- {this.props.t('hint.add_liquids_and_labware.summary', { - deck_setup_step: this.props.t( - 'nav:terminal_item.__initial_setup__' - ), + {t('hint.add_liquids_and_labware.summary', { + deck_setup_step: t('nav:terminal_item.__initial_setup__'), })}
Step 1: - - {this.props.t('hint.add_liquids_and_labware.step1')} - + {t('hint.add_liquids_and_labware.step1')}
@@ -79,9 +62,7 @@ class HintsComponent extends React.Component {
Step 2: - - {this.props.t('hint.add_liquids_and_labware.step2')} - + {t('hint.add_liquids_and_labware.step2')}
@@ -90,33 +71,31 @@ class HintsComponent extends React.Component { case 'deck_setup_explanation': return ( <> -

{this.props.t(`hint.${hintKey}.body1`)}

-

{this.props.t(`hint.${hintKey}.body2`)}

-

{this.props.t(`hint.${hintKey}.body3`)}

+

{t(`hint.${hintKey}.body1`)}

+

{t(`hint.${hintKey}.body2`)}

+

{t(`hint.${hintKey}.body3`)}

) case 'module_without_labware': return ( <> -

{this.props.t(`alert:hint.${hintKey}.body`)}

+

{t(`alert:hint.${hintKey}.body`)}

) case 'thermocycler_lid_passive_cooling': return ( <>

- {this.props.t(`alert:hint.${hintKey}.body1a`)} - - {this.props.t(`alert:hint.${hintKey}.strong_body1`)} - - {this.props.t(`alert:hint.${hintKey}.body1b`)} + {t(`alert:hint.${hintKey}.body1a`)} + {t(`alert:hint.${hintKey}.strong_body1`)} + {t(`alert:hint.${hintKey}.body1b`)}

  1. - {this.props.t(`alert:hint.${hintKey}.li1`)} + {t(`alert:hint.${hintKey}.li1`)}
  2. - {this.props.t(`alert:hint.${hintKey}.li2`)} + {t(`alert:hint.${hintKey}.li2`)}
@@ -128,33 +107,29 @@ class HintsComponent extends React.Component { -

{this.props.t(`alert:hint.${hintKey}.body1`)}

+

{t(`alert:hint.${hintKey}.body1`)}

- {this.props.t(`alert:hint.${hintKey}.body2`)} + {`alert:hint.${hintKey}.body2`}

  1. - {this.props.t(`alert:hint.${hintKey}.li1a`)} - - {this.props.t(`alert:hint.${hintKey}.strong_li1`)} - - {this.props.t(`alert:hint.${hintKey}.li1b`)} + {t(`alert:hint.${hintKey}.li1a`)} + {t(`alert:hint.${hintKey}.strong_li1`)} + {t(`alert:hint.${hintKey}.li1b`)}
  2. - {this.props.t(`alert:hint.${hintKey}.li2a`)} - - {this.props.t(`alert:hint.${hintKey}.strong_li2`)} - - {this.props.t(`alert:hint.${hintKey}.li2b`)} + {t(`alert:hint.${hintKey}.li2a`)} + {t(`alert:hint.${hintKey}.strong_li2`)} + {t(`alert:hint.${hintKey}.li2b`)}

- {this.props.t(`alert:hint.${hintKey}.body3a`)}
- {this.props.t(`alert:hint.${hintKey}.body3b`)} + {t(`alert:hint.${hintKey}.body3a`)}
+ {t(`alert:hint.${hintKey}.body3b`)}

- {this.props.t(`alert:hint.${hintKey}.body4a`)}
- {this.props.t(`alert:hint.${hintKey}.body4b`)} + {t(`alert:hint.${hintKey}.body4a`)}
+ {t(`alert:hint.${hintKey}.body4b`)}

@@ -162,7 +137,7 @@ class HintsComponent extends React.Component { case 'waste_chute_warning': return ( - {this.props.t(`hint.${hintKey}.body1`)} + {t(`hint.${hintKey}.body1`)} ) default: @@ -170,56 +145,34 @@ class HintsComponent extends React.Component { } } - render(): React.ReactNode { - const { hintKey } = this.props - if (!hintKey) return null - - const headingText = this.props.t(`hint.${hintKey}.title`) - const hintIsAlert = HINT_IS_ALERT.includes(hintKey) - return ( - - - {!hintIsAlert ? ( -
{headingText}
- ) : null} -
- {this.renderHintContents(hintKey)} -
-
- - - {this.props.t('button:ok')} - -
-
-
- ) - } -} + if (!hintKey) return null -const mapStateToProps = (state: BaseState): SP => { - const { t } = useTranslation(['alert', 'nav', 'button']) - return { - hintKey: selectors.getHint(state), - t: t, - } + const headingText = t(`hint.${hintKey}.title`) + const hintIsAlert = HINT_IS_ALERT.includes(hintKey) + return ( + + + {!hintIsAlert ? ( +
{headingText}
+ ) : null} +
+ {renderHintContents(hintKey)} +
+
+ toggleRememberDismissal(rememberDismissal)} + value={rememberDismissal} + /> + + {t('button:ok')} + +
+
+
+ ) } -const mapDispatchToProps = (dispatch: ThunkDispatch): DP => ({ - removeHint: (hintKey, rememberDismissal) => - dispatch(actions.removeHint(hintKey, rememberDismissal)), - selectTerminalItem: terminalId => - dispatch(stepsActions.selectTerminalItem(terminalId)), -}) - -export const Hints = connect( - mapStateToProps, - mapDispatchToProps -)(HintsComponent) diff --git a/protocol-designer/src/components/LiquidPlacementForm/LiquidPlacementForm.tsx b/protocol-designer/src/components/LiquidPlacementForm/LiquidPlacementForm.tsx index cb8185efe24..1270ecfaa83 100644 --- a/protocol-designer/src/components/LiquidPlacementForm/LiquidPlacementForm.tsx +++ b/protocol-designer/src/components/LiquidPlacementForm/LiquidPlacementForm.tsx @@ -1,7 +1,11 @@ import * as React from 'react' +import isEmpty from 'lodash/isEmpty' +import { useTranslation } from 'react-i18next' +import { useSelector, useDispatch } from 'react-redux' +import assert from 'assert' import { Formik } from 'formik' import * as Yup from 'yup' -// TODO: Ian 2018-10-19 move the processors out of steplist (chore) +import * as wellContentsSelectors from '../../top-selectors/well-contents' import * as fieldProcessors from '../../steplist/fieldLevel/processing' import { DropdownField, @@ -9,8 +13,16 @@ import { FormGroup, OutlineButton, DeprecatedPrimaryButton, - Options, } from '@opentrons/components' +import { deselectAllWells } from '../../well-selection/actions' +import { + removeWellsContents, + setWellContents, +} from '../../labware-ingred/actions' +import { getSelectedWells } from '../../well-selection/selectors' + +import { selectors as labwareIngredSelectors } from '../../labware-ingred/selectors' + import styles from './LiquidPlacementForm.css' import formStyles from '../forms/forms.css' import stepEditFormStyles from '../StepEditForm/StepEditForm.css' @@ -19,34 +31,50 @@ interface ValidFormValues { selectedLiquidId: string volume: string } - -export interface LiquidPlacementFormValues { +interface LiquidPlacementFormValues { selectedLiquidId?: string | null volume?: string | null } -export interface Props { - commonSelectedLiquidId?: string | null - commonSelectedVolume?: number | null - liquidSelectionOptions: Options - selectedWellsMaxVolume: number - showForm: boolean - t: any - cancelForm: () => unknown - clearWells: (() => unknown | null) | null - saveForm: (liquidPlacementFormValues: LiquidPlacementFormValues) => unknown -} +export const LiquidPlacementForm = (): JSX.Element | null => { + const { t } = useTranslation(['form', 'button', 'application']) + const selectedWellGroups = useSelector(getSelectedWells) + const selectedWells = Object.keys(selectedWellGroups) + const showForm = !isEmpty(selectedWellGroups) + const dispatch = useDispatch() + const labwareId = useSelector(labwareIngredSelectors.getSelectedLabwareId) + const liquidLocations = useSelector( + labwareIngredSelectors.getLiquidsByLabwareId + ) + const commonSelectedLiquidId = useSelector( + wellContentsSelectors.getSelectedWellsCommonIngredId + ) + const commonSelectedVolume = useSelector( + wellContentsSelectors.getSelectedWellsCommonVolume + ) + const selectedWellsMaxVolume = useSelector( + wellContentsSelectors.getSelectedWellsMaxVolume + ) + const liquidSelectionOptions = useSelector( + labwareIngredSelectors.getLiquidSelectionOptions + ) + + const selectionHasLiquids = Boolean( + labwareId && + liquidLocations[labwareId] && + Object.keys(selectedWellGroups).some( + well => liquidLocations[labwareId][well] + ) + ) -export class LiquidPlacementForm extends React.Component { - getInitialValues: () => ValidFormValues = () => { - const { commonSelectedLiquidId, commonSelectedVolume } = this.props + const getInitialValues: () => ValidFormValues = () => { return { selectedLiquidId: commonSelectedLiquidId || '', volume: commonSelectedVolume != null ? String(commonSelectedVolume) : '', } } - getValidationSchema: () => Yup.Schema< + const getValidationSchema: () => Yup.Schema< | { selectedLiquidId: string volume: number @@ -54,39 +82,52 @@ export class LiquidPlacementForm extends React.Component { | undefined, any > = () => { - const { selectedWellsMaxVolume } = this.props return Yup.object().shape({ selectedLiquidId: Yup.string().required( - this.props.t('generic.error.required', { - name: this.props.t('liquid_placement.liquid'), + t('generic.error.required', { + name: t('liquid_placement.liquid'), }) ), volume: Yup.number() .nullable() .required( - this.props.t('generic.error.required', { - name: this.props.t('liquid_placement.volume'), + t('generic.error.required', { + name: t('liquid_placement.volume'), }) ) - .moreThan(0, this.props.t('generic.error.more_than_zero')) + .moreThan(0, t('generic.error.more_than_zero')) .max( selectedWellsMaxVolume, - this.props.t('liquid_placement.volume_exceeded', { + t('liquid_placement.volume_exceeded', { volume: selectedWellsMaxVolume, }) ), }) } - handleCancelForm: () => void = () => { - this.props.cancelForm() + const handleCancelForm = (): void => { + dispatch(deselectAllWells()) } - handleClearWells: () => void = () => { - this.props.clearWells && this.props.clearWells() + const handleClearWells: () => void = () => { + if (labwareId && selectedWells && selectionHasLiquids) { + // TODO: Ian 2018-10-22 replace with modal later on if we like this UX + if ( + global.confirm( + 'Are you sure you want to remove liquids from all selected wells?' + ) + ) { + dispatch( + removeWellsContents({ + labwareId: labwareId, + wells: selectedWells, + }) + ) + } + } } - handleChangeVolume: ( + const handleChangeVolume: ( setFieldValue: (fieldName: string, value: unknown) => unknown ) => (e: React.ChangeEvent) => void = setFieldValue => e => { const value: string | null | undefined = e.currentTarget.value @@ -98,81 +139,114 @@ export class LiquidPlacementForm extends React.Component { setFieldValue('volume', masked) } - handleSubmit: (values: LiquidPlacementFormValues) => void = values => { - this.props.saveForm(values) - } + const handleSaveForm = (values: LiquidPlacementFormValues): void => { + const volume = Number(values.volume) + const { selectedLiquidId } = values + assert( + labwareId != null, + 'when saving liquid placement form, expected a selected labware ID' + ) + assert( + selectedWells && selectedWells.length > 0, + `when saving liquid placement form, expected selected wells to be array with length > 0 but got ${String( + selectedWells + )}` + ) + assert( + selectedLiquidId != null, + `when saving liquid placement form, expected selectedLiquidId to be non-nullsy but got ${String( + selectedLiquidId + )}` + ) + assert( + volume > 0, + `when saving liquid placement form, expected volume > 0, got ${volume}` + ) - render(): React.ReactNode | null { - const { liquidSelectionOptions, showForm } = this.props - if (!showForm) return null - return ( -
- - {({ - handleBlur, - handleChange, - handleSubmit, - errors, - setFieldValue, - touched, - values, - }) => ( -
-
- - - - - - -
+ if (labwareId != null && selectedLiquidId != null) { + dispatch( + setWellContents({ + liquidGroupId: selectedLiquidId, + labwareId: labwareId, + wells: selectedWells || [], + volume: Number(values.volume), + }) + ) + } + } -
- - {this.props.t('button:clear_wells')} - - - {this.props.t('button:cancel')} - - - {this.props.t('button:save')} - -
-
- )} -
-
- ) + const handleSubmit: (values: LiquidPlacementFormValues) => void = values => { + handleSaveForm(values) } + + if (!showForm) return null + return ( +
+ + {({ + handleBlur, + handleChange, + handleSubmit, + errors, + setFieldValue, + touched, + values, + }) => ( +
+
+ + + + + + +
+ +
+ + {t('button:clear_wells')} + + + {t('button:cancel')} + + + {t('button:save')} + +
+
+ )} +
+
+ ) } diff --git a/protocol-designer/src/components/LiquidPlacementForm/index.ts b/protocol-designer/src/components/LiquidPlacementForm/index.ts deleted file mode 100644 index e3d4541510e..00000000000 --- a/protocol-designer/src/components/LiquidPlacementForm/index.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { connect } from 'react-redux' -import assert from 'assert' -import isEmpty from 'lodash/isEmpty' -import { - removeWellsContents, - setWellContents, -} from '../../labware-ingred/actions' -import { selectors as labwareIngredSelectors } from '../../labware-ingred/selectors' -import * as wellContentsSelectors from '../../top-selectors/well-contents' -import { getSelectedWells } from '../../well-selection/selectors' -import { deselectAllWells } from '../../well-selection/actions' -import { - LiquidPlacementForm as LiquidPlacementFormComponent, - Props as LiquidPlacementFormProps, - LiquidPlacementFormValues, -} from './LiquidPlacementForm' -import { Dispatch } from 'redux' -import { BaseState } from '../../types' -import { useTranslation } from 'react-i18next' -type SP = Omit< - LiquidPlacementFormProps & { - _labwareId?: string | null - _selectedWells?: string[] | null - _selectionHasLiquids: boolean - }, - 'cancelForm' | 'clearWells' | 'saveForm' -> - -function mapStateToProps(state: BaseState): SP { - const selectedWells = getSelectedWells(state) - const { t } = useTranslation(['form', 'button', 'application']) - const _labwareId = labwareIngredSelectors.getSelectedLabwareId(state) - - const liquidLocations = labwareIngredSelectors.getLiquidsByLabwareId(state) - - const _selectionHasLiquids = Boolean( - _labwareId && - liquidLocations[_labwareId] && - Object.keys(selectedWells).some(well => liquidLocations[_labwareId][well]) - ) - - return { - commonSelectedLiquidId: wellContentsSelectors.getSelectedWellsCommonIngredId( - state - ), - commonSelectedVolume: wellContentsSelectors.getSelectedWellsCommonVolume( - state - ), - liquidSelectionOptions: labwareIngredSelectors.getLiquidSelectionOptions( - state - ), - showForm: !isEmpty(selectedWells), - selectedWellsMaxVolume: wellContentsSelectors.getSelectedWellsMaxVolume( - state - ), - _labwareId, - _selectedWells: Object.keys(selectedWells), - _selectionHasLiquids, - t: t, - } -} - -function mergeProps( - stateProps: SP, - dispatchProps: { - dispatch: Dispatch - } -): LiquidPlacementFormProps { - const { - _labwareId, - _selectedWells, - _selectionHasLiquids, - ...passThruProps - } = stateProps - const { dispatch } = dispatchProps - const clearWells = - _labwareId && _selectedWells && _selectionHasLiquids - ? () => { - // TODO: Ian 2018-10-22 replace with modal later on if we like this UX - if ( - global.confirm( - 'Are you sure you want to remove liquids from all selected wells?' - ) - ) { - dispatch( - removeWellsContents({ - labwareId: _labwareId, - wells: _selectedWells, - }) - ) - } - } - : null - return { - ...passThruProps, - cancelForm: () => dispatch(deselectAllWells()), - clearWells, - saveForm: (values: LiquidPlacementFormValues) => { - const { selectedLiquidId } = values - const volume = Number(values.volume) - assert( - _labwareId != null, - 'when saving liquid placement form, expected a selected labware ID' - ) - assert( - _selectedWells && _selectedWells.length > 0, - `when saving liquid placement form, expected selected wells to be array with length > 0 but got ${String( - _selectedWells - )}` - ) - assert( - selectedLiquidId != null, - `when saving liquid placement form, expected selectedLiquidId to be non-nullsy but got ${String( - selectedLiquidId - )}` - ) - assert( - volume > 0, - `when saving liquid placement form, expected volume > 0, got ${volume}` - ) - - if (_labwareId != null && selectedLiquidId != null) { - dispatch( - setWellContents({ - liquidGroupId: selectedLiquidId, - labwareId: _labwareId, - wells: _selectedWells || [], - volume: Number(values.volume), - }) - ) - } - }, - } -} - -export const LiquidPlacementForm = connect( - mapStateToProps, - null, - mergeProps -)(LiquidPlacementFormComponent) diff --git a/protocol-designer/src/components/LiquidPlacementModal.tsx b/protocol-designer/src/components/LiquidPlacementModal.tsx index 993f320db38..0739b6d740e 100644 --- a/protocol-designer/src/components/LiquidPlacementModal.tsx +++ b/protocol-designer/src/components/LiquidPlacementModal.tsx @@ -8,7 +8,7 @@ import { wellFillFromWellContents, SelectableLabware, } from '../components/labware' -import { LiquidPlacementForm } from '../components/LiquidPlacementForm' +import { LiquidPlacementForm } from './LiquidPlacementForm/LiquidPlacementForm' import { WellSelectionInstructions } from './WellSelectionInstructions' import { selectors } from '../labware-ingred/selectors' diff --git a/protocol-designer/src/components/LiquidsPage/LiquidEditForm.tsx b/protocol-designer/src/components/LiquidsPage/LiquidEditForm.tsx index edbb0425f4b..d1ccf0161a0 100644 --- a/protocol-designer/src/components/LiquidsPage/LiquidEditForm.tsx +++ b/protocol-designer/src/components/LiquidsPage/LiquidEditForm.tsx @@ -51,15 +51,10 @@ function checkColor(hex: string): boolean { const INVALID_DISPLAY_COLORS = ['#000000', '#ffffff', DEPRECATED_WHALE_GREY] export const liquidEditFormSchema: Yup.Schema< - { description: string; serialize: boolean } | undefined, + { name: string; description: string; serialize: boolean } | undefined, any > = Yup.object().shape({ - //TODO: FIX THIS - // name: Yup.string().required( - // i18n.t('form.generic.error.required', { - // name: i18n.t('form.liquid_edit.name'), - // }) - // ), + name: Yup.string().required('liquid name is required'), displayColor: Yup.string().test( 'disallowed-color', 'Invalid display color', diff --git a/protocol-designer/src/components/StepEditForm/FormAlerts.ts b/protocol-designer/src/components/StepEditForm/FormAlerts.ts deleted file mode 100644 index a10d37e7df2..00000000000 --- a/protocol-designer/src/components/StepEditForm/FormAlerts.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { connect } from 'react-redux' -import { Props, Alerts } from '../alerts/Alerts' -import { - actions as dismissActions, - selectors as dismissSelectors, -} from '../../dismiss' -import { getSelectedStepId } from '../../ui/steps' -import { selectors as stepFormSelectors } from '../../step-forms' -import { - getVisibleFormErrors, - getVisibleFormWarnings, - getVisibleProfileFormLevelErrors, -} from './utils' -import { Dispatch } from 'redux' -import { ProfileItem, StepIdType } from '../../form-types' -import { StepFieldName } from '../../steplist/fieldLevel' -import { BaseState } from '../../types' -import { ProfileFormError } from '../../steplist/formLevel/profileErrors' - -/* TODO: BC 2018-09-13 move to src/components/alerts and adapt and use src/components/alerts/Alerts - * see #1814 for reference - */ -interface SP { - errors: Props['errors'] - warnings: Props['warnings'] - stepId?: StepIdType | null | undefined -} -interface OP { - focusedField: StepFieldName | null - dirtyFields: StepFieldName[] -} - -const mapStateToProps = (state: BaseState, ownProps: OP): SP => { - const { focusedField, dirtyFields } = ownProps - const visibleWarnings = getVisibleFormWarnings({ - focusedField, - dirtyFields, - errors: dismissSelectors.getFormWarningsForSelectedStep(state), - }) - const formLevelErrors = stepFormSelectors.getFormLevelErrorsForUnsavedForm( - state - ) - const visibleErrors = getVisibleFormErrors({ - focusedField, - dirtyFields, - errors: formLevelErrors, - }) - // deal with special-case dynamic field form-level errors - const unsavedForm = stepFormSelectors.getHydratedUnsavedForm(state) - const profileItemsById: Record | null | undefined = - unsavedForm?.profileItemsById - let visibleDynamicFieldFormErrors: ProfileFormError[] = [] - - if (profileItemsById != null) { - const dynamicFieldFormErrors = stepFormSelectors.getDynamicFieldFormErrorsForUnsavedForm( - state - ) - visibleDynamicFieldFormErrors = getVisibleProfileFormLevelErrors({ - focusedField, - dirtyFields, - errors: dynamicFieldFormErrors, - profileItemsById, - }) - } - - return { - errors: [ - ...visibleErrors.map(error => ({ - title: error.title, - description: error.body || null, - })), - ...visibleDynamicFieldFormErrors.map(error => ({ - title: error.title, - description: error.body || null, - })), - ], - warnings: visibleWarnings.map(warning => ({ - title: warning.title, - description: warning.body || null, - dismissId: warning.type, - })), - stepId: getSelectedStepId(state), - } -} - -const mergeProps = ( - stateProps: SP, - dispatchProps: { - dispatch: Dispatch - } -): Props => { - const { stepId } = stateProps - const { dispatch } = dispatchProps - return { - ...stateProps, - dismissWarning: (dismissId: string) => { - if (stepId) - dispatch( - dismissActions.dismissFormWarning({ - type: dismissId, - stepId, - }) - ) - }, - } -} - -export const FormAlerts = connect(mapStateToProps, null, mergeProps)(Alerts) diff --git a/protocol-designer/src/components/StepEditForm/StepEditFormComponent.tsx b/protocol-designer/src/components/StepEditForm/StepEditFormComponent.tsx index 434c70e3695..6f146047e73 100644 --- a/protocol-designer/src/components/StepEditForm/StepEditFormComponent.tsx +++ b/protocol-designer/src/components/StepEditForm/StepEditFormComponent.tsx @@ -13,7 +13,7 @@ import { ThermocyclerForm, HeaterShakerForm, } from './forms' -import { FormAlerts } from './FormAlerts' +import { Alerts } from '../alerts/Alerts' import { ButtonRow } from './ButtonRow' import formStyles from '../forms/forms.css' import styles from './StepEditForm.css' @@ -82,7 +82,11 @@ export const StepEditFormComponent = (props: Props): JSX.Element => { {showMoreOptionsModal && ( )} - +
= [ }, ] -type PathFieldProps = FieldProps & - ValuesForPath & { - disabledPathMap: DisabledPathMap - } +type PathFieldProps = FieldProps & ValuesForPath interface ButtonProps { children?: React.ReactNode @@ -103,8 +106,33 @@ const getSubtitle = ( return reasonForDisabled || '' } -export const Path = (props: PathFieldProps): JSX.Element => { - const { disabledPathMap, value, updateValue } = props +export const PathField = (props: PathFieldProps): JSX.Element => { + const { + aspirate_airGap_checkbox, + aspirate_airGap_volume, + aspirate_wells, + changeTip, + dispense_wells, + pipette, + volume, + value, + updateValue, + } = props + const { t } = useTranslation('form') + const pipetteEntities = useSelector(stepFormSelectors.getPipetteEntities) + const disabledPathMap = getDisabledPathMap( + { + aspirate_airGap_checkbox, + aspirate_airGap_volume, + aspirate_wells, + changeTip, + dispense_wells, + pipette, + volume, + }, + pipetteEntities, + t + ) return (
    diff --git a/protocol-designer/src/components/StepEditForm/fields/PathField/index.ts b/protocol-designer/src/components/StepEditForm/fields/PathField/index.ts deleted file mode 100644 index 7ccd037f6fd..00000000000 --- a/protocol-designer/src/components/StepEditForm/fields/PathField/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -import * as React from 'react' -import { useTranslation } from 'react-i18next' -import { connect } from 'react-redux' -import { Path } from './Path' -import { selectors as stepFormSelectors } from '../../../../step-forms' -import { getDisabledPathMap } from './getDisabledPathMap' -import { BaseState } from '../../../../types' -type Props = React.ComponentProps -interface SP { - disabledPathMap: Props['disabledPathMap'] -} -type OP = Omit - -function mapSTP(state: BaseState, ownProps: OP): SP { - const { - aspirate_airGap_checkbox, - aspirate_airGap_volume, - aspirate_wells, - changeTip, - dispense_wells, - pipette, - volume, - } = ownProps - const { t } = useTranslation('form') - const pipetteEntities = stepFormSelectors.getPipetteEntities(state) - const disabledPathMap = getDisabledPathMap( - { - aspirate_airGap_checkbox, - aspirate_airGap_volume, - aspirate_wells, - changeTip, - dispense_wells, - pipette, - volume, - }, - pipetteEntities, - t - ) - return { - disabledPathMap, - } -} - -export const PathField = connect(mapSTP, () => ({}))(Path) diff --git a/protocol-designer/src/components/StepEditForm/fields/WellSelectionField/WellSelectionField.tsx b/protocol-designer/src/components/StepEditForm/fields/WellSelectionField/WellSelectionField.tsx new file mode 100644 index 00000000000..c42fd21212e --- /dev/null +++ b/protocol-designer/src/components/StepEditForm/fields/WellSelectionField/WellSelectionField.tsx @@ -0,0 +1,112 @@ +import * as React from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { useTranslation } from 'react-i18next' +import { ALL, COLUMN } from '@opentrons/shared-data' +import { selectors as stepFormSelectors } from '../../../../step-forms' +import { FormGroup, InputField } from '@opentrons/components' +import { Portal } from '../../../portals/MainPageModalPortal' +import { + actions as stepsActions, + getSelectedStepId, + getWellSelectionLabwareKey, +} from '../../../../ui/steps' +import { WellSelectionModal } from './WellSelectionModal' +import styles from '../../StepEditForm.css' + +import type { NozzleType } from '../../../../types' +import type { FieldProps } from '../../types' + +export type Props = FieldProps & { + nozzles: string | null + pipetteId?: string | null + labwareId?: string | null +} + +export const WellSelectionField = (props: Props): JSX.Element => { + const { t } = useTranslation('form') + const { + nozzles, + labwareId, + pipetteId, + onFieldFocus, + value: selectedWells, + updateValue, + onFieldBlur, + name, + disabled, + errorToShow, + } = props + const dispatch = useDispatch() + const stepId = useSelector(getSelectedStepId) + const pipetteEntities = useSelector(stepFormSelectors.getPipetteEntities) + const wellSelectionLabwareKey = useSelector(getWellSelectionLabwareKey) + const primaryWellCount = Array.isArray(selectedWells) + ? selectedWells.length.toString() + : undefined + const pipette = pipetteId != null ? pipetteEntities[pipetteId] : null + const is8Channel = pipette != null ? pipette.spec.channels === 8 : false + + let nozzleType: NozzleType | null = null + if (pipette !== null && is8Channel) { + nozzleType = '8-channel' + } else if (nozzles === COLUMN) { + nozzleType = COLUMN + } else if (nozzles === ALL) { + nozzleType = ALL + } + + const getModalKey = (): string => { + return `${String(stepId)}${name}${pipetteId || 'noPipette'}${ + labwareId || 'noLabware' + }` + } + + const onOpen = (key: string): void => { + dispatch(stepsActions.setWellSelectionLabwareKey(key)) + } + const handleOpen = (): void => { + if (onFieldFocus) { + onFieldFocus() + } + if (labwareId && pipetteId) { + onOpen(getModalKey()) + } + } + + const handleClose = (): void => { + if (onFieldBlur) { + onFieldBlur() + } + dispatch(stepsActions.clearWellSelectionLabwareKey()) + } + + const modalKey = getModalKey() + const label = + nozzleType === '8-channel' || nozzleType === COLUMN + ? t('step_edit_form.wellSelectionLabel.columns') + : t('step_edit_form.wellSelectionLabel.wells') + return ( + + + + + + + ) +} diff --git a/protocol-designer/src/components/StepEditForm/fields/WellSelectionField/WellSelectionInput.tsx b/protocol-designer/src/components/StepEditForm/fields/WellSelectionField/WellSelectionInput.tsx deleted file mode 100644 index d2d6914e1f5..00000000000 --- a/protocol-designer/src/components/StepEditForm/fields/WellSelectionField/WellSelectionInput.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import * as React from 'react' -import { Dispatch } from 'redux' -import { useTranslation } from 'react-i18next' -import { connect } from 'react-redux' -import { COLUMN } from '@opentrons/shared-data' -import { FormGroup, InputField } from '@opentrons/components' -import { Portal } from '../../../portals/MainPageModalPortal' -import { - actions as stepsActions, - getSelectedStepId, - getWellSelectionLabwareKey, -} from '../../../../ui/steps' -import { WellSelectionModal } from './WellSelectionModal' -import styles from '../../StepEditForm.css' - -import type { StepIdType } from '../../../../form-types' -import type { BaseState, NozzleType } from '../../../../types' -import type { FieldProps } from '../../types' - -export interface SP { - t: any - stepId?: StepIdType | null - wellSelectionLabwareKey?: string | null -} - -export interface DP { - onOpen: (val: string) => unknown - onClose: () => unknown -} - -export type OP = FieldProps & { - primaryWellCount?: number - nozzleType?: NozzleType | null - pipetteId?: string | null - labwareId?: string | null -} - -export type Props = OP & SP & DP - -export class WellSelectionInputComponent extends React.Component { - handleOpen = (): void => { - const { labwareId, pipetteId, onFieldFocus } = this.props - - if (onFieldFocus) { - onFieldFocus() - } - if (labwareId && pipetteId) { - this.props.onOpen(this.getModalKey()) - } - } - - handleClose = (): void => { - const { onFieldBlur, onClose } = this.props - if (onFieldBlur) { - onFieldBlur() - } - onClose() - } - - getModalKey = (): string => { - const { name, pipetteId, labwareId, stepId } = this.props - return `${String(stepId)}${name}${pipetteId || 'noPipette'}${ - labwareId || 'noLabware' - }` - } - - render(): JSX.Element { - const modalKey = this.getModalKey() - const label = - this.props.nozzleType === '8-channel' || this.props.nozzleType === COLUMN - ? this.props.t('step_edit_form.wellSelectionLabel.columns') - : this.props.t('step_edit_form.wellSelectionLabel.wells') - return ( - - - - - - - ) - } -} - -const mapStateToProps = (state: BaseState): SP => { - const { t } = useTranslation('form') - return { - stepId: getSelectedStepId(state), - wellSelectionLabwareKey: getWellSelectionLabwareKey(state), - t: t, - } -} -const mapDispatchToProps = (dispatch: Dispatch): DP => ({ - onOpen: key => dispatch(stepsActions.setWellSelectionLabwareKey(key)), - onClose: () => dispatch(stepsActions.clearWellSelectionLabwareKey()), -}) - -export const WellSelectionInput = connect( - mapStateToProps, - mapDispatchToProps -)(WellSelectionInputComponent) diff --git a/protocol-designer/src/components/StepEditForm/fields/WellSelectionField/index.ts b/protocol-designer/src/components/StepEditForm/fields/WellSelectionField/index.ts deleted file mode 100644 index cad915aee72..00000000000 --- a/protocol-designer/src/components/StepEditForm/fields/WellSelectionField/index.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { connect } from 'react-redux' -import { ALL, COLUMN } from '@opentrons/shared-data' -import { selectors as stepFormSelectors } from '../../../../step-forms' -import { - WellSelectionInput, - Props as WellSelectionInputProps, - DP, -} from './WellSelectionInput' -import type { BaseState, NozzleType } from '../../../../types' -import type { FieldProps } from '../../types' -import { useTranslation } from 'react-i18next' - -type Props = Omit< - JSX.LibraryManagedAttributes< - typeof WellSelectionInput, - WellSelectionInputProps - >, - keyof DP -> -type OP = FieldProps & { - nozzles: string | null - labwareId?: string | null - pipetteId?: string | null -} -interface SP { - nozzleType: Props['nozzleType'] - primaryWellCount: Props['primaryWellCount'] - t: any -} - -const mapStateToProps = (state: BaseState, ownProps: OP): SP => { - const { pipetteId, nozzles } = ownProps - const selectedWells = ownProps.value - const pipette = - pipetteId && stepFormSelectors.getPipetteEntities(state)[pipetteId] - const is8Channel = pipette ? pipette.spec.channels === 8 : false - const { t } = useTranslation('form') - let nozzleType: NozzleType | null = null - if (pipette !== null && is8Channel) { - nozzleType = '8-channel' - } else if (nozzles === COLUMN) { - nozzleType = COLUMN - } else if (nozzles === ALL) { - nozzleType = ALL - } - - return { - primaryWellCount: Array.isArray(selectedWells) - ? selectedWells.length - : undefined, - nozzleType, - t: t, - } -} - -function mergeProps(stateProps: SP, _dispatchProps: null, ownProps: OP): Props { - const { - disabled, - errorToShow, - labwareId, - name, - onFieldBlur, - onFieldFocus, - pipetteId, - updateValue, - value, - } = ownProps - return { - disabled, - errorToShow, - nozzleType: stateProps.nozzleType, - labwareId, - name, - onFieldBlur, - onFieldFocus, - pipetteId, - primaryWellCount: stateProps.primaryWellCount, - updateValue, - value, - t: stateProps.t, - } -} - -export const WellSelectionField = connect( - mapStateToProps, - null, - mergeProps -)(WellSelectionInput) diff --git a/protocol-designer/src/components/StepEditForm/fields/__tests__/makeSingleEditFieldProps.test.ts b/protocol-designer/src/components/StepEditForm/fields/__tests__/makeSingleEditFieldProps.test.ts index f80d4ddad70..08c4e8c3ed6 100644 --- a/protocol-designer/src/components/StepEditForm/fields/__tests__/makeSingleEditFieldProps.test.ts +++ b/protocol-designer/src/components/StepEditForm/fields/__tests__/makeSingleEditFieldProps.test.ts @@ -102,7 +102,8 @@ describe('makeSingleEditFieldProps', () => { focusHandlers, formData, handleChangeFormInput, - formData + formData, + [] ) expect(result).toEqual({ some_field: { diff --git a/protocol-designer/src/components/StepEditForm/fields/index.ts b/protocol-designer/src/components/StepEditForm/fields/index.ts index f7c0bd240a4..b59231db01a 100644 --- a/protocol-designer/src/components/StepEditForm/fields/index.ts +++ b/protocol-designer/src/components/StepEditForm/fields/index.ts @@ -13,7 +13,7 @@ export { DisposalVolumeField } from './DisposalVolumeField' export { FlowRateField } from './FlowRateField' export { LabwareField } from './LabwareField' export { LabwareLocationField } from './LabwareLocationField' -export { PathField } from './PathField' +export { PathField } from './PathField/PathField' export { PipetteField } from './PipetteField' export { ProfileItemRows } from './ProfileItemRows' export { StepFormDropdown } from './StepFormDropdownField' @@ -21,4 +21,4 @@ export { TipPositionField } from './TipPositionField' export { ToggleRowField } from './ToggleRowField' export { VolumeField } from './VolumeField' export { WellOrderField } from './WellOrderField' -export { WellSelectionField } from './WellSelectionField' +export { WellSelectionField } from './WellSelectionField/WellSelectionField' diff --git a/protocol-designer/src/components/alerts/Alerts.tsx b/protocol-designer/src/components/alerts/Alerts.tsx index c512bc9b218..1dee5380657 100644 --- a/protocol-designer/src/components/alerts/Alerts.tsx +++ b/protocol-designer/src/components/alerts/Alerts.tsx @@ -1,18 +1,43 @@ import * as React from 'react' import assert from 'assert' +import { useTranslation } from 'react-i18next' +import { useSelector, useDispatch } from 'react-redux' +import * as timelineWarningSelectors from '../../top-selectors/timelineWarnings' +import { getSelectedStepId } from '../../ui/steps' +import { + actions as dismissActions, + selectors as dismissSelectors, +} from '../../dismiss' +import { selectors as stepFormSelectors } from '../../step-forms' +import { StepFieldName } from '../../steplist/fieldLevel' +import { selectors as fileDataSelectors } from '../../file-data' +import { + getVisibleFormWarnings, + getVisibleFormErrors, + getVisibleProfileFormLevelErrors, +} from '../StepEditForm/utils' import { PDAlert } from './PDAlert' -import { AlertData, AlertType } from './types' +import { ErrorContents } from './ErrorContents' +import { WarningContents } from './WarningContents' -/* TODO: BC 2018-09-13 this component is an abstraction that is meant to be shared for timeline - * and form level alerts. Currently it is being used in TimelineAlerts, but it should be used in - * FormAlerts as well. This change will also include adding form level alert copy to i18n - * see #1814 for reference +import type { CommandCreatorError } from '@opentrons/step-generation' +import type { ProfileItem } from '../../form-types' +import type { ProfileFormError } from '../../steplist/formLevel/profileErrors' +import type { AlertData, AlertType } from './types' + +/** Errors and Warnings from step-generation are written for developers + * who are using step-generation as an API for writing Opentrons protocols. + * These 'overrides' replace the content of some of those errors/warnings + * in order to make things clearer to the PD user. + * + * When an override is not specified in /localization/en/alert/ , the default + * behavior is that the warning/error `message` gets put into the `title` of the Alert */ -export interface Props { - errors: AlertData[] - warnings: AlertData[] - dismissWarning: (val: string) => unknown +interface Props { + componentType: 'Form' | 'Timeline' + focusedField?: StepFieldName | null + dirtyFields?: StepFieldName[] } type MakeAlert = ( @@ -22,10 +47,78 @@ type MakeAlert = ( ) => JSX.Element const AlertsComponent = (props: Props): JSX.Element => { + const { componentType, focusedField, dirtyFields } = props + const { t } = useTranslation('alert') + const dispatch = useDispatch() + const formLevelErrorsForUnsavedForm = useSelector( + stepFormSelectors.getFormLevelErrorsForUnsavedForm + ) + const formWarningsForSelectedStep = useSelector( + dismissSelectors.getFormWarningsForSelectedStep + ) + const timeline = useSelector(fileDataSelectors.getRobotStateTimeline) + const timelineWarningsForSelectedStep = useSelector( + timelineWarningSelectors.getTimelineWarningsForSelectedStep + ) + const unsavedForm = useSelector(stepFormSelectors.getHydratedUnsavedForm) + const dynamicFieldFormErrors = useSelector( + stepFormSelectors.getDynamicFieldFormErrorsForUnsavedForm + ) + + const timelineErrors = (timeline.errors || ([] as CommandCreatorError[])).map( + (error: CommandCreatorError) => ({ + title: t(`timeline.error.${error.type}.title`, error.message), + description: , + }) + ) + const timelineWarnings = timelineWarningsForSelectedStep.map(warning => ({ + title: t(`timeline.warning.${warning.type}.title`), + description: ( + + ), + dismissId: warning.type, + })) + + const visibleFormWarnings = getVisibleFormWarnings({ + focusedField, + dirtyFields: dirtyFields ?? [], + errors: formWarningsForSelectedStep, + }) + const visibleFormErrors = getVisibleFormErrors({ + focusedField, + dirtyFields: dirtyFields ?? [], + errors: formLevelErrorsForUnsavedForm, + }) + const stepId = useSelector(getSelectedStepId) + + const profileItemsById: Record | null | undefined = + unsavedForm?.profileItemsById + + let visibleDynamicFieldFormErrors: ProfileFormError[] = [] + + if (profileItemsById != null) { + visibleDynamicFieldFormErrors = getVisibleProfileFormLevelErrors({ + focusedField, + dirtyFields: dirtyFields ?? [], + errors: dynamicFieldFormErrors, + profileItemsById, + }) + } + + const dismissWarning = (dismissId: string): void => { + if (stepId) { + dispatch( + dismissActions.dismissTimelineWarning({ + type: dismissId, + stepId, + }) + ) + } + } const makeHandleCloseWarning = (dismissId?: string | null) => () => { assert(dismissId, 'expected dismissId, Alert cannot dismiss warning') if (dismissId) { - props.dismissWarning(dismissId) + dismissWarning(dismissId) } } @@ -40,11 +133,33 @@ const AlertsComponent = (props: Props): JSX.Element => { } /> ) + const formErrors = [ + ...visibleFormErrors.map(error => ({ + title: error.title, + description: error.body || null, + })), + ...visibleDynamicFieldFormErrors.map(error => ({ + title: error.title, + description: error.body || null, + })), + ] + + const formWarnings = visibleFormWarnings.map(warning => ({ + title: warning.title, + description: warning.body || null, + dismissId: warning.type, + })) return ( <> - {props.errors.map((error, key) => makeAlert('error', error, key))} - {props.warnings.map((warning, key) => makeAlert('warning', warning, key))} + {componentType === 'Form' + ? formErrors.map((error, key) => makeAlert('error', error, key)) + : timelineErrors.map((error, key) => makeAlert('error', error, key))} + {componentType === 'Form' + ? formWarnings.map((warning, key) => makeAlert('warning', warning, key)) + : timelineWarnings.map((warning, key) => + makeAlert('warning', warning, key) + )} ) } diff --git a/protocol-designer/src/components/alerts/TimelineAlerts.tsx b/protocol-designer/src/components/alerts/TimelineAlerts.tsx deleted file mode 100644 index e91993d0380..00000000000 --- a/protocol-designer/src/components/alerts/TimelineAlerts.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import * as React from 'react' -import { useTranslation } from 'react-i18next' -import { Dispatch } from 'redux' -import { connect } from 'react-redux' -import { ErrorContents } from './ErrorContents' -import { WarningContents } from './WarningContents' -import { actions as dismissActions } from '../../dismiss' -import * as timelineWarningSelectors from '../../top-selectors/timelineWarnings' -import { getSelectedStepId } from '../../ui/steps' -import { selectors as fileDataSelectors } from '../../file-data' -import { Alerts, Props } from './Alerts' -import { CommandCreatorError } from '@opentrons/step-generation' -import { BaseState } from '../../types' - -interface SP { - errors: Props['errors'] - warnings: Props['warnings'] - _stepId?: string | null -} - -/** Errors and Warnings from step-generation are written for developers - * who are using step-generation as an API for writing Opentrons protocols. - * These 'overrides' replace the content of some of those errors/warnings - * in order to make things clearer to the PD user. - * - * When an override is not specified in /localization/en/alert/ , the default - * behavior is that the warning/error `message` gets put into the `title` of the Alert - */ - -function mapStateToProps(state: BaseState): SP { - const { t } = useTranslation('alert') - const timeline = fileDataSelectors.getRobotStateTimeline(state) - const errors = (timeline.errors || ([] as CommandCreatorError[])).map( - (error: CommandCreatorError) => ({ - title: t(`timeline.error.${error.type}.title`, error.message), - description: , - }) - ) - const warnings = timelineWarningSelectors - .getTimelineWarningsForSelectedStep(state) - .map(warning => ({ - title: t(`timeline.warning.${warning.type}.title`), - description: ( - - ), - dismissId: warning.type, - })) - const _stepId = getSelectedStepId(state) - - return { - errors, - warnings, - _stepId, - } -} - -function mergeProps( - stateProps: SP, - dispatchProps: { dispatch: Dispatch } -): Props { - const { dispatch } = dispatchProps - const stepId = stateProps._stepId - return { - ...stateProps, - dismissWarning: (dismissId: string) => { - if (stepId) { - dispatch( - dismissActions.dismissTimelineWarning({ - type: dismissId, - stepId, - }) - ) - } - }, - } -} -export const TimelineAlerts = connect(mapStateToProps, null, mergeProps)(Alerts) diff --git a/protocol-designer/src/components/modals/EditPipettesModal/index.ts b/protocol-designer/src/components/modals/EditPipettesModal/index.ts deleted file mode 100644 index ae5ee71312b..00000000000 --- a/protocol-designer/src/components/modals/EditPipettesModal/index.ts +++ /dev/null @@ -1,237 +0,0 @@ -import * as React from 'react' -import { withTranslation } from 'react-i18next' -import { connect } from 'react-redux' -import isEmpty from 'lodash/isEmpty' -import last from 'lodash/last' -import filter from 'lodash/filter' -import mapValues from 'lodash/mapValues' -import { PipetteName, RobotType } from '@opentrons/shared-data' -import { uuid } from '../../../utils' -import { INITIAL_DECK_SETUP_STEP_ID } from '../../../constants' -import { actions as steplistActions } from '../../../steplist' -import { selectors as featureFlagSelectors } from '../../../feature-flags' -import { - actions as stepFormActions, - selectors as stepFormSelectors, - PipetteOnDeck, - FormPipettesByMount, -} from '../../../step-forms' -import { FilePipettesModal, PipetteFieldsData } from '../FilePipettesModal' -import { NormalizedPipette } from '@opentrons/step-generation' -import { BaseState, ThunkDispatch } from '../../../types' -import { StepIdType } from '../../../form-types' -import { getRobotType } from '../../../file-data/selectors' -import { useTranslation } from 'react-i18next' - -type Props = React.ComponentProps - -interface SP { - initialPipetteValues: FormPipettesByMount - _prevPipettes: { [pipetteId: string]: PipetteOnDeck } - _orderedStepIds: StepIdType[] - moduleRestrictionsDisabled?: boolean | null - robotType: RobotType - t: any -} - -interface OP { - closeModal: () => unknown -} - -const mapSTP = (state: BaseState): SP => { - const initialPipettes = stepFormSelectors.getPipettesForEditPipetteForm(state) - const { t } = useTranslation(['modal', 'button', 'form']) - return { - robotType: getRobotType(state), - initialPipetteValues: initialPipettes, - _prevPipettes: stepFormSelectors.getInitialDeckSetup(state).pipettes, // TODO: Ian 2019-01-02 when multi-step editing is supported, don't use initial deck state. Instead, show the pipettes available for the selected step range - _orderedStepIds: stepFormSelectors.getOrderedStepIds(state), - moduleRestrictionsDisabled: featureFlagSelectors.getDisableModuleRestrictions( - state - ), - t: t, - } -} - -// NOTE: this function is doing some weird stuff because we are envisioning -// that the following changes will happen, and working to support them cleanly. -// We anticipate that: -// * pipettes will be created/deleted outside of the timeline (like liquids) -// * there will be multiple manualIntervention steps which set/unset pipettes -// on robot mounts on the timeline -// * there will be a facility to substitute pipettes used in steps across a -// selection of multiple steps -// -// Currently, PD's Edit Pipettes functionality is doing several of these steps -// in one click (create, change manualIntervention step, substitute pipettes -// across all steps, delete pipettes), which is why it's so funky! -const makeUpdatePipettes = ( - prevPipettes: SP['_prevPipettes'], - orderedStepIds: SP['_orderedStepIds'], - dispatch: ThunkDispatch, - closeModal: OP['closeModal'] -) => ({ pipettes: newPipetteArray }: { pipettes: PipetteFieldsData[] }) => { - const prevPipetteIds = Object.keys(prevPipettes) - const usedPrevPipettes: string[] = [] // IDs of pipettes in prevPipettes that were already put into nextPipettes - const nextPipettes: { - [pipetteId: string]: { - mount: string - name: PipetteName - tiprackDefURI: string - id: string - } - } = {} - - // from array of pipettes from Edit Pipette form (with no IDs), - // assign IDs and populate nextPipettes - newPipetteArray.forEach((newPipette: PipetteFieldsData) => { - if (newPipette && newPipette.name && newPipette.tiprackDefURI) { - const candidatePipetteIds = prevPipetteIds.filter(id => { - const prevPipette = prevPipettes[id] - const alreadyUsed = usedPrevPipettes.some(usedId => usedId === id) - return !alreadyUsed && prevPipette.name === newPipette.name - }) - const pipetteId: string | null | undefined = candidatePipetteIds[0] - if (pipetteId) { - // update used pipette list - usedPrevPipettes.push(pipetteId) - nextPipettes[pipetteId] = { ...newPipette, id: pipetteId } - } else { - const newId = uuid() - nextPipettes[newId] = { ...newPipette, id: newId } - } - } - }) - - dispatch( - stepFormActions.createPipettes( - mapValues( - nextPipettes, - ( - p: typeof nextPipettes[keyof typeof nextPipettes] - ): NormalizedPipette => ({ - id: p.id, - name: p.name, - tiprackDefURI: p.tiprackDefURI, - }) - ) - ) - ) - - // set/update pipette locations in initial deck setup step - dispatch( - steplistActions.changeSavedStepForm({ - stepId: INITIAL_DECK_SETUP_STEP_ID, - update: { - pipetteLocationUpdate: mapValues( - nextPipettes, - (p: PipetteOnDeck) => p.mount - ), - }, - }) - ) - - const pipetteIdsToDelete: string[] = Object.keys(prevPipettes).filter( - id => !(id in nextPipettes) - ) - - // SubstitutionMap represents a map of oldPipetteId => newPipetteId - // When a pipette's tiprack changes, the ids will be the same - interface SubstitutionMap { - [pipetteId: string]: string - } - - const pipetteReplacementMap: SubstitutionMap = pipetteIdsToDelete.reduce( - (acc: SubstitutionMap, deletedId: string): SubstitutionMap => { - const deletedPipette = prevPipettes[deletedId] - const replacementId = Object.keys(nextPipettes).find( - newId => nextPipettes[newId].mount === deletedPipette.mount - ) - // @ts-expect-error(sa, 2021-6-21): redlacementId will always be a string, so right side of the and will always be true - return replacementId && replacementId !== -1 - ? { ...acc, [deletedId]: replacementId } - : acc - }, - {} - ) - - const pipettesWithNewTipracks: string[] = filter( - nextPipettes, - (nextPipette: typeof nextPipettes[keyof typeof nextPipettes]) => { - const newPipetteId = nextPipette.id - const tiprackChanged = - newPipetteId in prevPipettes && - nextPipette.tiprackDefURI !== prevPipettes[newPipetteId].tiprackDefURI - return tiprackChanged - } - ).map(pipette => pipette.id) - - // this creates an identity map with all pipette ids that have new tipracks - // this will be used so that handleFormChange gets called even though the - // pipette id itself has not changed (only it's tiprack) - - const pipettesWithNewTiprackIdentityMap: SubstitutionMap = pipettesWithNewTipracks.reduce( - (acc: SubstitutionMap, id: string): SubstitutionMap => { - return { - ...acc, - ...{ [id]: id }, - } - }, - {} - ) - - const substitutionMap = { - ...pipetteReplacementMap, - ...pipettesWithNewTiprackIdentityMap, - } - - // substitute deleted pipettes with new pipettes on the same mount, if any - if (!isEmpty(substitutionMap) && orderedStepIds.length > 0) { - // NOTE: using start/end here is meant to future-proof this action for multi-step editing - dispatch( - stepFormActions.substituteStepFormPipettes({ - substitutionMap, - startStepId: orderedStepIds[0], - // @ts-expect-error(sa, 2021-6-22): last might return undefined - endStepId: last(orderedStepIds), - }) - ) - } - - // delete any pipettes no longer in use - if (pipetteIdsToDelete.length > 0) { - dispatch(stepFormActions.deletePipettes(pipetteIdsToDelete)) - } - - closeModal() -} - -const mergeProps = ( - stateProps: SP, - dispatchProps: { dispatch: ThunkDispatch }, - ownProps: OP -): Props => { - const { _prevPipettes, _orderedStepIds, ...passThruStateProps } = stateProps - const { dispatch } = dispatchProps - const { closeModal } = ownProps - - const updatePipettes = makeUpdatePipettes( - _prevPipettes, - _orderedStepIds, - dispatch, - closeModal - ) - - return { - ...passThruStateProps, - showProtocolFields: false, - onSave: updatePipettes, - onCancel: closeModal, - } -} - -export const EditPipettesModal = connect( - mapSTP, - null, - mergeProps -)(FilePipettesModal) diff --git a/protocol-designer/src/components/modals/FilePipettesModal/index.tsx b/protocol-designer/src/components/modals/FilePipettesModal/index.tsx index c6d430e29c4..908a6e03073 100644 --- a/protocol-designer/src/components/modals/FilePipettesModal/index.tsx +++ b/protocol-designer/src/components/modals/FilePipettesModal/index.tsx @@ -1,27 +1,15 @@ -import assert from 'assert' -import reduce from 'lodash/reduce' import * as React from 'react' +import isEmpty from 'lodash/isEmpty' +import last from 'lodash/last' +import filter from 'lodash/filter' +import mapValues from 'lodash/mapValues' import cx from 'classnames' +import { useTranslation } from 'react-i18next' +import { useSelector, useDispatch } from 'react-redux' + import { Formik, FormikProps } from 'formik' import * as Yup from 'yup' -import { - getIsCrashablePipetteSelected, - PipetteOnDeck, - FormPipette, - FormPipettesByMount, - FormModulesByType, -} from '../../../step-forms' -import { - Modal, - FormGroup, - InputField, - OutlineButton, - DropdownField, - Flex, - SPACING, - DIRECTION_COLUMN, - ALIGN_STRETCH, -} from '@opentrons/components' +import { Modal, OutlineButton } from '@opentrons/components' import { HEATERSHAKER_MODULE_V1, MAGNETIC_MODULE_TYPE, @@ -36,19 +24,32 @@ import { MAGNETIC_BLOCK_V1, MAGNETIC_BLOCK_TYPE, OT2_ROBOT_TYPE, - FLEX_ROBOT_TYPE, - RobotType, } from '@opentrons/shared-data' -import { SPAN7_8_10_11_SLOT } from '../../../constants' +import { + actions as stepFormActions, + selectors as stepFormSelectors, + getIsCrashablePipetteSelected, + PipetteOnDeck, + FormPipettesByMount, + FormModulesByType, +} from '../../../step-forms' +import { + INITIAL_DECK_SETUP_STEP_ID, + SPAN7_8_10_11_SLOT, +} from '../../../constants' + +import { actions as steplistActions } from '../../../steplist' +import { selectors as featureFlagSelectors } from '../../../feature-flags' import { StepChangesConfirmModal } from '../EditPipettesModal/StepChangesConfirmModal' -import { ModuleFields } from './ModuleFields' import { PipetteFields } from './PipetteFields' import { CrashInfoBox, isModuleWithCollisionIssue } from '../../modules' import styles from './FilePipettesModal.css' -import formStyles from '../../forms/forms.css' import modalStyles from '../modal.css' import { DeckSlot } from '../../../types' import { NewProtocolFields } from '../../../load-file' +import { getRobotType } from '../../../file-data/selectors' +import { uuid } from '../../../utils' +import { NormalizedPipette } from '@opentrons/step-generation' export type PipetteFieldsData = Omit< PipetteOnDeck, @@ -67,26 +68,10 @@ export interface FormState { modulesByType: FormModulesByType } -interface State { - showEditPipetteConfirmation: boolean -} - export interface Props { - showProtocolFields?: boolean | null - showModulesFields?: boolean | null - hideModal?: boolean - onCancel: () => unknown - initialPipetteValues?: FormState['pipettesByMount'] - initialModuleValues?: FormState['modulesByType'] - onSave: (args: { - newProtocolFields: NewProtocolFields - pipettes: PipetteFieldsData[] - modules: ModuleCreationArgs[] - }) => unknown - moduleRestrictionsDisabled?: boolean | null - robotType: RobotType - t: any + closeModal: () => void } + const initialFormState: FormState = { fields: { name: '', @@ -170,25 +155,164 @@ const validationSchema = Yup.object().shape({ }), }) -const ROBOT_TYPE_OPTIONS = [ - { value: OT2_ROBOT_TYPE, name: 'OT2' }, - { value: FLEX_ROBOT_TYPE, name: 'Opentrons Flex' }, -] -export class FilePipettesModal extends React.Component { - constructor(props: Props) { - super(props) +export const FilePipettesModal = (props: Props): JSX.Element => { + const [ + showEditPipetteConfirmation, + setShowEditPipetteConfirmation, + ] = React.useState(false) + const { t } = useTranslation(['modal', 'button', 'form']) + const robotType = useSelector(getRobotType) + const dispatch = useDispatch() + const initialPipettes = useSelector( + stepFormSelectors.getPipettesForEditPipetteForm + ) + const prevPipettes = useSelector(stepFormSelectors.getInitialDeckSetup) + .pipettes + const orderedStepIds = useSelector(stepFormSelectors.getOrderedStepIds) + const moduleRestrictionsDisabled = useSelector( + featureFlagSelectors.getDisableModuleRestrictions + ) - this.state = { - showEditPipetteConfirmation: false, + const makeUpdatePipettes = () => ({ + pipettes: newPipetteArray, + }: { + pipettes: PipetteFieldsData[] + }) => { + const prevPipetteIds = Object.keys(prevPipettes) + const usedPrevPipettes: string[] = [] // IDs of pipettes in prevPipettes that were already put into nextPipettes + const nextPipettes: { + [pipetteId: string]: { + mount: string + name: PipetteName + tiprackDefURI: string + id: string + } + } = {} + // from array of pipettes from Edit Pipette form (with no IDs), + // assign IDs and populate nextPipettes + newPipetteArray.forEach((newPipette: PipetteFieldsData) => { + if (newPipette && newPipette.name && newPipette.tiprackDefURI) { + const candidatePipetteIds = prevPipetteIds.filter(id => { + const prevPipette = prevPipettes[id] + const alreadyUsed = usedPrevPipettes.some(usedId => usedId === id) + return !alreadyUsed && prevPipette.name === newPipette.name + }) + const pipetteId: string | null | undefined = candidatePipetteIds[0] + if (pipetteId) { + // update used pipette list + usedPrevPipettes.push(pipetteId) + nextPipettes[pipetteId] = { ...newPipette, id: pipetteId } + } else { + const newId = uuid() + nextPipettes[newId] = { ...newPipette, id: newId } + } + } + }) + + dispatch( + stepFormActions.createPipettes( + mapValues( + nextPipettes, + ( + p: typeof nextPipettes[keyof typeof nextPipettes] + ): NormalizedPipette => ({ + id: p.id, + name: p.name, + tiprackDefURI: p.tiprackDefURI, + }) + ) + ) + ) + + // set/update pipette locations in initial deck setup step + dispatch( + steplistActions.changeSavedStepForm({ + stepId: INITIAL_DECK_SETUP_STEP_ID, + update: { + pipetteLocationUpdate: mapValues( + nextPipettes, + (p: PipetteOnDeck) => p.mount + ), + }, + }) + ) + + const pipetteIdsToDelete: string[] = Object.keys(prevPipettes).filter( + id => !(id in nextPipettes) + ) + + // SubstitutionMap represents a map of oldPipetteId => newPipetteId + // When a pipette's tiprack changes, the ids will be the same + interface SubstitutionMap { + [pipetteId: string]: string + } + + const pipetteReplacementMap: SubstitutionMap = pipetteIdsToDelete.reduce( + (acc: SubstitutionMap, deletedId: string): SubstitutionMap => { + const deletedPipette = prevPipettes[deletedId] + const replacementId = Object.keys(nextPipettes).find( + newId => nextPipettes[newId].mount === deletedPipette.mount + ) + // @ts-expect-error(sa, 2021-6-21): redlacementId will always be a string, so right side of the and will always be true + return replacementId && replacementId !== -1 + ? { ...acc, [deletedId]: replacementId } + : acc + }, + {} + ) + + const pipettesWithNewTipracks: string[] = filter( + nextPipettes, + (nextPipette: typeof nextPipettes[keyof typeof nextPipettes]) => { + const newPipetteId = nextPipette.id + const tiprackChanged = + newPipetteId in prevPipettes && + nextPipette.tiprackDefURI !== prevPipettes[newPipetteId].tiprackDefURI + return tiprackChanged + } + ).map(pipette => pipette.id) + + // this creates an identity map with all pipette ids that have new tipracks + // this will be used so that handleFormChange gets called even though the + // pipette id itself has not changed (only it's tiprack) + + const pipettesWithNewTiprackIdentityMap: SubstitutionMap = pipettesWithNewTipracks.reduce( + (acc: SubstitutionMap, id: string): SubstitutionMap => { + return { + ...acc, + ...{ [id]: id }, + } + }, + {} + ) + + const substitutionMap = { + ...pipetteReplacementMap, + ...pipettesWithNewTiprackIdentityMap, } - } - componentDidUpdate(prevProps: Props): void { - if (!prevProps.hideModal && this.props.hideModal) - this.setState({ showEditPipetteConfirmation: false }) + // substitute deleted pipettes with new pipettes on the same mount, if any + if (!isEmpty(substitutionMap) && orderedStepIds.length > 0) { + // NOTE: using start/end here is meant to future-proof this action for multi-step editing + dispatch( + stepFormActions.substituteStepFormPipettes({ + substitutionMap, + startStepId: orderedStepIds[0], + // @ts-expect-error(sa, 2021-6-22): last might return undefined + endStepId: last(orderedStepIds), + }) + ) + } + + // delete any pipettes no longer in use + if (pipetteIdsToDelete.length > 0) { + dispatch(stepFormActions.deletePipettes(pipetteIdsToDelete)) + } + + props.closeModal() } - getCrashableModuleSelected: ( + const getCrashableModuleSelected: ( modules: FormModulesByType, moduleType: ModuleType ) => boolean = (modules, moduleType) => { @@ -201,36 +325,33 @@ export class FilePipettesModal extends React.Component { return crashableModuleOnDeck } - handleSubmit: (values: FormState) => void = values => { - const { showProtocolFields } = this.props - const { showEditPipetteConfirmation } = this.state - - if (!showProtocolFields && !showEditPipetteConfirmation) { - return this.showEditPipetteConfirmationModal() + const handleSubmit: (values: FormState) => void = values => { + if (!showEditPipetteConfirmation) { + setShowEditPipetteConfirmation(true) } - const newProtocolFields = values.fields - const pipettes = reduce( - values.pipettesByMount, - (acc, formPipette: FormPipette, mount): PipetteFieldsData[] => { - assert(mount === 'left' || mount === 'right', `invalid mount: ${mount}`) // this is mostly for flow - // @ts-expect-error(sa, 2021-6-21): TODO validate that pipette names coming from the modal are actually valid pipette names on PipetteName type - return formPipette && - formPipette.pipetteName && - formPipette.tiprackDefURI && - (mount === 'left' || mount === 'right') - ? [ - ...acc, - { - mount, - name: formPipette.pipetteName, - tiprackDefURI: formPipette.tiprackDefURI, - }, - ] - : acc - }, - [] - ) + // const newProtocolFields = values.fields + // const pipettes = reduce( + // values.pipettesByMount, + // (acc, formPipette: FormPipette, mount): PipetteFieldsData[] => { + // assert(mount === 'left' || mount === 'right', `invalid mount: ${mount}`) // this is mostly for flow + // // @ts-expect-error(sa, 2021-6-21): TODO validate that pipette names coming from the modal are actually valid pipette names on PipetteName type + // return formPipette && + // formPipette.pipetteName && + // formPipette.tiprackDefURI && + // (mount === 'left' || mount === 'right') + // ? [ + // ...acc, + // { + // mount, + // name: formPipette.pipetteName, + // tiprackDefURI: formPipette.tiprackDefURI, + // }, + // ] + // : acc + // }, + // [] + // ) // NOTE: this is extra-explicit for flow. Reduce fns won't cooperate // with enum-typed key like `{[ModuleType]: ___}` @@ -261,169 +382,149 @@ export class FilePipettesModal extends React.Component { // if both are present, move the Mag mod to slot 9, since both can't be in slot 1 modules[magModIndex].slot = '9' } - this.props.onSave({ modules, newProtocolFields, pipettes }) - } - - showEditPipetteConfirmationModal: () => void = () => { - this.setState({ showEditPipetteConfirmation: true }) + makeUpdatePipettes() } - handleCancel: () => void = () => { - this.setState({ showEditPipetteConfirmation: false }) - } - - getInitialValues: () => FormState = () => { + const getInitialValues: () => FormState = () => { return { ...initialFormState, pipettesByMount: { ...initialFormState.pipettesByMount, - ...this.props.initialPipetteValues, + ...initialPipettes, }, modulesByType: { ...initialFormState.modulesByType, - ...this.props.initialModuleValues, }, } } - render(): React.ReactNode | null { - if (this.props.hideModal) return null - const { - showProtocolFields, - moduleRestrictionsDisabled, - robotType, - } = this.props - - return ( - -
    -
    - - {({ - handleChange, - handleSubmit, - errors, - setFieldValue, - touched, - values, - handleBlur, - setFieldTouched, - }: FormikProps) => { - const { left, right } = values.pipettesByMount - - const pipetteSelectionIsValid = - // at least one must not be none (empty string) - left.pipetteName || right.pipetteName - - const hasCrashableMagnetModuleSelected = this.getCrashableModuleSelected( - values.modulesByType, - MAGNETIC_MODULE_TYPE - ) - const hasCrashableTemperatureModuleSelected = this.getCrashableModuleSelected( - values.modulesByType, - TEMPERATURE_MODULE_TYPE - ) - const hasHeaterShakerSelected = Boolean( - values.modulesByType[HEATERSHAKER_MODULE_TYPE].onDeck - ) + return ( + +
    +
    + + {({ + handleChange, + handleSubmit, + errors, + setFieldValue, + touched, + values, + handleBlur, + setFieldTouched, + }: FormikProps) => { + const { left, right } = values.pipettesByMount - const showHeaterShakerPipetteCollisions = - hasHeaterShakerSelected && - [ - getPipetteNameSpecs(left.pipetteName as PipetteName), - getPipetteNameSpecs(right.pipetteName as PipetteName), - ].some( - pipetteSpecs => pipetteSpecs && pipetteSpecs.channels !== 1 - ) - - const crashablePipetteSelected = getIsCrashablePipetteSelected( - values.pipettesByMount + const pipetteSelectionIsValid = + // at least one must not be none (empty string) + left.pipetteName || right.pipetteName + + const hasCrashableMagnetModuleSelected = getCrashableModuleSelected( + values.modulesByType, + MAGNETIC_MODULE_TYPE + ) + const hasCrashableTemperatureModuleSelected = getCrashableModuleSelected( + values.modulesByType, + TEMPERATURE_MODULE_TYPE + ) + const hasHeaterShakerSelected = Boolean( + values.modulesByType[HEATERSHAKER_MODULE_TYPE].onDeck + ) + + const showHeaterShakerPipetteCollisions = + hasHeaterShakerSelected && + [ + getPipetteNameSpecs(left.pipetteName as PipetteName), + getPipetteNameSpecs(right.pipetteName as PipetteName), + ].some( + pipetteSpecs => pipetteSpecs && pipetteSpecs.channels !== 1 ) - const showTempPipetteCollisons = - crashablePipetteSelected && - hasCrashableTemperatureModuleSelected - const showMagPipetteCollisons = - crashablePipetteSelected && hasCrashableMagnetModuleSelected - - return ( - <> -
    - {showProtocolFields && ( -
    - -

    - {this.props.t('new_protocol.title.PROTOCOL_FILE')} -

    - - - -
    - + + {/* {showProtocolFields && ( +
    + +

    + {t('new_protocol.title.PROTOCOL_FILE')} +

    + - - -
    - )} - -

    - {showProtocolFields - ? this.props.t('new_protocol.title.PROTOCOL_PIPETTES') - : this.props.t('edit_pipettes.title')} -

    - - + + + + + +
    + )} */} - {this.props.showModulesFields && ( +

    + {t('edit_pipettes.title')} +

    + + + + {/* {showModulesFields && (

    - {this.props.t( + {t( 'new_protocol.title.PROTOCOL_MODULES' )}

    @@ -438,56 +539,55 @@ export class FilePipettesModal extends React.Component { onSetFieldTouched={setFieldTouched} />
    - )} - {!moduleRestrictionsDisabled && ( - - )} -
    - - {this.props.t('button:cancel')} - - - {this.props.t('button:save')} - -
    - - - {this.state.showEditPipetteConfirmation && ( - )} - - ) - }} -
    -
    +
    + + {t('button:cancel')} + + + {t('button:save')} + +
    + + + {showEditPipetteConfirmation ? ( + setShowEditPipetteConfirmation(false)} + onConfirm={handleSubmit} + /> + ) : null} + + ) + }} +
    -
    - ) - } +
    + + ) } diff --git a/protocol-designer/src/components/modals/LabwareUploadMessageModal/LabwareUploadMessageModal.tsx b/protocol-designer/src/components/modals/LabwareUploadMessageModal/LabwareUploadMessageModal.tsx index 2286c68cdc8..08e88586940 100644 --- a/protocol-designer/src/components/modals/LabwareUploadMessageModal/LabwareUploadMessageModal.tsx +++ b/protocol-designer/src/components/modals/LabwareUploadMessageModal/LabwareUploadMessageModal.tsx @@ -62,12 +62,16 @@ const MessageBody = (props: { {canOverwrite && (

    {t('labware_upload_message.name_conflict.overwrite')}

    )} - {canOverwrite && message.isOverwriteMismatched && ( -

    - {t('labware_upload_message.name_conflict.warning')}{' '} - {t('labware_upload_message.name_conflict.mismatched')} -

    - )} + {canOverwrite && + 'isOverwriteMismatched' in message && + message.isOverwriteMismatched && ( +

    + + {t('labware_upload_message.name_conflict.warning')} + {' '} + {t('labware_upload_message.name_conflict.mismatched')} +

    + )} ) } diff --git a/protocol-designer/src/containers/ConnectedFilePage.ts b/protocol-designer/src/containers/ConnectedFilePage.ts deleted file mode 100644 index 10bc951b5b1..00000000000 --- a/protocol-designer/src/containers/ConnectedFilePage.ts +++ /dev/null @@ -1,71 +0,0 @@ -import * as React from 'react' -import { connect } from 'react-redux' -import { useTranslation } from 'react-i18next' -import mapValues from 'lodash/mapValues' -import { FilePage } from '../components/FilePage' -import { - actions, - selectors as fileSelectors, - FileMetadataFields, -} from '../file-data' -import { selectors as stepFormSelectors, InitialDeckSetup } from '../step-forms' -import { actions as steplistActions } from '../steplist' -import { INITIAL_DECK_SETUP_STEP_ID } from '../constants' -import { actions as navActions } from '../navigation' - -import type { BaseState, ThunkDispatch } from '../types' - -type Props = React.ComponentProps -interface SP { - instruments: Props['instruments'] - formValues: Props['formValues'] - _initialDeckSetup: InitialDeckSetup - modules: Props['modules'] - t: any -} - -const mapStateToProps = (state: BaseState): SP => { - const { t } = useTranslation('button') - return { - formValues: fileSelectors.getFileMetadata(state), - instruments: stepFormSelectors.getPipettesForInstrumentGroup(state), - modules: stepFormSelectors.getModulesForEditModulesCard(state), - _initialDeckSetup: stepFormSelectors.getInitialDeckSetup(state), - t: t, - } -} - -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, - goToNextPage: () => dispatch(navActions.navigateToPage('liquids')), - saveFileMetadata: (nextFormValues: FileMetadataFields) => - dispatch(actions.saveFileMetadata(nextFormValues)), - swapPipettes: () => - dispatch( - steplistActions.changeSavedStepForm({ - stepId: INITIAL_DECK_SETUP_STEP_ID, - update: { - pipetteLocationUpdate: swapPipetteUpdate, - }, - }) - ), - } -} - -export const ConnectedFilePage = connect( - mapStateToProps, - null, - mergeProps -)(FilePage) diff --git a/protocol-designer/src/containers/ConnectedMainPanel.tsx b/protocol-designer/src/containers/ConnectedMainPanel.tsx index 4a04fbb39f1..febe576eea2 100644 --- a/protocol-designer/src/containers/ConnectedMainPanel.tsx +++ b/protocol-designer/src/containers/ConnectedMainPanel.tsx @@ -4,14 +4,14 @@ import { Splash } from '@opentrons/components' import { START_TERMINAL_ITEM_ID, TerminalItemId } from '../steplist' import { Portal as MainPageModalPortal } from '../components/portals/MainPageModalPortal' import { DeckSetupManager } from '../components/DeckSetupManager' -import { ConnectedFilePage } from '../containers/ConnectedFilePage' import { SettingsPage } from '../components/SettingsPage' +import { FilePage } from '../components/FilePage' import { LiquidsPage } from '../components/LiquidsPage' import { Hints } from '../components/Hints' import { LiquidPlacementModal } from '../components/LiquidPlacementModal' import { LabwareSelectionModal } from '../components/LabwareSelectionModal' import { FormManager } from '../components/FormManager' -import { TimelineAlerts } from '../components/alerts/TimelineAlerts' +import { Alerts } from '../components/alerts/Alerts' import { getSelectedTerminalItemId } from '../ui/steps' import { selectors as labwareIngredSelectors } from '../labware-ingred/selectors' @@ -30,7 +30,7 @@ function MainPanelComponent(props: Props): JSX.Element { case 'file-splash': return case 'file-detail': - return + return case 'liquids': return case 'settings-app': @@ -41,7 +41,7 @@ function MainPanelComponent(props: Props): JSX.Element { return ( <> - + {startTerminalItemSelected && } diff --git a/protocol-designer/src/containers/ConnectedSidebar.tsx b/protocol-designer/src/containers/ConnectedSidebar.tsx index 3843cc5720e..092c288f4d1 100644 --- a/protocol-designer/src/containers/ConnectedSidebar.tsx +++ b/protocol-designer/src/containers/ConnectedSidebar.tsx @@ -5,7 +5,7 @@ import { selectors as labwareIngredSelectors } from '../labware-ingred/selectors import { ConnectedStepList } from './ConnectedStepList' import { IngredientsList } from './IngredientsList' -import { FileSidebar } from '../components/FileSidebar' +import { FileSidebar } from '../components/FileSidebar/FileSidebar' import { LiquidsSidebar } from '../components/LiquidsSidebar' import { SettingsSidebar } from '../components/SettingsPage' diff --git a/protocol-designer/src/containers/ConnectedTitleBar.tsx b/protocol-designer/src/containers/ConnectedTitleBar.tsx index 45415c861e1..61f5f59f6c3 100644 --- a/protocol-designer/src/containers/ConnectedTitleBar.tsx +++ b/protocol-designer/src/containers/ConnectedTitleBar.tsx @@ -1,8 +1,8 @@ import * as React from 'react' -import { Dispatch } from 'redux' -import { connect } from 'react-redux' +import { useSelector, useDispatch } from 'react-redux' +import { useTranslation } from 'react-i18next' -import { TitleBar, Icon, IconName, TitleBarProps } from '@opentrons/components' +import { TitleBar, Icon, IconName } from '@opentrons/components' import { getLabwareDisplayName } from '@opentrons/shared-data' import styles from './TitleBar.css' import { START_TERMINAL_TITLE, END_TERMINAL_TITLE } from '../constants' @@ -19,23 +19,7 @@ import { END_TERMINAL_ITEM_ID, START_TERMINAL_ITEM_ID } from '../steplist' import { selectors as fileDataSelectors } from '../file-data' import { closeIngredientSelector } from '../labware-ingred/actions' import { stepIconsByType } from '../form-types' -import { selectors, Page } from '../navigation' - -import { BaseState } from '../types' -import { useTranslation } from 'react-i18next' - -type Props = React.ComponentProps - -interface DP { - onBackClick: Props['onBackClick'] -} - -type SP = Omit & { - _page: Page - _liquidPlacementMode?: boolean - _wellSelectionMode?: boolean -} - +import { selectors } from '../navigation' interface TitleWithIconProps { iconName: IconName | null | undefined text: string | null | undefined @@ -61,18 +45,25 @@ const Title = (props: TitleProps): JSX.Element => (
    ) -function mapStateToProps(state: BaseState): SP { +export const ConnectedTitleBar = (): JSX.Element => { const { t } = useTranslation(['nav', 'application']) - const selectedLabwareId = labwareIngredSelectors.getSelectedLabwareId(state) - const _page = selectors.getCurrentPage(state) - const fileName = fileDataSelectors.protocolName(state) - const selectedStepInfo = getSelectedStepTitleInfo(state) - const selectedTerminalId = getSelectedTerminalItemId(state) - const labwareNames = uiLabwareSelectors.getLabwareNicknamesById(state) - const drilledDownLabwareId = labwareIngredSelectors.getDrillDownLabwareId( - state + const dispatch = useDispatch() + const selectedLabwareId = useSelector( + labwareIngredSelectors.getSelectedLabwareId + ) + const _page = useSelector(selectors.getCurrentPage) + const labwareNicknamesById = useSelector( + uiLabwareSelectors.getLabwareNicknamesById ) - const wellSelectionLabwareKey = getWellSelectionLabwareKey(state) + const fileName = useSelector(fileDataSelectors.protocolName) + const labwareEntities = useSelector(stepFormSelectors.getLabwareEntities) + const selectedStepInfo = useSelector(getSelectedStepTitleInfo) + const selectedTerminalId = useSelector(getSelectedTerminalItemId) + const labwareNames = useSelector(uiLabwareSelectors.getLabwareNicknamesById) + const drilledDownLabwareId = useSelector( + labwareIngredSelectors.getDrillDownLabwareId + ) + const wellSelectionLabwareKey = useSelector(getWellSelectionLabwareKey) // TODO(mc, 2019-06-27): µL to uL replacement needed to handle CSS capitalization const labwareNickname = @@ -80,27 +71,30 @@ function mapStateToProps(state: BaseState): SP { ? labwareNames[selectedLabwareId].replace('µL', 'uL') : null const labwareEntity = - selectedLabwareId != null - ? stepFormSelectors.getLabwareEntities(state)[selectedLabwareId] - : null + selectedLabwareId != null ? labwareEntities[selectedLabwareId] : null + const liquidPlacementMode = selectedLabwareId != null + let title: React.ReactNode = <> + let subtitle: string | null = null + let backButtonLabel: string | undefined + let _wellSelectionMode: boolean = false + let _liquidPlacementMode: boolean = false + switch (_page) { case 'liquids': - case 'file-detail': - return { - _page, - title: t([`title.${_page}`, fileName]), - subtitle: t([`subtitle.${_page}`, '']), - } + case 'file-detail': { + title = <>{t([`title.${_page}`, fileName])} + subtitle = t([`subtitle.${_page}`, '']) + break + } case 'file-splash': case 'settings-features': - case 'settings-app': - return { - _page, - title: , - subtitle: t([`subtitle.${_page}`, '']), - } + case 'settings-app': { + title = <Title text={t([`title.${_page}`, fileName])} /> + subtitle = t([`subtitle.${_page}`, '']) + break + } case 'steplist': default: { // NOTE: this default case error should never be reached, it's just a sanity check @@ -109,32 +103,23 @@ function mapStateToProps(state: BaseState): SP { 'ConnectedTitleBar got an unsupported page, returning steplist instead' ) if (liquidPlacementMode) { - return { - _page, - _liquidPlacementMode: liquidPlacementMode, - title: labwareNickname, - // TODO(mc, 2019-06-27): µL to uL replacement needed to handle CSS capitalization - subtitle: - labwareEntity && - getLabwareDisplayName(labwareEntity.def).replace('µL', 'uL'), - backButtonLabel: 'Deck', - } + _liquidPlacementMode = liquidPlacementMode + title = labwareNickname + // TODO(mc, 2019-06-27): µL to uL replacement needed to handle CSS capitalization + subtitle = + labwareEntity && + getLabwareDisplayName(labwareEntity.def).replace('µL', 'uL') + backButtonLabel = 'Deck' } - let subtitle - let backButtonLabel - let title + if (selectedTerminalId === START_TERMINAL_ITEM_ID) { subtitle = START_TERMINAL_TITLE } else if (selectedTerminalId === END_TERMINAL_ITEM_ID) { subtitle = END_TERMINAL_TITLE if (drilledDownLabwareId) { backButtonLabel = 'Deck' - const labwareDef = stepFormSelectors.getLabwareEntities(state)[ - drilledDownLabwareId - ].def - const nickname = uiLabwareSelectors.getLabwareNicknamesById(state)[ - drilledDownLabwareId - ] + const labwareDef = labwareEntities[drilledDownLabwareId].def + const nickname = labwareNicknamesById[drilledDownLabwareId] // TODO(mc, 2019-06-27): µL to uL replacement needed to handle CSS capitalization title = nickname.replace('µL', 'uL') subtitle = @@ -146,43 +131,22 @@ function mapStateToProps(state: BaseState): SP { t(`application:stepType.${selectedStepInfo.stepType}`) if (wellSelectionLabwareKey) { // well selection modal - return { - _page, - _wellSelectionMode: true, - title: ( - <TitleWithIcon - iconName={stepIconsByType[selectedStepInfo.stepType]} - text={stepTitle} - /> - ), - subtitle: labwareNames[wellSelectionLabwareKey], - backButtonLabel: 'Back', - } + _wellSelectionMode = true + title = ( + <TitleWithIcon + iconName={stepIconsByType[selectedStepInfo.stepType]} + text={stepTitle} + /> + ) + + subtitle = labwareNames[wellSelectionLabwareKey] + backButtonLabel = 'Back' } else { subtitle = stepTitle } } - return { - _page: 'steplist', - title: title || fileName || '', - subtitle, - backButtonLabel, - } } } -} - -function mergeProps( - stateProps: SP, - dispatchProps: { dispatch: Dispatch } -): Props { - const { - _page, - _liquidPlacementMode, - _wellSelectionMode, - ...props - } = stateProps - const { dispatch } = dispatchProps let onBackClick @@ -191,23 +155,18 @@ function mergeProps( onBackClick = () => dispatch(closeIngredientSelector()) } else if (_wellSelectionMode) { onBackClick = () => dispatch(stepsActions.clearWellSelectionLabwareKey()) - } else if (props.backButtonLabel) { + } else if (backButtonLabel) { onBackClick = () => {} } } - - return { - ...props, - onBackClick, - } + return ( + <TitleBar + id="TitleBar_main" + title={title} + subtitle={subtitle} + backButtonLabel={backButtonLabel} + onBackClick={onBackClick} + className={styles.sticky_bar} + /> + ) } - -const StickyTitleBar = (props: TitleBarProps): JSX.Element => ( - <TitleBar id="TitleBar_main" {...props} className={styles.sticky_bar} /> -) - -export const ConnectedTitleBar = connect( - mapStateToProps, - null, - mergeProps -)(StickyTitleBar) diff --git a/protocol-designer/src/localization/index.ts b/protocol-designer/src/localization/index.ts index 033dd56a1e0..c5c643bbabe 100644 --- a/protocol-designer/src/localization/index.ts +++ b/protocol-designer/src/localization/index.ts @@ -36,7 +36,6 @@ i18n.use(initReactI18next).init( return value }, }, - keySeparator: false, saveMissing: true, missingKeyHandler: (lng, ns, key) => { process.env.NODE_ENV === 'test' diff --git a/protocol-designer/src/step-forms/utils/createPresavedStepForm.ts b/protocol-designer/src/step-forms/utils/createPresavedStepForm.ts index f76f61d972f..e934b430dc5 100644 --- a/protocol-designer/src/step-forms/utils/createPresavedStepForm.ts +++ b/protocol-designer/src/step-forms/utils/createPresavedStepForm.ts @@ -1,5 +1,4 @@ import last from 'lodash/last' -import { useTranslation } from 'react-i18next' import { HEATERSHAKER_MODULE_TYPE, MAGNETIC_MODULE_TYPE, @@ -263,12 +262,9 @@ export const createPresavedStepForm = ({ robotStateTimeline, additionalEquipmentEntities, }: CreatePresavedStepFormArgs): FormData => { - const { t } = useTranslation('application') - const formData = createBlankForm({ stepId, stepType, - t, }) const updateDefaultDropTip = _patchDefaultDropTipLocation({ diff --git a/protocol-designer/src/steplist/formLevel/createBlankForm.ts b/protocol-designer/src/steplist/formLevel/createBlankForm.ts index 8c10cbea5f8..f7fcd6a7743 100644 --- a/protocol-designer/src/steplist/formLevel/createBlankForm.ts +++ b/protocol-designer/src/steplist/formLevel/createBlankForm.ts @@ -3,15 +3,33 @@ import { StepType, StepIdType, BlankForm, FormData } from '../../form-types' interface NewFormArgs { stepId: StepIdType stepType: StepType - t: any } + +// TODO(jr, 1/17/24): add to i18n +const getStepType = (stepType: StepType): string => { + switch (stepType) { + case 'heaterShaker': { + return 'heater-shaker' + } + case 'moveLabware': { + return 'move labware' + } + case 'moveLiquid': { + return 'transfer' + } + default: { + return stepType + } + } +} + // Add default values to a new step form export function createBlankForm(args: NewFormArgs): FormData { - const { stepId, stepType, t } = args + const { stepId, stepType } = args const baseForm: BlankForm = { id: stepId, stepType: stepType, - stepName: t(`stepType.${stepType}`), + stepName: getStepType(stepType), stepDetails: '', } return { ...baseForm, ...getDefaultsForStepType(stepType) }