From f01f5c64c7df6e5d402f67e5669085f2800b8621 Mon Sep 17 00:00:00 2001 From: ncdiehl11 Date: Mon, 23 Sep 2024 13:20:59 -0400 Subject: [PATCH] feat(protocol-designer, components): wire up pause form in PD redesign This PR adds form functionality and UI for pause step in PD redesign. I consolidate pause hours, minutes, and seconds into a single colon-delimited time field, add errors and masking, and parse the time string when creating command arguments. I also touch several components that require styling refactors, including `Toolbox`, `DropdownMenu`, and `RadioButton`. Note that migrating separate pause time fields to a single field will be addressed in a future PR. Closes AUTH-809 --- components/src/atoms/MenuList/MenuItem.tsx | 1 + components/src/atoms/buttons/RadioButton.tsx | 4 +- .../src/molecules/DropdownMenu/index.tsx | 11 +- components/src/organisms/Toolbox/index.tsx | 4 +- .../assets/localization/en/application.json | 2 + protocol-designer/src/form-types.ts | 1 + .../StepForm/StepFormToolbox.tsx | 1 + .../StepForm/StepTools/PauseTools/index.tsx | 255 +++++++++++++++++- .../src/steplist/fieldLevel/errors.ts | 8 + .../src/steplist/fieldLevel/index.ts | 7 + .../src/steplist/fieldLevel/processing.ts | 7 + .../formLevel/getDefaultsForStepType.ts | 1 + .../dependentFieldsUpdatePause.ts | 3 +- .../stepFormToArgs/pauseFormToArgs.ts | 32 ++- 14 files changed, 322 insertions(+), 15 deletions(-) diff --git a/components/src/atoms/MenuList/MenuItem.tsx b/components/src/atoms/MenuList/MenuItem.tsx index c42fd087551..cd34c7c7f44 100644 --- a/components/src/atoms/MenuList/MenuItem.tsx +++ b/components/src/atoms/MenuList/MenuItem.tsx @@ -15,6 +15,7 @@ export const MenuItem = styled.button` color: ${COLORS.black90}; padding: ${SPACING.spacing8} ${SPACING.spacing12} ${SPACING.spacing8} ${SPACING.spacing12}; + border: ${props => (props.border != null ? props.border : 'inherit')}; &:hover { background-color: ${COLORS.blue10}; diff --git a/components/src/atoms/buttons/RadioButton.tsx b/components/src/atoms/buttons/RadioButton.tsx index e2a32c72b4d..d59c2db2ec6 100644 --- a/components/src/atoms/buttons/RadioButton.tsx +++ b/components/src/atoms/buttons/RadioButton.tsx @@ -66,7 +66,7 @@ export function RadioButton(props: RadioButtonProps): JSX.Element { &:hover, &:active { - background-color: ${COLORS.blue40}; + background-color: ${disabled ? COLORS.grey35 : COLORS.blue40}; } ` @@ -76,7 +76,7 @@ export function RadioButton(props: RadioButtonProps): JSX.Element { &:hover, &:active { - background-color: ${COLORS.blue55}; + background-color: ${disabled ? COLORS.grey35 : COLORS.blue60}; } ` diff --git a/components/src/molecules/DropdownMenu/index.tsx b/components/src/molecules/DropdownMenu/index.tsx index 924d554cd0d..ad8b12fcc1d 100644 --- a/components/src/molecules/DropdownMenu/index.tsx +++ b/components/src/molecules/DropdownMenu/index.tsx @@ -4,6 +4,7 @@ import { css } from 'styled-components' import { BORDERS, COLORS } from '../../helix-design-system' import { ALIGN_CENTER, + CURSOR_DEFAULT, CURSOR_POINTER, DIRECTION_COLUMN, DIRECTION_ROW, @@ -131,13 +132,15 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element { }, [filterOptions.length, dropDownMenuWrapperRef]) const toggleSetShowDropdownMenu = (): void => { - setShowDropdownMenu(!showDropdownMenu) + isDisabled ? null : setShowDropdownMenu(!showDropdownMenu) } + const isDisabled = filterOptions.length === 0 + const DROPDOWN_STYLE = css` flex-direction: ${DIRECTION_ROW}; background-color: ${COLORS.white}; - cursor: ${CURSOR_POINTER}; + cursor: ${isDisabled ? CURSOR_DEFAULT : CURSOR_POINTER}; padding: ${SPACING.spacing8} ${SPACING.spacing12}; border: 1px ${BORDERS.styleSolid} ${showDropdownMenu ? COLORS.blue50 : COLORS.grey50}; @@ -155,7 +158,8 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element { } &:active { - border: 1px ${BORDERS.styleSolid} ${COLORS.blue50}; + border: 1px ${BORDERS.styleSolid} + ${isDisabled ? COLORS.grey55 : COLORS.blue50}; } &:focus-visible { @@ -249,6 +253,7 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element { onClick(option.value) setShowDropdownMenu(false) }} + border="none" > {option.liquidColor != null ? ( diff --git a/components/src/organisms/Toolbox/index.tsx b/components/src/organisms/Toolbox/index.tsx index 183669414bf..25796ebd190 100644 --- a/components/src/organisms/Toolbox/index.tsx +++ b/components/src/organisms/Toolbox/index.tsx @@ -25,6 +25,7 @@ export interface ToolboxProps { closeButtonText?: string side?: 'left' | 'right' horizontalSide?: 'top' | 'bottom' + padding?: string } export function Toolbox(props: ToolboxProps): JSX.Element { @@ -41,6 +42,7 @@ export function Toolbox(props: ToolboxProps): JSX.Element { confirmButton, side = 'right', horizontalSide = 'bottom', + padding = SPACING.spacing16, } = props const slideOutRef = React.useRef(null) @@ -108,7 +110,7 @@ export function Toolbox(props: ToolboxProps): JSX.Element { ) : null} } height="calc(100vh - 64px)" + padding="0" title={ 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 index 2549d8aa6da..9450c0eb1f3 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/PauseTools/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/PauseTools/index.tsx @@ -1,5 +1,256 @@ import * as React from 'react' +import styled from 'styled-components' +import { + COLORS, + DIRECTION_COLUMN, + Divider, + DropdownMenu, + Flex, + RadioButton, + SPACING, + StyledText, + TYPOGRAPHY, +} from '@opentrons/components' +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' -export function PauseTools(): JSX.Element { - return
TODO: wire this up
+import { selectors as uiModuleSelectors } from '../../../../../../ui/modules' +import { + PAUSE_UNTIL_RESUME, + PAUSE_UNTIL_TEMP, + PAUSE_UNTIL_TIME, +} from '../../../../../../constants' + +import type { StepFormProps } from '../../types' +import { getInitialDeckSetup } from '../../../../../../step-forms/selectors' +import { + HEATERSHAKER_MODULE_TYPE, + TEMPERATURE_MODULE_TYPE, + getModuleDisplayName, +} from '@opentrons/shared-data' + +export function PauseTools(props: StepFormProps): JSX.Element { + const { propsForFields } = props + + const tempModuleLabwareOptions = useSelector( + uiModuleSelectors.getTemperatureLabwareOptions + ) + const { i18n, t } = useTranslation(['tooltip', 'application', 'form']) + + const heaterShakerModuleLabwareOptions = useSelector( + uiModuleSelectors.getHeaterShakerLabwareOptions + ) + + const { modules } = useSelector(getInitialDeckSetup) + interface ModuleOption { + name: string + value: string + } + const modulesOnDeck = Object.values(modules) + const moduleOptions = modulesOnDeck.reduce((acc, module) => { + if ( + [ + TEMPERATURE_MODULE_TYPE as string, + HEATERSHAKER_MODULE_TYPE as string, + ].includes(module.type) + ) { + const moduleName = getModuleDisplayName(module.model) + return [ + ...acc, + { value: module.id, name: `${moduleName} in ${module.slot}` }, + ] + } + return acc + }, []) + + const moduleLabwareOptions = [ + ...tempModuleLabwareOptions, + ...heaterShakerModuleLabwareOptions, + ] + + const pauseUntilModuleEnabled = moduleLabwareOptions.length > 0 + + const { pauseAction } = props.formData + + return ( + <> + + + + ) => { + propsForFields.pauseAction.updateValue(e.currentTarget.value) + }} + buttonLabel={t( + 'form:step_edit_form.field.pauseAction.options.untilResume' + )} + buttonValue={PAUSE_UNTIL_RESUME} + isSelected={ + propsForFields.pauseAction.value === PAUSE_UNTIL_RESUME + } + largeDesktopBorderRadius + /> + ) => { + propsForFields.pauseAction.updateValue(e.currentTarget.value) + }} + buttonLabel={t( + 'form:step_edit_form.field.pauseAction.options.untilTime' + )} + buttonValue={PAUSE_UNTIL_TIME} + isSelected={propsForFields.pauseAction.value === PAUSE_UNTIL_TIME} + largeDesktopBorderRadius + /> + ) => { + propsForFields.pauseAction.updateValue(e.currentTarget.value) + }} + buttonLabel={t( + 'form:step_edit_form.field.pauseAction.options.untilTemperature' + )} + buttonValue={PAUSE_UNTIL_TEMP} + isSelected={propsForFields.pauseAction.value === PAUSE_UNTIL_TEMP} + largeDesktopBorderRadius + disabled={!pauseUntilModuleEnabled} + /> + + + + {pauseAction === PAUSE_UNTIL_TIME ? ( + + + + {t('application:time')} + + ) => { + propsForFields.pauseTime.updateValue( + e.currentTarget.value + ) + }} + error={ + propsForFields.pauseTime.errorToShow != null && + propsForFields.pauseTime.value != null && + propsForFields.pauseTime.value !== '' + } + /> + {propsForFields.pauseTime.value !== '' && + propsForFields.pauseTime.value != null && + propsForFields.pauseTime.errorToShow != null ? ( + + {propsForFields.pauseTime.errorToShow} + + ) : null} + + + ) : null} + {pauseAction === PAUSE_UNTIL_TEMP ? ( + <> + + + {i18n.format( + t('form:step_edit_form.field.moduleActionLabware.label'), + 'capitalize' + )} + + { + propsForFields.moduleId.updateValue(value) + }} + currentOption={ + moduleOptions.find( + option => option.value === propsForFields.moduleId.value + ) ?? { name: '', value: '' } + } + dropdownType="neutral" + width="100%" + /> + + + + {t('application:temperature')} + + ) => { + propsForFields.pauseTemperature.updateValue( + e.currentTarget.value + ) + }} + error={propsForFields.pauseTemperature.errorToShow != null} + /> + {propsForFields.pauseTemperature.value !== '' && + propsForFields.pauseTemperature.errorToShow != null ? ( + + {propsForFields.pauseTemperature.errorToShow} + + ) : null} + + + ) : null} + + + + {i18n.format( + t('form:step_edit_form.field.pauseMessage.label'), + 'capitalize' + )} + + ) => { + propsForFields.pauseMessage.updateValue(e.currentTarget.value) + }} + height="7rem" + /> + + + + + ) } + +const StyledTextArea = styled.textarea<{ height?: string; error?: boolean }>` + width: 100%; + height: ${props => (props.height != null ? props.height : '2rem')}; + box-sizing: border-box; + border: 1px solid + ${props => + props.error != null && props.error ? COLORS.red50 : COLORS.grey50}; + border-radius: 4px; + padding: 8px; + font-size: ${TYPOGRAPHY.fontSizeH4}; + line-height: ${TYPOGRAPHY.lineHeight16}; + font-weight: ${TYPOGRAPHY.fontWeightRegular}; + resize: none; +` diff --git a/protocol-designer/src/steplist/fieldLevel/errors.ts b/protocol-designer/src/steplist/fieldLevel/errors.ts index 3097c71a09e..37c9694cf24 100644 --- a/protocol-designer/src/steplist/fieldLevel/errors.ts +++ b/protocol-designer/src/steplist/fieldLevel/errors.ts @@ -5,6 +5,7 @@ import isArray from 'lodash/isArray' ********************/ // TODO: reconcile difference between returning error string and key export type FieldError = + | 'BAD_TIME' | 'REQUIRED' | 'UNDER_WELL_MINIMUM' | 'NON_ZERO' @@ -13,6 +14,7 @@ export type FieldError = | 'NOT_A_REAL_NUMBER' | 'OUTSIDE_OF_RANGE' const FIELD_ERRORS: Record = { + BAD_TIME: 'Must be a valid time (hh:mm:ss)', REQUIRED: 'This field is required', UNDER_WELL_MINIMUM: 'or more wells are required', NON_ZERO: 'Must be greater than zero', @@ -29,6 +31,12 @@ const FIELD_ERRORS: Record = { export type ErrorChecker = (value: unknown) => string | null export const requiredField: ErrorChecker = (value: unknown) => !value ? FIELD_ERRORS.REQUIRED : null +export const isTimeFormat: ErrorChecker = (value: unknown): string | null => { + const timeRegex = new RegExp(/^\d{1,2}:\d{1,2}:\d{1,2}$/g) + return (typeof value === 'string' && timeRegex.test(value)) || value == null + ? null + : FIELD_ERRORS.BAD_TIME +} export const nonZero: ErrorChecker = (value: unknown) => value && Number(value) === 0 ? FIELD_ERRORS.NON_ZERO : null export const minimumWellCount = (minimum: number): ErrorChecker => ( diff --git a/protocol-designer/src/steplist/fieldLevel/index.ts b/protocol-designer/src/steplist/fieldLevel/index.ts index cd36ff69c41..3b4e736a4a3 100644 --- a/protocol-designer/src/steplist/fieldLevel/index.ts +++ b/protocol-designer/src/steplist/fieldLevel/index.ts @@ -7,10 +7,12 @@ import { maxFieldValue, temperatureRangeFieldValue, realNumber, + isTimeFormat, } from './errors' import { maskToInteger, maskToFloat, + maskToTime, numberOrNull, onlyPositiveNumbers, defaultTo, @@ -346,6 +348,11 @@ const stepFieldHelperMap: Record = { pauseAction: { getErrors: composeErrors(requiredField), }, + pauseTime: { + maskValue: composeMaskers(maskToTime), + getErrors: composeErrors(isTimeFormat), + castValue: String, + }, pauseTemperature: { getErrors: composeErrors( minFieldValue(MIN_TEMP_MODULE_TEMP), diff --git a/protocol-designer/src/steplist/fieldLevel/processing.ts b/protocol-designer/src/steplist/fieldLevel/processing.ts index 044434214ee..c3e832031c4 100644 --- a/protocol-designer/src/steplist/fieldLevel/processing.ts +++ b/protocol-designer/src/steplist/fieldLevel/processing.ts @@ -12,6 +12,13 @@ export const maskToInteger = (rawValue: unknown): string => { : String(rawValue) return rawNumericValue } +export const maskToTime = (rawValue: unknown): string => { + const rawTimeValue = + typeof rawValue === 'string' + ? rawValue.replace(/[^-0-9:]/g, '') + : String(rawValue) + return rawTimeValue +} const DEFAULT_DECIMAL_PLACES: number = 1 export const maskToFloat = (rawValue: unknown): string => typeof rawValue === 'string' diff --git a/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts b/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts index d4755cec0ca..b669b865e4e 100644 --- a/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts +++ b/protocol-designer/src/steplist/formLevel/getDefaultsForStepType.ts @@ -125,6 +125,7 @@ export function getDefaultsForStepType( pauseMinute: null, pauseSecond: null, pauseTemperature: null, + pauseTime: null, } case 'manualIntervention': diff --git a/protocol-designer/src/steplist/formLevel/handleFormChange/dependentFieldsUpdatePause.ts b/protocol-designer/src/steplist/formLevel/handleFormChange/dependentFieldsUpdatePause.ts index 902ec98309d..ee8556f7102 100644 --- a/protocol-designer/src/steplist/formLevel/handleFormChange/dependentFieldsUpdatePause.ts +++ b/protocol-designer/src/steplist/formLevel/handleFormChange/dependentFieldsUpdatePause.ts @@ -20,7 +20,8 @@ const updatePatchOnPauseTemperatureChange = ( 'pauseTemperature', 'pauseHour', 'pauseMinute', - 'pauseSecond' + 'pauseSecond', + 'pauseTime' ), } } diff --git a/protocol-designer/src/steplist/formLevel/stepFormToArgs/pauseFormToArgs.ts b/protocol-designer/src/steplist/formLevel/stepFormToArgs/pauseFormToArgs.ts index 1f2441ffe4f..8e35bea7319 100644 --- a/protocol-designer/src/steplist/formLevel/stepFormToArgs/pauseFormToArgs.ts +++ b/protocol-designer/src/steplist/formLevel/stepFormToArgs/pauseFormToArgs.ts @@ -8,18 +8,38 @@ import type { WaitForTemperatureArgs, PauseArgs, } from '@opentrons/step-generation' + +const TIME_DELIMITER = ':' + export const pauseFormToArgs = ( formData: FormData ): PauseArgs | WaitForTemperatureArgs | null => { - const hours = isNaN(parseFloat(formData.pauseHour as string)) + let hoursFromForm + let minutesFromForm + let secondsFromForm + + // importing results in stringified "null" value + if (formData.pauseTime != null && formData.pauseTime != 'null') { + const timeSplit = formData.pauseTime.split(TIME_DELIMITER) + ;[hoursFromForm, minutesFromForm, secondsFromForm] = timeSplit + } else { + // TODO (nd 09/23/2024): remove individual time units after redesign FF is removed + ;[hoursFromForm, minutesFromForm, secondsFromForm] = [ + formData.pauseHour, + formData.pauseMinutes, + formData.pauseSeconds, + ] + } + const hours = isNaN(parseFloat(hoursFromForm as string)) ? 0 - : parseFloat(formData.pauseHour as string) - const minutes = isNaN(parseFloat(formData.pauseMinute as string)) + : parseFloat(hoursFromForm as string) + const minutes = isNaN(parseFloat(minutesFromForm as string)) ? 0 - : parseFloat(formData.pauseMinute as string) - const seconds = isNaN(parseFloat(formData.pauseSecond as string)) + : parseFloat(minutesFromForm as string) + const seconds = isNaN(parseFloat(secondsFromForm as string)) ? 0 - : parseFloat(formData.pauseSecond as string) + : parseFloat(secondsFromForm as string) + const totalSeconds = hours * 3600 + minutes * 60 + seconds const temperature = parseFloat(formData.pauseTemperature as string) const message = formData.pauseMessage ?? ''