Skip to content

Commit

Permalink
ui/isopicker: Add soft limits for min and max, apply to override and …
Browse files Browse the repository at this point in the history
…temp sched. (#3474)

* implement soft validation for isopicker min and max

* fix render loop

* don't validate when disabled

* don't fire onChange if the value is identical

* revert recharts update
  • Loading branch information
mastercactapus committed Nov 21, 2023
1 parent e97d969 commit f799baa
Show file tree
Hide file tree
Showing 8 changed files with 158 additions and 41 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@
"react-redux": "8.1.3",
"react-transition-group": "4.4.5",
"react-virtualized-auto-sizer": "1.0.20",
"recharts": "2.9.3",
"recharts": "2.8.0",
"redux": "4.2.1",
"redux-devtools-extension": "2.13.9",
"redux-thunk": "2.4.2",
Expand Down
24 changes: 22 additions & 2 deletions web/src/app/forms/FormContainer.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ export function FormContainer(props) {
children,
} = props
const [validationErrors, setValidationErrors] = useState([])
const [fieldErrors, setFieldErrors] = useState({})
const _fields = useRef({}).current

const fieldErrorList = Object.values(fieldErrors).filter((e) => e)

const { disabled: formDisabled, addSubmitCheck } = useContext(FormContext)

const addField = (fieldName, validate) => {
Expand All @@ -36,6 +39,21 @@ export function FormContainer(props) {
}
}

const setValidationError = (fieldName, errMsg) => {
if (!errMsg) {
setFieldErrors((errs) => ({ ...errs, [fieldName]: null }))
return
}

const err = new Error(errMsg)
err.field = fieldName
setFieldErrors((errs) => ({ ...errs, [fieldName]: err }))

return () => {
setFieldErrors((errs) => ({ ...errs, [fieldName]: null }))
}
}

const onSubmit = () => {
const validate = (field) => {
let err
Expand All @@ -52,11 +70,12 @@ export function FormContainer(props) {
.filter((e) => e)
setValidationErrors(errs)
if (errs.length) return false
if (fieldErrorList.length) return false

return true
}

useEffect(() => addSubmitCheck(onSubmit), [value])
useEffect(() => addSubmitCheck(onSubmit), [value, fieldErrors])

const onChange = (fieldName, e) => {
// copy into a mutable object
Expand Down Expand Up @@ -99,9 +118,10 @@ export function FormContainer(props) {
value={{
value: mapValue(value),
disabled: formDisabled || containerDisabled,
errors: validationErrors.concat(errors),
errors: validationErrors.concat(errors).concat(fieldErrorList),
onChange,
addField,
setValidationError,
optionalLabels,
}}
>
Expand Down
10 changes: 10 additions & 0 deletions web/src/app/forms/FormField.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,16 @@ FormField.propTypes = {
min: p.oneOfType([p.number, p.string]),
max: p.oneOfType([p.number, p.string]),

// softMin and softMax values specify the range to filter changes
// expects an ISO timestamp, if string
softMin: p.oneOfType([p.number, p.string]),
softMax: p.oneOfType([p.number, p.string]),

// softMinLabel and softMaxLabel values specify the label to display
// if the softMin or softMax is exceeded.
softMinLabel: p.string,
softMaxLabel: p.string,

// used if name is set,
// but the error name is different from graphql responses
errorName: p.string,
Expand Down
1 change: 1 addition & 0 deletions web/src/app/forms/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const FormContainerContext = React.createContext({
errors: [],
value: {},
addField: () => () => {},
setValidationError: () => {},
optionalLabels: false,
})
FormContainerContext.displayName = 'FormContainerContext'
Expand Down
4 changes: 4 additions & 0 deletions web/src/app/schedules/ScheduleOverrideForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ export default function ScheduleOverrideForm(
timeZone={zone}
required
name='start'
softMax={props.value.end}
softMaxLabel='end time'
disabled={!zone}
hint={isLocalZone ? '' : fmtLocal(value.start)}
/>
Expand All @@ -145,6 +147,8 @@ export default function ScheduleOverrideForm(
timeZone={zone}
name='end'
required
softMin={props.value.start}
softMinLabel='start time'
disabled={!zone}
hint={isLocalZone ? '' : fmtLocal(value.end)}
/>
Expand Down
5 changes: 4 additions & 1 deletion web/src/app/schedules/temp-sched/TempSchedDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,8 @@ export default function TempSchedDialog({
max={DateTime.fromISO(now, { zone })
.plus({ year: 1 })
.toISO()}
softMax={value.end}
softMaxLabel='end time'
validate={() => validate()}
timeZone={zone}
disabled={q.loading}
Expand All @@ -314,7 +316,8 @@ export default function TempSchedDialog({
required
name='end'
label='Schedule End'
min={value.start}
softMin={value.start}
softMinLabel='start time'
max={DateTime.fromISO(value.start, { zone })
.plus({ month: 3 })
.toISO()}
Expand Down
106 changes: 84 additions & 22 deletions web/src/app/util/ISOPickers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'
import { DateTime, DateTimeUnit } from 'luxon'
import { TextField, TextFieldProps, useTheme } from '@mui/material'
import { useURLParam } from '../actions'
import { FormContainerContext } from '../forms/context'

interface ISOPickerProps extends ISOTextFieldProps {
format: string
Expand All @@ -11,6 +12,16 @@ interface ISOPickerProps extends ISOTextFieldProps {

min?: string
max?: string

// softMin and softMax are used to set the min and max values of the input
// without restricting the value that can be entered.
//
// Values outside of the softMin and softMax will be stored in local state
// and will not be passed to the parent onChange.
softMin?: string
softMinLabel?: string
softMax?: string
softMaxLabel?: string
}

// Used for the native textfield component or the nested input component
Expand All @@ -32,31 +43,68 @@ function ISOPicker(props: ISOPickerProps): JSX.Element {
min,
max,

softMin,
softMax,

softMinLabel,
softMaxLabel,

...textFieldProps
} = props

const theme = useTheme()
const [_zone] = useURLParam('tz', 'local')
const zone = timeZone || _zone
let valueAsDT = props.value ? DateTime.fromISO(props.value, { zone }) : null
const valueAsDT = React.useMemo(
() => (props.value ? DateTime.fromISO(props.value, { zone }) : null),
[props.value, zone],
)

// store input value as DT.format() string. pass to parent onChange as ISO string
const [inputValue, setInputValue] = useState(
valueAsDT?.toFormat(format) ?? '',
)

function getSoftValidationError(value: string): string {
if (props.disabled) return ''
let dt: DateTime
try {
dt = DateTime.fromISO(value, { zone })
} catch (e) {
return `Invalid date/time`
}

if (softMin) {
const sMin = DateTime.fromISO(softMin)
if (dt < sMin) {
return `Value must be after ${softMinLabel || sMin.toFormat(format)}`
}
}
if (softMax) {
const sMax = DateTime.fromISO(softMax)
if (dt > sMax) {
return `Value must be before ${softMaxLabel || sMax.toFormat(format)}`
}
}

return ''
}

const { setValidationError } = React.useContext(FormContainerContext) as {
setValidationError: (name: string, errMsg: string) => void
}
useEffect(
() =>
setValidationError(props.name || '', getSoftValidationError(inputValue)),
[inputValue, props.disabled, valueAsDT, props.name, softMin, softMax],
)

useEffect(() => {
setInputValue(valueAsDT?.toFormat(format) ?? '')
}, [valueAsDT])

// update isopickers render on reset
useEffect(() => {
valueAsDT = props.value ? DateTime.fromISO(props.value, { zone }) : null
setInputValue(valueAsDT ? valueAsDT.toFormat(format) : '')
}, [props.value])

const dtToISO = (dt: DateTime): string => {
return dt.startOf(truncateTo).setZone(zone).toISO()
return dt.startOf(truncateTo).toUTC().toISO()
}

// parseInputToISO takes input from the form control and returns a string
Expand All @@ -65,8 +113,9 @@ function ISOPicker(props: ISOPickerProps): JSX.Element {
if (!input) return ''

// handle input in specific format e.g. MM/dd/yyyy
const inputAsDT = DateTime.fromFormat(input, format, { zone })
if (inputAsDT.isValid) {
try {
const inputAsDT = DateTime.fromFormat(input, format, { zone })

if (valueAsDT && type === 'time') {
return dtToISO(
valueAsDT.set({
Expand All @@ -76,24 +125,37 @@ function ISOPicker(props: ISOPickerProps): JSX.Element {
)
}
return dtToISO(inputAsDT)
} catch (e) {
// ignore if input doesn't match format
}

// if format string invalid, try validating input as iso string
const iso = DateTime.fromISO(input, { zone })
if (iso.isValid) return dtToISO(iso)
try {
const iso = DateTime.fromISO(input, { zone })
return dtToISO(iso)
} catch (e) {
// ignore if input doesn't match iso format
}

return ''
}

function handleChange(e: React.ChangeEvent<HTMLInputElement>): void {
setInputValue(e.target.value)
const newVal = parseInputToISO(e.target.value)
const handleChange = (newInputValue: string): void => {
const newVal = parseInputToISO(newInputValue)
if (!newVal) return
if (getSoftValidationError(newVal)) return
if (newVal === valueAsDT?.toUTC().toISO()) return

// only fire the parent's `onChange` handler when we have a new valid value,
// taking care to ensure we ignore any zonal differences.
if (!valueAsDT || (newVal && newVal !== valueAsDT.toISO())) {
onChange(newVal)
}
onChange(newVal)
}

useEffect(() => {
handleChange(inputValue)
}, [softMin, softMax]) // inputValue intentionally omitted to prevent loop

const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
setInputValue(e.target.value)
handleChange(e.target.value)
}

const defaultLabel = type === 'time' ? 'Select a time...' : 'Select a date...'
Expand All @@ -103,8 +165,8 @@ function ISOPicker(props: ISOPickerProps): JSX.Element {
label={defaultLabel}
{...textFieldProps}
type={type}
value={valueAsDT ? valueAsDT.toFormat(format) : inputValue}
onChange={handleChange}
value={inputValue}
onChange={handleInputChange}
InputLabelProps={{ shrink: true, ...textFieldProps?.InputLabelProps }}
inputProps={{
min: min ? DateTime.fromISO(min, { zone }).toFormat(format) : undefined,
Expand Down
Loading

0 comments on commit f799baa

Please sign in to comment.