Skip to content

Commit 3b6ac05

Browse files
🐛 [open-formulieren/open-forms#5994] Ensure validation errors for hidden fields are cleared
Similarly to the values update (from clearOnHide), ensure the error state is updated when a component becomes hidden. We always remove the validation errors for hidden fields, irrespective of clear on hide. We also do not restore validation errors when the field becomes visible again, as the validation schema will restore these, or the backend validation errors will do so. Co-authored-by: vasileios <zigras00@gmail.com>
1 parent aa36569 commit 3b6ac05

File tree

9 files changed

+244
-56
lines changed

9 files changed

+244
-56
lines changed

src/components/FormioForm.tsx

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from '@/validationSchema';
1818
import {deepMergeValues, extractInitialValues} from '@/values';
1919
import {processVisibility} from '@/visibility';
20+
import type {Errors as VisibilityErrors} from '@/visibility';
2021

2122
import FormFieldContainer from './FormFieldContainer';
2223
import FormSettingsProvider from './FormSettingsProvider';
@@ -271,21 +272,32 @@ const InnerFormioForm = forwardRef<FormStateRef, InnerFormioFormProps>(
271272
// values via clearOnHide, and this implies multiple (render) passes to converge.
272273
// XXX: this means that component definitions may not have reference cycles in their
273274
// conditional logic to prevent infinite render loops!
274-
const {visibleComponents: componentsToRender, updatedValues} = useMemo(() => {
275-
const {visibleComponents, updatedValues} = processVisibility(components, values, {
276-
parentHidden: false,
277-
initialValues,
278-
getRegistryEntry,
279-
componentsMap,
280-
});
275+
const {
276+
visibleComponents: componentsToRender,
277+
updatedValues,
278+
updatedErrors,
279+
} = useMemo(() => {
280+
const {visibleComponents, updatedValues, updatedErrors} = processVisibility(
281+
components,
282+
values,
283+
// type cast necessary because Formik's FormikErrors type is wrong for nested
284+
// structures/generics like JSONObject
285+
errors as VisibilityErrors,
286+
{
287+
parentHidden: false,
288+
initialValues,
289+
getRegistryEntry,
290+
componentsMap,
291+
}
292+
);
281293

282294
const updatedValidationSchema = buildValidationSchema(visibleComponents, {
283295
intl,
284296
getRegistryEntry,
285297
validatePlugins: validatePlugins.bind(null, validatePluginCallback),
286298
});
287299
onValidationSchemaChange(updatedValidationSchema);
288-
return {visibleComponents, updatedValues};
300+
return {visibleComponents, updatedValues, updatedErrors};
289301
}, [
290302
intl,
291303
validatePluginCallback,
@@ -294,6 +306,7 @@ const InnerFormioForm = forwardRef<FormStateRef, InnerFormioFormProps>(
294306
componentsMap,
295307
initialValues,
296308
values,
309+
errors,
297310
]);
298311

299312
// handle the side-effects from the visibility checks that apply clearOnHide to the
@@ -306,7 +319,15 @@ const InnerFormioForm = forwardRef<FormStateRef, InnerFormioFormProps>(
306319
if (updatedValues !== values) {
307320
setValues(updatedValues);
308321
}
309-
}, [setValues, values, updatedValues]);
322+
323+
// update the errors the same way - values and errors are typically mutated at
324+
// the same time
325+
if (updatedErrors !== errors) {
326+
// type cast necessary because Formik's FormikErrors type is wrong for nested
327+
// structures/generics like JSONObject
328+
setErrors(updatedErrors as FormikErrors<JSONObject>);
329+
}
330+
}, [setValues, values, updatedValues, setErrors, errors, updatedErrors]);
310331

311332
return (
312333
<Form noValidate id={id}>

src/registry/columns/visibility.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,20 @@ import {processVisibility} from '@/visibility';
88
const applyVisibility: ApplyVisibility<ColumnsComponentSchema> = (
99
componentDefinition,
1010
values,
11+
errors,
1112
context
1213
) => {
1314
const updatedColumns: Column[] = componentDefinition.columns.map(column => {
14-
const {visibleComponents, updatedValues} = processVisibility(
15+
const {visibleComponents, updatedValues, updatedErrors} = processVisibility(
1516
column.components,
1617
values,
18+
errors,
1719
context
1820
);
1921
// make sure to update this for the next iteration so that it sees the up-to-date
2022
// side-effects of clearOnHide
2123
values = updatedValues;
24+
errors = updatedErrors;
2225
return setIn(column, 'components', visibleComponents);
2326
});
2427

@@ -28,7 +31,7 @@ const applyVisibility: ApplyVisibility<ColumnsComponentSchema> = (
2831
updatedColumns
2932
);
3033

31-
return {updatedDefinition, updatedValues: values};
34+
return {updatedDefinition, updatedValues: values, updatedErrors: errors};
3235
};
3336

3437
export default applyVisibility;

src/registry/editgrid/ItemPreview.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,17 @@ const ItemPreview: React.FC<ItemPreviewProps> = ({
5252
// The `ItemBody` component takes care of (recursively) processing the value changes
5353
// from clearOnHide side-effects. In the preview, we only need to worry about whether
5454
// a component is visible or not.
55-
const {visibleComponents} = processVisibility(components, values, {
56-
parentHidden: false,
57-
initialValues: {}, // actual value is not relevant here, it's handled in `ItemBody`
58-
getRegistryEntry,
59-
componentsMap,
60-
});
55+
const {visibleComponents} = processVisibility(
56+
components,
57+
values,
58+
{},
59+
{
60+
parentHidden: false,
61+
initialValues: {}, // actual value is not relevant here, it's handled in `ItemBody`
62+
getRegistryEntry,
63+
componentsMap,
64+
}
65+
);
6166
return (
6267
<DataList appearance="rows">
6368
{visibleComponents.map(component => (

src/registry/editgrid/index.tsx

Lines changed: 57 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {JSONObject} from '@/types';
1414
import {buildValidationSchema, useValidationSchemas, validatePlugins} from '@/validationSchema';
1515
import {deepMergeValues, extractInitialValues} from '@/values';
1616
import {processVisibility} from '@/visibility';
17+
import type {Errors} from '@/visibility';
1718

1819
import ItemPreview from './ItemPreview';
1920
import isEmpty from './empty';
@@ -47,6 +48,7 @@ export interface ItemBodyProps {
4748
parentComponentsMap: Record<string, AnyComponentSchema>;
4849
initialValues: JSONObject;
4950
onItemValuesUpdated: (itemValues: JSONObject) => void;
51+
onItemErrorsUpdated: (itemErrors: Errors) => void;
5052
onValidationSchemaChange: (index: number, schema: z.ZodSchema<JSONObject>) => void;
5153
expanded: boolean;
5254
}
@@ -61,11 +63,12 @@ const ItemBody: React.FC<ItemBodyProps> = ({
6163
parentComponentsMap,
6264
initialValues,
6365
onItemValuesUpdated,
66+
onItemErrorsUpdated,
6467
onValidationSchemaChange,
6568
expanded,
6669
}) => {
6770
const intl = useIntl();
68-
const {values} = useFormikContext<WrappedJSONObject>();
71+
const {values, errors} = useFormikContext<WrappedJSONObject>();
6972
const {validatePluginCallback} = useFormSettings();
7073

7174
// ensure we peek deep inside the formik data skipping over any prefixes applied by
@@ -75,6 +78,7 @@ const ItemBody: React.FC<ItemBodyProps> = ({
7578
if (!rawNamePrefix.endsWith('.')) throw new Error('Unexpected name prefix');
7679
const namePrefix = rawNamePrefix.slice(0, -1);
7780
const itemValues: JSONObject = getIn(values, namePrefix);
81+
const itemErrors: Errors = getIn(errors, namePrefix);
7882

7983
const componentsMap = useMemo(() => {
8084
const localComponentsMap: Record<string, AnyComponentSchema> = Object.fromEntries(
@@ -88,33 +92,33 @@ const ItemBody: React.FC<ItemBodyProps> = ({
8892
return {...parentComponentsMap, ...localComponentsMap};
8993
}, [parentComponentsMap, components, parentKey]);
9094

91-
const {visibleComponents, updatedItemValues} = useMemo(() => {
92-
const {visibleComponents, updatedValues: updatedItemValues} = processVisibility(
93-
components,
94-
itemValues,
95-
{
96-
// in this case, the parent is the item itself rather than the `editgrid`
97-
// component. There are no mechanisms to hide an entire item. If the editgrid
98-
// component were to be hidden, matching key of that component will be cleared
99-
// and/or items won't be rendered at all because the editgrid component is
100-
// filtered out of the visible components.
101-
parentHidden: false,
102-
initialValues,
103-
getRegistryEntry,
104-
getEvaluationScope: (values: JSONObject): JSONObject => {
105-
const result: JSONObject = setIn(parentValues, parentKey, values);
106-
return result;
107-
},
108-
componentsMap,
109-
}
110-
);
95+
const {visibleComponents, updatedItemValues, updatedItemErrors} = useMemo(() => {
96+
const {
97+
visibleComponents,
98+
updatedValues: updatedItemValues,
99+
updatedErrors: updatedItemErrors,
100+
} = processVisibility(components, itemValues, itemErrors, {
101+
// in this case, the parent is the item itself rather than the `editgrid`
102+
// component. There are no mechanisms to hide an entire item. If the editgrid
103+
// component were to be hidden, matching key of that component will be cleared
104+
// and/or items won't be rendered at all because the editgrid component is
105+
// filtered out of the visible components.
106+
parentHidden: false,
107+
initialValues,
108+
getRegistryEntry,
109+
getEvaluationScope: (values: JSONObject): JSONObject => {
110+
const result: JSONObject = setIn(parentValues, parentKey, values);
111+
return result;
112+
},
113+
componentsMap,
114+
});
111115
const updatedValidationSchema = buildValidationSchema(visibleComponents, {
112116
intl,
113117
getRegistryEntry,
114118
validatePlugins: validatePlugins.bind(null, validatePluginCallback),
115119
});
116120
onValidationSchemaChange(index, updatedValidationSchema);
117-
return {visibleComponents, updatedItemValues};
121+
return {visibleComponents, updatedItemValues, updatedItemErrors};
118122
}, [
119123
intl,
120124
index,
@@ -127,6 +131,7 @@ const ItemBody: React.FC<ItemBodyProps> = ({
127131
componentsMap,
128132
initialValues,
129133
itemValues,
134+
itemErrors,
130135
]);
131136

132137
// handle the side-effects from the visibility checks that apply clearOnHide to the
@@ -141,7 +146,18 @@ const ItemBody: React.FC<ItemBodyProps> = ({
141146
if (updatedItemValues !== itemValues) {
142147
onItemValuesUpdated(updatedItemValues);
143148
}
144-
}, [onItemValuesUpdated, itemValues, updatedItemValues]);
149+
150+
if (updatedItemErrors !== itemErrors) {
151+
onItemErrorsUpdated(updatedItemErrors);
152+
}
153+
}, [
154+
onItemValuesUpdated,
155+
itemValues,
156+
updatedItemValues,
157+
onItemErrorsUpdated,
158+
itemErrors,
159+
updatedItemErrors,
160+
]);
145161

146162
if (!expanded) {
147163
// assign the local item values to the editgrid scope - `parentKey` is the key of the
@@ -199,8 +215,13 @@ export const FormioEditGrid: React.FC<EditGridProps> = ({
199215
renderNested: FormioComponent,
200216
getRegistryEntry,
201217
}) => {
202-
const {values, setFieldValue, getFieldProps} = useFormikContext<WrappedJSONObject>();
218+
const {values, setFieldValue, getFieldProps, getFieldMeta, setFieldError} =
219+
useFormikContext<WrappedJSONObject>();
203220
const {value} = getFieldProps<JSONObject[]>(key);
221+
const {error} = getFieldMeta<JSONObject[]>(key);
222+
// type cast because the FormikErrors type is plain wrong for nested structures like
223+
// edit grids
224+
const fieldError = error as Errors;
204225

205226
// ensure we peek deep inside the formik data skipping over any prefixes applied by
206227
// the EditGridItem for the isolation-mode-editing. Note that prefix ends with a
@@ -257,6 +278,18 @@ export const FormioEditGrid: React.FC<EditGridProps> = ({
257278
const updatedFieldValue = replace(value, index, newItemValues);
258279
setFieldValue(key, updatedFieldValue);
259280
}}
281+
onItemErrorsUpdated={newItemErrors => {
282+
// we can only replace the item errors if the field errors are an array of
283+
// item-level errors. Possibly there are:
284+
// - no errors at all (undefined)
285+
// - a string error, for the editgrid as a whole
286+
if (Array.isArray(fieldError)) {
287+
const updatedFieldErrors = replace(fieldError, index, newItemErrors);
288+
// @ts-expect-error the formik type expects a string, but we're working
289+
// with nested objects here
290+
setFieldError(key, updatedFieldErrors);
291+
}
292+
}}
260293
onValidationSchemaChange={setSchema}
261294
expanded={expanded}
262295
/>

src/registry/editgrid/visibility.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import {getIn, replace, setIn} from 'formik';
44
import type {ApplyVisibility} from '@/registry/types';
55
import type {JSONObject} from '@/types';
66
import {extractInitialValues} from '@/values';
7+
import type {Errors} from '@/visibility';
78
import {processVisibility} from '@/visibility';
89

910
const applyVisibility: ApplyVisibility<EditGridComponentSchema> = (
1011
componentDefinition,
1112
values,
13+
errors,
1214
context
1315
) => {
1416
const {key, components} = componentDefinition;
@@ -18,17 +20,35 @@ const applyVisibility: ApplyVisibility<EditGridComponentSchema> = (
1820
const outerGetEvaluationScope = context?.getEvaluationScope ?? ((v: JSONObject): JSONObject => v);
1921

2022
let items: JSONObject[] | undefined = getIn(values, key);
23+
let itemsErrors: Errors[] | string | undefined = getIn(errors, key);
24+
2125
// Make sure `clearOnHide` actually clears the edit-grid
2226
if (items === undefined) {
2327
return {
2428
updatedDefinition: componentDefinition,
2529
updatedValues: values,
30+
updatedErrors: errors,
2631
};
2732
}
2833

2934
for (let index: number = 0; index < items.length; index++) {
3035
const itemValues = items[index];
3136

37+
// extract the errors for the particular edit grid item. These are either:
38+
// - undefined (no errors at all)
39+
// - a string - for an error for the item as a whole
40+
// - an array of strings, for the errors for a field with `multiple: true`
41+
// - an object (Errors) with nested errors for the item fields
42+
//
43+
// Only the last variant is relevant and requires removing errors if one of the
44+
// child components is hidden.
45+
const itemErrors =
46+
(itemsErrors && Array.isArray(itemsErrors) && itemsErrors[index]) || undefined;
47+
const relevantItemErrors =
48+
itemErrors && !Array.isArray(itemErrors) && typeof itemErrors !== 'string'
49+
? itemErrors
50+
: undefined;
51+
3252
const getEvaluationScope = (itemValues: JSONObject): JSONObject => {
3353
const innerEvaluationScope: JSONObject = setIn(values, key, itemValues);
3454
return outerGetEvaluationScope(innerEvaluationScope);
@@ -37,25 +57,31 @@ const applyVisibility: ApplyVisibility<EditGridComponentSchema> = (
3757
// we cannot process visibleComponents and omit the hidden ones, as there is no
3858
// definition for a single item -> the display layer needs to do this. We can only
3959
// process the value mutations, which (in turn) drive the presentation.
40-
const {updatedValues: updatedItemValues} = processVisibility(components, itemValues, {
41-
...context,
42-
initialValues,
43-
getEvaluationScope,
44-
});
60+
const {updatedValues: updatedItemValues, updatedErrors: updatedRelevantItemErrors} =
61+
processVisibility(components, itemValues, relevantItemErrors, {
62+
...context,
63+
initialValues,
64+
getEvaluationScope,
65+
});
4566

4667
// process an update by replace the item
4768
if (updatedItemValues !== itemValues) {
4869
items = replace(items, index, updatedItemValues) as JSONObject[];
4970
}
71+
if (itemsErrors && updatedRelevantItemErrors !== relevantItemErrors) {
72+
itemsErrors = replace(itemsErrors, index, updatedRelevantItemErrors) as Errors[];
73+
}
5074
}
5175

5276
// update the array value of the edit grid - if no items were changed, this is an
5377
// identity update and no state changes will trigger
5478
const updatedValues: JSONObject = setIn(values, key, items);
79+
const updatedErrors: Errors = errors !== undefined ? setIn(errors, key, itemsErrors) : errors;
5580

5681
return {
5782
updatedDefinition: componentDefinition,
5883
updatedValues: updatedValues,
84+
updatedErrors: updatedErrors,
5985
};
6086
};
6187

src/registry/fieldset/visibility.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,24 @@ import {processVisibility} from '@/visibility';
77
const applyVisibility: ApplyVisibility<FieldsetComponentSchema> = (
88
componentDefinition,
99
values,
10+
errors,
1011
context
1112
) => {
1213
const {components: nestedComponents} = componentDefinition;
1314

14-
const {visibleComponents, updatedValues} = processVisibility(nestedComponents, values, context);
15+
const {visibleComponents, updatedValues, updatedErrors} = processVisibility(
16+
nestedComponents,
17+
values,
18+
errors,
19+
context
20+
);
1521

1622
const updatedDefinition: FieldsetComponentSchema = setIn(
1723
componentDefinition,
1824
'components',
1925
visibleComponents
2026
);
21-
return {updatedDefinition, updatedValues};
27+
return {updatedDefinition, updatedValues, updatedErrors};
2228
};
2329

2430
export default applyVisibility;

0 commit comments

Comments
 (0)