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 {