Skip to content

Commit

Permalink
feat(protocol-designer, components): wire up pause form in PD redesign (
Browse files Browse the repository at this point in the history
#16328)

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
  • Loading branch information
ncdiehl11 authored Sep 25, 2024
1 parent bd3f65d commit 786e2bd
Show file tree
Hide file tree
Showing 17 changed files with 349 additions and 34 deletions.
1 change: 1 addition & 0 deletions components/src/atoms/MenuList/MenuItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const MenuItem = styled.button<ButtonProps>`
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};
Expand Down
4 changes: 2 additions & 2 deletions components/src/atoms/buttons/RadioButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export function RadioButton(props: RadioButtonProps): JSX.Element {
&:hover,
&:active {
background-color: ${COLORS.blue40};
background-color: ${disabled ? COLORS.grey35 : COLORS.blue40};
}
`

Expand All @@ -76,7 +76,7 @@ export function RadioButton(props: RadioButtonProps): JSX.Element {
&:hover,
&:active {
background-color: ${COLORS.blue55};
background-color: ${disabled ? COLORS.grey35 : COLORS.blue60};
}
`

Expand Down
10 changes: 8 additions & 2 deletions components/src/molecules/DropdownMenu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -136,9 +137,13 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element {
}, [filterOptions.length, dropDownMenuWrapperRef])

const toggleSetShowDropdownMenu = (): void => {
setShowDropdownMenu(!showDropdownMenu)
if (!isDisabled) {
setShowDropdownMenu(!showDropdownMenu)
}
}

const isDisabled = filterOptions.length === 0

let defaultBorderColor = COLORS.grey50
let hoverBorderColor = COLORS.grey55
if (showDropdownMenu) {
Expand All @@ -152,7 +157,7 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element {
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} ${defaultBorderColor};
border-radius: ${dropdownType === 'rounded'
Expand Down Expand Up @@ -264,6 +269,7 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element {
onClick(option.value)
setShowDropdownMenu(false)
}}
border="none"
>
<Flex gridGap={SPACING.spacing8} alignItems={ALIGN_CENTER}>
{option.liquidColor != null ? (
Expand Down
2 changes: 2 additions & 0 deletions protocol-designer/src/assets/localization/en/application.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
"profile_steps": "profile steps",
"ending_hold": "ending hold"
},
"temperature": "Temperature (˚C)",
"time": "Time (hh:mm:ss)",
"units": {
"millimeter": "mm",
"microliter": "μL",
Expand Down
1 change: 1 addition & 0 deletions protocol-designer/src/form-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ export type PauseForm = AnnotationFields & {
pauseSecond?: string
pauseMessage?: string
pauseTemperature?: string
pauseTime?: string
}
// TODO: separate field values from from metadata
export interface FormData {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,252 @@
import * as React from 'react'
import styled from 'styled-components'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'

export function PauseTools(): JSX.Element {
return <div>TODO: wire this up</div>
import {
BORDERS,
COLORS,
DIRECTION_COLUMN,
Divider,
DropdownMenu,
Flex,
RadioButton,
SPACING,
StyledText,
TYPOGRAPHY,
} from '@opentrons/components'
import {
HEATERSHAKER_MODULE_TYPE,
TEMPERATURE_MODULE_TYPE,
getModuleDisplayName,
} from '@opentrons/shared-data'

import {
PAUSE_UNTIL_RESUME,
PAUSE_UNTIL_TEMP,
PAUSE_UNTIL_TIME,
} from '../../../../../../constants'
import { selectors as uiModuleSelectors } from '../../../../../../ui/modules'
import { getInitialDeckSetup } from '../../../../../../step-forms/selectors'

import type { StepFormProps } from '../../types'

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<ModuleOption[]>((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 (
<>
<Flex flexDirection={DIRECTION_COLUMN}>
<Flex flexDirection={DIRECTION_COLUMN} gridGap={SPACING.spacing12}>
<Flex
flexDirection={DIRECTION_COLUMN}
gridGap={SPACING.spacing4}
width="100%"
padding={`${SPACING.spacing16} ${SPACING.spacing16} 0 ${SPACING.spacing16}`}
>
<RadioButton
onChange={(e: React.ChangeEvent<any>) => {
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
/>
<RadioButton
onChange={(e: React.ChangeEvent<any>) => {
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
/>
<RadioButton
onChange={(e: React.ChangeEvent<any>) => {
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}
/>
</Flex>
<Divider marginY="0" />
<Flex
flexDirection={DIRECTION_COLUMN}
gridGap={SPACING.spacing12}
paddingX={SPACING.spacing16}
>
{pauseAction === PAUSE_UNTIL_TIME ? (
<Flex
flexDirection={DIRECTION_COLUMN}
gridGap={SPACING.spacing12}
>
<Flex
flexDirection={DIRECTION_COLUMN}
gridGap={SPACING.spacing4}
>
<StyledText desktopStyle="captionRegular">
{t('application:time')}
</StyledText>
<StyledTextArea
value={propsForFields.pauseTime.value as string}
onChange={(e: React.ChangeEvent<any>) => {
propsForFields.pauseTime.updateValue(
e.currentTarget.value
)
}}
error={propsForFields.pauseTime.errorToShow != null}
/>
{propsForFields.pauseTime.errorToShow != null ? (
<StyledText
desktopStyle="captionRegular"
color={COLORS.red50}
>
{propsForFields.pauseTime.errorToShow}
</StyledText>
) : null}
</Flex>
</Flex>
) : null}
{pauseAction === PAUSE_UNTIL_TEMP ? (
<>
<Flex flexDirection={DIRECTION_COLUMN}>
<StyledText desktopStyle="captionRegular">
{i18n.format(
t('form:step_edit_form.field.moduleActionLabware.label'),
'capitalize'
)}
</StyledText>
<DropdownMenu
filterOptions={moduleOptions}
onClick={value => {
propsForFields.moduleId.updateValue(value)
}}
currentOption={
moduleOptions.find(
option => option.value === propsForFields.moduleId.value
) ?? { name: '', value: '' }
}
dropdownType="neutral"
width="100%"
/>
</Flex>
<Flex
flexDirection={DIRECTION_COLUMN}
gridGap={SPACING.spacing4}
>
<StyledText desktopStyle="captionRegular">
{t('application:temperature')}
</StyledText>
<StyledTextArea
value={propsForFields.pauseTemperature.value as string}
onChange={(e: React.ChangeEvent<any>) => {
propsForFields.pauseTemperature.updateValue(
e.currentTarget.value
)
}}
error={propsForFields.pauseTemperature.errorToShow != null}
/>
{propsForFields.pauseTemperature.value !== '' &&
propsForFields.pauseTemperature.errorToShow != null ? (
<StyledText
desktopStyle="captionRegular"
color={COLORS.red50}
>
{propsForFields.pauseTemperature.errorToShow}
</StyledText>
) : null}
</Flex>
</>
) : null}
</Flex>
<Flex
flexDirection={DIRECTION_COLUMN}
gridGap={SPACING.spacing4}
paddingX={SPACING.spacing16}
>
<StyledText desktopStyle="captionRegular">
{i18n.format(
t('form:step_edit_form.field.pauseMessage.label'),
'capitalize'
)}
</StyledText>
<StyledTextArea
value={propsForFields.pauseMessage.value as string}
onChange={(e: React.ChangeEvent<any>) => {
propsForFields.pauseMessage.updateValue(e.currentTarget.value)
}}
height="7rem"
/>
</Flex>
</Flex>
</Flex>
</>
)
}

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: ${BORDERS.borderRadius4};
padding: ${SPACING.spacing8};
font-size: ${TYPOGRAPHY.fontSizeH4};
line-height: ${TYPOGRAPHY.lineHeight16};
font-weight: ${TYPOGRAPHY.fontWeightRegular};
resize: none;
`
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ describe('createPresavedStepForm', () => {
pauseMessage: '',
pauseMinute: null,
pauseSecond: null,
pauseTime: null,
pauseTemperature: null,
stepDetails: '',
stepName: 'pause',
Expand Down
8 changes: 8 additions & 0 deletions protocol-designer/src/steplist/fieldLevel/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -13,6 +14,7 @@ export type FieldError =
| 'NOT_A_REAL_NUMBER'
| 'OUTSIDE_OF_RANGE'
const FIELD_ERRORS: Record<FieldError, string> = {
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',
Expand All @@ -29,6 +31,12 @@ const FIELD_ERRORS: Record<FieldError, string> = {
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 => (
Expand Down
7 changes: 7 additions & 0 deletions protocol-designer/src/steplist/fieldLevel/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import {
maxFieldValue,
temperatureRangeFieldValue,
realNumber,
isTimeFormat,
} from './errors'
import {
maskToInteger,
maskToFloat,
maskToTime,
numberOrNull,
onlyPositiveNumbers,
defaultTo,
Expand Down Expand Up @@ -346,6 +348,11 @@ const stepFieldHelperMap: Record<StepFieldName, StepFieldHelpers> = {
pauseAction: {
getErrors: composeErrors(requiredField),
},
pauseTime: {
maskValue: composeMaskers(maskToTime),
getErrors: composeErrors(isTimeFormat),
castValue: String,
},
pauseTemperature: {
getErrors: composeErrors(
minFieldValue(MIN_TEMP_MODULE_TEMP),
Expand Down
Loading

0 comments on commit 786e2bd

Please sign in to comment.