diff --git a/docs/reference/formApi.md b/docs/reference/formApi.md index 95b43242..04f293db 100644 --- a/docs/reference/formApi.md +++ b/docs/reference/formApi.md @@ -137,6 +137,14 @@ A class representing the Form API. It handles the logic and interactions with th validateAllFields(cause: ValidationCause): Promise ``` - Validates all fields in the form using the correct handlers for a given validation type. +- ```tsx + validateArrayFieldsStartingFrom>(field: TField, index: number, cause: ValidationCause): ValidationError[] | Promise + ``` + - 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>(field: TField, cause: ValidationCause): ValidationError[] | Promise + ``` + - Validates a specified field in the form using the correct handlers for a given validation type. - ```tsx handleSubmit() diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 44254bd8..46d69874 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -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) diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 3be35501..87016428 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -370,6 +370,59 @@ export class FormApi< return fieldErrorMapMap.flat() } + validateArrayFieldsStartingFrom = async >( + 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[] + + // Validate the fields + const fieldValidationPromises: Promise[] = [] 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 = >( + 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) @@ -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 = >( + insertFieldValue = async >( field: TField, index: number, value: DeepValue extends any[] @@ -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 >( @@ -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 = >( @@ -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, 'change') + this.validateField(`${field}[${index2}]` as DeepKeys, 'change') } moveFieldValues = >( @@ -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, 'change') + this.validateField(`${field}[${index2}]` as DeepKeys, 'change') } } diff --git a/packages/form-core/src/tests/FieldApi.spec.ts b/packages/form-core/src/tests/FieldApi.spec.ts index 36023b93..a35109f8 100644 --- a/packages/form-core/src/tests/FieldApi.spec.ts +++ b/packages/form-core/src/tests/FieldApi.spec.ts @@ -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: { @@ -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: { @@ -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: { @@ -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: { @@ -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: { diff --git a/packages/form-core/src/tests/FormApi.spec.ts b/packages/form-core/src/tests/FormApi.spec.ts index c9f402b6..1390002b 100644 --- a/packages/form-core/src/tests/FormApi.spec.ts +++ b/packages/form-core/src/tests/FormApi.spec.ts @@ -324,6 +324,25 @@ describe('form api', () => { expect(form.getFieldValue('names')).toStrictEqual(['test', 'other']) }) + it("should run onChange validation when pushing an array field's value", () => { + const form = new FormApi({ + defaultValues: { + names: ['test'], + }, + validators: { + onChange: ({ value }) => + value.names.length > 3 ? undefined : 'At least 3 names are required', + }, + }) + form.mount() + // Since validation runs through the field, a field must be mounted for that array + new FieldApi({ form, name: 'names' }).mount() + + form.pushFieldValue('names', 'other') + + expect(form.state.errors).toStrictEqual(['At least 3 names are required']) + }) + it("should insert an array field's value", () => { const form = new FormApi({ defaultValues: { @@ -336,6 +355,56 @@ describe('form api', () => { expect(form.getFieldValue('names')).toStrictEqual(['one', 'other', 'three']) }) + it("should run onChange validation when inserting an array field's value", () => { + const form = new FormApi({ + defaultValues: { + names: ['test'], + }, + validators: { + onChange: ({ value }) => + value.names.length > 3 ? undefined : 'At least 3 names are required', + }, + }) + form.mount() + // Since validation runs through the field, a field must be mounted for that array + new FieldApi({ form, name: 'names' }).mount() + + form.insertFieldValue('names', 1, 'other') + + expect(form.state.errors).toStrictEqual(['At least 3 names are required']) + }) + + it("should validate all shifted fields when inserting an array field's value", async () => { + const form = new FormApi({ + defaultValues: { + names: [{ first: 'test' }, { first: 'test2' }], + }, + validators: { + onChange: ({ value }) => + value.names.length > 3 ? undefined : 'At least 3 names are required', + }, + }) + form.mount() + // Since validation runs through the field, a field must be mounted for that array + new FieldApi({ form, name: 'names' }).mount() + + const field1 = new FieldApi({ + form, + name: 'names[0].first', + defaultValue: 'test', + validators: { + onChange: ({ value }) => value !== 'test' && 'Invalid value', + }, + }) + field1.mount() + + expect(field1.state.meta.errors).toStrictEqual([]) + + await form.insertFieldValue('names', 0, { first: 'other' }) + + expect(field1.state.meta.errors).toStrictEqual(['Invalid value']) + }) + it("should remove an array field's value", () => { const form = new FormApi({ defaultValues: { @@ -348,6 +417,79 @@ describe('form api', () => { expect(form.getFieldValue('names')).toStrictEqual(['one', 'three']) }) + it("should run onChange validation when removing an array field's value", () => { + const form = new FormApi({ + defaultValues: { + names: ['test'], + }, + validators: { + onChange: ({ value }) => + value.names.length > 1 ? undefined : 'At least 1 name is required', + }, + }) + form.mount() + // Since validation runs through the field, a field must be mounted for that array + new FieldApi({ form, name: 'names' }).mount() + + form.removeFieldValue('names', 0) + + expect(form.state.errors).toStrictEqual(['At least 1 name is required']) + }) + + it("should validate following fields when removing an array field's value", async () => { + const form = new FormApi({ + defaultValues: { + names: ['test', 'test2', 'test3'], + }, + validators: { + onChange: ({ value }) => + value.names.length > 1 ? undefined : 'At least 1 name is required', + }, + }) + form.mount() + // Since validation runs through the field, a field must be mounted for that array + new FieldApi({ form, name: 'names' }).mount() + + const field1 = new FieldApi({ + form, + name: 'names[0]', + defaultValue: 'test', + validators: { + onChange: ({ value }) => value !== 'test' && 'Invalid value', + }, + }) + field1.mount() + const field2 = new FieldApi({ + form, + name: 'names[1]', + defaultValue: 'test2', + validators: { + onChange: ({ value }) => value !== 'test2' && 'Invalid value', + }, + }) + field2.mount() + const field3 = new FieldApi({ + form, + name: 'names[2]', + defaultValue: 'test3', + validators: { + onChange: ({ value }) => value !== 'test3' && 'Invalid value', + }, + }) + field3.mount() + + expect(field1.state.meta.errors).toStrictEqual([]) + expect(field2.state.meta.errors).toStrictEqual([]) + expect(field3.state.meta.errors).toStrictEqual([]) + + await form.removeFieldValue('names', 1) + + expect(field1.state.meta.errors).toStrictEqual([]) + expect(field2.state.meta.errors).toStrictEqual(['Invalid value']) + // This field does not exist anymore. Therefore, its validation should also not run + expect(field3.state.meta.errors).toStrictEqual([]) + }) + it("should swap an array field's value", () => { const form = new FormApi({ defaultValues: { @@ -355,11 +497,146 @@ describe('form api', () => { }, }) form.mount() + // Since validation runs through the field, a field must be mounted for that array + new FieldApi({ form, name: 'names' }).mount() + form.swapFieldValues('names', 1, 2) expect(form.getFieldValue('names')).toStrictEqual(['one', 'three', 'two']) }) + it("should run onChange validation when swapping an array field's value", () => { + const form = new FormApi({ + defaultValues: { + names: ['test', 'test2'], + }, + validators: { + onChange: ({ value }) => + value.names.length > 3 ? undefined : 'At least 3 names are required', + }, + }) + form.mount() + // Since validation runs through the field, a field must be mounted for that array + new FieldApi({ form, name: 'names' }).mount() + expect(form.state.errors).toStrictEqual([]) + + form.swapFieldValues('names', 1, 2) + + expect(form.state.errors).toStrictEqual(['At least 3 names are required']) + }) + + it('should run validation on swapped fields', () => { + const form = new FormApi({ + defaultValues: { + names: ['test', 'test2'], + }, + validators: { + onChange: ({ value }) => + value.names.length > 3 ? undefined : 'At least 3 names are required', + }, + }) + form.mount() + // Since validation runs through the field, a field must be mounted for that array + new FieldApi({ form, name: 'names' }).mount() + + const field1 = new FieldApi({ + form, + name: 'names[0]', + defaultValue: 'test', + validators: { + onChange: ({ value }) => value !== 'test' && 'Invalid value', + }, + }) + field1.mount() + + const field2 = new FieldApi({ + form, + name: 'names[1]', + defaultValue: 'test2', + }) + field2.mount() + + expect(field1.state.meta.errors).toStrictEqual([]) + expect(field2.state.meta.errors).toStrictEqual([]) + + form.swapFieldValues('names', 0, 1) + + expect(field1.state.meta.errors).toStrictEqual(['Invalid value']) + expect(field2.state.meta.errors).toStrictEqual([]) + }) + + it("should move an array field's value", () => { + const form = new FormApi({ + defaultValues: { + names: ['one', 'two', 'three'], + }, + }) + form.mount() + form.moveFieldValues('names', 1, 2) + + expect(form.getFieldValue('names')).toStrictEqual(['one', 'three', 'two']) + }) + + it("should run onChange validation when moving an array field's value", () => { + const form = new FormApi({ + defaultValues: { + names: ['test', 'test2'], + }, + validators: { + onChange: ({ value }) => + value.names.length > 3 ? undefined : 'At least 3 names are required', + }, + }) + form.mount() + // Since validation runs through the field, a field must be mounted for that array + new FieldApi({ form, name: 'names' }).mount() + + expect(form.state.errors).toStrictEqual([]) + form.moveFieldValues('names', 0, 1) + + expect(form.state.errors).toStrictEqual(['At least 3 names are required']) + }) + + it('should run validation on moved fields', () => { + const form = new FormApi({ + defaultValues: { + names: ['test', 'test2'], + }, + validators: { + onChange: ({ value }) => + value.names.length > 3 ? undefined : 'At least 3 names are required', + }, + }) + form.mount() + // Since validation runs through the field, a field must be mounted for that array + new FieldApi({ form, name: 'names' }).mount() + + const field1 = new FieldApi({ + form, + name: 'names[0]', + defaultValue: 'test', + validators: { + onChange: ({ value }) => value !== 'test' && 'Invalid value', + }, + }) + field1.mount() + + const field2 = new FieldApi({ + form, + name: 'names[1]', + defaultValue: 'test2', + }) + field2.mount() + + expect(field1.state.meta.errors).toStrictEqual([]) + expect(field2.state.meta.errors).toStrictEqual([]) + + form.swapFieldValues('names', 0, 1) + + expect(field1.state.meta.errors).toStrictEqual(['Invalid value']) + expect(field2.state.meta.errors).toStrictEqual([]) + }) + it('should handle fields inside an array', async () => { interface Employee { firstName: string @@ -1052,6 +1329,35 @@ describe('form api', () => { expect(field.getMeta().errorMap.onChange).toEqual('first name is required') }) + it('should validate a single field consistently if touched', async () => { + const form = new FormApi({ + defaultValues: { + firstName: '', + lastName: '', + }, + }) + + const field = new FieldApi({ + form, + name: 'firstName', + validators: { + onChange: ({ value }) => + value.length > 0 ? undefined : 'first name is required', + }, + defaultMeta: { + isTouched: true, + }, + }) + + field.mount() + form.mount() + + await form.validateField('firstName', 'change') + expect(field.getMeta().errorMap.onChange).toEqual('first name is required') + await form.validateField('firstName', 'change') + expect(field.getMeta().errorMap.onChange).toEqual('first name is required') + }) + it('should show onSubmit errors', async () => { const form = new FormApi({ defaultValues: {