From 22868fbbe899b932fd9547d1e0d1d2e3cbe47221 Mon Sep 17 00:00:00 2001 From: Joshua Gawenda Date: Sun, 12 May 2024 20:04:05 +0200 Subject: [PATCH 01/18] fix(FormApi): missing onChange validation after array field helper methods --- packages/form-core/src/FormApi.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index e085c1986..b8d2244b3 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -689,11 +689,12 @@ export class FormApi< : never, opts?: { touch?: boolean }, ) => { - return this.setFieldValue( + this.setFieldValue( field, (prev) => [...(Array.isArray(prev) ? prev : []), value] as any, opts, ) + this.validate('change') } insertFieldValue = >( @@ -713,6 +714,7 @@ export class FormApi< }, opts, ) + this.validate('change') } removeFieldValue = >( @@ -729,6 +731,7 @@ export class FormApi< }, opts, ) + this.validate('change') } swapFieldValues = >( @@ -741,6 +744,7 @@ export class FormApi< const prev2 = prev[index2]! return setBy(setBy(prev, `${index1}`, prev2), `${index2}`, prev1) }) + this.validate('change') } moveFieldValues = >( @@ -752,6 +756,7 @@ export class FormApi< prev.splice(index2, 0, prev.splice(index1, 1)[0]) return prev }) + this.validate('change') } } From 7afd72ae132941b0bdef527a0b8200f5d3cf252d Mon Sep 17 00:00:00 2001 From: Joshua Gawenda Date: Sun, 12 May 2024 20:04:54 +0200 Subject: [PATCH 02/18] test(FormApi): onChange validation after array field helper methods + add missing moveFieldValues test --- packages/form-core/src/tests/FormApi.spec.ts | 94 ++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/packages/form-core/src/tests/FormApi.spec.ts b/packages/form-core/src/tests/FormApi.spec.ts index 2429111b6..6dbebc0fd 100644 --- a/packages/form-core/src/tests/FormApi.spec.ts +++ b/packages/form-core/src/tests/FormApi.spec.ts @@ -324,6 +324,22 @@ 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() + 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 +352,22 @@ 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() + form.insertFieldValue('names', 1, 'other') + + expect(form.state.errors).toStrictEqual(["At least 3 names are required"]) + }) + it("should remove an array field's value", () => { const form = new FormApi({ defaultValues: { @@ -348,6 +380,22 @@ 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() + form.removeFieldValue('names', 0) + + expect(form.state.errors).toStrictEqual(["At least 1 name is required"]) + }) + it("should swap an array field's value", () => { const form = new FormApi({ defaultValues: { @@ -360,6 +408,52 @@ describe('form api', () => { 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() + expect(form.state.errors).toStrictEqual([]) + form.swapFieldValues('names', 1, 2) + + expect(form.state.errors).toStrictEqual(["At least 3 names are required"]) + }) + + 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() + expect(form.state.errors).toStrictEqual([]) + form.moveFieldValues('names', 0, 1) + + expect(form.state.errors).toStrictEqual(["At least 3 names are required"]) + }) + it('should handle fields inside an array', async () => { interface Employee { firstName: string From f4508c8bc715d8aa82e3d771446f5b2331a07bc0 Mon Sep 17 00:00:00 2001 From: Joshua Gawenda Date: Sun, 12 May 2024 20:09:22 +0200 Subject: [PATCH 03/18] style(FormApi): Run linter --- packages/form-core/src/tests/FormApi.spec.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/form-core/src/tests/FormApi.spec.ts b/packages/form-core/src/tests/FormApi.spec.ts index 6dbebc0fd..e33b8da19 100644 --- a/packages/form-core/src/tests/FormApi.spec.ts +++ b/packages/form-core/src/tests/FormApi.spec.ts @@ -337,7 +337,7 @@ describe('form api', () => { form.mount() form.pushFieldValue('names', 'other') - expect(form.state.errors).toStrictEqual(["At least 3 names are required"]) + expect(form.state.errors).toStrictEqual(['At least 3 names are required']) }) it("should insert an array field's value", () => { @@ -355,7 +355,7 @@ describe('form api', () => { it("should run onChange validation when inserting an array field's value", () => { const form = new FormApi({ defaultValues: { - names: ["test"], + names: ['test'], }, validators: { onChange: ({ value }) => @@ -365,7 +365,7 @@ describe('form api', () => { form.mount() form.insertFieldValue('names', 1, 'other') - expect(form.state.errors).toStrictEqual(["At least 3 names are required"]) + expect(form.state.errors).toStrictEqual(['At least 3 names are required']) }) it("should remove an array field's value", () => { @@ -383,7 +383,7 @@ describe('form api', () => { it("should run onChange validation when removing an array field's value", () => { const form = new FormApi({ defaultValues: { - names: ["test"], + names: ['test'], }, validators: { onChange: ({ value }) => @@ -393,7 +393,7 @@ describe('form api', () => { form.mount() form.removeFieldValue('names', 0) - expect(form.state.errors).toStrictEqual(["At least 1 name is required"]) + expect(form.state.errors).toStrictEqual(['At least 1 name is required']) }) it("should swap an array field's value", () => { @@ -411,7 +411,7 @@ describe('form api', () => { it("should run onChange validation when swapping an array field's value", () => { const form = new FormApi({ defaultValues: { - names: ["test", "test2"], + names: ['test', 'test2'], }, validators: { onChange: ({ value }) => @@ -422,7 +422,7 @@ describe('form api', () => { expect(form.state.errors).toStrictEqual([]) form.swapFieldValues('names', 1, 2) - expect(form.state.errors).toStrictEqual(["At least 3 names are required"]) + expect(form.state.errors).toStrictEqual(['At least 3 names are required']) }) it("should move an array field's value", () => { @@ -440,7 +440,7 @@ describe('form api', () => { it("should run onChange validation when moving an array field's value", () => { const form = new FormApi({ defaultValues: { - names: ["test", "test2"], + names: ['test', 'test2'], }, validators: { onChange: ({ value }) => @@ -451,7 +451,7 @@ describe('form api', () => { expect(form.state.errors).toStrictEqual([]) form.moveFieldValues('names', 0, 1) - expect(form.state.errors).toStrictEqual(["At least 3 names are required"]) + expect(form.state.errors).toStrictEqual(['At least 3 names are required']) }) it('should handle fields inside an array', async () => { From 4aaecd9ee4137db8b64d43169d79caf01b94c763 Mon Sep 17 00:00:00 2001 From: Joshua Gawenda Date: Sun, 12 May 2024 20:45:22 +0200 Subject: [PATCH 04/18] feat(FormApi): add helper function to validate a single field on a form --- packages/form-core/src/FormApi.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index b8d2244b3..631e04f15 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -370,6 +370,23 @@ export class FormApi< 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) @@ -695,6 +712,7 @@ export class FormApi< opts, ) this.validate('change') + this.validateField(field, 'change') } insertFieldValue = >( @@ -715,6 +733,7 @@ export class FormApi< opts, ) this.validate('change') + this.validateField(field, 'change') } removeFieldValue = >( @@ -732,6 +751,7 @@ export class FormApi< opts, ) this.validate('change') + this.validateField(field, 'change') } swapFieldValues = >( @@ -745,6 +765,7 @@ export class FormApi< return setBy(setBy(prev, `${index1}`, prev2), `${index2}`, prev1) }) this.validate('change') + this.validateField(field, 'change') } moveFieldValues = >( @@ -757,6 +778,7 @@ export class FormApi< return prev }) this.validate('change') + this.validateField(field, 'change') } } From c679d6f45416efc83a5bc22ecb13bf91f63959ba Mon Sep 17 00:00:00 2001 From: Joshua Gawenda Date: Sun, 12 May 2024 20:52:23 +0200 Subject: [PATCH 05/18] test(FieldApi): onChange validation after array field helper methods --- packages/form-core/src/tests/FieldApi.spec.ts | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/packages/form-core/src/tests/FieldApi.spec.ts b/packages/form-core/src/tests/FieldApi.spec.ts index 293832339..40c1e1845 100644 --- a/packages/form-core/src/tests/FieldApi.spec.ts +++ b/packages/form-core/src/tests/FieldApi.spec.ts @@ -107,6 +107,41 @@ describe('field api', () => { expect(field.getValue()).toStrictEqual(['one', 'other']) }) + it.only('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 }) => { + console.log('value', value) + if (value.length < 3) { + return 'At least 3 names are required' + } + return + }, + }, + defaultMeta: { + isTouched: true, + }, + }) + field.mount() + + field.pushValue('other') + + await new Promise(r => setTimeout(r, 0)) + + 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 +159,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 +208,38 @@ describe('field api', () => { expect(field.getValue()).toStrictEqual(['one']) }) + it('should run onChange validation when removing 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.removeValue(0) + + expect(field.getMeta().errors).toStrictEqual([ + 'At least 3 names are required', + ]) + }) + it('should swap a value from an array value correctly', () => { const form = new FormApi({ defaultValues: { @@ -158,6 +257,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: { @@ -175,6 +306,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: { From e773f987b8d82d8a4460e48c8c7c1532a6323c8e Mon Sep 17 00:00:00 2001 From: Joshua Gawenda Date: Sun, 12 May 2024 20:52:41 +0200 Subject: [PATCH 06/18] test(FieldApi): single field validation --- packages/form-core/src/tests/FormApi.spec.ts | 26 ++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/form-core/src/tests/FormApi.spec.ts b/packages/form-core/src/tests/FormApi.spec.ts index e33b8da19..b762d4ec1 100644 --- a/packages/form-core/src/tests/FormApi.spec.ts +++ b/packages/form-core/src/tests/FormApi.spec.ts @@ -1107,6 +1107,32 @@ describe('form api', () => { expect(field.getMeta().errorMap.onChange).toEqual('first name is required') }) + it("should validate a single field consistently", 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', + }, + }) + + 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: { From cdc1fde71749806084ccdf936461fd9f3d206e9c Mon Sep 17 00:00:00 2001 From: Joshua Gawenda Date: Sun, 12 May 2024 20:53:19 +0200 Subject: [PATCH 07/18] style: Run linter --- packages/form-core/src/tests/FieldApi.spec.ts | 2 -- packages/form-core/src/tests/FormApi.spec.ts | 6 +++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/form-core/src/tests/FieldApi.spec.ts b/packages/form-core/src/tests/FieldApi.spec.ts index 40c1e1845..aeeeee2f9 100644 --- a/packages/form-core/src/tests/FieldApi.spec.ts +++ b/packages/form-core/src/tests/FieldApi.spec.ts @@ -135,8 +135,6 @@ describe('field api', () => { field.pushValue('other') - await new Promise(r => setTimeout(r, 0)) - expect(field.getMeta().errors).toStrictEqual([ 'At least 3 names are required', ]) diff --git a/packages/form-core/src/tests/FormApi.spec.ts b/packages/form-core/src/tests/FormApi.spec.ts index b762d4ec1..832f29e92 100644 --- a/packages/form-core/src/tests/FormApi.spec.ts +++ b/packages/form-core/src/tests/FormApi.spec.ts @@ -1107,7 +1107,7 @@ describe('form api', () => { expect(field.getMeta().errorMap.onChange).toEqual('first name is required') }) - it("should validate a single field consistently", async () => { + it('should validate a single field consistently', async () => { const form = new FormApi({ defaultValues: { firstName: '', @@ -1127,9 +1127,9 @@ describe('form api', () => { field.mount() form.mount() - await form.validateField("firstName", 'change') + await form.validateField('firstName', 'change') expect(field.getMeta().errorMap.onChange).toEqual('first name is required') - await form.validateField("firstName", 'change') + await form.validateField('firstName', 'change') expect(field.getMeta().errorMap.onChange).toEqual('first name is required') }) From 475996419a5c9a61d0f99f6952aa5a9c101058d8 Mon Sep 17 00:00:00 2001 From: Joshua Gawenda Date: Sun, 12 May 2024 20:58:43 +0200 Subject: [PATCH 08/18] docs(FormApi): add `validateField` to references documentation --- docs/reference/formApi.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/reference/formApi.md b/docs/reference/formApi.md index 95b432426..b20e4f27b 100644 --- a/docs/reference/formApi.md +++ b/docs/reference/formApi.md @@ -137,6 +137,10 @@ 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 + 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() From 184a2e56ab0857ab669e8a57f6f6f8d5bf8fd67c Mon Sep 17 00:00:00 2001 From: Joshua Gawenda Date: Mon, 20 May 2024 14:43:53 +0200 Subject: [PATCH 09/18] chore: Remove log + only test --- packages/form-core/src/tests/FieldApi.spec.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/form-core/src/tests/FieldApi.spec.ts b/packages/form-core/src/tests/FieldApi.spec.ts index aeeeee2f9..3aee7c35f 100644 --- a/packages/form-core/src/tests/FieldApi.spec.ts +++ b/packages/form-core/src/tests/FieldApi.spec.ts @@ -107,7 +107,7 @@ describe('field api', () => { expect(field.getValue()).toStrictEqual(['one', 'other']) }) - it.only('should run onChange validation when pushing an array fields value', async () => { + it('should run onChange validation when pushing an array fields value', async () => { const form = new FormApi({ defaultValues: { names: ['test'], @@ -120,7 +120,6 @@ describe('field api', () => { name: 'names', validators: { onChange: ({ value }) => { - console.log('value', value) if (value.length < 3) { return 'At least 3 names are required' } From 54867b5467a07b128c6b0528aa10b7fe7fe2fb70 Mon Sep 17 00:00:00 2001 From: Joshua Gawenda Date: Thu, 30 May 2024 22:55:36 +0200 Subject: [PATCH 10/18] chore: fix failing tests after merge --- packages/form-core/src/FormApi.ts | 3 ++- packages/form-core/src/tests/FieldApi.spec.ts | 4 ++-- packages/form-core/src/tests/FormApi.spec.ts | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index a42c52a82..191c57373 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -768,7 +768,8 @@ export class FormApi< } this.validate('change') - this.validateField(field, 'change') + // Here all fields have to be validated, since the indexes in the meta map have changed + await this.validateAllFields('change') } swapFieldValues = >( diff --git a/packages/form-core/src/tests/FieldApi.spec.ts b/packages/form-core/src/tests/FieldApi.spec.ts index c1c42c866..d35216337 100644 --- a/packages/form-core/src/tests/FieldApi.spec.ts +++ b/packages/form-core/src/tests/FieldApi.spec.ts @@ -205,7 +205,7 @@ describe('field api', () => { expect(field.getValue()).toStrictEqual(['one']) }) - it('should run onChange validation when removing an array fields value', () => { + it('should run onChange validation when removing an array fields value', async () => { const form = new FormApi({ defaultValues: { names: ['test'], @@ -230,7 +230,7 @@ describe('field api', () => { }) field.mount() - field.removeValue(0) + await field.removeValue(0) expect(field.getMeta().errors).toStrictEqual([ 'At least 3 names are required', diff --git a/packages/form-core/src/tests/FormApi.spec.ts b/packages/form-core/src/tests/FormApi.spec.ts index 85c8485da..44e0aeee3 100644 --- a/packages/form-core/src/tests/FormApi.spec.ts +++ b/packages/form-core/src/tests/FormApi.spec.ts @@ -380,7 +380,7 @@ describe('form api', () => { expect(form.getFieldValue('names')).toStrictEqual(['one', 'three']) }) - it("should run onChange validation when removing an array field's value", () => { + it("should run onChange validation when removing an array field's value", async () => { const form = new FormApi({ defaultValues: { names: ['test'], @@ -391,7 +391,7 @@ describe('form api', () => { }, }) form.mount() - form.removeFieldValue('names', 0) + await form.removeFieldValue('names', 0) expect(form.state.errors).toStrictEqual(['At least 1 name is required']) }) From c87b69f3b6805a21605b1374b94c0c8675ff60c3 Mon Sep 17 00:00:00 2001 From: Joshua Gawenda Date: Fri, 31 May 2024 12:04:45 +0200 Subject: [PATCH 11/18] chore(core): prevent field validation from forcing the `isTouched` state --- packages/form-core/src/FormApi.ts | 6 ----- packages/form-core/src/tests/FieldApi.spec.ts | 27 +++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 191c57373..fc834d58e 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -378,12 +378,6 @@ export class FormApi< 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) } diff --git a/packages/form-core/src/tests/FieldApi.spec.ts b/packages/form-core/src/tests/FieldApi.spec.ts index d35216337..0aa2676c4 100644 --- a/packages/form-core/src/tests/FieldApi.spec.ts +++ b/packages/form-core/src/tests/FieldApi.spec.ts @@ -188,6 +188,33 @@ describe('field api', () => { ]) }) + it("should not run onChange validation when inserting an array fields value if the field isn't touched", () => { + 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.insertValue(1, 'other') + + expect(field.getMeta().errors).toStrictEqual([]) + }) + it('should remove a value from an array value correctly', () => { const form = new FormApi({ defaultValues: { From de8d8aaf70b07492f4362640cffacf658627cbf5 Mon Sep 17 00:00:00 2001 From: Joshua Gawenda Date: Fri, 31 May 2024 20:08:15 +0200 Subject: [PATCH 12/18] chore(core): fix tests --- packages/form-core/src/tests/FormApi.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/form-core/src/tests/FormApi.spec.ts b/packages/form-core/src/tests/FormApi.spec.ts index 44e0aeee3..8c4afdf4a 100644 --- a/packages/form-core/src/tests/FormApi.spec.ts +++ b/packages/form-core/src/tests/FormApi.spec.ts @@ -1146,7 +1146,7 @@ describe('form api', () => { expect(field.getMeta().errorMap.onChange).toEqual('first name is required') }) - it('should validate a single field consistently', async () => { + it('should validate a single field consistently if touched', async () => { const form = new FormApi({ defaultValues: { firstName: '', @@ -1161,6 +1161,9 @@ describe('form api', () => { onChange: ({ value }) => value.length > 0 ? undefined : 'first name is required', }, + defaultMeta: { + isTouched: true, + } }) field.mount() From 7c92b8d6a560b8063300428611a8807c5081afd1 Mon Sep 17 00:00:00 2001 From: Joshua Gawenda Date: Sun, 2 Jun 2024 12:27:59 +0200 Subject: [PATCH 13/18] chore(core): remove redundant async notation --- packages/form-core/src/FieldApi.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 44254bd8e..46d698740 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) From dbeea0f730220a9fe9731c40d6e9b72df09d0a2f Mon Sep 17 00:00:00 2001 From: Joshua Gawenda Date: Sun, 2 Jun 2024 12:32:10 +0200 Subject: [PATCH 14/18] chore(core): touch fields during validation again --- packages/form-core/src/FormApi.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index fc834d58e..191c57373 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -378,6 +378,12 @@ export class FormApi< 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) } From 6412d2dac5bb1cfb48df66e6ed28933af3ae89e8 Mon Sep 17 00:00:00 2001 From: Joshua Gawenda Date: Sun, 2 Jun 2024 12:32:36 +0200 Subject: [PATCH 15/18] chore(core): add method to validate array fields sub-fields from a given index --- packages/form-core/src/FormApi.ts | 36 +++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 191c57373..648e24a59 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -370,6 +370,42 @@ 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, From 72816d640d9dff05031ca995572a9478ac3f6ff5 Mon Sep 17 00:00:00 2001 From: Joshua Gawenda Date: Sun, 2 Jun 2024 12:33:36 +0200 Subject: [PATCH 16/18] fix(core): validate array + array subfields when using helper functions --- packages/form-core/src/FormApi.ts | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 648e24a59..870164285 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -747,11 +747,10 @@ export class FormApi< (prev) => [...(Array.isArray(prev) ? prev : []), value] as any, opts, ) - this.validate('change') this.validateField(field, 'change') } - insertFieldValue = >( + insertFieldValue = async >( field: TField, index: number, value: DeepValue extends any[] @@ -768,8 +767,10 @@ export class FormApi< }, opts, ) - this.validate('change') - this.validateField(field, 'change') + + // Validate the whole array + all fields that have shifted + await this.validateField(field, 'change') + await this.validateArrayFieldsStartingFrom(field, index, 'change') } removeFieldValue = async >( @@ -803,9 +804,9 @@ export class FormApi< fieldsToDelete.forEach((f) => this.deleteField(f as TField)) } - this.validate('change') - // Here all fields have to be validated, since the indexes in the meta map have changed - 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 = >( @@ -818,8 +819,12 @@ export class FormApi< const prev2 = prev[index2]! return setBy(setBy(prev, `${index1}`, prev2), `${index2}`, prev1) }) - this.validate('change') + + // 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 = >( @@ -831,8 +836,12 @@ export class FormApi< prev.splice(index2, 0, prev.splice(index1, 1)[0]) return prev }) - this.validate('change') + + // 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') } } From 60a20182719e9408f2b9c3f8901f7f22435591dd Mon Sep 17 00:00:00 2001 From: Joshua Gawenda Date: Sun, 2 Jun 2024 12:33:54 +0200 Subject: [PATCH 17/18] test(core): adjust test to fixes in array helper function validation --- packages/form-core/src/tests/FieldApi.spec.ts | 30 --- packages/form-core/src/tests/FormApi.spec.ts | 189 +++++++++++++++++- 2 files changed, 186 insertions(+), 33 deletions(-) diff --git a/packages/form-core/src/tests/FieldApi.spec.ts b/packages/form-core/src/tests/FieldApi.spec.ts index 0aa2676c4..a35109f86 100644 --- a/packages/form-core/src/tests/FieldApi.spec.ts +++ b/packages/form-core/src/tests/FieldApi.spec.ts @@ -126,9 +126,6 @@ describe('field api', () => { return }, }, - defaultMeta: { - isTouched: true, - }, }) field.mount() @@ -188,33 +185,6 @@ describe('field api', () => { ]) }) - it("should not run onChange validation when inserting an array fields value if the field isn't touched", () => { - 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.insertValue(1, 'other') - - expect(field.getMeta().errors).toStrictEqual([]) - }) - it('should remove a value from an array value correctly', () => { 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 8c4afdf4a..1390002b0 100644 --- a/packages/form-core/src/tests/FormApi.spec.ts +++ b/packages/form-core/src/tests/FormApi.spec.ts @@ -335,6 +335,9 @@ 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.pushFieldValue('names', 'other') expect(form.state.errors).toStrictEqual(['At least 3 names are required']) @@ -363,11 +366,45 @@ 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.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: { @@ -380,7 +417,7 @@ describe('form api', () => { expect(form.getFieldValue('names')).toStrictEqual(['one', 'three']) }) - it("should run onChange validation when removing an array field's value", async () => { + it("should run onChange validation when removing an array field's value", () => { const form = new FormApi({ defaultValues: { names: ['test'], @@ -391,11 +428,68 @@ describe('form api', () => { }, }) form.mount() - await form.removeFieldValue('names', 0) + // 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: { @@ -403,6 +497,9 @@ 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']) @@ -419,12 +516,55 @@ 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() 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: { @@ -448,12 +588,55 @@ 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() + 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 @@ -1163,7 +1346,7 @@ describe('form api', () => { }, defaultMeta: { isTouched: true, - } + }, }) field.mount() From 43dff3675f639b1b9765ad32de295f9295de787e Mon Sep 17 00:00:00 2001 From: Joshua Gawenda Date: Sun, 2 Jun 2024 12:34:17 +0200 Subject: [PATCH 18/18] docs(core): add ref to new `validateArrayFieldsStartingFrom` function --- docs/reference/formApi.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/reference/formApi.md b/docs/reference/formApi.md index b20e4f27b..04f293db0 100644 --- a/docs/reference/formApi.md +++ b/docs/reference/formApi.md @@ -137,6 +137,10 @@ 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 ```