Skip to content

Commit

Permalink
fix(core): validate array fields properly if their values are changed
Browse files Browse the repository at this point in the history
* fix(FormApi): missing onChange validation after array field helper methods

* test(FormApi): onChange validation after array field helper methods + add missing moveFieldValues test

* style(FormApi): Run linter

* feat(FormApi): add helper function to validate a single field on a form

* test(FieldApi): onChange validation after array field helper methods

* test(FieldApi): single field validation

* style: Run linter

* docs(FormApi): add `validateField` to references documentation

* chore: Remove log + only test

* chore: fix failing tests after merge

* chore(core): prevent field validation from forcing the `isTouched` state

* chore(core): fix tests

* chore(core): remove redundant async notation

* chore(core): touch fields during validation again

* chore(core): add method to validate array fields sub-fields from a given index

* fix(core): validate array + array subfields when using helper functions

* test(core): adjust test to fixes in array helper function validation

* docs(core): add ref to new `validateArrayFieldsStartingFrom` function
  • Loading branch information
gutentag2012 authored Jun 2, 2024
1 parent 1272ff8 commit 0201e65
Show file tree
Hide file tree
Showing 5 changed files with 548 additions and 6 deletions.
8 changes: 8 additions & 0 deletions docs/reference/formApi.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,14 @@ A class representing the Form API. It handles the logic and interactions with th
validateAllFields(cause: ValidationCause): Promise<ValidationError[]>
```
- Validates all fields in the form using the correct handlers for a given validation type.
- ```tsx
validateArrayFieldsStartingFrom<TField extends DeepKeys<TFormData>>(field: TField, index: number, cause: ValidationCause): ValidationError[] | Promise<ValidationError[]>
```
- Validates the children of a specified array in the form starting from a given index until the end using the correct handlers for a given validation type.
- ```tsx
validateField<TField extends DeepKeys<TFormData>>(field: TField, cause: ValidationCause): ValidationError[] | Promise<ValidationError[]>
```
- Validates a specified field in the form using the correct handlers for a given validation type.

- ```tsx
handleSubmit()
Expand Down
5 changes: 2 additions & 3 deletions packages/form-core/src/FieldApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,9 +477,8 @@ export class FieldApi<
value: TData extends any[] ? TData[number] : never,
) => this.form.insertFieldValue(this.name, index, value as any)

removeValue = async (index: number, opts?: { touch: boolean }) => {
await this.form.removeFieldValue(this.name, index, opts)
}
removeValue = (index: number, opts?: { touch: boolean }) =>
this.form.removeFieldValue(this.name, index, opts)

swapValues = (aIndex: number, bIndex: number) =>
this.form.swapFieldValues(this.name, aIndex, bIndex)
Expand Down
78 changes: 75 additions & 3 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,59 @@ export class FormApi<
return fieldErrorMapMap.flat()
}

validateArrayFieldsStartingFrom = async <TField extends DeepKeys<TFormData>>(
field: TField,
index: number,
cause: ValidationCause,
) => {
const currentValue = this.getFieldValue(field)

const lastIndex = Array.isArray(currentValue)
? Math.max(currentValue.length - 1, 0)
: null

// We have to validate all fields that have shifted (at least the current field)
const fieldKeysToValidate = [`${field}[${index}]`]
for (let i = index + 1; i <= (lastIndex ?? 0); i++) {
fieldKeysToValidate.push(`${field}[${i}]`)
}

// We also have to include all fields that are nested in the shifted fields
const fieldsToValidate = Object.keys(this.fieldInfo).filter((fieldKey) =>
fieldKeysToValidate.some((key) => fieldKey.startsWith(key)),
) as DeepKeys<TFormData>[]

// Validate the fields
const fieldValidationPromises: Promise<ValidationError[]>[] = [] as any
this.store.batch(() => {
fieldsToValidate.forEach((nestedField) => {
fieldValidationPromises.push(
Promise.resolve().then(() => this.validateField(nestedField, cause)),
)
})
})

const fieldErrorMapMap = await Promise.all(fieldValidationPromises)
return fieldErrorMapMap.flat()
}

validateField = <TField extends DeepKeys<TFormData>>(
field: TField,
cause: ValidationCause,
) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const fieldInstance = this.fieldInfo[field]?.instance
if (!fieldInstance) return []

// If the field is not touched (same logic as in validateAllFields)
if (!fieldInstance.state.meta.isTouched) {
// Mark it as touched
fieldInstance.setMeta((prev) => ({ ...prev, isTouched: true }))
}

return fieldInstance.validate(cause)
}

// TODO: This code is copied from FieldApi, we should refactor to share
validateSync = (cause: ValidationCause) => {
const validates = getSyncValidatorArray(cause, this.options)
Expand Down Expand Up @@ -689,14 +742,15 @@ export class FormApi<
: never,
opts?: { touch?: boolean },
) => {
return this.setFieldValue(
this.setFieldValue(
field,
(prev) => [...(Array.isArray(prev) ? prev : []), value] as any,
opts,
)
this.validateField(field, 'change')
}

insertFieldValue = <TField extends DeepKeys<TFormData>>(
insertFieldValue = async <TField extends DeepKeys<TFormData>>(
field: TField,
index: number,
value: DeepValue<TFormData, TField> extends any[]
Expand All @@ -713,6 +767,10 @@ export class FormApi<
},
opts,
)

// Validate the whole array + all fields that have shifted
await this.validateField(field, 'change')
await this.validateArrayFieldsStartingFrom(field, index, 'change')
}

