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")