Skip to content

Commit 7cf3728

Browse files
jiji-hoon96autofix-ci[bot]LeCarbonator
authored
fix(form-core): handle string array indices in standard schema error paths (#1689)
* fix(form-core): handle string array indices in prefixSchemaToErrors - Fix array path handling for Standard Schema validators - Support both numeric (Zod) and string (Yup) array indices Fixes #1683 * ci: apply automated fixes and generate docs * fix(form-core): improve array index path handling in standard schema validator by inspecting form values * refactor(react-core): simplify path construction in schema validation error handling * ci: apply automated fixes and generate docs * Create poor-drinks-heal.md * ci: apply automated fixes and generate docs --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: LeCarbonator <[email protected]>
1 parent 888bfcd commit 7cf3728

File tree

3 files changed

+225
-15
lines changed

3 files changed

+225
-15
lines changed

.changeset/poor-drinks-heal.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/form-core': patch
3+
---
4+
5+
fix(form-core): handle string array indices in prefixSchemaToErrors

packages/form-core/src/standardSchemaValidator.ts

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,40 @@ export type TStandardSchemaValidatorIssue<
1919
? StandardSchemaV1Issue[]
2020
: never
2121

22-
function prefixSchemaToErrors(issues: readonly StandardSchemaV1Issue[]) {
22+
function prefixSchemaToErrors(
23+
issues: readonly StandardSchemaV1Issue[],
24+
formValue: unknown,
25+
) {
2326
const schema = new Map<string, StandardSchemaV1Issue[]>()
2427

2528
for (const issue of issues) {
26-
const path = [...(issue.path ?? [])]
27-
.map((segment) => {
28-
const normalizedSegment =
29-
typeof segment === 'object' ? segment.key : segment
30-
return typeof normalizedSegment === 'number'
31-
? `[${normalizedSegment}]`
32-
: normalizedSegment
33-
})
34-
.join('.')
35-
.replace(/\.\[/g, '[')
36-
29+
const issuePath = issue.path ?? []
30+
31+
let currentFormValue = formValue
32+
let path = ''
33+
34+
for (let i = 0; i < issuePath.length; i++) {
35+
const pathSegment = issuePath[i]
36+
if (pathSegment === undefined) continue
37+
38+
const segment =
39+
typeof pathSegment === 'object' ? pathSegment.key : pathSegment
40+
41+
// Standard Schema doesn't specify if paths should use numbers or stringified numbers for array access.
42+
// However, if we follow the path it provides and encounter an array, then we can assume it's intended for array access.
43+
const segmentAsNumber = Number(segment)
44+
if (Array.isArray(currentFormValue) && !Number.isNaN(segmentAsNumber)) {
45+
path += `[${segmentAsNumber}]`
46+
} else {
47+
path += (i > 0 ? '.' : '') + String(segment)
48+
}
49+
50+
if (typeof currentFormValue === 'object' && currentFormValue !== null) {
51+
currentFormValue = currentFormValue[segment as never]
52+
} else {
53+
currentFormValue = undefined
54+
}
55+
}
3756
schema.set(path, (schema.get(path) ?? []).concat(issue))
3857
}
3958

@@ -42,8 +61,9 @@ function prefixSchemaToErrors(issues: readonly StandardSchemaV1Issue[]) {
4261

4362
const transformFormIssues = <TSource extends ValidationSource>(
4463
issues: readonly StandardSchemaV1Issue[],
64+
formValue: unknown,
4565
): TStandardSchemaValidatorIssue<TSource> => {
46-
const schemaErrors = prefixSchemaToErrors(issues)
66+
const schemaErrors = prefixSchemaToErrors(issues, formValue)
4767
return {
4868
form: schemaErrors,
4969
fields: schemaErrors,
@@ -68,7 +88,7 @@ export const standardSchemaValidators = {
6888

6989
if (validationSource === 'field')
7090
return result.issues as TStandardSchemaValidatorIssue<TSource>
71-
return transformFormIssues<TSource>(result.issues)
91+
return transformFormIssues<TSource>(result.issues, value)
7292
},
7393
async validateAsync<TSource extends ValidationSource>(
7494
{
@@ -83,7 +103,7 @@ export const standardSchemaValidators = {
83103

84104
if (validationSource === 'field')
85105
return result.issues as TStandardSchemaValidatorIssue<TSource>
86-
return transformFormIssues<TSource>(result.issues)
106+
return transformFormIssues<TSource>(result.issues, value)
87107
},
88108
}
89109

packages/form-core/tests/standardSchemaValidator.spec.ts

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,4 +398,189 @@ describe('standard schema validator', () => {
398398
it.todo(
399399
'Should allow for `disableErrorFlat` to disable flattening `errors` array',
400400
)
401+
402+
describe('array path handling', () => {
403+
it('should handle numeric array indices correctly', async () => {
404+
const form = new FormApi({
405+
defaultValues: {
406+
people: [{ name: '' }],
407+
},
408+
validators: {
409+
onChange: z.object({
410+
people: z.array(
411+
z.object({
412+
name: z.string().min(1, 'Name is required'),
413+
}),
414+
),
415+
}),
416+
},
417+
})
418+
419+
const field = new FieldApi({
420+
form,
421+
name: 'people[0].name',
422+
})
423+
424+
field.mount()
425+
426+
field.setValue('')
427+
expect(form.state.errors).toMatchObject([
428+
{
429+
'people[0].name': [{ message: 'Name is required' }],
430+
},
431+
])
432+
})
433+
434+
it('should handle string array indices from standard schema validators', async () => {
435+
// Use Zod's superRefine to simulate string paths that some standard schema validators return
436+
const schemaWithStringPaths = z
437+
.object({
438+
people: z.array(
439+
z.object({
440+
name: z.string(),
441+
}),
442+
),
443+
})
444+
.superRefine((_, ctx) => {
445+
ctx.addIssue({
446+
code: 'custom',
447+
message: 'Name is required',
448+
path: ['people', '0', 'name'], // String index to test path handling
449+
})
450+
})
451+
452+
const form = new FormApi({
453+
defaultValues: {
454+
people: [{ name: '' }],
455+
},
456+
validators: {
457+
onChange: schemaWithStringPaths,
458+
},
459+
})
460+
461+
const field = new FieldApi({
462+
form,
463+
name: 'people[0].name',
464+
})
465+
466+
field.mount()
467+
468+
field.setValue('')
469+
expect(form.state.errors).toMatchObject([
470+
{
471+
'people[0].name': [{ message: 'Name is required' }],
472+
},
473+
])
474+
})
475+
476+
it('should handle nested arrays with mixed numeric and string indices', async () => {
477+
const form = new FormApi({
478+
defaultValues: {
479+
users: [
480+
{
481+
addresses: [
482+
{ street: 'Main St' },
483+
{ street: '' }, // This will fail validation
484+
],
485+
},
486+
],
487+
},
488+
validators: {
489+
onChange: z.object({
490+
users: z.array(
491+
z.object({
492+
addresses: z.array(
493+
z.object({
494+
street: z.string().min(1, 'Street is required'),
495+
}),
496+
),
497+
}),
498+
),
499+
}),
500+
},
501+
})
502+
503+
const field = new FieldApi({
504+
form,
505+
name: 'users[0].addresses[1].street',
506+
})
507+
508+
field.mount()
509+
field.setValue('')
510+
511+
expect(form.state.errors).toMatchObject([
512+
{
513+
'users[0].addresses[1].street': [{ message: 'Street is required' }],
514+
},
515+
])
516+
})
517+
518+
it('should handle regular object paths without array indices', async () => {
519+
const form = new FormApi({
520+
defaultValues: {
521+
user: {
522+
profile: {
523+
name: '',
524+
},
525+
},
526+
},
527+
validators: {
528+
onChange: z.object({
529+
user: z.object({
530+
profile: z.object({
531+
name: z.string().min(1, 'Name is required'),
532+
}),
533+
}),
534+
}),
535+
},
536+
})
537+
538+
const field = new FieldApi({
539+
form,
540+
name: 'user.profile.name',
541+
})
542+
543+
field.mount()
544+
545+
field.setValue('')
546+
expect(form.state.errors).toMatchObject([
547+
{
548+
'user.profile.name': [{ message: 'Name is required' }],
549+
},
550+
])
551+
})
552+
553+
it('should allow numeric object properties for standard schema issue paths', () => {
554+
const form = new FormApi({
555+
defaultValues: {
556+
foo: {
557+
0: { bar: '' },
558+
},
559+
},
560+
validators: {
561+
onChange: z.object({
562+
foo: z.object({
563+
0: z.object({ bar: z.string().email('Must be an email') }),
564+
}),
565+
}),
566+
},
567+
})
568+
form.mount()
569+
570+
const field = new FieldApi({
571+
form,
572+
name: 'foo.0.bar',
573+
})
574+
field.mount()
575+
576+
field.setValue('test')
577+
578+
expect(form.state.errors).toMatchObject([
579+
{ 'foo.0.bar': [{ message: 'Must be an email' }] },
580+
])
581+
expect(field.state.meta.errors).toMatchObject([
582+
{ message: 'Must be an email' },
583+
])
584+
})
585+
})
401586
})

0 commit comments

Comments
 (0)