admin: submit on enter key for login form
Massimo Melina committed
May 28, 2022 at 14:47 UTC
73e2226181b8ea88471c140844a1934341196395
5 files changed
+119
-86
admin/src/ArrayField.ts
+1
-1
@@ -6,7 +6,7 @@ import { DataGrid, GridAlignment } from '@mui/x-data-grid'
6
import { FieldDescriptor, FieldProps, labelFromKey } from './Form'
7
import { Box, FormHelperText, FormLabel } from '@mui/material'
8
9
-export function ArrayField<T=any>({ label, helperText, fields, value, onChange, ...rest }: FieldProps<T[]> & { fields: FieldDescriptor[], height?: number }) {
9
+export function ArrayField<T=any>({ label, helperText, fields, value, onChange, onError, ...rest }: FieldProps<T[]> & { fields: FieldDescriptor[], height?: number }) {
10
const rows = useMemo(() => (value||[]).map((x,$idx) =>
11
setHidden({ ...x } as any, 'id' in x ? { $idx } : { id: $idx })),
12
[JSON.stringify(value)]) //eslint-disable-line
admin/src/Form.ts
+111
-72
@@ -29,11 +29,12 @@ import { LoadingButton } from '@mui/lab'
29
import _ from 'lodash'
30
import { SxProps } from '@mui/system'
31
32
+type Validate = (v: any, extra?: any) => string | boolean
33
export interface FieldDescriptor<T=any> {
34
k: string
35
comp?: any
36
label?: ReactNode
36
- validate?: RegExp | ((v: any, extra:any) => string | boolean)
37
+ validate?: Validate
38
toField?: (v: T) => any
39
fromField?: (v: any) => T
40
[extraProp: string]: any
@@ -53,10 +54,12 @@ export interface FormProps<Values> extends Partial<BoxProps> {
54
stickyBar?: boolean
55
addToBar?: ReactNode[]
56
barSx?: Dict
56
- onError?: (err: any) => void
57
+ onError?: (err: any) => any
58
formRef?: MutableRefObject<HTMLFormElement | undefined>
59
+ saveOnEnter?: boolean
60
}
59
-export function Form<Values extends Dict>({ fields, values, set, defaults, save, stickyBar, addToBar=[], barSx, formRef, onError, ...rest }: FormProps<Values>) {
61
+enum Phase { Idle, WaitValues, Validating }
62
+export function Form<Values extends Dict>({ fields, values, set, defaults, save, stickyBar, addToBar=[], barSx, formRef, onError, saveOnEnter, ...rest }: FormProps<Values>) {
63
const mounted = useRef(false)
64
useEffect(() => {
65
mounted.current = true
@@ -65,17 +68,14 @@ export function Form<Values extends Dict>({ fields, values, set, defaults, save,
68
}
69
}, [])
70
68
- const [loading, setLoading] = useState(false)
69
- const [errors, setErrors] = useState<Dict>({})
70
- const [fieldErrors, setFieldErrors] = useState<Dict>({})
71
+ const [errors, setErrors] = useState<Dict<string | false>>({})
72
const saveBtn = typeof save === 'function' ? { onClick: save } : save // normalize
72
- const [pendingSubmit, setPendingSubmit] = useState(false)
73
- useEffect(() => {
74
- if (!pendingSubmit) return
75
- setTimeout(wrappedSave)
76
- setPendingSubmit(false)
77
- }, [pendingSubmit]) //eslint-disable-line
73
+ const [phase, setPhase] = useState(Phase.Idle)
74
+ const submitAfterValidation = useRef(false)
75
+ const validateUpTo = useRef('')
76
+ useEffect(() => void(phaseChange()), [phase]) //eslint-disable-line
77
78
+ const apis: Dict<FieldApi> = {}
79
return h('form', {
80
ref: formRef && (x => formRef.current = x ? x as HTMLFormElement : undefined),
81
onSubmit(ev) {
@@ -83,7 +83,7 @@ export function Form<Values extends Dict>({ fields, values, set, defaults, save,
83
},
84
onKeyDown(ev) {
85
if (!saveBtn.disabled && (ev.ctrlKey || ev.metaKey) && ev.key === 'Enter')
86
- setPendingSubmit(true) // we need to let outer component perform its state changes
86
+ pleaseSubmit()
87
}
88
},
89
h(Box, {
@@ -98,36 +98,45 @@ export function Form<Values extends Dict>({ fields, values, set, defaults, save,
98
return null
99
if (isValidElement(row))
100
return h(Grid, { key: idx, item: true, xs: 12 }, row)
101
- let field = row
102
- const { k, fromField=_.identity, toField=_.identity } = field
103
- let error = errors[k] || fieldErrors[k]
104
- if (error === true)
101
+ const { k, fromField=_.identity, toField=_.identity, validate, ...field } = row
102
+ let error = errors[k]
103
+ if (error === '')
104
error = "Not valid"
105
if (k) {
106
const originalValue = values?.[k]
108
- field = {
107
+ const whole = { ...row, ...field }
108
+ Object.assign(field, {
109
value: toField(originalValue),
110
- ...field,
111
- error: field.error || Boolean(error) || undefined,
112
- onChange(v: any) {
113
- try { v = fromField(v) } // catch parsing exceptions
114
- catch (e) { v = e }
115
- const err = v instanceof Error ? v.message || true : undefined
116
- setFieldErrors({ ...fieldErrors, [k]: err })
117
- if (!err && !_.isEqual(v, originalValue))
110
+ error: Boolean(error) || undefined,
111
+ getApi(api) { apis[k] = api },
112
+ onBlur() {
113
+ pleaseValidate(k)
114
+ },
115
+ async onChange(v, { event }) {
116
+ try {
117
+ v = fromField(v)
118
+ if (_.isEqual(v, originalValue)) return
119
set(v, k)
120
+ if (saveOnEnter && event.key === 'Enter')
121
+ pleaseSubmit()
122
+ else
123
+ pleaseValidate(k)
124
+ }
125
+ catch (e) {
126
+ onError?.(e)
127
+ }
128
},
120
- }
129
+ } as Partial<FieldProps<any>>)
130
if (error) // special rendering when we have both error and helperText. "hr" would be nice but issues a warning because contained in a <p>
131
field.helperText = field.helperText ? h(Fragment, {}, h('span', { style: { borderBottom: '1px solid' } }, error), h('br'), field.helperText)
132
: error
133
if (field.label === undefined)
134
field.label = labelFromKey(k)
126
- _.defaults(field, defaults?.(field))
135
+ _.defaults(field, defaults?.(whole))
136
}
137
{
138
const { xs=12, sm, md, lg, xl, comp=StringField,
130
- validate, fromField, toField, // don't propagate
139
+ fromField, toField, // don't propagate
140
...rest } = field
141
return h(Grid, { key: k, item: true, xs, sm, md, lg, xl },
142
isValidElement(comp) ? comp : h(comp, rest) )
@@ -145,45 +154,59 @@ export function Form<Values extends Dict>({ fields, values, set, defaults, save,
154
variant: 'contained',
155
startIcon: h(Save),
156
children: "Save",
148
- loading,
157
+ loading: phase !== Phase.Idle,
158
...saveBtn,
150
- onClick: wrappedSave,
159
+ onClick: pleaseSubmit,
160
}),
161
...addToBar,
162
)
163
)
164
)
165
157
- async function wrappedSave(...args: Parameters<NonNullable<typeof saveBtn.onClick>>) {
158
- const cb = saveBtn.onClick
159
- if (!cb) return
166
+ function pleaseSubmit() { // we use state here to let outer component perform its state changes
167
+ submitAfterValidation.current = true
168
+ pleaseValidate()
169
+ }
170
+
171
+ function pleaseValidate(k='') {
172
+ if (phase !== Phase.Idle) return
173
+ validateUpTo.current = k
174
+ setPhase(Phase.WaitValues)
175
+ }
176
+
177
+ async function phaseChange() {
178
+ if (phase === Phase.Idle) return
179
+ if (phase === Phase.WaitValues)
180
+ return setPhase(Phase.Validating)
181
const MSG = "Please review errors"
161
- setLoading(true)
162
- try {
163
- for (const f of fields) {
164
- if (!f || isValidElement(f) || !f.k) continue
165
- if (fieldErrors[f.k])
166
- return onError?.(MSG)
167
- let fv = f.validate
168
- if (!fv) continue
169
- if (fv instanceof RegExp) {
170
- const re = fv
171
- fv = x => re.test(x)
172
- }
173
- const res = await fv(values?.[f.k], { values, fields })
174
- if (!mounted.current) return
175
- if (res !== true) {
176
- setErrors({ [f.k]: res || true })
177
- return onError?.(MSG)
178
- }
182
+ const errs: typeof errors = {}
183
+ for (const f of fields) {
184
+ if (!f || isValidElement(f) || !f.k) continue
185
+ const { k } = f
186
+ const v = values?.[k]
187
+ let err = await apis[k]?.getError()
188
+ if (!err) {
189
+ const res = await f.validate?.(v, { values, fields })
190
+ err = res !== undefined && res !== true && (res || '')
191
}
180
- setErrors({})
181
- return await cb(...args)
192
+ errs[k] = err
193
+ if (k === validateUpTo.current) break
194
+ if (!mounted.current) return // abort
195
}
183
- catch(e) { onError?.(e) }
196
+ setErrors(errs)
197
+ try {
198
+ if (!submitAfterValidation.current) return
199
+ if (Object.values(errs).some(Boolean))
200
+ return await onError?.(MSG)
201
+ const cb = saveBtn.onClick
202
+ if (cb) // @ts-ignore
203
+ await cb()
204
+ }
205
+ catch(e) { await onError?.(e) }
206
finally {
207
+ submitAfterValidation.current = false
208
if (mounted.current)
186
- setLoading(false)
209
+ setPhase(Phase.Idle)
210
}
211
}
212
@@ -193,27 +216,37 @@ export function labelFromKey(k: string) {
216
return _.capitalize(k.replace(/_/g, ' '))
217
}
218
219
+type Promisable<T> = T | Promise<T>
220
+interface FieldApi { getError: () => Promisable<string | false>, [rest: string]: any }
221
export interface FieldProps<T> {
222
label?: string | ReactElement
223
value?: T
199
- onChange: (v: T | Error, more: { was?: T, event: any, [rest: string]: any }) => void
224
+ onChange: (v: T, more: { was?: T, event: any, [rest: string]: any }) => void
225
+ getApi?: (api: FieldApi) => void
226
error?: true
227
[rest: string]: any
228
}
229
204
-export function StringField({ value, onChange, typing, start, end, ...props }: FieldProps<string>) {
230
+export function StringField({ value, onChange, min, max, required, getApi, typing, start, end, ...props }: FieldProps<string>) {
231
const setter = () => value ?? ''
232
+ getApi?.({
233
+ getError() {
234
+ return !value && required ? "required"
235
+ : value?.length! < min ? "too short"
236
+ : value?.length! > max ? "too long"
237
+ : false
238
+ }
239
+ })
240
const [state, setState] = useState(setter)
241
242
+ const lastChange = useRef(value)
243
useEffect(() => setState(setter), [value]) //eslint-disable-line
209
- let lastChange = value
244
return h(TextField, {
245
fullWidth: true,
246
InputLabelProps: state || props.placeholder ? { shrink: true } : undefined,
247
...props,
248
value: state,
249
onChange(ev) {
216
- props.onChange?.(ev)
250
const val = ev.target.value
251
setState(val)
252
if (typing // change state as the user is typing
@@ -238,8 +271,8 @@ export function StringField({ value, onChange, typing, start, end, ...props }: F
271
272
function go(event: any, val: string=state) {
273
const newV = val.trim()
241
- if (newV === lastChange) return // don't compare to 'value' as that represents only accepted changes, while we are interested also in changes through discarded values
242
- lastChange = newV
274
+ if (newV === lastChange.current) return // don't compare to 'value' as that represents only accepted changes, while we are interested also in changes through discarded values
275
+ lastChange.current = newV
276
onChange(newV, {
277
was: value,
278
event,
@@ -261,7 +294,7 @@ type SelectOption<T> = SelectPair<T> | (T extends string | number ? T : never)
294
interface SelectPair<T> { label: string, value:T }
295
296
export function SelectField<T>(props: FieldProps<T> & { options:SelectOptions<T> }) {
264
- const { value, onChange, options, sx, ...rest } = props
297
+ const { value, onChange, getApi, options, sx, ...rest } = props
298
return h(TextField, { // using TextField because Select is not displaying label correctly
299
...commonSelectProps(props),
300
...rest,
@@ -277,7 +310,7 @@ export function SelectField<T>(props: FieldProps<T> & { options:SelectOptions<T>
310
}
311
312
export function MultiSelectField<T>(props: FieldProps<T[]> & { options:SelectOptions<T> }) {
280
- const { value, options, sx, ...rest } = props
313
+ const { value, onChange, getApi, options, sx, ...rest } = props
314
return h(TextField, {
315
...commonSelectProps({ ...props, value: undefined }),
316
...rest,
@@ -287,7 +320,7 @@ export function MultiSelectField<T>(props: FieldProps<T[]> & { options:SelectOpt
320
try {
321
let v: any = event.target.value
322
v = Array.isArray(v) ? v.map(x => JSON.parse(x)) : []
290
- props.onChange(v as T[], { was: value, event })
323
+ onChange(v as T[], { was: value, event })
324
}
325
catch {}
326
}
@@ -315,22 +348,28 @@ function commonSelectProps<T>(props: { sx?:SxProps, label?: FieldProps<T>['label
348
}
349
}
350
318
-export function NumberField({ value, onChange, min, max, step, required, ...props }: FieldProps<number | null>) {
351
+export function NumberField({ value, onChange, getApi, required, min, max, step, ...props }: FieldProps<number | null>) {
352
+ getApi?.({
353
+ getError() {
354
+ return value == null ? (required ? "required" : false)
355
+ : (value < min ? "too low" : value > max ? "too high" : false)
356
+ }
357
+ })
358
return h(StringField, {
359
type: 'number',
321
- value: typeof value === 'number' ? String(value) : '',
360
+ value: value == null ? '' : String(value),
361
onChange(v, { was, ...rest }) {
323
- const n = Number(v)
324
- onChange(!v ? (required ? Error('required') : null)
325
- : n < min ? Error('too low') : n > max ? Error('too high') : n,
326
- { ...rest, was: was ? Number(was) : null })
362
+ onChange(!v ? null : Number(v), {
363
+ ...rest,
364
+ was: was ? Number(was) : null,
365
+ })
366
},
367
inputProps: { min, max, step, },
368
...props,
369
})
370
}
371
333
-export function BoolField({ label='', value, onChange, helperText, error,
372
+export function BoolField({ label='', value, onChange, getApi, helperText, error,
373
type, // avoid passing this by accident, as it disrupts the control
374
...props }: FieldProps<boolean>) {
375
const setter = () => value ?? false
admin/src/LoginRequired.ts
+5
-6
@@ -18,24 +18,23 @@ function LoginForm() {
18
const [error, setError] = useState('')
19
return h(Center, {},
20
h(Form, {
21
- values: {},
21
+ values,
22
set(v, k) {
23
setValues({ ...values, [k]: v })
24
},
25
fields: [
26
- { k: 'username', autoComplete: 'username', autoFocus: true },
27
- { k: 'password', type: 'password', autoComplete: 'current-password' },
26
+ { k: 'username', autoComplete: 'username', autoFocus: true, required: true },
27
+ { k: 'password', type: 'password', autoComplete: 'current-password', required: true },
28
],
29
addToBar: [ error && h(Alert, { severity: 'error', sx: { flex: 1 } }, error) ],
30
+ saveOnEnter: true,
31
save: {
32
children: "Enter",
33
startIcon: null,
34
async onClick() {
34
- const { username, password } = values
35
- if (!username || !password) return
35
try {
36
setError('')
38
- await login(username, password)
37
+ await login(values.username, values.password)
38
}
39
catch(e) {
40
setError(String(e))
admin/src/PermField.ts
-3
@@ -33,7 +33,6 @@ 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
36
const copy: any = { ...value, [v]: value![was!] }
37
delete copy[was!]
38
onChange(copy, { was:value, ...rest })
@@ -45,7 +44,6 @@ export default function PermField({ label, value, onChange }: FieldProps<Dict<st
44
options: permOptions,
45
value: perm,
46
onChange(v, { was, ...rest }){
48
- if (v instanceof Error) return
47
const copy = { ...value }
48
if (v)
49
copy[username] = v
@@ -71,7 +69,6 @@ export default function PermField({ label, value, onChange }: FieldProps<Dict<st
69
disabled: !temp,
70
options: permOptions,
71
onChange(v, rest) {
74
- if (v instanceof Error) return
72
if (v)
73
onChange({ ...value, [temp!]: v }, { ...rest, was: value })
74
setTemp(undefined)
admin/src/StringStringField.ts
+2
-4
@@ -2,7 +2,7 @@
2
3
import { makeStyles } from '@mui/styles'
4
import { Dict } from './misc'
5
-import { createElement as h, useEffect, useRef } from 'react'
5
+import { createElement as h, useRef } from 'react'
6
import { Grid, IconButton } from '@mui/material'
7
import { Add, Delete } from '@mui/icons-material'
8
import { FieldProps, StringField } from './Form'
@@ -37,7 +37,6 @@ export default function StringStringField({ value, onChange, keyLabel='key', val
37
h(StringField, {
38
value: id,
39
onChange(v, { was, ...rest }){
40
- if (v instanceof Error) return
40
const copy = { ...value }
41
if (v)
42
copy[v] = was !== undefined ? copy[was] : ''
@@ -55,7 +54,6 @@ export default function StringStringField({ value, onChange, keyLabel='key', val
54
},
55
value: v,
56
onChange(v, { was, ...rest }){
58
- if (v instanceof Error) return
57
const copy = { ...value }
58
if (v)
59
copy[id] = v
@@ -79,7 +77,7 @@ export default function StringStringField({ value, onChange, keyLabel='key', val
77
inputRef: refNew,
78
value: '',
79
onChange(v, more){
82
- if (!v || v instanceof Error) return
80
+ if (!v) return
81
more.cancel()
82
if (value && v in value)
83
return alert(keyLabel + " entry already present")