fix: admin/plugins: "save" button was not validating values
Massimo Melina committed
Mar 26, 2026 at 20:07 UTC
c3647cf109072a291266810bf275df22e25c4130
2 files changed
+44
-9
admin/src/pluginOptions.ts
+11
-2
@@ -6,7 +6,7 @@ import { Btn, Flex, iconTooltip, NetmaskField } from './mui'
6
import { MilitaryTech, Clear } from '@mui/icons-material'
7
import { Html, md, replaceStringToReact, useAutoScroll } from '@hfs/shared'
8
import {
9
- BoolField, Field, FieldProps, MultiSelectField, NumberField, SelectField, StringField
9
+ BoolField, Field, FieldProps, MultiSelectField, NumberField, SelectField, StringField, FormApi
10
} from '@hfs/mui-grid-form'
11
import { ArrayField } from './ArrayField'
12
import _ from 'lodash'
@@ -21,6 +21,7 @@ import { Account, account2icon } from './AccountsPage'
21
export async function showPluginOptions(row: any, maxWidth: string) {
22
const {id} = row
23
const { config: lastSaved } = await apiCall('get_plugin', { id })
24
+ const apiRef = { current: undefined as FormApi | undefined }
25
// support css values without having to wrap in sx, as in DialogProps it only supports breakpoints
26
const showOptions = Boolean(row.config)
27
const values = await formDialog({
@@ -30,7 +31,15 @@ export async function showPluginOptions(row: any, maxWidth: string) {
31
fields: makeFields(callable(row.config, values) || {}, values),
32
save: showOptions ? { children: "Save and close" } : false,
33
barSx: { gap: 1 },
33
- addToBar: [h(Btn, { variant: 'outlined', onClick: () => save(values) }, "Save")],
34
+ apiRef,
35
+ addToBar: [h(Btn, {
36
+ variant: 'outlined',
37
+ async onClick() {
38
+ // this action must reuse form validation without falling through to the dialog-closing submit path
39
+ if (await apiRef.current?.validate())
40
+ await save(values)
41
+ }
42
+ }, "Save")],
43
}),
44
values: lastSaved,
45
dialogProps: _.merge({ maxWidth: 'md', sx: { m: 'auto' } }, // center content when it is smaller than mobile (because of full-screen)
mui-grid-form/index.ts
+33
-7
@@ -34,7 +34,7 @@ export interface FieldDescriptor<T=any> extends FieldApi<T> {
34
// it seems necessary to cast (Multi)SelectField sometimes
35
export type Field<T> = FC<FieldProps<T>>
36
37
-type GetError = (v: any, extra?: any) => Promisable<ValidationError>
37
+type GetError = (v: any, { values, fields }: any) => Promisable<ValidationError>
38
export type Promisable<T> = T | Promise<T>
39
interface FieldApi<T> {
40
// provide getError if you want your error to be visible by the Form component
@@ -53,6 +53,11 @@ export interface FieldProps<T> {
53
54
export type Dict<T=any> = Record<string,T>
55
56
+export interface FormApi {
57
+ submit(): void
58
+ validate(): Promise<boolean>
59
+}
60
+
61
export interface FormProps<Values> extends Partial<BoxProps> {
62
fields: (FieldDescriptor | ReactElement<unknown> | null | undefined | false)[]
63
defaults?: (f:FieldDescriptor) => Partial<FieldDescriptor>
@@ -65,6 +70,7 @@ export interface FormProps<Values> extends Partial<BoxProps> {
70
barSx?: Dict
71
onError?: (err: any) => any
72
onValidation?: (errs: false | Dict<ValidationError>) => any
73
+ apiRef?: MutableRefObject<FormApi | undefined>
74
formRef?: MutableRefObject<HTMLFormElement | undefined>
75
saveOnEnter?: boolean
76
gridProps?: Partial<GridProps>
@@ -82,6 +88,7 @@ export function Form<Values extends Dict>({
88
stickyBar,
89
addToBar = [],
90
barSx,
91
+ apiRef,
92
formRef,
93
onError,
94
onValidation,
@@ -102,7 +109,17 @@ export function Form<Values extends Dict>({
109
const saveBtn = typeof save === 'function' ? { onClick: save } : save // normalize
110
const [phase, setPhase] = useState(Phase.Idle)
111
const submitAfterValidation = useRef(false)
112
+ const validationRequest = useRef<((ok: boolean) => void) | undefined>()
113
const validateUpTo = useRef('')
114
+ if (apiRef) apiRef.current = {
115
+ submit: pleaseSubmitAndValidate,
116
+ validate: () => new Promise<boolean>(resolve => {
117
+ submitAfterValidation.current = false
118
+ validationRequest.current = resolve // will be called later
119
+ if (!pleaseValidate())
120
+ resolve(false)
121
+ })
122
+ }
123
formRef ||= useRef()
124
useEffect(() => void phaseChange(), [phase]) //eslint-disable-line
125
const keyMet: Dict<number> = {}
@@ -119,7 +136,7 @@ export function Form<Values extends Dict>({
136
},
137
onKeyDown(ev) {
138
if (saveBtn && !saveBtn.disabled && (ev.ctrlKey || ev.metaKey) && ev.key === 'Enter')
122
- pleaseSubmit()
139
+ pleaseSubmitAndValidate()
140
},
141
...rest,
142
},
@@ -147,7 +164,7 @@ export function Form<Values extends Dict>({
164
setApi(api) { apis[k] = api },
165
onKeyDown(event: any) {
166
if (saveOnEnter && event.key === 'Enter')
150
- pleaseSubmit()
167
+ pleaseSubmitAndValidate()
168
},
169
onChange(v: unknown) {
170
try {
@@ -200,22 +217,25 @@ export function Form<Values extends Dict>({
217
children: "Save",
218
loading: useDebounce(phase !== Phase.Idle), // debounce fixes click being ignored at state change and flickering
219
...saveBtn,
203
- onClick: pleaseSubmit,
220
+ onClick() {
221
+ pleaseSubmitAndValidate()
222
+ },
223
}) }),
224
...addToBar,
225
)
226
)
227
209
- function pleaseSubmit() { // we use state here to let the outer component perform its state changes
228
+ function pleaseSubmitAndValidate() { // we use state here to let the outer component perform its state changes
229
submitAfterValidation.current = true
230
pleaseValidate()
231
}
232
233
function pleaseValidate(k='') {
215
- if (phase !== Phase.Idle) return
234
+ if (phase !== Phase.Idle) return false
235
validateUpTo.current = k
236
setTimeout(() => // starting validation immediately will lose clicks on the saveBtn, so delay just a bit
237
setPhase(cur => cur === Phase.Idle ? Phase.WaitValues : cur)) // don't interfere with the ongoing process
238
+ return true
239
}
240
241
function getValueFor(k : string) {
@@ -249,8 +269,14 @@ export function Form<Values extends Dict>({
269
setErrors(errs)
270
const anyError = Object.values(errs).some(Boolean)
271
onValidation?.(anyError && errs)
272
+ validationRequest.current?.(!anyError)
273
+ validationRequest.current = undefined
274
+ if (!submitAfterValidation.current) {
275
+ if (mounted.current)
276
+ setPhase(Phase.Idle)
277
+ return
278
+ }
279
try {
253
- if (!submitAfterValidation.current) return
280
if (anyError) {
281
try { return await onError?.(MSG) }
282
finally {