From 2fc941ed761e66d188de6d5f7fa033230fcb881c Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sun, 5 Nov 2023 05:14:29 -0800 Subject: [PATCH] feat: add back form validation (#505) * initial attempt to add back form validation * uncomment tests * fixed form validation not running * onChange + onBlur * feat: mount method on FormApi * fix solid-form test case * fix checkLatest * add onMount logic + test * fix: run mount on proper API * test: add React Form onChange validation tests --------- Co-authored-by: aadito123 Co-authored-by: aadito123 <63646058+aadito123@users.noreply.github.com> --- packages/form-core/src/FieldApi.ts | 12 +- packages/form-core/src/FormApi.ts | 236 +++++++++++- packages/form-core/src/tests/FormApi.spec.ts | 352 ++++++++++++++++++ .../react-form/src/tests/useForm.test.tsx | 45 +++ packages/react-form/src/useForm.tsx | 2 +- packages/vue-form/src/useForm.tsx | 3 +- 6 files changed, 632 insertions(+), 18 deletions(-) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 418719d..2eff78d 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1,7 +1,7 @@ -import { type DeepKeys, type DeepValue, type Updater } from './utils' -import type { FormApi, ValidationErrorMap } from './FormApi' import { Store } from '@tanstack/store' -import type { Validator, ValidationError } from './types' +import type { FormApi, ValidationErrorMap } from './FormApi' +import type { ValidationError, Validator } from './types' +import type { DeepKeys, DeepValue, Updater } from './utils' export type ValidationCause = 'change' | 'blur' | 'submit' | 'mount' @@ -495,7 +495,7 @@ export class FieldApi< } // Always return the latest validation promise to the caller - return this.getInfo().validationPromise ?? [] + return (await this.getInfo().validationPromise) ?? [] } validate = ( @@ -505,6 +505,10 @@ export class FieldApi< // If the field is pristine and validatePristine is false, do not validate if (!this.state.meta.isTouched) return [] + try { + this.form.validate(cause) + } catch (_) {} + // Store the previous error for the errorMapKey (eg. onChange, onBlur, onSubmit) const errorMapKey = getErrorMapKey(cause) const prevError = this.getMeta().errorMap[errorMapKey] diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 1cefa60..977c276 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -21,6 +21,7 @@ type ValidateAsyncFn = ( export type FormOptions = { defaultValues?: TData defaultState?: Partial> + asyncAlways?: boolean asyncDebounceMs?: number validator?: ValidatorType onMount?: ValidateOrFn @@ -47,8 +48,8 @@ export type FieldInfo = { export type ValidationMeta = { validationCount?: number validationAsyncCount?: number - validationPromise?: Promise - validationResolve?: (errors: ValidationError[]) => void + validationPromise?: Promise + validationResolve?: (errors: ValidationError[] | undefined) => void validationReject?: (errors: unknown) => void } @@ -64,7 +65,8 @@ export type FormState = { isFormValidating: boolean formValidationCount: number isFormValid: boolean - formError?: ValidationError + errors: ValidationError[] + errorMap: ValidationErrorMap // Fields fieldMeta: Record, FieldMeta> isFieldsValidating: boolean @@ -84,6 +86,8 @@ function getDefaultFormState( ): FormState { return { values: defaultState.values ?? ({} as never), + errors: defaultState.errors ?? [], + errorMap: defaultState.errorMap ?? {}, fieldMeta: defaultState.fieldMeta ?? ({} as never), canSubmit: defaultState.canSubmit ?? true, isFieldsValid: defaultState.isFieldsValid ?? false, @@ -141,7 +145,10 @@ export class FormApi { const isTouched = fieldMetaValues.some((field) => field?.isTouched) const isValidating = isFieldsValidating || state.isFormValidating - const isFormValid = !state.formError + state.errors = Object.values(state.errorMap).filter( + (val: unknown) => val !== undefined, + ) + const isFormValid = state.errors.length === 0 const isValid = isFieldsValid && isFormValid const canSubmit = (state.submissionAttempts === 0 && !isTouched) || @@ -169,14 +176,23 @@ export class FormApi { } mount = () => { - if (typeof this.options.onMount === 'function') { - return this.options.onMount(this.state.values, this) + const doValidate = () => { + if (typeof this.options.onMount === 'function') { + return this.options.onMount(this.state.values, this) + } + if (this.options.validator) { + return (this.options.validator as Validator)().validate( + this.state.values, + this.options.onMount, + ) + } } - if (this.options.validator) { - return (this.options.validator as Validator)().validate( - this.state.values, - this.options.onMount, - ) + const error = doValidate() + if (error) { + this.store.setState((prev) => ({ + ...prev, + errorMap: { ...prev.errorMap, onMount: error }, + })) } } @@ -245,6 +261,177 @@ export class FormApi { return Promise.all(fieldValidationPromises) } + validateSync = (cause: ValidationCause): void => { + const { onChange, onBlur } = this.options + const validate = + cause === 'change' ? onChange : cause === 'blur' ? onBlur : undefined + if (!validate) return + + const errorMapKey = getErrorMapKey(cause) + const doValidate = () => { + if (typeof validate === 'function') { + return validate(this.state.values, this) as ValidationError + } + if (this.options.validator && typeof validate !== 'function') { + return (this.options.validator as Validator)().validate( + this.state.values, + validate, + ) + } + throw new Error( + `Form validation for ${errorMapKey} failed. ${errorMapKey} should either be a function, or \`validator\` should be correct.`, + ) + } + + const error = normalizeError(doValidate()) + if (this.state.errorMap[errorMapKey] !== error) { + this.store.setState((prev) => ({ + ...prev, + errorMap: { + ...prev.errorMap, + [errorMapKey]: error, + }, + })) + } + + if (this.state.errorMap[errorMapKey]) { + this.cancelValidateAsync() + } + } + + __leaseValidateAsync = () => { + const count = (this.validationMeta.validationAsyncCount || 0) + 1 + this.validationMeta.validationAsyncCount = count + return count + } + + cancelValidateAsync = () => { + // Lease a new validation count to ignore any pending validations + this.__leaseValidateAsync() + // Cancel any pending validation state + this.store.setState((prev) => ({ + ...prev, + isFormValidating: false, + })) + } + + validateAsync = async ( + cause: ValidationCause, + ): Promise => { + const { + onChangeAsync, + onBlurAsync, + asyncDebounceMs, + onBlurAsyncDebounceMs, + onChangeAsyncDebounceMs, + } = this.options + + const validate = + cause === 'change' + ? onChangeAsync + : cause === 'blur' + ? onBlurAsync + : undefined + + if (!validate) return [] + const debounceMs = + (cause === 'change' ? onChangeAsyncDebounceMs : onBlurAsyncDebounceMs) ?? + asyncDebounceMs ?? + 0 + + if (!this.state.isFormValidating) { + this.store.setState((prev) => ({ ...prev, isFormValidating: true })) + } + + // Use the validationCount for all field instances to + // track freshness of the validation + const validationAsyncCount = this.__leaseValidateAsync() + + const checkLatest = () => + validationAsyncCount === this.validationMeta.validationAsyncCount + + if (!this.validationMeta.validationPromise) { + this.validationMeta.validationPromise = new Promise((resolve, reject) => { + this.validationMeta.validationResolve = resolve + this.validationMeta.validationReject = reject + }) + } + + if (debounceMs > 0) { + await new Promise((r) => setTimeout(r, debounceMs)) + } + + const doValidate = () => { + if (typeof validate === 'function') { + return validate(this.state.values, this) as ValidationError + } + if (this.options.validator && typeof validate !== 'function') { + return (this.options.validator as Validator)().validateAsync( + this.state.values, + validate, + ) + } + const errorMapKey = getErrorMapKey(cause) + throw new Error( + `Form validation for ${errorMapKey}Async failed. ${errorMapKey}Async should either be a function, or \`validator\` should be correct.`, + ) + } + + // Only kick off validation if this validation is the latest attempt + if (checkLatest()) { + const prevErrors = this.state.errors + try { + const rawError = await doValidate() + if (checkLatest()) { + const error = normalizeError(rawError) + this.store.setState((prev) => ({ + ...prev, + isFormValidating: false, + errorMap: { + ...prev.errorMap, + [getErrorMapKey(cause)]: error, + }, + })) + this.validationMeta.validationResolve?.([...prevErrors, error]) + } + } catch (error) { + if (checkLatest()) { + this.validationMeta.validationReject?.([...prevErrors, error]) + throw error + } + } finally { + if (checkLatest()) { + this.store.setState((prev) => ({ ...prev, isFormValidating: false })) + delete this.validationMeta.validationPromise + } + } + } + // Always return the latest validation promise to the caller + return (await this.validationMeta.validationPromise) ?? [] + } + + validate = ( + cause: ValidationCause, + ): ValidationError[] | Promise => { + // Store the previous error for the errorMapKey (eg. onChange, onBlur, onSubmit) + const errorMapKey = getErrorMapKey(cause) + const prevError = this.state.errorMap[errorMapKey] + + // Attempt to sync validate first + this.validateSync(cause) + + const newError = this.state.errorMap[errorMapKey] + if ( + prevError !== newError && + !this.options.asyncAlways && + !(newError === undefined && prevError !== undefined) + ) + return this.state.errors + + // No error? Attempt async validation + return this.validateAsync(cause) + } + handleSubmit = async () => { // Check to see that the form and all fields have been touched // If they have not, touch them all and run validation @@ -279,7 +466,7 @@ export class FormApi { } // Run validation for the form - // await this.validateForm() + await this.validate('submit') if (!this.state.isValid) { done() @@ -428,3 +615,28 @@ export class FormApi { }) } } + +function normalizeError(rawError?: ValidationError) { + if (rawError) { + if (typeof rawError !== 'string') { + return 'Invalid Form Values' + } + + return rawError + } + + return undefined +} + +function getErrorMapKey(cause: ValidationCause) { + switch (cause) { + case 'submit': + return 'onSubmit' + case 'change': + return 'onChange' + case 'blur': + return 'onBlur' + case 'mount': + return 'onMount' + } +} diff --git a/packages/form-core/src/tests/FormApi.spec.ts b/packages/form-core/src/tests/FormApi.spec.ts index 43a24e1..fcdb866 100644 --- a/packages/form-core/src/tests/FormApi.spec.ts +++ b/packages/form-core/src/tests/FormApi.spec.ts @@ -2,6 +2,7 @@ import { expect } from 'vitest' import { FormApi } from '../FormApi' import { FieldApi } from '../FieldApi' +import { sleep } from './utils' describe('form api', () => { it('should get default form state', () => { @@ -16,6 +17,8 @@ describe('form api', () => { isFormValid: true, isFormValidating: false, isSubmitted: false, + errors: [], + errorMap: {}, isSubmitting: false, isTouched: false, isValid: true, @@ -39,6 +42,8 @@ describe('form api', () => { fieldMeta: {}, canSubmit: true, isFieldsValid: true, + errors: [], + errorMap: {}, isFieldsValidating: false, isFormValid: true, isFormValidating: false, @@ -62,6 +67,8 @@ describe('form api', () => { expect(form.state).toEqual({ values: {}, fieldMeta: {}, + errors: [], + errorMap: {}, canSubmit: true, isFieldsValid: true, isFieldsValidating: false, @@ -97,6 +104,8 @@ describe('form api', () => { values: { name: 'other', }, + errors: [], + errorMap: {}, fieldMeta: {}, canSubmit: true, isFieldsValid: true, @@ -129,6 +138,8 @@ describe('form api', () => { values: { name: 'test', }, + errors: [], + errorMap: {}, fieldMeta: {}, canSubmit: true, isFieldsValid: true, @@ -316,4 +327,345 @@ describe('form api', () => { expect(form.state.isFieldsValid).toEqual(true) expect(form.state.canSubmit).toEqual(true) }) + + it('should run validation onChange', () => { + const form = new FormApi({ + defaultValues: { + name: 'test', + }, + onChange: (value) => { + if (value.name === 'other') return 'Please enter a different value' + return + }, + }) + + const field = new FieldApi({ + form, + name: 'name', + }) + form.mount() + field.mount() + + expect(form.state.errors.length).toBe(0) + field.setValue('other', { touch: true }) + expect(form.state.errors).toContain('Please enter a different value') + expect(form.state.errorMap).toMatchObject({ + onChange: 'Please enter a different value', + }) + }) + + it('should run async validation onChange', async () => { + vi.useFakeTimers() + + const form = new FormApi({ + defaultValues: { + name: 'test', + }, + onChangeAsync: async (value) => { + await sleep(1000) + if (value.name === 'other') return 'Please enter a different value' + return + }, + }) + const field = new FieldApi({ + form, + name: 'name', + }) + form.mount() + + field.mount() + + expect(form.state.errors.length).toBe(0) + field.setValue('other', { touch: true }) + await vi.runAllTimersAsync() + expect(form.state.errors).toContain('Please enter a different value') + expect(form.state.errorMap).toMatchObject({ + onChange: 'Please enter a different value', + }) + }) + + it('should run async validation onChange with debounce', async () => { + vi.useFakeTimers() + const sleepMock = vi.fn().mockImplementation(sleep) + + const form = new FormApi({ + defaultValues: { + name: 'test', + }, + onChangeAsyncDebounceMs: 1000, + onChangeAsync: async (value) => { + await sleepMock(1000) + if (value.name === 'other') return 'Please enter a different value' + return + }, + }) + const field = new FieldApi({ + form, + name: 'name', + }) + form.mount() + + field.mount() + + expect(form.state.errors.length).toBe(0) + 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(form.state.errors).toContain('Please enter a different value') + expect(form.state.errorMap).toMatchObject({ + onChange: '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', + }, + asyncDebounceMs: 1000, + onChangeAsync: async (value) => { + await sleepMock(1000) + if (value.name === 'other') return 'Please enter a different value' + return + }, + }) + const field = new FieldApi({ + form, + name: 'name', + }) + + form.mount() + field.mount() + + expect(form.state.errors.length).toBe(0) + 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(form.state.errors).toContain('Please enter a different value') + expect(form.state.errorMap).toMatchObject({ + onChange: 'Please enter a different value', + }) + }) + + it('should run validation onBlur', () => { + const form = new FormApi({ + defaultValues: { + name: 'other', + }, + onBlur: (value) => { + if (value.name === 'other') return 'Please enter a different value' + return + }, + }) + const field = new FieldApi({ + form, + name: 'name', + }) + + form.mount() + field.mount() + + field.setValue('other', { touch: true }) + field.validate('blur') + expect(form.state.errors).toContain('Please enter a different value') + expect(form.state.errorMap).toMatchObject({ + onBlur: 'Please enter a different value', + }) + }) + + it('should run async validation onBlur', async () => { + vi.useFakeTimers() + + const form = new FormApi({ + defaultValues: { + name: 'test', + }, + onBlurAsync: async (value) => { + await sleep(1000) + if (value.name === 'other') return 'Please enter a different value' + return + }, + }) + const field = new FieldApi({ + form, + name: 'name', + }) + + form.mount() + field.mount() + + expect(form.state.errors.length).toBe(0) + field.setValue('other', { touch: true }) + field.validate('blur') + await vi.runAllTimersAsync() + expect(form.state.errors).toContain('Please enter a different value') + expect(form.state.errorMap).toMatchObject({ + onBlur: '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', + }, + onBlurAsyncDebounceMs: 1000, + onBlurAsync: async (value) => { + await sleepMock(10) + if (value.name === 'other') return 'Please enter a different value' + return + }, + }) + const field = new FieldApi({ + form, + name: 'name', + }) + + form.mount() + field.mount() + + expect(form.state.errors.length).toBe(0) + 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(form.state.errors).toContain('Please enter a different value') + expect(form.state.errorMap).toMatchObject({ + onBlur: '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', + }, + asyncDebounceMs: 1000, + onBlurAsync: async (value) => { + await sleepMock(10) + if (value.name === 'other') return 'Please enter a different value' + return + }, + }) + const field = new FieldApi({ + form, + name: 'name', + }) + + form.mount() + field.mount() + + expect(form.state.errors.length).toBe(0) + 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(form.state.errors).toContain('Please enter a different value') + expect(form.state.errorMap).toMatchObject({ + onBlur: 'Please enter a different value', + }) + }) + + it('should contain multiple errors when running validation onBlur and onChange', () => { + const form = new FormApi({ + defaultValues: { + name: 'other', + }, + onBlur: (value) => { + if (value.name === 'other') return 'Please enter a different value' + return + }, + onChange: (value) => { + if (value.name === 'other') return 'Please enter a different value' + return + }, + }) + const field = new FieldApi({ + form, + name: 'name', + }) + + form.mount() + field.mount() + + field.setValue('other', { touch: true }) + field.validate('blur') + expect(form.state.errors).toStrictEqual([ + 'Please enter a different value', + 'Please enter a different value', + ]) + expect(form.state.errorMap).toEqual({ + onBlur: 'Please enter a different value', + onChange: 'Please enter a different value', + }) + }) + + it('should reset onChange errors when the issue is resolved', () => { + const form = new FormApi({ + defaultValues: { + name: 'other', + }, + onChange: (value) => { + if (value.name === 'other') return 'Please enter a different value' + return + }, + }) + const field = new FieldApi({ + form, + name: 'name', + }) + + form.mount() + field.mount() + + field.setValue('other', { touch: true }) + expect(form.state.errors).toStrictEqual(['Please enter a different value']) + expect(form.state.errorMap).toEqual({ + onChange: 'Please enter a different value', + }) + field.setValue('test', { touch: true }) + expect(form.state.errors).toStrictEqual([]) + expect(form.state.errorMap).toEqual({}) + }) + + it('should return error onMount', () => { + const form = new FormApi({ + defaultValues: { + name: 'other', + }, + onMount: (value) => { + if (value.name === 'other') return 'Please enter a different value' + return + }, + }) + const field = new FieldApi({ + form, + name: 'name', + }) + + form.mount() + field.mount() + + expect(form.state.errors).toStrictEqual(['Please enter a different value']) + expect(form.state.errorMap).toEqual({ + onMount: 'Please enter a different value', + }) + }) }) diff --git a/packages/react-form/src/tests/useForm.test.tsx b/packages/react-form/src/tests/useForm.test.tsx index e008875..c6e8066 100644 --- a/packages/react-form/src/tests/useForm.test.tsx +++ b/packages/react-form/src/tests/useForm.test.tsx @@ -157,4 +157,49 @@ describe('useForm', () => { await user.click(getByText('Mount form')) await waitFor(() => expect(getByText('Form mounted')).toBeInTheDocument()) }) + + it('should validate async on change for the form', async () => { + type Person = { + firstName: string + lastName: string + } + const error = 'Please enter a different value' + + const formFactory = createFormFactory() + + function Comp() { + const form = formFactory.useForm({ + onChange() { + return error + }, + }) + + return ( + + ( + field.handleChange(e.target.value)} + /> + )} + /> + state.errorMap}> + {(errorMap) =>

{errorMap.onChange}

} +
+
+ ) + } + + const { getByTestId, getByText, queryByText } = render() + const input = getByTestId('fieldinput') + expect(queryByText(error)).not.toBeInTheDocument() + await user.type(input, 'other') + await waitFor(() => getByText(error)) + expect(getByText(error)).toBeInTheDocument() + }) }) diff --git a/packages/react-form/src/useForm.tsx b/packages/react-form/src/useForm.tsx index 97f07db..5711db8 100644 --- a/packages/react-form/src/useForm.tsx +++ b/packages/react-form/src/useForm.tsx @@ -31,7 +31,7 @@ export function useForm( const api = new FormApi(opts) api.Provider = function Provider(props) { - useIsomorphicLayoutEffect(formApi.mount, []) + useIsomorphicLayoutEffect(api.mount, []) return } api.Field = Field as any diff --git a/packages/vue-form/src/useForm.tsx b/packages/vue-form/src/useForm.tsx index e6815fd..bb8c0d7 100644 --- a/packages/vue-form/src/useForm.tsx +++ b/packages/vue-form/src/useForm.tsx @@ -40,13 +40,14 @@ export function useForm( api.Provider = defineComponent( (_, context) => { - onMounted(formApi.mount) + onMounted(api.mount) provideFormContext({ formApi: formApi as never }) return () => context.slots.default!() }, { name: 'Provider' }, ) api.provideFormContext = () => { + onMounted(api.mount) provideFormContext({ formApi: formApi as never }) } api.Field = Field as never