| 1 | // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt |
| 2 | |
| 3 | import { |
| 4 | createElement as h, FC, Fragment, isValidElement, ReactElement, ReactNode, useEffect, useState, useRef, |
| 5 | MutableRefObject |
| 6 | } from 'react' |
| 7 | import { Box, BoxProps, Button, Grid, GridProps, Tooltip } from '@mui/material' |
| 8 | import { Save } from '@mui/icons-material' |
| 9 | import _ from 'lodash' |
| 10 | import { StringField } from './StringField' |
| 11 | import { useDebounce } from 'usehooks-ts' |
| 12 | import type { SxProps } from '@mui/system' |
| 13 | import type { Theme } from '@mui/material/styles' |
| 14 | export * from './SelectField' |
| 15 | export * from './misc-fields' |
| 16 | export { StringField } |
| 17 | |
| 18 | type ValidationError = ReactNode // false = no error |
| 19 | export interface FieldDescriptor<T=any> extends FieldApi<T> { |
| 20 | k: string |
| 21 | comp?: any |
| 22 | label?: ReactNode |
| 23 | error?: ReactNode |
| 24 | toField?: (v: T) => any |
| 25 | fromField?: (v: any, { originalValue }: { originalValue: T }) => T |
| 26 | before?: ReactNode |
| 27 | after?: ReactNode |
| 28 | getError?: GetError |
| 29 | parentProps?: Partial<GridProps>, |
| 30 | [extraProp: string]: any |
| 31 | } |
| 32 | |
| 33 | // it seems necessary to cast (Multi)SelectField sometimes |
| 34 | export type Field<T> = FC<FieldProps<T>> |
| 35 | |
| 36 | type GetError = (v: any, { values, fields }: any) => Promisable<ValidationError> |
| 37 | export type Promisable<T> = T | Promise<T> |
| 38 | interface FieldApi<T> { |
| 39 | // provide getError if you want your error to be visible by the Form component |
| 40 | getError?: GetError |
| 41 | isEqual?: (a: T, b: T) => boolean, |
| 42 | } |
| 43 | export interface FieldProps<T> { |
| 44 | label?: string | ReactElement |
| 45 | value?: T |
| 46 | onChange: (v: T, more: { was?: T, event: any, [rest: string]: any }) => void |
| 47 | setApi?: (api: FieldApi<T>) => void |
| 48 | error?: boolean |
| 49 | helperText?: ReactNode |
| 50 | [rest: string]: any |
| 51 | } |
| 52 | |
| 53 | export type Dict<T=any> = Record<string,T> |
| 54 | |
| 55 | export function mergeSx(...parts: Array<SxProps<Theme> | false | null | undefined>): SxProps<Theme> { |
| 56 | return parts.filter(Boolean).flatMap(x => _.castArray(x)) as SxProps<Theme> |
| 57 | } |
| 58 | |
| 59 | export interface FormApi { |
| 60 | submit(): void |
| 61 | validate(): Promise<boolean> |
| 62 | } |
| 63 | |
| 64 | export interface FormProps<Values> extends Partial<BoxProps> { |
| 65 | fields: (FieldDescriptor | ReactElement<unknown> | null | undefined | false)[] |
| 66 | defaults?: (f:FieldDescriptor) => Partial<FieldDescriptor> |
| 67 | values: Values |
| 68 | set: (v: any, fieldK: keyof Values) => void |
| 69 | get?: (fieldK: keyof Values | string) => any // the string is for a strange TS behavior on templated types |
| 70 | save: false | Partial<Parameters<typeof Button>[0]> | (()=>any) |
| 71 | stickyBar?: boolean |
| 72 | addToBar?: ReactNode[] |
| 73 | barSx?: Dict |
| 74 | onError?: (err: any) => any |
| 75 | onValidation?: (errs: false | Dict<ValidationError>) => any |
| 76 | apiRef?: MutableRefObject<FormApi | undefined> |
| 77 | formRef?: MutableRefObject<HTMLFormElement | undefined> |
| 78 | saveOnEnter?: boolean |
| 79 | gridProps?: Partial<GridProps> |
| 80 | } |
| 81 | enum Phase { Idle, WaitValues, Validating } |
| 82 | |
| 83 | const ERROR_CLASS = 'field-container-with-error' |
| 84 | export function Form<Values extends Dict>({ |
| 85 | fields, |
| 86 | values, |
| 87 | set, |
| 88 | get, |
| 89 | defaults, |
| 90 | save, |
| 91 | stickyBar, |
| 92 | addToBar = [], |
| 93 | barSx, |
| 94 | apiRef, |
| 95 | formRef, |
| 96 | onError, |
| 97 | onValidation, |
| 98 | saveOnEnter, |
| 99 | gridProps, |
| 100 | sx, |
| 101 | ...boxProps |
| 102 | }: FormProps<Values>) { |
| 103 | const mounted = useRef(false) |
| 104 | useEffect(() => { |
| 105 | mounted.current = true |
| 106 | return () => { |
| 107 | mounted.current = false |
| 108 | } |
| 109 | }, []) |
| 110 | |
| 111 | const [errors, setErrors] = useState<Dict<ValidationError>>({}) |
| 112 | const [fieldExceptions, setFieldExceptions] = useState<Dict<ValidationError>>({}) |
| 113 | const saveBtn = typeof save === 'function' ? { onClick: save } : save // normalize |
| 114 | const [phase, setPhase] = useState(Phase.Idle) |
| 115 | const submitAfterValidation = useRef(false) |
| 116 | const validationRequest = useRef<((ok: boolean) => void) | undefined>() |
| 117 | const validateUpTo = useRef('') |
| 118 | if (apiRef) apiRef.current = { |
| 119 | submit: pleaseSubmitAndValidate, |
| 120 | validate: () => new Promise<boolean>(resolve => { |
| 121 | submitAfterValidation.current = false |
| 122 | validationRequest.current = resolve // will be called later |
| 123 | if (!pleaseValidate()) |
| 124 | resolve(false) |
| 125 | }) |
| 126 | } |
| 127 | formRef ||= useRef() |
| 128 | useEffect(() => void phaseChange(), [phase]) //eslint-disable-line |
| 129 | const keyMet: Dict<number> = {} |
| 130 | |
| 131 | const apis: Dict<FieldApi<unknown>> = {} // consider { [K in keyof Values]?: FieldApi<Values[K]> } |
| 132 | return h(Box, { |
| 133 | component: 'form', |
| 134 | sx: mergeSx({ |
| 135 | display: 'flex', |
| 136 | flexDirection: 'column', |
| 137 | gap: 3, |
| 138 | }, sx), |
| 139 | ref: formRef, |
| 140 | onSubmit(ev) { |
| 141 | ev.preventDefault() |
| 142 | }, |
| 143 | onKeyDown(ev) { |
| 144 | if (saveBtn && !saveBtn.disabled && (ev.ctrlKey || ev.metaKey) && ev.key === 'Enter') |
| 145 | pleaseSubmitAndValidate() |
| 146 | }, |
| 147 | // maxWidth is a layout hint, so keep it in sx instead of forwarding it to the form DOM node |
| 148 | ...boxProps, |
| 149 | }, |
| 150 | h(Grid, { container:true, rowSpacing:3, columnSpacing:1, ...gridProps }, |
| 151 | fields.map((row, idx) => { |
| 152 | if (!row) |
| 153 | return null |
| 154 | if (isValidElement(row)) |
| 155 | return h(Grid, { key: idx, size: 12 }, row) |
| 156 | if (defaults) |
| 157 | row = { ...defaults?.(row), ...row } |
| 158 | const { k, fromField=_.identity, toField=_.identity, getError, error, |
| 159 | xs=12, sm, md, lg, xl, comp=StringField, before, after, parentProps, |
| 160 | ...field } = row |
| 161 | const size = legacySpanToGridSize({ xs, sm, md, lg, xl }) |
| 162 | let errMsg = errors[k] || error || fieldExceptions[k] |
| 163 | if (errMsg === true) |
| 164 | errMsg = "Not valid" |
| 165 | const anyError = Boolean(errMsg || error) || undefined |
| 166 | if (k) { |
| 167 | const originalValue = row.hasOwnProperty('value') ? row.value : getValueFor(k) |
| 168 | Object.assign(field, { |
| 169 | name: k, |
| 170 | value: toField(originalValue), |
| 171 | error: anyError, |
| 172 | setApi(api) { apis[k] = api }, |
| 173 | onKeyDown(event: any) { |
| 174 | if (saveOnEnter && event.key === 'Enter') |
| 175 | pleaseSubmitAndValidate() |
| 176 | }, |
| 177 | onChange(v: unknown) { |
| 178 | try { |
| 179 | v = fromField(v, { originalValue }) |
| 180 | setFieldExceptions(x => ({ ...x, [k]: false })) |
| 181 | if ((apis[k]?.isEqual || _.isEqual)(v, originalValue)) return |
| 182 | set(v, k) |
| 183 | pleaseValidate(k) |
| 184 | } |
| 185 | catch (e) { |
| 186 | setFieldExceptions(x => ({ ...x, [k]: (e as any)?.message || String(e) || true })) |
| 187 | } |
| 188 | }, |
| 189 | } as Partial<FieldProps<any>>) |
| 190 | if (Array.isArray(field.helperText)) |
| 191 | field.helperText = h(Fragment, {}, ...field.helperText) |
| 192 | if (errMsg) // special rendering when we have both error and helperText. "hr" would be nice but issues a warning because contained in a <p> |
| 193 | field.helperText = !field.helperText ? errMsg |
| 194 | : h(Box as any, { sx: { color: 'text.primary' }, component: 'span' }, |
| 195 | h(Box as any, { |
| 196 | sx: { color: 'error.main', display: 'block' }, |
| 197 | style: { borderBottom: '1px solid' }, |
| 198 | component: 'span' // avoid console warning, but keep it on separate line |
| 199 | }, errMsg), |
| 200 | field.helperText, |
| 201 | ) |
| 202 | if (field.label === undefined) |
| 203 | field.label = labelFromKey(k) |
| 204 | } |
| 205 | const n = (keyMet[k] = (keyMet[k] || 0) + 1) |
| 206 | return h(Grid, { key: k ? k + n : idx, size, className: anyError && ERROR_CLASS, ...parentProps }, |
| 207 | before, |
| 208 | isValidElement(comp) ? comp : h(comp, field), |
| 209 | after |
| 210 | ) |
| 211 | }) |
| 212 | ), |
| 213 | saveBtn && h(Box, { |
| 214 | sx: { |
| 215 | display: 'flex', |
| 216 | alignItems: 'center', |
| 217 | ...stickyBar && { |
| 218 | width: 'fit-content', zIndex: 2, backgroundColor: 'background.paper', borderRadius: 1, |
| 219 | position: 'sticky', bottom: 0, p: 1, m: -1, boxShadow: '0px 0px 15px #000', |
| 220 | }, |
| 221 | ...barSx, |
| 222 | } |
| 223 | }, h(Tooltip, { title: "ctrl + enter", children: h(Button as any, { |
| 224 | // mui v6 moved LoadingButton behavior into Button, but current typings here still miss loading props |
| 225 | variant: 'contained', |
| 226 | startIcon: h(Save), |
| 227 | children: "Save", |
| 228 | loading: useDebounce(phase !== Phase.Idle), // debounce fixes click being ignored at state change and flickering |
| 229 | ...saveBtn, |
| 230 | className: `saveBtn ${saveBtn?.className||''}`, |
| 231 | onClick() { |
| 232 | pleaseSubmitAndValidate() |
| 233 | }, |
| 234 | } as any) }), |
| 235 | ...addToBar, |
| 236 | ) |
| 237 | ) |
| 238 | |
| 239 | function pleaseSubmitAndValidate() { // we use state here to let the outer component perform its state changes |
| 240 | submitAfterValidation.current = true |
| 241 | pleaseValidate() |
| 242 | } |
| 243 | |
| 244 | function pleaseValidate(k='') { |
| 245 | if (phase !== Phase.Idle) return false |
| 246 | validateUpTo.current = k |
| 247 | setTimeout(() => // starting validation immediately will lose clicks on the saveBtn, so delay just a bit |
| 248 | setPhase(cur => cur === Phase.Idle ? Phase.WaitValues : cur)) // don't interfere with the ongoing process |
| 249 | return true |
| 250 | } |
| 251 | |
| 252 | function getValueFor(k : string) { |
| 253 | return get ? get(k) : values?.[k] |
| 254 | } |
| 255 | |
| 256 | async function phaseChange() { |
| 257 | if (phase === Phase.Idle) return |
| 258 | if (phase === Phase.WaitValues) |
| 259 | return setPhase(Phase.Validating) |
| 260 | const MSG = "Please review errors" |
| 261 | const errs: typeof errors = {} |
| 262 | for (const f of fields) { |
| 263 | if (!f || isValidElement(f) || !f.k) continue |
| 264 | const { k } = f |
| 265 | const v = getValueFor(k) |
| 266 | let err: ReactNode |
| 267 | try { |
| 268 | err = (await apis[k]?.getError?.(v, { values, fields })) |
| 269 | || (await f.getError?.(v, { values, fields })) |
| 270 | || fieldExceptions[k] |
| 271 | || false |
| 272 | } |
| 273 | catch(e) { |
| 274 | err = String(e) |
| 275 | } |
| 276 | errs[k] = err |
| 277 | if (!submitAfterValidation.current && k === validateUpTo.current) break |
| 278 | if (!mounted.current) return // abort |
| 279 | } |
| 280 | setErrors(errs) |
| 281 | const anyError = Object.values(errs).some(Boolean) |
| 282 | onValidation?.(anyError && errs) |
| 283 | validationRequest.current?.(!anyError) |
| 284 | validationRequest.current = undefined |
| 285 | if (!submitAfterValidation.current) { |
| 286 | if (mounted.current) |
| 287 | setPhase(Phase.Idle) |
| 288 | return |
| 289 | } |
| 290 | try { |
| 291 | if (anyError) { |
| 292 | try { return await onError?.(MSG) } |
| 293 | finally { |
| 294 | setTimeout(() => formRef?.current?.querySelector('.' + ERROR_CLASS)?.scrollIntoView({ block: 'center' }), 1) |
| 295 | } |
| 296 | } |
| 297 | const cb = saveBtn && saveBtn.onClick |
| 298 | if (cb) // @ts-ignore |
| 299 | await cb() |
| 300 | } |
| 301 | catch(e) { await onError?.(e) } |
| 302 | finally { |
| 303 | submitAfterValidation.current = false |
| 304 | if (mounted.current) |
| 305 | setPhase(Phase.Idle) |
| 306 | } |
| 307 | } |
| 308 | |
| 309 | } |
| 310 | |
| 311 | export function labelFromKey(k: string) { |
| 312 | return _.upperFirst(k.indexOf('_') > 0 ? k.replace(/_/g, ' ') |
| 313 | : k.replace(/([a-z])([A-Z])/g, (_all, a, b) => a + ' ' + b.toLowerCase())) |
| 314 | } |
| 315 | |
| 316 | function legacySpanToGridSize({ xs, sm, md, lg, xl }: { xs?: unknown, sm?: unknown, md?: unknown, lg?: unknown, xl?: unknown }) { |
| 317 | // keep compatibility with existing form descriptors still using xs/sm/md while grid2 expects size |
| 318 | const sizeByBreakpoint = { |
| 319 | xs: normalizeLegacySpan(xs), |
| 320 | sm: normalizeLegacySpan(sm), |
| 321 | md: normalizeLegacySpan(md), |
| 322 | lg: normalizeLegacySpan(lg), |
| 323 | xl: normalizeLegacySpan(xl), |
| 324 | } |
| 325 | if (sm === undefined && md === undefined && lg === undefined && xl === undefined) |
| 326 | return sizeByBreakpoint.xs |
| 327 | return sizeByBreakpoint as any |
| 328 | } |
| 329 | |
| 330 | function normalizeLegacySpan(span: unknown) { |
| 331 | // in legacy Grid, `true` means auto-grow width; in grid2 this is expressed with `size="grow"` |
| 332 | if (span === true) |
| 333 | return 'grow' |
| 334 | return span |
| 335 | } |