removeFieldValue = async <TField extends DeepKeys<TFormData>>(
Expand Down Expand Up @@ -746,7 +804,9 @@ export class FormApi<
fieldsToDelete.forEach((f) => this.deleteField(f as TField))
}

await this.validateAllFields('change')
// Validate the whole array + all fields that have shifted
await this.validateField(field, 'change')
await this.validateArrayFieldsStartingFrom(field, index, 'change')
}

swapFieldValues = <TField extends DeepKeys<TFormData>>(
Expand All @@ -759,6 +819,12 @@ export class FormApi<
const prev2 = prev[index2]!
return setBy(setBy(prev, `${index1}`, prev2), `${index2}`, prev1)
})

// Validate the whole array
this.validateField(field, 'change')
// Validate the swapped fields
this.validateField(`${field}[${index1}]` as DeepKeys<TFormData>, 'change')
this.validateField(`${field}[${index2}]` as DeepKeys<TFormData>, 'change')
}

moveFieldValues = <TField extends DeepKeys<TFormData>>(
Expand All @@ -770,6 +836,12 @@ export class FormApi<
prev.splice(index2, 0, prev.splice(index1, 1)[0])
return prev
})

// Validate the whole array
this.validateField(field, 'change')
// Validate the moved fields
this.validateField(`${field}[${index1}]` as DeepKeys<TFormData>, 'change')
this.validateField(`${field}[${index2}]` as DeepKeys<TFormData>, 'change')
}
}

Expand Down
157 changes: 157 additions & 0 deletions packages/form-core/src/tests/FieldApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,35 @@ describe('field api', () => {
expect(field.getValue()).toStrictEqual(['one', 'other'])
})

it('should run onChange validation when pushing an array fields value', async () => {
const form = new FormApi({
defaultValues: {
names: ['test'],
},
})
form.mount()

const field = new FieldApi({
form,
name: 'names',
validators: {
onChange: ({ value }) => {
if (value.length < 3) {
return 'At least 3 names are required'
}
return
},
},
})
field.mount()

field.pushValue('other')

expect(field.getMeta().errors).toStrictEqual([
'At least 3 names are required',
])
})

it('should insert a value into an array value correctly', () => {
const form = new FormApi({
defaultValues: {
Expand All @@ -124,6 +153,38 @@ describe('field api', () => {
expect(field.getValue()).toStrictEqual(['one', 'other'])
})

it('should run onChange validation when inserting an array fields value', () => {
const form = new FormApi({
defaultValues: {
names: ['test'],
},
})
form.mount()

const field = new FieldApi({
form,
name: 'names',
validators: {
onChange: ({ value }) => {
if (value.length < 3) {
return 'At least 3 names are required'
}
return
},
},
defaultMeta: {
isTouched: true,
},
})
field.mount()

field.insertValue(1, 'other')

expect(field.getMeta().errors).toStrictEqual([
'At least 3 names are required',
])
})

it('should remove a value from an array value correctly', () => {
const form = new FormApi({
defaultValues: {
Expand All @@ -141,6 +202,38 @@ describe('field api', () => {
expect(field.getValue()).toStrictEqual(['one'])
})

it('should run onChange validation when removing an array fields value', async () => {
const form = new FormApi({
defaultValues: {
names: ['test'],
},
})
form.mount()

const field = new FieldApi({
form,
name: 'names',
validators: {
onChange: ({ value }) => {
if (value.length < 3) {
return 'At least 3 names are required'
}
return
},
},
defaultMeta: {
isTouched: true,
},
})
field.mount()

await field.removeValue(0)

expect(field.getMeta().errors).toStrictEqual([
'At least 3 names are required',
])
})

it('should remove a subfield from an array field correctly', async () => {
const form = new FormApi({
defaultValues: {
Expand Down Expand Up @@ -269,6 +362,38 @@ describe('field api', () => {
expect(field.getValue()).toStrictEqual(['two', 'one'])
})

it('should run onChange validation when swapping an array fields value', () => {
const form = new FormApi({
defaultValues: {
names: ['test', 'test2'],
},
})
form.mount()

const field = new FieldApi({
form,
name: 'names',
validators: {
onChange: ({ value }) => {
if (value.length < 3) {
return 'At least 3 names are required'
}
return
},
},
defaultMeta: {
isTouched: true,
},
})
field.mount()

field.swapValues(0, 1)

expect(field.getMeta().errors).toStrictEqual([
'At least 3 names are required',
])
})

it('should move a value from an array value correctly', () => {
const form = new FormApi({
defaultValues: {
Expand All @@ -286,6 +411,38 @@ describe('field api', () => {
expect(field.getValue()).toStrictEqual(['three', 'one', 'two', 'four'])
})

it('should run onChange validation when moving an array fields value', () => {
const form = new FormApi({
defaultValues: {
names: ['test', 'test2'],
},
})
form.mount()

const field = new FieldApi({
form,
name: 'names',
validators: {
onChange: ({ value }) => {
if (value.length < 3) {
return 'At least 3 names are required'
}
return
},
},
defaultMeta: {
isTouched: true,
},
})
field.mount()

field.moveValue(0, 1)

expect(field.getMeta().errors).toStrictEqual([
'At least 3 names are required',
])
})

it('should not throw errors when no meta info is stored on a field and a form re-renders', async () => {
const form = new FormApi({
defaultValues: {
Expand Down
Loading

0 comments on commit 0201e65

Please sign in to comment.