Skip to content

fix(form-core): handle string array indices in prefixSchemaToErrors #1689

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 35 additions & 15 deletions packages/form-core/src/standardSchemaValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,40 @@ export type TStandardSchemaValidatorIssue<
? StandardSchemaV1Issue[]
: never

function prefixSchemaToErrors(issues: readonly StandardSchemaV1Issue[]) {
function prefixSchemaToErrors(
issues: readonly StandardSchemaV1Issue[],
formValue: unknown,
) {
const schema = new Map<string, StandardSchemaV1Issue[]>()

for (const issue of issues) {
const path = [...(issue.path ?? [])]
.map((segment) => {
const normalizedSegment =
typeof segment === 'object' ? segment.key : segment
return typeof normalizedSegment === 'number'
? `[${normalizedSegment}]`
: normalizedSegment
})
.join('.')
.replace(/\.\[/g, '[')

const issuePath = issue.path ?? []

let currentFormValue = formValue
let path = ''

for (let i = 0; i < issuePath.length; i++) {
const pathSegment = issuePath[i]
if (pathSegment === undefined) continue

const segment =
typeof pathSegment === 'object' ? pathSegment.key : pathSegment

// Standard Schema doesn't specify if paths should use numbers or stringified numbers for array access.
// However, if we follow the path it provides and encounter an array, then we can assume it's intended for array access.
const segmentAsNumber = Number(segment)
if (Array.isArray(currentFormValue) && !Number.isNaN(segmentAsNumber)) {
path += `[${segmentAsNumber}]`
} else {
path += (i > 0 ? '.' : '') + String(segment)
}

if (typeof currentFormValue === 'object' && currentFormValue !== null) {
currentFormValue = currentFormValue[segment as never]
} else {
currentFormValue = undefined
}
}
schema.set(path, (schema.get(path) ?? []).concat(issue))
}

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

const transformFormIssues = <TSource extends ValidationSource>(
issues: readonly StandardSchemaV1Issue[],
formValue: unknown,
): TStandardSchemaValidatorIssue<TSource> => {
const schemaErrors = prefixSchemaToErrors(issues)
const schemaErrors = prefixSchemaToErrors(issues, formValue)
return {
form: schemaErrors,
fields: schemaErrors,
Expand All @@ -68,7 +88,7 @@ export const standardSchemaValidators = {

if (validationSource === 'field')
return result.issues as TStandardSchemaValidatorIssue<TSource>
return transformFormIssues<TSource>(result.issues)
return transformFormIssues<TSource>(result.issues, value)
},
async validateAsync<TSource extends ValidationSource>(
{
Expand All @@ -83,7 +103,7 @@ export const standardSchemaValidators = {

if (validationSource === 'field')
return result.issues as TStandardSchemaValidatorIssue<TSource>
return transformFormIssues<TSource>(result.issues)
return transformFormIssues<TSource>(result.issues, value)
},
}

Expand Down
185 changes: 185 additions & 0 deletions packages/form-core/tests/standardSchemaValidator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,4 +398,189 @@ describe('standard schema validator', () => {
it.todo(
'Should allow for `disableErrorFlat` to disable flattening `errors` array',
)

describe('array path handling', () => {
it('should handle numeric array indices correctly', async () => {
const form = new FormApi({
defaultValues: {
people: [{ name: '' }],
},
validators: {
onChange: z.object({
people: z.array(
z.object({
name: z.string().min(1, 'Name is required'),
}),
),
}),
},
})

const field = new FieldApi({
form,
name: 'people[0].name',
})

field.mount()

field.setValue('')
expect(form.state.errors).toMatchObject([
{
'people[0].name': [{ message: 'Name is required' }],
},
])
})

it('should handle string array indices from standard schema validators', async () => {
// Use Zod's superRefine to simulate string paths that some standard schema validators return
const schemaWithStringPaths = z
.object({
people: z.array(
z.object({
name: z.string(),
}),
),
})
.superRefine((_, ctx) => {
ctx.addIssue({
code: 'custom',
message: 'Name is required',
path: ['people', '0', 'name'], // String index to test path handling
})
})

const form = new FormApi({
defaultValues: {
people: [{ name: '' }],
},
validators: {
onChange: schemaWithStringPaths,
},
})

const field = new FieldApi({
form,
name: 'people[0].name',
})

field.mount()

field.setValue('')
expect(form.state.errors).toMatchObject([
{
'people[0].name': [{ message: 'Name is required' }],
},
])
})

it('should handle nested arrays with mixed numeric and string indices', async () => {
const form = new FormApi({
defaultValues: {
users: [
{
addresses: [
{ street: 'Main St' },
{ street: '' }, // This will fail validation
],
},
],
},
validators: {
onChange: z.object({
users: z.array(
z.object({
addresses: z.array(
z.object({
street: z.string().min(1, 'Street is required'),
}),
),
}),
),
}),
},
})

const field = new FieldApi({
form,
name: 'users[0].addresses[1].street',
})

field.mount()
field.setValue('')

expect(form.state.errors).toMatchObject([
{
'users[0].addresses[1].street': [{ message: 'Street is required' }],
},
])
})

it('should handle regular object paths without array indices', async () => {
const form = new FormApi({
defaultValues: {
user: {
profile: {
name: '',
},
},
},
validators: {
onChange: z.object({
user: z.object({
profile: z.object({
name: z.string().min(1, 'Name is required'),
}),
}),
}),
},
})

const field = new FieldApi({
form,
name: 'user.profile.name',
})

field.mount()

field.setValue('')
expect(form.state.errors).toMatchObject([
{
'user.profile.name': [{ message: 'Name is required' }],
},
])
})

it('should allow numeric object properties for standard schema issue paths', () => {
const form = new FormApi({
defaultValues: {
foo: {
0: { bar: '' },
},
},
validators: {
onChange: z.object({
foo: z.object({
0: z.object({ bar: z.string().email('Must be an email') }),
}),
}),
},
})
form.mount()

const field = new FieldApi({
form,
name: 'foo.0.bar',
})
field.mount()

field.setValue('test')

expect(form.state.errors).toMatchObject([
{ 'foo.0.bar': [{ message: 'Must be an email' }] },
])
expect(field.state.meta.errors).toMatchObject([
{ message: 'Must be an email' },
])
})
})
})