admin/accounts: ux: proper form validation
Massimo Melina committed
Mar 15, 2022 at 10:42 UTC
cd309d583edf0e7e50b11d36a9ae1013428987ad
4 files changed
+59
-41
admin/src/AccountsPage.ts
+24
-31
@@ -170,9 +170,12 @@ function AccountForm({ account, done, groups }: { account: Account, groups: stri
170
barSx: { width: 'initial', justifyContent: 'space-between' },
171
addToBar: [ account2icon(values, { fontSize: 'large', sx: { p: 1 }}) ],
172
fields: [
173
- { k: 'username', label: group ? 'Group name' : undefined, autoComplete: 'off' },
174
- !group && { k: 'password', comp: StringField, md: 6, type: 'password', autoComplete: 'new-password', label: add ? 'Password' : 'Change password' },
175
- !group && { k: 'password2', comp: StringField, md: 6, type: 'password', autoComplete: 'off', label: 'Repeat password' },
173
+ { k: 'username', label: group ? 'Group name' : undefined, autoComplete: 'off', validate: x => x>'' || "Required" },
174
+ !group && { k: 'password', comp: StringField, md: 6, type: 'password', autoComplete: 'new-password', label: add ? "Password" : "Change password",
175
+ validate: x => x>'' || !add || "Please provide a password"
176
+ },
177
+ !group && { k: 'password2', comp: StringField, md: 6, type: 'password', autoComplete: 'off', label: 'Repeat password',
178
+ validate: (x, { values }) => x === values.password || "Enter same password" },
179
{ k: 'ignore_limits', comp: BoolField,
180
helperText: values.ignore_limits ? "Speed limits don't apply to this account" : "Speed limits apply to this account" },
181
{ k: 'admin', comp: BoolField, fromField: (v:boolean) => v||null, label: "Permission to access Admin interface",
@@ -184,41 +187,31 @@ function AccountForm({ account, done, groups }: { account: Account, groups: stri
187
helperText: "Options and permissions of the selected groups will be applied to this account. "
188
+ (belongsOptions.length ? '' : "There are no groups available, create one first.") }
189
],
190
+ onError: alertDialog,
191
save: {
192
disabled: isEqualLax(values, account),
193
async onClick() {
194
+ const { password='', password2, ...withoutPassword } = values
195
const { username } = values
191
- if (!username)
192
- return alertDialog(`Username cannot be empty`, 'warning')
193
- const { hasPassword, password, password2, ...withoutPassword } = values
194
- if (password !== password2)
195
- return alertDialog("You entered 2 different passwords, please fix", 'error')
196
- try {
197
- if (add) {
198
- if (hasPassword && !password)
199
- return alertDialog("Please provide a password", 'warning')
200
- await apiCall('add_account', withoutPassword)
201
- if (password)
202
- try { await apiNewPassword(username, password) }
203
- catch(e) {
204
- apiCall('del_account', { username }).then() // best effort, don't wait
205
- throw e
206
- }
207
- done(username)
208
- return alertDialog("Account created", 'success')
209
- }
210
- await apiCall('set_account', {
211
- username: account.username,
212
- changes: withoutPassword,
213
- })
196
+ if (add) {
197
+ await apiCall('add_account', withoutPassword)
198
if (password)
215
- await apiNewPassword(username, password)
199
+ try { await apiNewPassword(username, password) }
200
+ catch(e) {
201
+ apiCall('del_account', { username }).then() // best effort, don't wait
202
+ throw e
203
+ }
204
done(username)
217
- return alertDialog("Account modified", 'success')
218
- }
219
- catch (e) {
220
- return alertDialog(e as Error)
205
+ return alertDialog("Account created", 'success')
206
}
207
+ await apiCall('set_account', {
208
+ username: account.username,
209
+ changes: withoutPassword,
210
+ })
211
+ if (password)
212
+ await apiNewPassword(username, password)
213
+ done(username)
214
+ return alertDialog("Account modified", 'success')
215
}
216
}
217
})
admin/src/FileCard.ts
+3
-4
@@ -7,7 +7,6 @@ import { BoolField, DisplayField, Field, FieldProps, Form, MultiSelectField, Sel
7
import { apiCall, useApi } from './api'
8
import { formatBytes, isEqualLax, onlyTruthy } from './misc'
9
import { reloadVfs, Who } from './VfsPage'
10
-import { alertDialog } from './dialog'
10
import md from './md'
11
import _ from 'lodash'
12
@@ -65,8 +64,6 @@ function FileForm({ file }: { file: ReturnType<typeof useSnapState>['selectedFil
64
save: {
65
disabled: isEqualLax(values, file),
66
async onClick() {
68
- if (file.id !== '/' && !values.name)
69
- return alertDialog(`Name cannot be empty`, 'warning')
67
const props = _.pickBy(values, (v,k) =>
68
v !== file[k as keyof typeof values])
69
if (!props.masks)
@@ -82,7 +79,9 @@ function FileForm({ file }: { file: ReturnType<typeof useSnapState>['selectedFil
79
}
80
},
81
fields: [
85
- !isRoot && { k: 'name', helperText: source && "You can decide a name that's different from the one on your disk" },
82
+ !isRoot && { k: 'name', validate: x => x>'' || `Required`,
83
+ helperText: source && "You can decide a name that's different from the one on your disk",
84
+ },
85
hasSource && { k: 'source', comp: DisplayField },
86
{ k: 'can_read', label:"Who can download", md: showCanSee && 6, comp: WhoField, parent, accounts, inherit: inheritedPerms.can_read,
87
helperText: "Who cannot download also cannot see in list"
admin/src/Form.ts
+31
-5
@@ -17,14 +17,20 @@ import { Save } from '@mui/icons-material'
17
import { LoadingButton } from '@mui/lab'
18
import _ from 'lodash'
19
20
-interface FieldDescriptor { k:string, comp?: any, label?: string | ReactElement, [extraProp:string]:any }
20
+interface FieldDescriptor {
21
+ k:string
22
+ comp?: any
23
+ label?: string | ReactElement
24
+ validate?: (v: any, extra:any) => string | boolean
25
+ [extraProp:string]:any
26
+}
27
28
// it seems necessary to cast (Multi)SelectField sometimes
29
export type Field<T> = FC<FieldProps<T>>
30
31
interface FormProps {
32
fields: (FieldDescriptor | ReactElement | null | undefined | false)[]
27
- defaults?: (f:FieldDescriptor) => Dict | void
33
+ defaults?: (f:FieldDescriptor) => Dict | any
34
values: Dict
35
set: (v: any, field: FieldDescriptor) => void
36
save?: Partial<Parameters<typeof Button>[0]>
@@ -36,11 +42,21 @@ interface FormProps {
42
}
43
export function Form({ fields, values, set, defaults, save, stickyBar, addToBar=[], barSx, formRef, onError, ...rest }: FormProps) {
44
const [loading, setLoading] = useStateMounted(false)
45
+ const [errors, setErrors] = useStateMounted<Dict>({})
46
const onClick = save?.onClick
47
if (onClick)
48
save.onClick = async function (ev) {
49
setLoading(true)
43
- try { return await onClick(ev) }
50
+ try {
51
+ for (const f of fields) {
52
+ if (!f || isValidElement(f) || !f.k || !f.validate) continue
53
+ const res = await f.validate(values?.[f.k], { values, fields })
54
+ if (res !== true)
55
+ return setErrors({ [f.k]: res || true })
56
+ }
57
+ setErrors({})
58
+ return await onClick(ev)
59
+ }
60
catch(e) { onError?.(e) }
61
finally { setLoading(false) }
62
}
@@ -76,21 +92,30 @@ export function Form({ fields, values, set, defaults, save, stickyBar, addToBar=
92
return h(Grid, { key: idx, item: true, xs: 12 }, row)
93
let field = row
94
const { k, onChange } = field
95
+ let error = errors[k]
96
+ if (error === true)
97
+ error = "Not valid"
98
if (k) {
99
field = {
100
value: values?.[k],
101
...field,
102
+ error: field.error || Boolean(error) || undefined,
103
onChange(v:any) {
104
if (onChange)
105
v = onChange(v)
106
set(v, field)
107
},
108
}
109
+ if (error)
110
+ field.helperText = field.helperText ? h(Fragment, {}, error, h('br'), field.helperText)
111
+ : error
112
if (field.label === undefined)
113
field.label = _.capitalize(k.replaceAll('_', ' '))
114
_.defaults(field, defaults?.(field))
115
}
93
- const { xs=12, sm, md, lg, xl, comp=StringField, ...rest } = field
116
+ const { xs=12, sm, md, lg, xl, comp=StringField,
117
+ validate, // don't propagate
118
+ ...rest } = field
119
return h(Grid, { key: k, item: true, xs, sm, md, lg, xl },
120
isValidElement(comp) ? comp : h(comp, rest) )
121
})
@@ -119,8 +144,9 @@ export interface FieldProps<T> {
144
label?: string | ReactElement
145
value?: T
146
onChange: (v: T, more: { was?: T, event: any, [rest: string]: any }) => void
122
- toField?: (v: any) => T,
147
+ toField?: (v: any) => T
148
fromField?: (v: T) => any
149
+ error?: true
150
[rest: string]: any
151
}
152
admin/src/dialog.ts
+1
-1
@@ -46,7 +46,7 @@ const type2ico = {
46
47
export async function alertDialog(msg: ReactElement | string | Error, type:AlertType='info', icon?: ReactElement) {
48
if (msg instanceof Error) {
49
- msg = String(msg)
49
+ msg = msg.message || String(msg)
50
type = 'error'
51
}
52
return new Promise(resolve => newDialog({