Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ui/isopicker: Add soft limits for min and max, apply to override and temp sched. #3474

Merged
merged 6 commits into from
Nov 21, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
105 changes: 83 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,36 @@ 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

// 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 +164,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