main
ts 335 lines 12.9 KB
Raw
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 }