From 1d30e93494446a103e948907e8630a61174f2f8c Mon Sep 17 00:00:00 2001 From: Jethary Alcid <66035149+jerader@users.noreply.github.com> Date: Wed, 18 Sep 2024 15:07:28 -0400 Subject: [PATCH] feat(protocol-designer): stepForm skeleton (#16284) closes AUTH-828 --- .../StepForm/StepFormToolbox.tsx | 117 +++++++ .../StepForm/StepTools/CommentTools/index.tsx | 5 + .../StepTools/HeaterShakerTools/index.tsx | 5 + .../StepForm/StepTools/MagnetTools/index.tsx | 5 + .../StepForm/StepTools/MixTools/index.tsx | 5 + .../StepTools/MoveLabwareTools/index.tsx | 5 + .../StepTools/MoveLiquidTools/index.tsx | 5 + .../StepForm/StepTools/PauseTools/index.tsx | 5 + .../StepTools/TemperatureTools/index.tsx | 5 + .../StepTools/ThermocyclerTools/index.tsx | 5 + .../ProtocolSteps/StepForm/StepTools/index.ts | 9 + .../Designer/ProtocolSteps/StepForm/index.tsx | 249 +++++++++++++++ .../Designer/ProtocolSteps/StepForm/types.ts | 26 ++ .../Designer/ProtocolSteps/StepForm/utils.ts | 291 ++++++++++++++++++ .../__tests__/ProtocolSteps.test.tsx | 3 +- .../pages/Designer/ProtocolSteps/index.tsx | 5 +- 16 files changed, 740 insertions(+), 5 deletions(-) create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/CommentTools/index.tsx create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/HeaterShakerTools/index.tsx create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MagnetTools/index.tsx create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MixTools/index.tsx create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MoveLabwareTools/index.tsx create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MoveLiquidTools/index.tsx create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/PauseTools/index.tsx create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/TemperatureTools/index.tsx create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/index.tsx create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/index.ts create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/index.tsx create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/types.ts create mode 100644 protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/utils.ts diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx new file mode 100644 index 00000000000..628b196c998 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx @@ -0,0 +1,117 @@ +import * as React from 'react' +import get from 'lodash/get' +import { useTranslation } from 'react-i18next' + +import { + ALIGN_CENTER, + Flex, + Icon, + PrimaryButton, + SPACING, + StyledText, + Toolbox, +} from '@opentrons/components' +import { stepIconsByType } from '../../../../form-types' +import { + CommentTools, + HeaterShakerTools, + MagnetTools, + MixTools, + MoveLabwareTools, + MoveLiquidTools, + PauseTools, + TemperatureTools, + ThermocyclerTools, +} from './StepTools' +import type { StepFieldName } from '../../../../steplist/fieldLevel' +import type { FormData, StepType } from '../../../../form-types' +import type { FieldPropsByName, FocusHandlers, StepFormProps } from './types' + +type StepFormMap = { + [K in StepType]?: React.ComponentType | null +} + +const STEP_FORM_MAP: StepFormMap = { + mix: MixTools, + pause: PauseTools, + moveLabware: MoveLabwareTools, + moveLiquid: MoveLiquidTools, + magnet: MagnetTools, + temperature: TemperatureTools, + thermocycler: ThermocyclerTools, + heaterShaker: HeaterShakerTools, + comment: CommentTools, +} + +interface StepFormToolboxProps { + canSave: boolean + dirtyFields: string[] + focusHandlers: FocusHandlers + focusedField: StepFieldName | null + formData: FormData + propsForFields: FieldPropsByName + handleClose: () => void + // TODO: add abiltiy to delete step? + handleDelete: () => void + handleSave: () => void +} + +export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { + const { + formData, + focusHandlers, + canSave, + handleClose, + handleSave, + propsForFields, + } = props + const { t, i18n } = useTranslation(['application', 'shared']) + const icon = stepIconsByType[formData.stepType] + + const Tools: typeof STEP_FORM_MAP[keyof typeof STEP_FORM_MAP] = get( + STEP_FORM_MAP, + formData.stepType + ) + + if (!Tools) { + // early-exit if step form doesn't exist, this is a good check for when new steps + // are added + return ( +
+
Todo: support {formData && formData.stepType} step
+
+ ) + } + + return ( + <> + {/* TODO: update alerts */} + {/* */} + + + {t('shared:save')} + + } + height="calc(100vh - 64px)" + title={ + + + + {i18n.format(t(`stepType.${formData.stepType}`), 'capitalize')} + + + } + > + + + + ) +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/CommentTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/CommentTools/index.tsx new file mode 100644 index 00000000000..56299f7ac9b --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/CommentTools/index.tsx @@ -0,0 +1,5 @@ +import * as React from 'react' + +export function CommentTools(): JSX.Element { + return
TODO: wire this up
+} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/HeaterShakerTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/HeaterShakerTools/index.tsx new file mode 100644 index 00000000000..741d28124fd --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/HeaterShakerTools/index.tsx @@ -0,0 +1,5 @@ +import * as React from 'react' + +export function HeaterShakerTools(): JSX.Element { + return
TODO: wire this up
+} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MagnetTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MagnetTools/index.tsx new file mode 100644 index 00000000000..3d636116f87 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MagnetTools/index.tsx @@ -0,0 +1,5 @@ +import * as React from 'react' + +export function MagnetTools(): JSX.Element { + return
TODO: wire this up
+} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MixTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MixTools/index.tsx new file mode 100644 index 00000000000..8f3b8a826c5 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MixTools/index.tsx @@ -0,0 +1,5 @@ +import * as React from 'react' + +export function MixTools(): JSX.Element { + return
TODO: wire this up
+} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MoveLabwareTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MoveLabwareTools/index.tsx new file mode 100644 index 00000000000..1343331cbaf --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MoveLabwareTools/index.tsx @@ -0,0 +1,5 @@ +import * as React from 'react' + +export function MoveLabwareTools(): JSX.Element { + return
TODO: wire this up
+} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MoveLiquidTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MoveLiquidTools/index.tsx new file mode 100644 index 00000000000..1115172241a --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MoveLiquidTools/index.tsx @@ -0,0 +1,5 @@ +import * as React from 'react' + +export function MoveLiquidTools(): JSX.Element { + return
TODO: wire this up
+} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/PauseTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/PauseTools/index.tsx new file mode 100644 index 00000000000..2549d8aa6da --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/PauseTools/index.tsx @@ -0,0 +1,5 @@ +import * as React from 'react' + +export function PauseTools(): JSX.Element { + return
TODO: wire this up
+} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/TemperatureTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/TemperatureTools/index.tsx new file mode 100644 index 00000000000..ea7c0065077 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/TemperatureTools/index.tsx @@ -0,0 +1,5 @@ +import * as React from 'react' + +export function TemperatureTools(): JSX.Element { + return
TODO: wire this up
+} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/index.tsx new file mode 100644 index 00000000000..6d475a006c6 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/index.tsx @@ -0,0 +1,5 @@ +import * as React from 'react' + +export function ThermocyclerTools(): JSX.Element { + return
TODO: wire this up
+} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/index.ts b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/index.ts new file mode 100644 index 00000000000..7f0eff60340 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/index.ts @@ -0,0 +1,9 @@ +export { CommentTools } from './CommentTools' +export { HeaterShakerTools } from './HeaterShakerTools' +export { MagnetTools } from './MagnetTools' +export { MixTools } from './MixTools' +export { MoveLabwareTools } from './MoveLabwareTools' +export { MoveLiquidTools } from './MoveLiquidTools' +export { PauseTools } from './PauseTools' +export { TemperatureTools } from './TemperatureTools' +export { ThermocyclerTools } from './ThermocyclerTools' diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/index.tsx new file mode 100644 index 00000000000..4f8e428cb52 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/index.tsx @@ -0,0 +1,249 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { connect } from 'react-redux' +import { useConditionalConfirm } from '@opentrons/components' +import { actions } from '../../../../steplist' +import { actions as stepsActions } from '../../../../ui/steps' +import { + getHydratedForm, + selectors as stepFormSelectors, +} from '../../../../step-forms' +import { maskField } from '../../../../steplist/fieldLevel' +import { getInvariantContext } from '../../../../step-forms/selectors' +import { + CLOSE_STEP_FORM_WITH_CHANGES, + CLOSE_UNSAVED_STEP_FORM, + ConfirmDeleteModal, + DELETE_STEP_FORM, +} from '../../../../components/modals/ConfirmDeleteModal' +import { AutoAddPauseUntilTempStepModal } from '../../../../components/modals/AutoAddPauseUntilTempStepModal' +import { AutoAddPauseUntilHeaterShakerTempStepModal } from '../../../../components/modals/AutoAddPauseUntilHeaterShakerTempStepModal' +import { getDirtyFields, makeSingleEditFieldProps } from './utils' +import { StepFormToolbox } from './StepFormToolbox' + +import type { InvariantContext } from '@opentrons/step-generation' +import type { BaseState, ThunkDispatch } from '../../../../types' +import type { + FormData, + StepFieldName, + StepIdType, +} from '../../../../form-types' + +interface StateProps { + canSave: boolean + formHasChanges: boolean + isNewStep: boolean + isPristineSetTempForm: boolean + isPristineSetHeaterShakerTempForm: boolean + invariantContext: InvariantContext + formData?: FormData | null +} +interface DispatchProps { + deleteStep: (stepId: string) => void + handleClose: () => void + saveSetTempFormWithAddedPauseUntilTemp: () => void + saveHeaterShakerFormWithAddedPauseUntilTemp: () => void + saveStepForm: () => void + handleChangeFormInput: (name: string, value: unknown) => void +} +type StepEditFormManagerProps = StateProps & DispatchProps + +function StepFormManager(props: StepEditFormManagerProps): JSX.Element | null { + const { + canSave, + deleteStep, + formData, + formHasChanges, + handleChangeFormInput, + handleClose, + isNewStep, + isPristineSetTempForm, + isPristineSetHeaterShakerTempForm, + saveSetTempFormWithAddedPauseUntilTemp, + saveHeaterShakerFormWithAddedPauseUntilTemp, + saveStepForm, + invariantContext, + } = props + const { t } = useTranslation('tooltip') + const [focusedField, setFocusedField] = React.useState(null) + const [dirtyFields, setDirtyFields] = React.useState( + getDirtyFields(isNewStep, formData) + ) + const handleBlur = (fieldName: StepFieldName): void => { + if (fieldName === focusedField) { + setFocusedField(null) + } + if (!dirtyFields.includes(fieldName)) { + setDirtyFields([...dirtyFields, fieldName]) + } + } + const stepId = formData?.id + const handleDelete = (): void => { + if (stepId != null) { + deleteStep(stepId) + } else { + console.error( + `StepEditForm: tried to delete step with no step id, this should not happen` + ) + } + } + const { + confirm: confirmDelete, + showConfirmation: showConfirmDeleteModal, + cancel: cancelDelete, + } = useConditionalConfirm(handleDelete, true) + const { + confirm: confirmClose, + showConfirmation: showConfirmCancelModal, + cancel: cancelClose, + } = useConditionalConfirm(handleClose, isNewStep || formHasChanges) + const { + confirm: confirmAddPauseUntilTempStep, + showConfirmation: showAddPauseUntilTempStepModal, + } = useConditionalConfirm( + saveSetTempFormWithAddedPauseUntilTemp, + isPristineSetTempForm + ) + const { + confirm: confirmAddPauseUntilHeaterShakerTempStep, + showConfirmation: showAddPauseUntilHeaterShakerTempStepModal, + } = useConditionalConfirm( + saveHeaterShakerFormWithAddedPauseUntilTemp, + isPristineSetHeaterShakerTempForm + ) + // no form selected + if (formData == null) { + return null + } + const hydratedForm = getHydratedForm(formData, invariantContext) + const focusHandlers = { + focusedField, + dirtyFields, + focus: setFocusedField, + blur: handleBlur, + } + const propsForFields = makeSingleEditFieldProps( + focusHandlers, + formData, + handleChangeFormInput, + hydratedForm, + t + ) + let handleSave = saveStepForm + if (isPristineSetTempForm) { + handleSave = confirmAddPauseUntilTempStep + } else if ( + isPristineSetHeaterShakerTempForm && + formData.heaterShakerSetTimer !== true + ) { + handleSave = confirmAddPauseUntilHeaterShakerTempStep + } + + return ( + <> + {/* TODO: update these modals to match new modal design */} + {showConfirmDeleteModal && ( + + )} + {showConfirmCancelModal && ( + + )} + {showAddPauseUntilTempStepModal && ( + + )} + {showAddPauseUntilHeaterShakerTempStepModal && ( + + )} + + + ) +} + +const mapStateToProps = (state: BaseState): StateProps => { + return { + canSave: stepFormSelectors.getCurrentFormCanBeSaved(state), + formData: stepFormSelectors.getUnsavedForm(state), + formHasChanges: stepFormSelectors.getCurrentFormHasUnsavedChanges(state), + isNewStep: stepFormSelectors.getCurrentFormIsPresaved(state), + isPristineSetHeaterShakerTempForm: stepFormSelectors.getUnsavedFormIsPristineHeaterShakerForm( + state + ), + isPristineSetTempForm: stepFormSelectors.getUnsavedFormIsPristineSetTempForm( + state + ), + invariantContext: getInvariantContext(state), + } +} + +const mapDispatchToProps = (dispatch: ThunkDispatch): DispatchProps => { + const deleteStep = (stepId: StepIdType): void => + dispatch(actions.deleteStep(stepId)) + const handleClose = (): void => dispatch(actions.cancelStepForm()) + const saveHeaterShakerFormWithAddedPauseUntilTemp = (): void => + dispatch(stepsActions.saveHeaterShakerFormWithAddedPauseUntilTemp()) + const saveSetTempFormWithAddedPauseUntilTemp = (): void => + dispatch(stepsActions.saveSetTempFormWithAddedPauseUntilTemp()) + const saveStepForm = (): void => dispatch(stepsActions.saveStepForm()) + + const handleChangeFormInput = (name: string, value: unknown): void => { + const maskedValue = maskField(name, value) + dispatch(actions.changeFormInput({ update: { [name]: maskedValue } })) + } + + return { + deleteStep, + handleChangeFormInput, + handleClose, + saveSetTempFormWithAddedPauseUntilTemp, + saveStepForm, + saveHeaterShakerFormWithAddedPauseUntilTemp, + } +} + +// NOTE(IL, 2020-04-22): This is using connect instead of useSelector in order to +// avoid zombie children in the many connected field components. +// (Children of a useSelector parent must always be written to use selectors defensively +// if their parent (StepEditForm) is NOT using connect. +// It doesn't matter if the children are using connect or useSelector, +// only the parent matters.) +// https://react-redux.js.org/api/hooks#stale-props-and-zombie-children +export const StepForm = connect( + mapStateToProps, + mapDispatchToProps +)((props: StepEditFormManagerProps) => { + const { formData } = props + return ( + // key by ID so manager state doesn't persist across different forms + + ) +}) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/types.ts b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/types.ts new file mode 100644 index 00000000000..540e39ffb74 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/types.ts @@ -0,0 +1,26 @@ +import type { FormData, StepFieldName } from '../../../../form-types' +export interface FocusHandlers { + focusedField: StepFieldName | null + dirtyFields: StepFieldName[] + focus: (arg: StepFieldName) => void + blur: (arg: StepFieldName) => void +} +export interface FieldProps { + disabled: boolean + name: string + onFieldBlur: () => void + onFieldFocus: () => void + updateValue: (arg: unknown) => void + value: unknown + errorToShow?: string | null + isIndeterminate?: boolean + tooltipContent?: string | null +} +export type FieldPropsByName = Record + +// Shared props across all step forms +export interface StepFormProps { + formData: FormData + focusHandlers: FocusHandlers + propsForFields: FieldPropsByName +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/utils.ts b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/utils.ts new file mode 100644 index 00000000000..d14412238d2 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/utils.ts @@ -0,0 +1,291 @@ +import difference from 'lodash/difference' +import isEqual from 'lodash/isEqual' +import without from 'lodash/without' +import { + SOURCE_WELL_BLOWOUT_DESTINATION, + DEST_WELL_BLOWOUT_DESTINATION, +} from '@opentrons/step-generation' +import { ALL, COLUMN } from '@opentrons/shared-data' +import { getFieldErrors } from '../../../../steplist/fieldLevel' +import { + getDisabledFields, + getDefaultsForStepType, +} from '../../../../steplist/formLevel' +import { PROFILE_CYCLE } from '../../../../form-types' +import type { PipetteEntity } from '@opentrons/step-generation' +import type { Options } from '@opentrons/components' +import type { ProfileFormError } from '../../../../steplist/formLevel/profileErrors' +import type { FormWarning } from '../../../../steplist/formLevel/warnings' +import type { StepFormErrors } from '../../../../steplist/types' +import type { + FormData, + ProfileItem, + StepFieldName, + StepType, + PathOption, +} from '../../../../form-types' +import type { NozzleType } from '../../../../types' +import type { FieldProps, FieldPropsByName, FocusHandlers } from './types' + +export function getBlowoutLocationOptionsForForm(args: { + stepType: StepType + path?: PathOption | null | undefined +}): Options { + const { stepType, path } = args + // TODO: Ian 2019-02-21 use i18n for names + const destOption = { + name: 'Destination Well', + value: DEST_WELL_BLOWOUT_DESTINATION, + } + const sourceOption = { + name: 'Source Well', + value: SOURCE_WELL_BLOWOUT_DESTINATION, + } + + if (stepType === 'mix') { + return [destOption] + } else if (stepType === 'moveLiquid') { + switch (path) { + case 'single': { + return [sourceOption, destOption] + } + + case 'multiDispense': { + return [sourceOption, { ...destOption, disabled: true }] + } + + case 'multiAspirate': { + return [{ ...sourceOption, disabled: true }, destOption] + } + + default: { + // is moveLiquid but no path -- assume we're in batch edit mode + // with mixed/indeterminate path values + return [ + { ...sourceOption, disabled: true }, + { ...destOption, disabled: true }, + ] + } + } + } + + return [] +} +// TODO: type fieldNames, don't use `string` +export const getDirtyFields = ( + isNewStep: boolean, + formData?: FormData | null +): string[] => { + let dirtyFields = [] + + if (formData == null) { + return [] + } + + if (!isNewStep) { + dirtyFields = Object.keys(formData) + } else { + const data = formData + // new step, but may have auto-populated fields. + // "Dirty" any fields that differ from default new form values + const defaultFormData = getDefaultsForStepType(formData.stepType) + dirtyFields = Object.keys(defaultFormData).reduce( + (acc: string[], fieldName: StepFieldName) => { + const currentValue = data[fieldName] + const initialValue = defaultFormData[fieldName] + return isEqual(currentValue, initialValue) ? acc : [...acc, fieldName] + }, + [] + ) + } + + // exclude form "metadata" (not really fields) + return without(dirtyFields, 'stepType', 'id') +} +export const getVisibleFormErrors = (args: { + focusedField?: string | null + dirtyFields: string[] + errors: StepFormErrors +}): StepFormErrors => { + const { focusedField, dirtyFields, errors } = args + return errors.filter(error => { + const dependentFieldsAreNotFocused = !error.dependentFields.includes( + // @ts-expect-error(sa, 2021-6-22): focusedField might be undefined + focusedField + ) + const dependentFieldsAreDirty = + difference(error.dependentFields, dirtyFields).length === 0 + return dependentFieldsAreNotFocused && dependentFieldsAreDirty + }) +} +export const getVisibleFormWarnings = (args: { + focusedField?: string | null + dirtyFields: string[] + errors: FormWarning[] +}): FormWarning[] => { + const { focusedField, dirtyFields, errors } = args + return errors.filter(error => { + const dependentFieldsAreNotFocused = !error.dependentFields.includes( + // @ts-expect-error(sa, 2021-6-22): focusedField might be undefined + focusedField + ) + const dependentFieldsAreDirty = + difference(error.dependentFields, dirtyFields).length === 0 + return dependentFieldsAreNotFocused && dependentFieldsAreDirty + }) +} +// for the purpose of focus handlers, derive a unique ID for each dynamic field +export const getDynamicFieldFocusHandlerId = ({ + id, + name, +}: { + id: string + name: string +}): string => `${id}:${name}` +// NOTE: if any fields of a given name are pristine, treat all fields of that name as pristine. +// (Errors don't currently specify the id, so if we later want to only mask form-level errors +// for specific profile fields, the field's parent ProfileItem id needs to be included in the error) +export const getVisibleProfileFormLevelErrors = (args: { + focusedField?: string | null + dirtyFields: string[] + errors: ProfileFormError[] + profileItemsById: Record +}): ProfileFormError[] => { + const { dirtyFields, focusedField, errors, profileItemsById } = args + const profileItemIds = Object.keys(profileItemsById) + return errors.filter(error => { + return profileItemIds.every(itemId => { + const item = profileItemsById[itemId] + const steps = item.type === PROFILE_CYCLE ? item.steps : [item] + return steps.every(step => { + const fieldsForStep = error.dependentProfileFields.map(fieldName => + getDynamicFieldFocusHandlerId({ + id: step.id, + name: fieldName, + }) + ) + const dependentFieldsAreNotFocused = !fieldsForStep.includes( + // @ts-expect-error(sa, 2021-6-22): focusedField might be undefined + focusedField + ) + const dependentProfileFieldsAreDirty = + difference(fieldsForStep, dirtyFields).length === 0 + return dependentFieldsAreNotFocused && dependentProfileFieldsAreDirty + }) + }) + }) +} +export const getFieldDefaultTooltip = (name: string, t: any): string => + name != null ? t(`step_fields.defaults.${name}`) : '' +export const getFieldIndeterminateTooltip = (name: string, t: any): string => + name != null ? t(`step_fields.indeterminate.${name}`) : '' +export const getSingleSelectDisabledTooltip = ( + name: string, + stepType: string, + t: any +): string => + name != null + ? t(`step_fields.${stepType}.disabled.${name}`) + : t(`step_fields.${stepType}.disabled.$generic`) + +// TODO(IL, 2021-03-03): keys for fieldMap are more strictly of TipOffsetFields type, +// but since utils like addFieldNamePrefix return StepFieldName/string instead +// of strict TipOffsetFields, we have to be more lenient with the types +export function getLabwareFieldForPositioningField( + name: StepFieldName +): StepFieldName { + const fieldMap: Record = { + aspirate_mmFromBottom: 'aspirate_labware', + aspirate_touchTip_mmFromBottom: 'aspirate_labware', + aspirate_delay_mmFromBottom: 'aspirate_labware', + dispense_mmFromBottom: 'dispense_labware', + dispense_touchTip_mmFromBottom: 'dispense_labware', + dispense_delay_mmFromBottom: 'dispense_labware', + mix_mmFromBottom: 'labware', + mix_touchTip_mmFromBottom: 'labware', + } + return fieldMap[name] +} + +export const getNozzleType = ( + pipette: PipetteEntity | null, + nozzles: string | null +): NozzleType | null => { + const is8Channel = pipette != null && pipette.spec.channels === 8 + if (is8Channel) { + return '8-channel' + } else if (nozzles === COLUMN) { + return COLUMN + } else if (nozzles === ALL) { + return ALL + } else { + return null + } +} + +interface ShowFieldErrorParams { + name: StepFieldName + focusedField: StepFieldName | null + dirtyFields?: StepFieldName[] +} +export const showFieldErrors = ({ + name, + focusedField, + dirtyFields, +}: ShowFieldErrorParams): boolean | undefined | StepFieldName[] => + !(name === focusedField) && dirtyFields != null && dirtyFields.includes(name) +export const makeSingleEditFieldProps = ( + focusHandlers: FocusHandlers, + formData: FormData, + handleChangeFormInput: (name: string, value: unknown) => void, + hydratedForm: { [key: string]: any }, + t: any +): FieldPropsByName => { + const { dirtyFields, blur, focusedField, focus } = focusHandlers + const fieldNames: string[] = Object.keys( + getDefaultsForStepType(formData.stepType) + ) + return fieldNames.reduce((acc, name) => { + const disabled = + hydratedForm != null ? getDisabledFields(hydratedForm).has(name) : false + const value = formData != null ? formData[name] : null + const showErrors = showFieldErrors({ + name, + focusedField, + dirtyFields, + }) + const errors = getFieldErrors(name, value) + const errorToShow = + showErrors != null && errors.length > 0 ? errors.join(', ') : null + + const updateValue = (value: unknown): void => { + handleChangeFormInput(name, value) + } + + const onFieldBlur = (): void => { + blur(name) + } + + const onFieldFocus = (): void => { + focus(name) + } + + const defaultTooltip = getFieldDefaultTooltip(name, t) + const disabledTooltip = getSingleSelectDisabledTooltip( + name, + formData.stepType, + t + ) + const fieldProps: FieldProps = { + disabled, + errorToShow, + name, + updateValue, + value, + onFieldBlur, + onFieldFocus, + tooltipContent: disabled ? disabledTooltip : defaultTooltip, + } + return { ...acc, [name]: fieldProps } + }, {}) +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/__tests__/ProtocolSteps.test.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/__tests__/ProtocolSteps.test.tsx index 8f56f55e9be..7b2834c7e3d 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/__tests__/ProtocolSteps.test.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/__tests__/ProtocolSteps.test.tsx @@ -6,7 +6,7 @@ import { DeckSetupContainer } from '../../DeckSetup' import { TimelineToolbox } from '../Timeline' import { ProtocolSteps } from '..' -vi.mock('../../../../components/StepEditForm') +vi.mock('../StepForm') vi.mock('../../DeckSetup') vi.mock('../Timeline') const render = () => { @@ -19,7 +19,6 @@ describe('ProtocolSteps', () => { vi.mocked(DeckSetupContainer).mockReturnValue(
mock DeckSetupContainer
) - render() screen.getByText('mock TimelineToolbox') screen.getByText('mock DeckSetupContainer') diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx index e69875a5b93..62189202c96 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/index.tsx @@ -1,15 +1,14 @@ import * as React from 'react' import { COLORS, Flex, POSITION_ABSOLUTE, SPACING } from '@opentrons/components' -import { StepEditForm } from '../../../components/StepEditForm' import { DeckSetupContainer } from '../DeckSetup' import { TimelineToolbox } from './Timeline' +import { StepForm } from './StepForm' export function ProtocolSteps(): JSX.Element { return ( <> - {/* TODO: wire up the step edit form designs */} - +