diff --git a/examples/react/simple/package.json b/examples/react/simple/package.json index c36571b35..88178d2b1 100644 --- a/examples/react/simple/package.json +++ b/examples/react/simple/package.json @@ -8,15 +8,15 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/react-form": "0.7.1", + "@tanstack/react-form": "0.8.0", "axios": "^0.26.1", "react": "^18.0.0", "react-dom": "^18.0.0", - "@tanstack/form-core": "0.7.1", - "@tanstack/vue-form": "0.7.1", - "@tanstack/zod-form-adapter": "0.7.1", - "@tanstack/yup-form-adapter": "0.7.1", - "@tanstack/solid-form": "0.7.1" + "@tanstack/form-core": "0.8.0", + "@tanstack/vue-form": "0.8.0", + "@tanstack/zod-form-adapter": "0.8.0", + "@tanstack/yup-form-adapter": "0.8.0", + "@tanstack/solid-form": "0.8.0" }, "devDependencies": { "@vitejs/plugin-react": "^4.0.4", diff --git a/examples/react/yup/package.json b/examples/react/yup/package.json index 26266d0fe..469f9239a 100644 --- a/examples/react/yup/package.json +++ b/examples/react/yup/package.json @@ -8,16 +8,16 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/react-form": "0.7.1", + "@tanstack/react-form": "0.8.0", "axios": "^0.26.1", "react": "^18.0.0", "react-dom": "^18.0.0", "yup": "^1.3.2", - "@tanstack/form-core": "0.7.1", - "@tanstack/yup-form-adapter": "0.7.1", - "@tanstack/vue-form": "0.7.1", - "@tanstack/zod-form-adapter": "0.7.1", - "@tanstack/solid-form": "0.7.1" + "@tanstack/form-core": "0.8.0", + "@tanstack/yup-form-adapter": "0.8.0", + "@tanstack/vue-form": "0.8.0", + "@tanstack/zod-form-adapter": "0.8.0", + "@tanstack/solid-form": "0.8.0" }, "devDependencies": { "@vitejs/plugin-react": "^4.0.4", diff --git a/examples/react/zod/package.json b/examples/react/zod/package.json index cb3d1bde4..8bcf9d48a 100644 --- a/examples/react/zod/package.json +++ b/examples/react/zod/package.json @@ -8,16 +8,16 @@ "preview": "vite preview" }, "dependencies": { - "@tanstack/react-form": "0.7.1", + "@tanstack/react-form": "0.8.0", "axios": "^0.26.1", "react": "^18.0.0", "react-dom": "^18.0.0", "zod": "^3.21.4", - "@tanstack/form-core": "0.7.1", - "@tanstack/zod-form-adapter": "0.7.1", - "@tanstack/vue-form": "0.7.1", - "@tanstack/yup-form-adapter": "0.7.1", - "@tanstack/solid-form": "0.7.1" + "@tanstack/form-core": "0.8.0", + "@tanstack/zod-form-adapter": "0.8.0", + "@tanstack/vue-form": "0.8.0", + "@tanstack/yup-form-adapter": "0.8.0", + "@tanstack/solid-form": "0.8.0" }, "devDependencies": { "@vitejs/plugin-react": "^4.0.4", diff --git a/examples/vue/simple/package.json b/examples/vue/simple/package.json index dc372c9c8..b7a7aaa56 100644 --- a/examples/vue/simple/package.json +++ b/examples/vue/simple/package.json @@ -9,13 +9,13 @@ "serve": "vite preview" }, "dependencies": { - "@tanstack/form-core": "0.7.1", - "@tanstack/vue-form": "0.7.1", + "@tanstack/form-core": "0.8.0", + "@tanstack/vue-form": "0.8.0", "vue": "^3.3.4", - "@tanstack/react-form": "0.7.1", - "@tanstack/zod-form-adapter": "0.7.1", - "@tanstack/yup-form-adapter": "0.7.1", - "@tanstack/solid-form": "0.7.1" + "@tanstack/react-form": "0.8.0", + "@tanstack/zod-form-adapter": "0.8.0", + "@tanstack/yup-form-adapter": "0.8.0", + "@tanstack/solid-form": "0.8.0" }, "devDependencies": { "@vitejs/plugin-vue": "^4.3.4", diff --git a/examples/vue/yup/package.json b/examples/vue/yup/package.json index c05140d51..9b15c728f 100644 --- a/examples/vue/yup/package.json +++ b/examples/vue/yup/package.json @@ -9,14 +9,14 @@ "serve": "vite preview" }, "dependencies": { - "@tanstack/form-core": "0.7.1", - "@tanstack/vue-form": "0.7.1", - "@tanstack/yup-form-adapter": "0.7.1", + "@tanstack/form-core": "0.8.0", + "@tanstack/vue-form": "0.8.0", + "@tanstack/yup-form-adapter": "0.8.0", "vue": "^3.3.4", "yup": "^1.3.2", - "@tanstack/react-form": "0.7.1", - "@tanstack/zod-form-adapter": "0.7.1", - "@tanstack/solid-form": "0.7.1" + "@tanstack/react-form": "0.8.0", + "@tanstack/zod-form-adapter": "0.8.0", + "@tanstack/solid-form": "0.8.0" }, "devDependencies": { "@vitejs/plugin-vue": "^4.3.4", diff --git a/examples/vue/zod/package.json b/examples/vue/zod/package.json index f061acedb..ccd8ec4d4 100644 --- a/examples/vue/zod/package.json +++ b/examples/vue/zod/package.json @@ -9,14 +9,14 @@ "serve": "vite preview" }, "dependencies": { - "@tanstack/form-core": "0.7.1", - "@tanstack/vue-form": "0.7.1", - "@tanstack/zod-form-adapter": "0.7.1", + "@tanstack/form-core": "0.8.0", + "@tanstack/vue-form": "0.8.0", + "@tanstack/zod-form-adapter": "0.8.0", "vue": "^3.3.4", "zod": "^3.21.4", - "@tanstack/react-form": "0.7.1", - "@tanstack/yup-form-adapter": "0.7.1", - "@tanstack/solid-form": "0.7.1" + "@tanstack/react-form": "0.8.0", + "@tanstack/yup-form-adapter": "0.8.0", + "@tanstack/solid-form": "0.8.0" }, "devDependencies": { "@vitejs/plugin-vue": "^4.3.4", diff --git a/packages/form-core/package.json b/packages/form-core/package.json index d2309ccba..c1809c26f 100644 --- a/packages/form-core/package.json +++ b/packages/form-core/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/form-core", - "version": "0.7.1", + "version": "0.8.0", "description": "Powerful, type-safe, framework agnostic forms.", "author": "tannerlinsley", "license": "MIT", diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 418719d1c..2eff78d86 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 59a5ed17b..57452c969 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() @@ -360,12 +547,14 @@ export class FormApi { } deleteField = >(field: TField) => { + this.store.setState((prev) => { const newState = { ...prev } delete newState.values[field as keyof TFormData] delete newState.fieldMeta[field] return newState }) + } pushFieldValue = >( @@ -429,3 +618,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 43a24e1a8..fcdb866c4 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/package.json b/packages/react-form/package.json index f76a81d10..17174666e 100644 --- a/packages/react-form/package.json +++ b/packages/react-form/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/react-form", - "version": "0.7.1", + "version": "0.8.0", "description": "Powerful, type-safe forms for React.", "author": "tannerlinsley", "license": "MIT", diff --git a/packages/react-form/src/tests/useForm.test.tsx b/packages/react-form/src/tests/useForm.test.tsx index e0088755d..c6e8066db 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 97f07db73..5711db884 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/solid-form/package.json b/packages/solid-form/package.json index bd1055412..ed95f86ef 100644 --- a/packages/solid-form/package.json +++ b/packages/solid-form/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/solid-form", - "version": "0.7.1", + "version": "0.8.0", "description": "Powerful, type-safe forms for Solid.", "author": "tannerlinsley", "license": "MIT", diff --git a/packages/vue-form/package.json b/packages/vue-form/package.json index 2c4885f60..4541b6e72 100644 --- a/packages/vue-form/package.json +++ b/packages/vue-form/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/vue-form", - "version": "0.7.1", + "version": "0.8.0", "description": "Powerful, type-safe forms for Vue.", "author": "tannerlinsley", "license": "MIT", diff --git a/packages/vue-form/src/useForm.tsx b/packages/vue-form/src/useForm.tsx index e6815fd98..bb8c0d746 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 diff --git a/packages/yup-form-adapter/package.json b/packages/yup-form-adapter/package.json index 8a420c3b4..b4bbbff4e 100644 --- a/packages/yup-form-adapter/package.json +++ b/packages/yup-form-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/yup-form-adapter", - "version": "0.7.1", + "version": "0.8.0", "description": "The Yup adapter for TanStack Form.", "author": "tannerlinsley", "license": "MIT", diff --git a/packages/zod-form-adapter/package.json b/packages/zod-form-adapter/package.json index ea06da2d1..423346ee5 100644 --- a/packages/zod-form-adapter/package.json +++ b/packages/zod-form-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/zod-form-adapter", - "version": "0.7.1", + "version": "0.8.0", "description": "The Zod adapter for TanStack Form.", "author": "tannerlinsley", "license": "MIT",