admin: from/toField moved to Form, while fields can now generate errors without form logic

Massimo Melina committed May 3, 2022 at 17:33 UTC da2fade8bd1bfbe51e6a1aa98bf1eccd6c1db607
7 files changed +59 -57
admin/src/ConfigPage.ts
-1
@@ -44,7 +44,6 @@ export default function ConfigPage() {
44 comp: NumberField,
45 min: 1,
46 placeholder: "no limit",
47 - onChange: (v: any) => v < 1 ? null : v,
47 }
48 return h(Form, {
49 sx: { maxWidth: '60em' },
admin/src/FileForm.ts
+1 -1
@@ -95,7 +95,7 @@ export default function FileForm({ file }: { file: VfsNode }) {
95 toField: Boolean, fromField: (v:boolean) => v ? 'index.html' : null,
96 helperText: md("This folder may be a website because contains `index.html`. Enabling this will show the website instead of the list of files.")
97 },
98 - isDir && { k: 'masks', multiline: true, xl: 6, toField: JSON.stringify, fromField: JSON.parse,
98 + isDir && { k: 'masks', multiline: true, xl: 6, toField: JSON.stringify, fromField: v => v ? JSON.parse(v) : undefined,
99 helperText: "This is a special field. Leave it empty unless you know what you are doing." }
100 ]
101 })
admin/src/FilePicker.ts
+1 -1
@@ -76,7 +76,7 @@ export default function FilePicker({ onSelect, multiple=true, from }: FilePicker
76 h(StringField, {
77 label: 'Current path',
78 value: cwd,
79 - onChange: setCwd
79 + onChange: setCwd as any,
80 }),
81 ),
82 error ? h(Alert, { severity:'error' }, String(error))
admin/src/Form.ts
+49 -51
@@ -33,7 +33,8 @@ export interface FieldDescriptor<T=any> {
33 comp?: any
34 label?: ReactNode
35 validate?: RegExp | ((v: any, extra:any) => string | boolean)
36 - onChange?: (v: T) => void
36 + toField?: (v: T) => any
37 + fromField?: (v: any) => T
38 [extraProp: string]: any
39 }
40
@@ -65,6 +66,7 @@ export function Form<Values extends Dict>({ fields, values, set, defaults, save,
66
67 const [loading, setLoading] = useState(false)
68 const [errors, setErrors] = useState<Dict>({})
69 + const [fieldErrors, setFieldErrors] = useState<Dict>({})
70 const saveBtn = typeof save === 'function' ? { onClick: save } : save // normalize
71 const [pendingSubmit, setPendingSubmit] = useState(false)
72 useEffect(() => {
@@ -96,19 +98,23 @@ export function Form<Values extends Dict>({ fields, values, set, defaults, save,
98 if (isValidElement(row))
99 return h(Grid, { key: idx, item: true, xs: 12 }, row)
100 let field = row
99 - const { k, onChange } = field
100 - let error = errors[k]
101 + const { k, fromField=_.identity, toField=_.identity } = field
102 + let error = errors[k] || fieldErrors[k]
103 if (error === true)
104 error = "Not valid"
105 if (k) {
106 + const originalValue = values?.[k]
107 field = {
105 - value: values?.[k],
108 + value: toField(originalValue),
109 ...field,
110 error: field.error || Boolean(error) || undefined,
108 - onChange(v:any) {
109 - if (onChange)
110 - v = onChange(v)
111 - set(v, field.k)
111 + onChange(v: any) {
112 + try { v = fromField(v) } // catch parsing exceptions
113 + catch (e) { v = e }
114 + const err = v instanceof Error ? v.message || true : undefined
115 + setFieldErrors({ ...fieldErrors, [k]: err })
116 + if (!err && !_.isEqual(v, originalValue))
117 + set(v, k)
118 },
119 }
120 if (error)
@@ -118,11 +124,13 @@ export function Form<Values extends Dict>({ fields, values, set, defaults, save,
124 field.label = labelFromKey(k)
125 _.defaults(field, defaults?.(field))
126 }
121 - const { xs=12, sm, md, lg, xl, comp=StringField,
122 - validate, // don't propagate
123 - ...rest } = field
124 - return h(Grid, { key: k, item: true, xs, sm, md, lg, xl },
125 - isValidElement(comp) ? comp : h(comp, rest) )
127 + {
128 + const { xs=12, sm, md, lg, xl, comp=StringField,
129 + validate, fromField, toField, // don't propagate
130 + ...rest } = field
131 + return h(Grid, { key: k, item: true, xs, sm, md, lg, xl },
132 + isValidElement(comp) ? comp : h(comp, rest) )
133 + }
134 })
135 ),
136 saveBtn && h(Box, {
@@ -148,19 +156,25 @@ export function Form<Values extends Dict>({ fields, values, set, defaults, save,
156 async function wrappedSave(...args: Parameters<NonNullable<typeof saveBtn.onClick>>) {
157 const cb = saveBtn.onClick
158 if (!cb) return
159 + const MSG = "Please review errors"
160 setLoading(true)
161 try {
162 for (const f of fields) {
154 - if (!f || isValidElement(f) || !f.k || !f.validate) continue
163 + if (!f || isValidElement(f) || !f.k) continue
164 + if (fieldErrors[f.k])
165 + return onError?.(MSG)
166 let fv = f.validate
167 + if (!fv) continue
168 if (fv instanceof RegExp) {
169 const re = fv
170 fv = x => re.test(x)
171 }
172 const res = await fv(values?.[f.k], { values, fields })
173 if (!mounted.current) return
162 - if (res !== true)
163 - return setErrors({ [f.k]: res || true })
174 + if (res !== true) {
175 + setErrors({ [f.k]: res || true })
176 + return onError?.(MSG)
177 + }
178 }
179 setErrors({})
180 return await cb(...args)
@@ -181,27 +195,18 @@ export function labelFromKey(k: string) {
195 export interface FieldProps<T> {
196 label?: string | ReactElement
197 value?: T
184 - onChange: (v: T, more: { was?: T, event: any, [rest: string]: any }) => void
185 - toField?: (v: any) => T
186 - fromField?: (v: T) => any
198 + onChange: (v: T | Error, more: { was?: T, event: any, [rest: string]: any }) => void
199 error?: true
200 [rest: string]: any
201 }
202
191 -export function StringField({ value, onChange, fromField=_.identity, toField=_.identity, ...props }: FieldProps<string>) {
192 - if (fromField === JSON.parse)
193 - fromField = v => v ? JSON.parse(v) : undefined
194 - const [state, setState] = useState(() => toField(value) ?? '')
195 - const [err, setErr] = useState('')
196 - if (err) {
197 - props.error = true
198 - props.helperText = h(Fragment, {}, err, props.helperText && h('br'), props.helperText ) // keep existing helperText, if any
199 - }
203 +export function StringField({ value, onChange, ...props }: FieldProps<string>) {
204 + const setter = () => value ?? ''
205 + const [state, setState] = useState(setter)
206
207 useEffect(() => {
202 - setState(() => toField(value) ?? '')
203 - setErr('')
204 - }, [value, toField])
208 + setState(setter)
209 + }, [value])
210 return h(TextField, {
211 fullWidth: true,
212 InputLabelProps: state || props.placeholder ? { shrink: true } : undefined,
@@ -226,21 +231,13 @@ export function StringField({ value, onChange, fromField=_.identity, toField=_.i
231 })
232
233 function go(event: any, val: string=state) {
229 - let newV
230 - try { // catch parsing exceptions
231 - newV = fromField(val.trim())
232 - }
233 - catch (e) {
234 - return setErr(String(e))
235 - }
236 - if (newV !== value)
237 - onChange(newV, {
238 - was: value,
239 - event,
240 - cancel() {
241 - setState(value ?? '')
242 - }
243 - })
234 + onChange(val.trim(), {
235 + was: value,
236 + event,
237 + cancel() {
238 + setState(setter)
239 + }
240 + })
241 }
242 }
243
@@ -310,22 +307,23 @@ function commonSelectProps<T>(props: { sx?:SxProps, label?: FieldProps<T>['label
307 }
308
309 export function NumberField({ value, onChange, min, max, step, ...props }: FieldProps<number | null>) {
313 - // @ts-ignore
310 return h(StringField, {
311 type: 'number',
312 value: typeof value === 'number' ? String(value) : '',
313 onChange(v, { was, ...rest }) {
318 - onChange(v ? Number(v) : null, { ...rest, was:was ? Number(was) : null })
314 + const n = Number(v)
315 + onChange(!v ? null : n < min ? Error('too low') : n > max ? Error('too high') : n,
316 + { ...rest, was: was ? Number(was) : null })
317 },
318 inputProps: { min, max, step, },
319 ...props,
320 })
321 }
322
325 -export function BoolField({ label='', value, onChange, helperText, error, fromField=_.identity, toField=_.identity,
323 +export function BoolField({ label='', value, onChange, helperText, error,
324 type, // avoid passing this by accident, as it disrupts the control
325 ...props }: FieldProps<boolean>) {
328 - const setter = () => toField(value) ?? false
326 + const setter = () => value ?? false
327 const [state, setState] = useState(setter)
328 useEffect(() => setState(setter),
329 [value]) //eslint-disable-line
@@ -333,7 +331,7 @@ export function BoolField({ label='', value, onChange, helperText, error, fromFi
331 checked: state,
332 ...props,
333 onChange(event) {
336 - onChange(fromField(event.target.checked), { event, was: value })
334 + onChange(event.target.checked, { event, was: value })
335 }
336 })
337 return h(Box, { ml: 1, mt: 1, sx: error && { color: 'error.main', outlineOffset: 6, outline: '1px solid' } },
admin/src/MonitorPage.ts
+1 -1
@@ -143,7 +143,7 @@ function Connections() {
143 h(SelectField as Field<boolean>, {
144 fullWidth: false,
145 value: filtered,
146 - onChange: setFiltered,
146 + onChange: setFiltered as any,
147 options: { "Downloads connections": true, "All connections": false }
148 }),
149
admin/src/PermField.ts
+4 -1
@@ -33,6 +33,7 @@ export default function PermField({ label, value, onChange }: FieldProps<Dict<st
33 options: usernames,
34 value: username,
35 onChange(v, { was, ...rest }){
36 + if (v instanceof Error) return
37 const copy: any = { ...value, [v]: value![was!] }
38 delete copy[was!]
39 onChange(copy, { was:value, ...rest })
@@ -44,6 +45,7 @@ export default function PermField({ label, value, onChange }: FieldProps<Dict<st
45 options: permOptions,
46 value: perm,
47 onChange(v, { was, ...rest }){
48 + if (v instanceof Error) return
49 const copy = { ...value }
50 if (v)
51 copy[username] = v
@@ -60,7 +62,7 @@ export default function PermField({ label, value, onChange }: FieldProps<Dict<st
62 label: value ? "Add access to" : "Restrict access to ",
63 value: temp,
64 options: usernamesLeft,
63 - onChange: setTemp
65 + onChange: setTemp as any,
66 })),
67 usernamesLeft.length>0 && h(Grid, { item: true, xs: 6 },
68 h(SelectField as Field<string>, {
@@ -69,6 +71,7 @@ export default function PermField({ label, value, onChange }: FieldProps<Dict<st
71 disabled: !temp,
72 options: permOptions,
73 onChange(v, rest) {
74 + if (v instanceof Error) return
75 if (v)
76 onChange({ ...value, [temp!]: v }, { ...rest, was: value })
77 setTemp(undefined)
admin/src/StringStringField.ts
+3 -1
@@ -39,6 +39,7 @@ export default function StringStringField({ value, onChange, keyLabel='key', val
39 h(StringField, {
40 value: id,
41 onChange(v, { was, ...rest }){
42 + if (v instanceof Error) return
43 const copy = { ...value }
44 if (v)
45 copy[v] = was !== undefined ? copy[was] : ''
@@ -52,6 +53,7 @@ export default function StringStringField({ value, onChange, keyLabel='key', val
53 inputRef: justEntered.current === id ? justEntered : undefined,
54 value: v,
55 onChange(v, { was, ...rest }){
56 + if (v instanceof Error) return
57 const copy = { ...value }
58 if (v)
59 copy[id] = v
@@ -75,7 +77,7 @@ export default function StringStringField({ value, onChange, keyLabel='key', val
77 inputRef: refNew,
78 value: '',
79 onChange(v, more){
78 - if (!v) return
80 + if (!v || v instanceof Error) return
81 more.cancel()
82 if (value && v in value)
83 return alert(keyLabel + " entry already present")