diff --git a/package.json b/package.json index 2ea14eb393..632c8acaff 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/web/src/app/forms/FormContainer.js b/web/src/app/forms/FormContainer.js index 995cee2f2c..a6b4f6659f 100644 --- a/web/src/app/forms/FormContainer.js +++ b/web/src/app/forms/FormContainer.js @@ -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) => { @@ -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 @@ -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 @@ -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, }} > diff --git a/web/src/app/forms/FormField.js b/web/src/app/forms/FormField.js index 0b7882f4ba..a14ff82a62 100644 --- a/web/src/app/forms/FormField.js +++ b/web/src/app/forms/FormField.js @@ -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, diff --git a/web/src/app/forms/context.js b/web/src/app/forms/context.js index 73bb3da321..605448b48e 100644 --- a/web/src/app/forms/context.js +++ b/web/src/app/forms/context.js @@ -6,6 +6,7 @@ export const FormContainerContext = React.createContext({ errors: [], value: {}, addField: () => () => {}, + setValidationError: () => {}, optionalLabels: false, }) FormContainerContext.displayName = 'FormContainerContext' diff --git a/web/src/app/schedules/ScheduleOverrideForm.tsx b/web/src/app/schedules/ScheduleOverrideForm.tsx index e390edb203..e27ce76a19 100644 --- a/web/src/app/schedules/ScheduleOverrideForm.tsx +++ b/web/src/app/schedules/ScheduleOverrideForm.tsx @@ -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)} /> @@ -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)} /> diff --git a/web/src/app/schedules/temp-sched/TempSchedDialog.tsx b/web/src/app/schedules/temp-sched/TempSchedDialog.tsx index 9fd1e13e33..8745f593ac 100644 --- a/web/src/app/schedules/temp-sched/TempSchedDialog.tsx +++ b/web/src/app/schedules/temp-sched/TempSchedDialog.tsx @@ -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} @@ -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()} diff --git a/web/src/app/util/ISOPickers.tsx b/web/src/app/util/ISOPickers.tsx index a5e0d29c0d..880af25cfd 100644 --- a/web/src/app/util/ISOPickers.tsx +++ b/web/src/app/util/ISOPickers.tsx @@ -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 @@ -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 @@ -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 @@ -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({ @@ -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): 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): void => { + setInputValue(e.target.value) + handleChange(e.target.value) } const defaultLabel = type === 'time' ? 'Select a time...' : 'Select a date...' @@ -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, diff --git a/yarn.lock b/yarn.lock index f4d5e4e0d1..1c62074178 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4773,6 +4773,13 @@ __metadata: languageName: node linkType: hard +"css-unit-converter@npm:^1.1.1": + version: 1.1.2 + resolution: "css-unit-converter@npm:1.1.2" + checksum: 07888033346a5128f34dbe2f72884c966d24e9f29db24416dcde92860242490617ef9a178ac193a92f730834bbeea026cdc7027701d92ba9bbbe59db7a37eb2a + languageName: node + linkType: hard + "css-vendor@npm:^2.0.8": version: 2.0.8 resolution: "css-vendor@npm:2.0.8" @@ -10456,6 +10463,13 @@ __metadata: languageName: node linkType: hard +"postcss-value-parser@npm:^3.3.0": + version: 3.3.1 + resolution: "postcss-value-parser@npm:3.3.1" + checksum: 62cd26e1cdbcf2dcc6bcedf3d9b409c9027bc57a367ae20d31dd99da4e206f730689471fd70a2abe866332af83f54dc1fa444c589e2381bf7f8054c46209ce16 + languageName: node + linkType: hard + "postcss-value-parser@npm:^4.2.0": version: 4.2.0 resolution: "postcss-value-parser@npm:4.2.0" @@ -10861,7 +10875,7 @@ __metadata: languageName: node linkType: hard -"react-smooth@npm:^2.0.4": +"react-smooth@npm:^2.0.2": version: 2.0.5 resolution: "react-smooth@npm:2.0.5" dependencies: @@ -10984,24 +10998,24 @@ __metadata: languageName: node linkType: hard -"recharts@npm:2.9.3": - version: 2.9.3 - resolution: "recharts@npm:2.9.3" +"recharts@npm:2.8.0": + version: 2.8.0 + resolution: "recharts@npm:2.8.0" dependencies: classnames: ^2.2.5 eventemitter3: ^4.0.1 lodash: ^4.17.19 react-is: ^16.10.2 react-resize-detector: ^8.0.4 - react-smooth: ^2.0.4 + react-smooth: ^2.0.2 recharts-scale: ^0.4.4 - tiny-invariant: ^1.3.1 + reduce-css-calc: ^2.1.8 victory-vendor: ^36.6.8 peerDependencies: prop-types: ^15.6.0 react: ^16.0.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 - checksum: e08bcc76a1ec2ea0cbcfcf8f57013acd11fb5ac44f40f43bb208880a8c410a9bc8d0eb421e8d1a5943eb0899729ae136a579a8008c720cb2e18367771edd86d7 + checksum: 4638bd5c6c2af8f5c79de5e13cce0e38f06e0bbb0a3c4df27a9b12632fd72c0a0604c8246f55e830f323dfa84a3da7cb2634c2243bb9c775d899fd71f9d4c87a languageName: node linkType: hard @@ -11015,6 +11029,16 @@ __metadata: languageName: node linkType: hard +"reduce-css-calc@npm:^2.1.8": + version: 2.1.8 + resolution: "reduce-css-calc@npm:2.1.8" + dependencies: + css-unit-converter: ^1.1.1 + postcss-value-parser: ^3.3.0 + checksum: 8fd27c06c4b443b84749a69a8b97d10e6ec7d142b625b41923a8807abb22b9e37e44df14e26cc606a802957be07bdce5e8ee2976a6952a7b438a7727007101e9 + languageName: node + linkType: hard + "redux-devtools-extension@npm:2.13.9": version: 2.13.9 resolution: "redux-devtools-extension@npm:2.13.9" @@ -11440,7 +11464,7 @@ __metadata: 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 @@ -12348,13 +12372,6 @@ __metadata: languageName: node linkType: hard -"tiny-invariant@npm:^1.3.1": - version: 1.3.1 - resolution: "tiny-invariant@npm:1.3.1" - checksum: 872dbd1ff20a21303a2fd20ce3a15602cfa7fcf9b228bd694a52e2938224313b5385a1078cb667ed7375d1612194feaca81c4ecbe93121ca1baebe344de4f84c - languageName: node - linkType: hard - "tiny-warning@npm:^1.0.2": version: 1.0.3 resolution: "tiny-warning@npm:1.0.3"