diff --git a/components/src/atoms/InputField/index.tsx b/components/src/atoms/InputField/index.tsx index 8933ca5345c..14a4f520377 100644 --- a/components/src/atoms/InputField/index.tsx +++ b/components/src/atoms/InputField/index.tsx @@ -71,6 +71,7 @@ export interface InputFieldProps { leftIcon?: IconName showDeleteIcon?: boolean onDelete?: () => void + hasBackgroundError?: boolean } export const InputField = React.forwardRef( @@ -83,6 +84,7 @@ export const InputField = React.forwardRef( tooltipText, tabIndex = 0, showDeleteIcon = false, + hasBackgroundError = false, ...inputProps } = props const hasError = props.error != null @@ -103,11 +105,13 @@ export const InputField = React.forwardRef( const INPUT_FIELD = css` display: flex; - background-color: ${COLORS.white}; + background-color: ${hasBackgroundError ? COLORS.red30 : COLORS.white}; border-radius: ${BORDERS.borderRadius4}; padding: ${SPACING.spacing8}; - border: 1px ${BORDERS.styleSolid} - ${hasError ? COLORS.red50 : COLORS.grey50}; + border: ${hasBackgroundError + ? 'none' + : `1px ${BORDERS.styleSolid} + ${hasError ? COLORS.red50 : COLORS.grey50}`}; font-size: ${TYPOGRAPHY.fontSizeP}; width: 100%; height: ${size === 'small' ? '2rem' : '2.75rem'}; @@ -321,10 +325,7 @@ export const InputField = React.forwardRef( ) : null} {hasError ? ( - + {props.error} ) : null} @@ -335,6 +336,7 @@ export const InputField = React.forwardRef( ) const StyledInput = styled.input` + background-color: transparent; &::placeholder { color: ${COLORS.grey40}; } diff --git a/components/src/molecules/DropdownMenu/index.tsx b/components/src/molecules/DropdownMenu/index.tsx index 30a02209121..af336737fa5 100644 --- a/components/src/molecules/DropdownMenu/index.tsx +++ b/components/src/molecules/DropdownMenu/index.tsx @@ -63,6 +63,10 @@ export interface DropdownMenuProps { tabIndex?: number /** optional error */ error?: string | null + /** focus handler */ + onFocus?: React.FocusEventHandler + /** blur handler */ + onBlur?: React.FocusEventHandler } // TODO: (smb: 4/15/22) refactor this to use html select for accessibility @@ -79,6 +83,8 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element { tooltipText, tabIndex = 0, error, + onFocus, + onBlur, } = props const [targetProps, tooltipProps] = useHoverTooltip() const [showDropdownMenu, setShowDropdownMenu] = React.useState(false) @@ -222,6 +228,8 @@ export function DropdownMenu(props: DropdownMenuProps): JSX.Element { e.preventDefault() toggleSetShowDropdownMenu() }} + onFocus={onFocus} + onBlur={onBlur} css={DROPDOWN_STYLE} tabIndex={tabIndex} > diff --git a/protocol-designer/src/molecules/DropdownStepFormField/index.tsx b/protocol-designer/src/molecules/DropdownStepFormField/index.tsx index a6777a5be00..6cd81c742c4 100644 --- a/protocol-designer/src/molecules/DropdownStepFormField/index.tsx +++ b/protocol-designer/src/molecules/DropdownStepFormField/index.tsx @@ -22,6 +22,8 @@ export function DropdownStepFormField( tooltipContent, addPadding = true, width = '17.5rem', + onFieldFocus, + onFieldBlur, } = props const { t } = useTranslation('tooltip') const availableOptionId = options.find(opt => opt.value === value) @@ -35,6 +37,8 @@ export function DropdownStepFormField( dropdownType="neutral" filterOptions={options} title={title} + onBlur={onFieldBlur} + onFocus={onFieldFocus} currentOption={ availableOptionId ?? { name: 'Choose option', value: '' } } diff --git a/protocol-designer/src/organisms/Alerts/FormAlerts.tsx b/protocol-designer/src/organisms/Alerts/FormAlerts.tsx index 290295305af..d08e25e1ec6 100644 --- a/protocol-designer/src/organisms/Alerts/FormAlerts.tsx +++ b/protocol-designer/src/organisms/Alerts/FormAlerts.tsx @@ -105,6 +105,7 @@ function FormAlertsComponent(props: FormAlertsProps): JSX.Element | null { : undefined } width="100%" + iconMarginLeft={SPACING.spacing4} > diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/WellSelectionField.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/WellSelectionField.tsx index 0775e14304e..57125f7b8a1 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/WellSelectionField.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/WellSelectionField.tsx @@ -28,6 +28,7 @@ export type WellSelectionFieldProps = FieldProps & { nozzles: string | null pipetteId?: string | null labwareId?: string | null + hasFormError?: boolean } export const WellSelectionField = ( @@ -45,6 +46,7 @@ export const WellSelectionField = ( disabled, errorToShow, tooltipContent, + hasFormError, } = props const { t, i18n } = useTranslation(['form', 'tooltip']) const dispatch = useDispatch() @@ -90,7 +92,6 @@ export const WellSelectionField = ( ? t(`step_edit_form.wellSelectionLabel.columns_${name}`) : t(`step_edit_form.wellSelectionLabel.wells_${name}`) const [targetProps, tooltipProps] = useHoverTooltip() - return ( <> @@ -116,6 +117,7 @@ export const WellSelectionField = ( error={errorToShow} value={primaryWellCount} onClick={handleOpen} + hasBackgroundError={hasFormError} /> {createPortal( diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx index 9c60605163c..79287acc49f 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx @@ -36,10 +36,15 @@ import { TemperatureTools, ThermocyclerTools, } from './StepTools' -import { getSaveStepSnackbarText } from './utils' +import { + getSaveStepSnackbarText, + getVisibleFormErrors, + getVisibleFormWarnings, +} from './utils' import type { StepFieldName } from '../../../../steplist/fieldLevel' import type { FormData, StepType } from '../../../../form-types' import type { FieldPropsByName, FocusHandlers, StepFormProps } from './types' +import { getFormLevelErrorsForUnsavedForm } from '../../../../step-forms/selectors' type StepFormMap = { [K in StepType]?: React.ComponentType | null @@ -91,6 +96,9 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { const timelineWarningsForSelectedStep = useSelector( getTimelineWarningsForSelectedStep ) + const formLevelErrorsForUnsavedForm = useSelector( + getFormLevelErrorsForUnsavedForm + ) const timeline = useSelector(getRobotStateTimeline) const [toolboxStep, setToolboxStep] = useState( // progress to step 2 if thermocycler form is populated @@ -103,6 +111,16 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { showFormErrorsAndWarnings, setShowFormErrorsAndWarnings, ] = useState(false) + const visibleFormWarnings = getVisibleFormWarnings({ + focusedField, + dirtyFields: dirtyFields ?? [], + errors: formWarningsForSelectedStep, + }) + const visibleFormErrors = getVisibleFormErrors({ + focusedField, + dirtyFields: dirtyFields ?? [], + errors: formLevelErrorsForUnsavedForm, + }) const [isRename, setIsRename] = useState(false) const icon = stepIconsByType[formData.stepType] @@ -126,7 +144,7 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { formData.stepType === 'mix' || formData.stepType === 'thermocycler' const numWarnings = - formWarningsForSelectedStep.length + timelineWarningsForSelectedStep.length + visibleFormWarnings.length + timelineWarningsForSelectedStep.length const numErrors = timeline.errors?.length ?? 0 const handleSaveClick = (): void => { @@ -229,6 +247,7 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { propsForFields, focusHandlers, toolboxStep, + visibleFormErrors, }} /> 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 index b9781a92118..893a2b4d5f1 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MixTools/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MixTools/index.tsx @@ -42,7 +42,7 @@ import { import type { StepFormProps } from '../../types' export function MixTools(props: StepFormProps): JSX.Element { - const { propsForFields, formData, toolboxStep } = props + const { propsForFields, formData, toolboxStep, visibleFormErrors } = props const pipettes = useSelector(getPipetteEntities) const enableReturnTip = useSelector(getEnableReturnTip) const labwares = useSelector(getLabwareEntities) @@ -89,6 +89,11 @@ export function MixTools(props: StepFormProps): JSX.Element { labwareId={formData.labware} pipetteId={formData.pipette} nozzles={String(propsForFields.nozzles.value) ?? null} + hasFormError={ + visibleFormErrors?.some(error => + error.dependentFields.includes('labware') + ) ?? false + } /> 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 index f30e5338b60..551754dc75e 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MoveLiquidTools/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MoveLiquidTools/index.tsx @@ -50,7 +50,7 @@ const makeAddFieldNamePrefix = (prefix: string) => ( ): StepFieldName => `${prefix}_${fieldName}` export function MoveLiquidTools(props: StepFormProps): JSX.Element { - const { toolboxStep, propsForFields, formData } = props + const { toolboxStep, propsForFields, formData, visibleFormErrors } = props const { t, i18n } = useTranslation(['protocol_steps', 'form']) const { path } = formData const [tab, setTab] = useState<'aspirate' | 'dispense'>('aspirate') @@ -126,6 +126,11 @@ export function MoveLiquidTools(props: StepFormProps): JSX.Element { labwareId={String(propsForFields.aspirate_labware.value)} pipetteId={formData.pipette} nozzles={String(propsForFields.nozzles.value) ?? null} + hasFormError={ + visibleFormErrors?.some(error => + error.dependentFields.includes('aspirate_labware') + ) ?? false + } /> @@ -136,6 +141,11 @@ export function MoveLiquidTools(props: StepFormProps): JSX.Element { labwareId={String(propsForFields.dispense_labware.value)} pipetteId={formData.pipette} nozzles={String(propsForFields.nozzles.value) ?? null} + hasFormError={ + visibleFormErrors?.some(error => + error.dependentFields.includes('dispense_wells') + ) ?? false + } /> )} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/__tests__/MagnetTools.test.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/__tests__/MagnetTools.test.tsx index 5a901290c37..c6cb5f13383 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/__tests__/MagnetTools.test.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/__tests__/MagnetTools.test.tsx @@ -47,6 +47,7 @@ describe('MagnetTools', () => { dirtyFields: [], focusedField: null, }, + visibleFormErrors: [], toolboxStep: 1, propsForFields: { magnetAction: { diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/__tests__/TemperatureTools.test.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/__tests__/TemperatureTools.test.tsx index 34b0b0bd501..8edd87af457 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/__tests__/TemperatureTools.test.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/__tests__/TemperatureTools.test.tsx @@ -40,6 +40,7 @@ describe('TemperatureTools', () => { dirtyFields: [], focusedField: null, }, + visibleFormErrors: [], toolboxStep: 1, propsForFields: { moduleId: { diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/index.tsx index 0b7049bd546..ad668a98852 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/index.tsx @@ -79,9 +79,12 @@ function StepFormManager(props: StepFormManagerProps): JSX.Element | null { if (fieldName === focusedField) { setFocusedField(null) } - if (!dirtyFields.includes(fieldName)) { - setDirtyFields([...dirtyFields, fieldName]) - } + setDirtyFields(prevDirtyFields => { + if (!prevDirtyFields.includes(fieldName)) { + return [...prevDirtyFields, fieldName] + } + return prevDirtyFields + }) } const stepId = formData?.id const handleDelete = (): void => { @@ -144,7 +147,6 @@ function StepFormManager(props: StepFormManagerProps): JSX.Element | null { ) { handleSave = confirmAddPauseUntilHeaterShakerTempStep } - return ( <> {/* TODO: update these modals to match new modal design */} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/types.ts b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/types.ts index 9d9762594af..c7b063c4731 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/types.ts +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/types.ts @@ -1,4 +1,5 @@ import type { FormData, StepFieldName } from '../../../../form-types' +import type { StepFormErrors } from '../../../../steplist' export interface FocusHandlers { focusedField: StepFieldName | null dirtyFields: StepFieldName[] @@ -24,4 +25,5 @@ export interface StepFormProps { focusHandlers: FocusHandlers propsForFields: FieldPropsByName toolboxStep: number + visibleFormErrors: StepFormErrors } diff --git a/protocol-designer/src/steplist/formLevel/errors.ts b/protocol-designer/src/steplist/formLevel/errors.ts index e5203cdc84e..9bf495b3134 100644 --- a/protocol-designer/src/steplist/formLevel/errors.ts +++ b/protocol-designer/src/steplist/formLevel/errors.ts @@ -50,15 +50,15 @@ export interface FormError { dependentFields: StepFieldName[] } const INCOMPATIBLE_ASPIRATE_LABWARE: FormError = { - title: 'Selected aspirate labware is incompatible with selected pipette', + title: 'Selected aspirate labware is incompatible with pipette', dependentFields: ['aspirate_labware', 'pipette'], } const INCOMPATIBLE_DISPENSE_LABWARE: FormError = { - title: 'Selected dispense labware is incompatible with selected pipette', + title: 'Selected dispense labware is incompatible with pipette', dependentFields: ['dispense_labware', 'pipette'], } const INCOMPATIBLE_LABWARE: FormError = { - title: 'Selected labware is incompatible with selected pipette', + title: 'Selected labware is incompatible with pipette', dependentFields: ['labware', 'pipette'], } const PAUSE_TYPE_REQUIRED: FormError = {