From d898b311b83365c1e3c791f311cf3d4882d945fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Magalh=C3=A3es?= Date: Sat, 2 Sep 2023 11:57:22 -0300 Subject: [PATCH 1/5] test(form-core): add async validation tests --- packages/form-core/src/tests/FieldApi.spec.ts | 193 ++++++++++++++++-- packages/form-core/src/tests/utils.ts | 5 + 2 files changed, 186 insertions(+), 12 deletions(-) create mode 100644 packages/form-core/src/tests/utils.ts diff --git a/packages/form-core/src/tests/FieldApi.spec.ts b/packages/form-core/src/tests/FieldApi.spec.ts index 7869446e4..84d2488f7 100644 --- a/packages/form-core/src/tests/FieldApi.spec.ts +++ b/packages/form-core/src/tests/FieldApi.spec.ts @@ -2,6 +2,7 @@ import { expect } from 'vitest' import { FormApi } from '../FormApi' import { FieldApi } from '../FieldApi' +import { sleep } from './utils' describe('field api', () => { it('should have an initial value', () => { @@ -151,7 +152,30 @@ describe('field api', () => { expect(subfield.getValue()).toBe('one') }) - it('should run validation onChange', async () => { + 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: { + name: 'test', + }, + }) + + const field = new FieldApi({ + form, + name: 'name', + }) + + field.mount() + + expect(() => + form.update({ + defaultValues: { + name: 'other', + }, + }), + ).not.toThrow() + }) + + it('should run validation onChange', () => { const form = new FormApi({ defaultValues: { name: 'test', @@ -162,10 +186,33 @@ describe('field api', () => { form, name: 'name', onChange: (value) => { - if (value === 'other') { - return 'Please enter a different value' - } + if (value === 'other') return 'Please enter a different value' + return + }, + }) + + field.mount() + + expect(field.getMeta().error).toBeUndefined() + field.setValue('other', { touch: true }) + expect(field.getMeta().error).toBe('Please enter a different value') + }) + + it('should run async validation onChange', async () => { + vi.useFakeTimers() + + const form = new FormApi({ + defaultValues: { + name: 'test', + }, + }) + const field = new FieldApi({ + form, + name: 'name', + onChangeAsync: async (value) => { + await sleep(1000) + if (value === 'other') return 'Please enter a different value' return }, }) @@ -174,10 +221,14 @@ describe('field api', () => { expect(field.getMeta().error).toBeUndefined() field.setValue('other', { touch: true }) + await vi.runAllTimersAsync() expect(field.getMeta().error).toBe('Please enter a different value') }) - it('should not throw errors when no meta info is stored on a field and a form re-renders', async () => { + it('should run async validation onChange with debounce', async () => { + vi.useFakeTimers() + const sleepMock = vi.fn().mockImplementation(sleep) + const form = new FormApi({ defaultValues: { name: 'test', @@ -187,16 +238,134 @@ describe('field api', () => { const field = new FieldApi({ form, name: 'name', + onChangeAsyncDebounceMs: 1000, + onChangeAsync: async (value) => { + await sleepMock(1000) + if (value === 'other') return 'Please enter a different value' + return + }, }) field.mount() - expect(() => - form.update({ - defaultValues: { - name: 'other', - }, - }), - ).not.toThrow() + expect(field.getMeta().error).toBeUndefined() + field.setValue('other', { touch: true }) + field.setValue('other') + await vi.runAllTimersAsync() + // sleepMock will have been called 2 times without onChangeAsyncDebounceMs + expect(sleepMock).toHaveBeenCalledTimes(1) + expect(field.getMeta().error).toBe('Please enter a different value') + }) + + it('should run validation onBlur', () => { + const form = new FormApi({ + defaultValues: { + name: 'other', + }, + }) + + const field = new FieldApi({ + form, + name: 'name', + onBlur: (value) => { + if (value === 'other') return 'Please enter a different value' + return + }, + }) + + field.mount() + + field.setValue('other', { touch: true }) + field.validate('blur') + expect(field.getMeta().error).toBe('Please enter a different value') + }) + + it('should run async validation onBlur', async () => { + vi.useFakeTimers() + + const form = new FormApi({ + defaultValues: { + name: 'test', + }, + }) + + const field = new FieldApi({ + form, + name: 'name', + onBlurAsync: async (value) => { + await sleep(1000) + if (value === 'other') return 'Please enter a different value' + return + }, + }) + + field.mount() + + expect(field.getMeta().error).toBeUndefined() + field.setValue('other', { touch: true }) + field.validate('blur') + await vi.runAllTimersAsync() + expect(field.getMeta().error).toBe('Please enter a different value') + }) + + it('should run async validation onBlur with debounce', async () => { + vi.useFakeTimers() + const sleepMock = vi.fn().mockImplementation(sleep) + + const form = new FormApi({ + defaultValues: { + name: 'test', + }, + }) + + const field = new FieldApi({ + form, + name: 'name', + onBlurAsyncDebounceMs: 1000, + onBlurAsync: async (value) => { + await sleepMock(10) + if (value === 'other') return 'Please enter a different value' + return + }, + }) + + field.mount() + + expect(field.getMeta().error).toBeUndefined() + field.setValue('other', { touch: true }) + field.validate('blur') + field.validate('blur') + await vi.runAllTimersAsync() + // sleepMock will have been called 2 times without onBlurAsyncDebounceMs + expect(sleepMock).toHaveBeenCalledTimes(1) + expect(field.getMeta().error).toBe('Please enter a different value') + }) + + it('should run async validation onSubmit', async () => { + vi.useFakeTimers() + + const form = new FormApi({ + defaultValues: { + name: 'test', + }, + }) + + const field = new FieldApi({ + form, + name: 'name', + onSubmitAsync: async (value) => { + await sleep(1000) + if (value === 'other') return 'Please enter a different value' + return + }, + }) + + field.mount() + + expect(field.getMeta().error).toBeUndefined() + field.setValue('other', { touch: true }) + field.validate('submit') + await vi.runAllTimersAsync() + expect(field.getMeta().error).toBe('Please enter a different value') }) }) diff --git a/packages/form-core/src/tests/utils.ts b/packages/form-core/src/tests/utils.ts new file mode 100644 index 000000000..1a3a619a2 --- /dev/null +++ b/packages/form-core/src/tests/utils.ts @@ -0,0 +1,5 @@ +export function sleep(timeout: number): Promise { + return new Promise((resolve, _reject) => { + setTimeout(resolve, timeout) + }) +} From cd7dbc11ec3acdc529d77df10dc1f990f8a7bfcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Magalh=C3=A3es?= Date: Sat, 2 Sep 2023 12:00:32 -0300 Subject: [PATCH 2/5] chore(form-core): remove old asyncDebounceMs api --- packages/form-core/src/FieldApi.ts | 17 ++++++----------- packages/form-core/src/FormApi.ts | 5 ++--- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 925a096e2..0a3432c7d 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -30,7 +30,6 @@ export interface FieldOptions< name: TName index?: TData extends any[] ? number : never defaultValue?: TData - asyncDebounceMs?: number asyncAlways?: boolean onMount?: (formApi: FieldApi) => void onChange?: ValidateFn @@ -186,7 +185,6 @@ export class FieldApi { update = (opts: FieldApiOptions) => { this.options = { - asyncDebounceMs: this.form.options.asyncDebounceMs ?? 0, onChangeAsyncDebounceMs: this.form.options.onChangeAsyncDebounceMs ?? 0, onBlurAsyncDebounceMs: this.form.options.onBlurAsyncDebounceMs ?? 0, ...opts, @@ -199,12 +197,12 @@ export class FieldApi { this.setValue(this.options.defaultValue as never) } else if ( opts.form.options.defaultValues?.[ - this.options.name as keyof TFormData + this.options.name as keyof TFormData ] !== undefined ) { this.setValue( opts.form.options.defaultValues[ - this.options.name as keyof TFormData + this.options.name as keyof TFormData ] as never, ) } @@ -313,7 +311,6 @@ export class FieldApi { onChangeAsync, onBlurAsync, onSubmitAsync, - asyncDebounceMs, onBlurAsyncDebounceMs, onChangeAsyncDebounceMs, } = this.options @@ -322,8 +319,8 @@ export class FieldApi { cause === 'change' ? onChangeAsync : cause === 'submit' - ? onSubmitAsync - : onBlurAsync + ? onSubmitAsync + : onBlurAsync if (!validate) return @@ -331,10 +328,8 @@ export class FieldApi { cause === 'submit' ? 0 : (cause === 'change' - ? onChangeAsyncDebounceMs - : onBlurAsyncDebounceMs) ?? - asyncDebounceMs ?? - 500 + ? onChangeAsyncDebounceMs + : onBlurAsyncDebounceMs) ?? 0 if (this.state.meta.isValidating !== true) this.setMeta((prev) => ({ ...prev, isValidating: true })) diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 74bffba24..9aa123ae6 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -17,7 +17,6 @@ export type FormSubmitEvent = Register extends { export type FormOptions = { defaultValues?: TData defaultState?: Partial> - asyncDebounceMs?: number onMount?: (values: TData, formApi: FormApi) => ValidationError onMountAsync?: ( values: TData, @@ -180,8 +179,8 @@ export class FormApi { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition shouldUpdateValues ? { - values: options.defaultValues, - } + values: options.defaultValues, + } : {}, ), ), From c4f4d3e13d1510c1af1f75e72f8ec88848e2789e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Magalh=C3=A3es?= Date: Sat, 2 Sep 2023 12:18:52 -0300 Subject: [PATCH 3/5] Revert "chore(form-core): remove old asyncDebounceMs api" This reverts commit cd7dbc11ec3acdc529d77df10dc1f990f8a7bfcf. --- packages/form-core/src/FieldApi.ts | 17 +++++++++++------ packages/form-core/src/FormApi.ts | 5 +++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 0a3432c7d..925a096e2 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -30,6 +30,7 @@ export interface FieldOptions< name: TName index?: TData extends any[] ? number : never defaultValue?: TData + asyncDebounceMs?: number asyncAlways?: boolean onMount?: (formApi: FieldApi) => void onChange?: ValidateFn @@ -185,6 +186,7 @@ export class FieldApi { update = (opts: FieldApiOptions) => { this.options = { + asyncDebounceMs: this.form.options.asyncDebounceMs ?? 0, onChangeAsyncDebounceMs: this.form.options.onChangeAsyncDebounceMs ?? 0, onBlurAsyncDebounceMs: this.form.options.onBlurAsyncDebounceMs ?? 0, ...opts, @@ -197,12 +199,12 @@ export class FieldApi { this.setValue(this.options.defaultValue as never) } else if ( opts.form.options.defaultValues?.[ - this.options.name as keyof TFormData + this.options.name as keyof TFormData ] !== undefined ) { this.setValue( opts.form.options.defaultValues[ - this.options.name as keyof TFormData + this.options.name as keyof TFormData ] as never, ) } @@ -311,6 +313,7 @@ export class FieldApi { onChangeAsync, onBlurAsync, onSubmitAsync, + asyncDebounceMs, onBlurAsyncDebounceMs, onChangeAsyncDebounceMs, } = this.options @@ -319,8 +322,8 @@ export class FieldApi { cause === 'change' ? onChangeAsync : cause === 'submit' - ? onSubmitAsync - : onBlurAsync + ? onSubmitAsync + : onBlurAsync if (!validate) return @@ -328,8 +331,10 @@ export class FieldApi { cause === 'submit' ? 0 : (cause === 'change' - ? onChangeAsyncDebounceMs - : onBlurAsyncDebounceMs) ?? 0 + ? onChangeAsyncDebounceMs + : onBlurAsyncDebounceMs) ?? + asyncDebounceMs ?? + 500 if (this.state.meta.isValidating !== true) this.setMeta((prev) => ({ ...prev, isValidating: true })) diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 9aa123ae6..74bffba24 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -17,6 +17,7 @@ export type FormSubmitEvent = Register extends { export type FormOptions = { defaultValues?: TData defaultState?: Partial> + asyncDebounceMs?: number onMount?: (values: TData, formApi: FormApi) => ValidationError onMountAsync?: ( values: TData, @@ -179,8 +180,8 @@ export class FormApi { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition shouldUpdateValues ? { - values: options.defaultValues, - } + values: options.defaultValues, + } : {}, ), ), From fc4f668b4a80896d61a00f0e56c98beea7e86a1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Magalh=C3=A3es?= Date: Sat, 2 Sep 2023 12:30:42 -0300 Subject: [PATCH 4/5] fix(form-core): make sure to fallback to asyncDebounceMs --- packages/form-core/src/FieldApi.ts | 18 +++-- packages/form-core/src/tests/FieldApi.spec.ts | 65 +++++++++++++++++++ 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 925a096e2..cb5aee2e9 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -187,8 +187,6 @@ export class FieldApi { update = (opts: FieldApiOptions) => { this.options = { asyncDebounceMs: this.form.options.asyncDebounceMs ?? 0, - onChangeAsyncDebounceMs: this.form.options.onChangeAsyncDebounceMs ?? 0, - onBlurAsyncDebounceMs: this.form.options.onBlurAsyncDebounceMs ?? 0, ...opts, } as never @@ -199,12 +197,12 @@ export class FieldApi { this.setValue(this.options.defaultValue as never) } else if ( opts.form.options.defaultValues?.[ - this.options.name as keyof TFormData + this.options.name as keyof TFormData ] !== undefined ) { this.setValue( opts.form.options.defaultValues[ - this.options.name as keyof TFormData + this.options.name as keyof TFormData ] as never, ) } @@ -322,8 +320,8 @@ export class FieldApi { cause === 'change' ? onChangeAsync : cause === 'submit' - ? onSubmitAsync - : onBlurAsync + ? onSubmitAsync + : onBlurAsync if (!validate) return @@ -331,10 +329,10 @@ export class FieldApi { cause === 'submit' ? 0 : (cause === 'change' - ? onChangeAsyncDebounceMs - : onBlurAsyncDebounceMs) ?? - asyncDebounceMs ?? - 500 + ? onChangeAsyncDebounceMs + : onBlurAsyncDebounceMs) ?? + asyncDebounceMs ?? + 500 if (this.state.meta.isValidating !== true) this.setMeta((prev) => ({ ...prev, isValidating: true })) diff --git a/packages/form-core/src/tests/FieldApi.spec.ts b/packages/form-core/src/tests/FieldApi.spec.ts index 84d2488f7..c23a3eee7 100644 --- a/packages/form-core/src/tests/FieldApi.spec.ts +++ b/packages/form-core/src/tests/FieldApi.spec.ts @@ -257,6 +257,38 @@ describe('field api', () => { expect(field.getMeta().error).toBe('Please enter a different value') }) + it('should run async validation onChange with asyncDebounceMs', async () => { + vi.useFakeTimers() + const sleepMock = vi.fn().mockImplementation(sleep) + + const form = new FormApi({ + defaultValues: { + name: 'test', + }, + }) + + const field = new FieldApi({ + form, + name: 'name', + asyncDebounceMs: 1000, + onChangeAsync: async (value) => { + await sleepMock(1000) + if (value === 'other') return 'Please enter a different value' + return + }, + }) + + field.mount() + + expect(field.getMeta().error).toBeUndefined() + field.setValue('other', { touch: true }) + field.setValue('other') + await vi.runAllTimersAsync() + // sleepMock will have been called 2 times without asyncDebounceMs + expect(sleepMock).toHaveBeenCalledTimes(1) + expect(field.getMeta().error).toBe('Please enter a different value') + }) + it('should run validation onBlur', () => { const form = new FormApi({ defaultValues: { @@ -341,6 +373,39 @@ describe('field api', () => { expect(field.getMeta().error).toBe('Please enter a different value') }) + it('should run async validation onBlur with asyncDebounceMs', async () => { + vi.useFakeTimers() + const sleepMock = vi.fn().mockImplementation(sleep) + + const form = new FormApi({ + defaultValues: { + name: 'test', + }, + }) + + const field = new FieldApi({ + form, + name: 'name', + asyncDebounceMs: 1000, + onBlurAsync: async (value) => { + await sleepMock(10) + if (value === 'other') return 'Please enter a different value' + return + }, + }) + + field.mount() + + expect(field.getMeta().error).toBeUndefined() + field.setValue('other', { touch: true }) + field.validate('blur') + field.validate('blur') + await vi.runAllTimersAsync() + // sleepMock will have been called 2 times without asyncDebounceMs + expect(sleepMock).toHaveBeenCalledTimes(1) + expect(field.getMeta().error).toBe('Please enter a different value') + }) + it('should run async validation onSubmit', async () => { vi.useFakeTimers() From 59f4dc6f3cf8de6b1ff3042a3461370fb1fca1fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Magalh=C3=A3es?= Date: Sun, 3 Sep 2023 11:55:50 -0300 Subject: [PATCH 5/5] chore(form-core): remove 500ms fallback --- packages/form-core/src/FieldApi.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index cb5aee2e9..ade4c18d6 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -197,12 +197,12 @@ export class FieldApi { this.setValue(this.options.defaultValue as never) } else if ( opts.form.options.defaultValues?.[ - this.options.name as keyof TFormData + this.options.name as keyof TFormData ] !== undefined ) { this.setValue( opts.form.options.defaultValues[ - this.options.name as keyof TFormData + this.options.name as keyof TFormData ] as never, ) } @@ -320,8 +320,8 @@ export class FieldApi { cause === 'change' ? onChangeAsync : cause === 'submit' - ? onSubmitAsync - : onBlurAsync + ? onSubmitAsync + : onBlurAsync if (!validate) return @@ -329,10 +329,10 @@ export class FieldApi { cause === 'submit' ? 0 : (cause === 'change' - ? onChangeAsyncDebounceMs - : onBlurAsyncDebounceMs) ?? - asyncDebounceMs ?? - 500 + ? onChangeAsyncDebounceMs + : onBlurAsyncDebounceMs) ?? + asyncDebounceMs ?? + 0 if (this.state.meta.isValidating !== true) this.setMeta((prev) => ({ ...prev, isValidating: true }))