Skip to content

Commit 48b45d9

Browse files
authored
fix: hoist nested errors to the nearest parent state (#4259)
1 parent 9bfbfaa commit 48b45d9

File tree

3 files changed

+82
-2
lines changed

3 files changed

+82
-2
lines changed

.changeset/yellow-socks-poke.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'vee-validate': patch
3+
---
4+
5+
fix: hoist nested errors path to the deepest direct parent

packages/vee-validate/src/useForm.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -326,13 +326,13 @@ export function useForm<
326326
// this ensures we have a complete key map of all the fields
327327
const paths = [
328328
...new Set([...keysOf(formResult.results), ...pathStates.value.map(p => p.path), ...currentErrorsPaths]),
329-
] as string[];
329+
].sort() as string[];
330330

331331
// aggregates the paths into a single result object while applying the results on the fields
332332
return paths.reduce(
333333
(validation, _path) => {
334334
const path = _path as Path<TValues>;
335-
const pathState = findPathState(path);
335+
const pathState = findPathState(path) || findHoistedPath(path);
336336
const messages = (formResult.results[path] || { errors: [] as string[] }).errors;
337337
const fieldResult = {
338338
errors: messages,
@@ -384,6 +384,20 @@ export function useForm<
384384
return pathState as PathState<PathValue<TValues, TPath>> | undefined;
385385
}
386386

387+
function findHoistedPath(path: Path<TValues>) {
388+
const candidates = pathStates.value.filter(state => path.startsWith(state.path));
389+
390+
return candidates.reduce((bestCandidate, candidate) => {
391+
if (!bestCandidate) {
392+
return candidate as PathState<PathValue<TValues, Path<TValues>>>;
393+
}
394+
395+
return (candidate.path.length > bestCandidate.path.length ? candidate : bestCandidate) as PathState<
396+
PathValue<TValues, Path<TValues>>
397+
>;
398+
}, undefined as PathState<PathValue<TValues, Path<TValues>>> | undefined);
399+
}
400+
387401
let UNSET_BATCH: Path<TValues>[] = [];
388402
let PENDING_UNSET: Promise<void> | null;
389403
function unsetPathValue<TPath extends Path<TValues>>(path: TPath) {

packages/vee-validate/tests/useForm.spec.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,67 @@ describe('useForm()', () => {
288288
spy.mockRestore();
289289
});
290290

291+
test('hoists nested field errors to their parent if no field has it', async () => {
292+
let form!: FormContext;
293+
mountWithHoc({
294+
setup() {
295+
form = useForm({
296+
validationSchema: yup.object({
297+
name: yup.object({
298+
value: yup.string().required(REQUIRED_MESSAGE),
299+
}),
300+
}),
301+
validateOnMount: true,
302+
});
303+
304+
useField('name');
305+
306+
return {};
307+
},
308+
template: `
309+
<div></div>
310+
`,
311+
});
312+
313+
await flushPromises();
314+
expect(form.errors.value.name).toBe(REQUIRED_MESSAGE);
315+
expect(form.meta.value.valid).toBe(false);
316+
});
317+
318+
test('selects the deepest candidate for hoisted errors', async () => {
319+
let form!: FormContext<any>;
320+
mountWithHoc({
321+
setup() {
322+
form = useForm({
323+
validationSchema: yup.object({
324+
names: yup.object({
325+
value: yup.array().of(yup.object({ name: yup.string().required(REQUIRED_MESSAGE) })),
326+
}),
327+
}),
328+
validateOnMount: true,
329+
initialValues: {
330+
names: {
331+
value: [{ name: '' }, { name: '' }, { name: '' }],
332+
},
333+
},
334+
});
335+
336+
useField('names.value');
337+
useField('names');
338+
339+
return {};
340+
},
341+
template: `
342+
<div></div>
343+
`,
344+
});
345+
346+
await flushPromises();
347+
expect(form.errors.value.names).toBe(undefined);
348+
expect(form.errors.value['names.value']).toBe(REQUIRED_MESSAGE);
349+
expect(form.meta.value.valid).toBe(false);
350+
});
351+
291352
test('resets the meta valid state on reset', async () => {
292353
let passwordValue!: Ref<string>;
293354
mountWithHoc({

0 commit comments

Comments
 (0)