fix: MultiSelectField

Massimo Melina committed Mar 1, 2022 at 12:30 UTC 8baee099a813b96db4c0f8165cd972f8b135f455
2 files changed +47 -17
admin/src/AccountsPage.ts
+2 -2
@@ -2,7 +2,7 @@ import { isValidElement, createElement as h, useState, useEffect, Fragment } fro
2 import { apiCall, useApiComp } from './api'
3 import { Box, Button, Card, CardContent, Grid, List, ListItem, ListItemText, Typography } from '@mui/material'
4 import { Delete, Group, Person, PersonAdd, Refresh } from '@mui/icons-material'
5 -import { BoolField, Form, SelectField, StringField } from './Form'
5 +import { BoolField, Form, MultiSelectField, SelectField, StringField } from './Form'
6 import { alertDialog, confirmDialog } from './dialog'
7 import { isEqualLax, onlyTruthy } from './misc'
8 import { TreeItem, TreeView } from '@mui/lab'
@@ -148,7 +148,7 @@ function AccountForm({ account, done, groups }: { account: Account, groups: stri
148 { k: 'ignore_limits', comp: BoolField,
149 helperText: values.ignore_limits ? "Speed limits don't apply to this account" : "Speed limits apply to this account" },
150 { k: 'redirect', comp: StringField, helperText: "If you want this account to be redirected to a specific folder/address at login time" },
151 - { k: 'belongs', comp: SelectField, multiple: true, label: "Inherits from", options: belongsOptions,
151 + { k: 'belongs', comp: MultiSelectField, label: "Inherits from", options: belongsOptions,
152 helperText: "Options and permissions of the selected groups will be applied to this account. "
153 + (belongsOptions.length ? '' : "There are no groups available, create one first.") }
154 ],
admin/src/Form.ts
+45 -15
@@ -17,6 +17,9 @@ import _ from 'lodash'
17
18 interface FieldDescriptor { k:string, comp?: any, label?: string | ReactElement, [extraProp:string]:any }
19
20 +// it seems necessary to cast (Multi)SelectField sometimes
21 +export type FieldComponent<T> = (props:FieldProps<T>) => ReactElement
22 +
23 interface FormProps {
24 fields: (FieldDescriptor | ReactElement | null | undefined | false)[]
25 defaults?: (f:FieldDescriptor) => Dict | void
@@ -130,25 +133,19 @@ export function DisplayField({ map, value, empty='-', ...props }: any) {
133 }
134
135 interface SelectPair<T> { label: string, value:T }
133 -export function SelectField<T>({ value, onChange, options, multiple, ...props }: FieldProps<T> & { options:SelectPair<T>[] }) {
136 +export function SelectField<T>(props: FieldProps<T> & { options:SelectPair<T>[] }) {
137 + const { value, onChange, options, ...rest } = props
138 + const jsonValue = JSON.stringify(value)
139 + const currentOption = options.find(x => JSON.stringify(x.value) === jsonValue)
140 return h(TextField, { // using TextField because Select is not displaying label correctly
135 - ...props,
136 - select: true,
137 - fullWidth: true,
138 - disabled: !options?.length || props.disabled,
139 - SelectProps: multiple && { multiple: true },
140 - value: multiple ? (!Array.isArray(value) ? [] : value.map(x => JSON.stringify(x)))
141 - : value === undefined ? '' : JSON.stringify(value),
142 - children: options.map((o,i) => {
143 - const obj = o && typeof o === 'object'
144 - const value = obj && 'value' in o ? o.value : o
145 - const label = obj && 'label' in o ? o.label : o
146 - return h(MenuItem, { key: i, value: JSON.stringify(value), children: label })
147 - }),
141 + ...rest,
142 + ...commonSelectProps(props),
143 + // avoid warning for invalid option. This can easily happen for a split-second when you keep value in a useState (or other async way) and calculate options with a useMemo (or other sync way) causing a temporary misalignment.
144 + value: currentOption ? jsonValue : '',
145 onChange(event) {
146 try {
147 let newVal: any = event.target.value
151 - newVal = multiple && Array.isArray(newVal) ? newVal.map(x => JSON.parse(x)) : JSON.parse(newVal) as T
148 + newVal = JSON.parse(newVal) as T
149 onChange(newVal, { was: value, event })
150 }
151 catch {}
@@ -156,6 +153,39 @@ export function SelectField<T>({ value, onChange, options, multiple, ...props }:
153 })
154 }
155
156 +export function MultiSelectField<T>(props: FieldProps<T[]> & { options:SelectPair<T>[] }) {
157 + const { value, onChange, options, ...rest } = props
158 + return h(TextField, {
159 + ...rest,
160 + ...commonSelectProps(props),
161 + SelectProps: { multiple: true },
162 + value: !Array.isArray(value) ? [] : value.map(x => JSON.stringify(x)),
163 + onChange(event) {
164 + try {
165 + let v: any = event.target.value
166 + v = Array.isArray(v) ? v.map(x => JSON.parse(x)) : []
167 + onChange(v as T[], { was: value, event })
168 + }
169 + catch {}
170 + }
171 + })
172 +}
173 +
174 +function commonSelectProps<T>(props: { disabled?: boolean, options:SelectPair<T>[] }) {
175 + const { options, disabled } = props
176 + return {
177 + select: true,
178 + fullWidth: true,
179 + disabled: !options?.length || disabled,
180 + children: options.map((o, i) => {
181 + const obj = o && typeof o === 'object'
182 + const value = obj && 'value' in o ? o.value : o
183 + const label = obj && 'label' in o ? o.label : o
184 + return h(MenuItem, { key: i, value: JSON.stringify(value), children: label })
185 + })
186 + }
187 +}
188 +
189 export function NumberField({ value, onChange, ...props }: FieldProps<number | null>) {
190 // @ts-ignore
191 return h(StringField, {