mirror of
https://github.com/LukeHagar/form.git
synced 2025-12-06 04:19:43 +00:00
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 <aaditolkar123@gmail.com> Co-authored-by: aadito123 <63646058+aadito123@users.noreply.github.com>
This commit is contained in:
@@ -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 { 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'
|
export type ValidationCause = 'change' | 'blur' | 'submit' | 'mount'
|
||||||
|
|
||||||
@@ -495,7 +495,7 @@ export class FieldApi<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Always return the latest validation promise to the caller
|
// Always return the latest validation promise to the caller
|
||||||
return this.getInfo().validationPromise ?? []
|
return (await this.getInfo().validationPromise) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
validate = (
|
validate = (
|
||||||
@@ -505,6 +505,10 @@ export class FieldApi<
|
|||||||
// If the field is pristine and validatePristine is false, do not validate
|
// If the field is pristine and validatePristine is false, do not validate
|
||||||
if (!this.state.meta.isTouched) return []
|
if (!this.state.meta.isTouched) return []
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.form.validate(cause)
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
// Store the previous error for the errorMapKey (eg. onChange, onBlur, onSubmit)
|
// Store the previous error for the errorMapKey (eg. onChange, onBlur, onSubmit)
|
||||||
const errorMapKey = getErrorMapKey(cause)
|
const errorMapKey = getErrorMapKey(cause)
|
||||||
const prevError = this.getMeta().errorMap[errorMapKey]
|
const prevError = this.getMeta().errorMap[errorMapKey]
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type ValidateAsyncFn<TData, ValidatorType> = (
|
|||||||
export type FormOptions<TData, ValidatorType> = {
|
export type FormOptions<TData, ValidatorType> = {
|
||||||
defaultValues?: TData
|
defaultValues?: TData
|
||||||
defaultState?: Partial<FormState<TData>>
|
defaultState?: Partial<FormState<TData>>
|
||||||
|
asyncAlways?: boolean
|
||||||
asyncDebounceMs?: number
|
asyncDebounceMs?: number
|
||||||
validator?: ValidatorType
|
validator?: ValidatorType
|
||||||
onMount?: ValidateOrFn<TData, ValidatorType>
|
onMount?: ValidateOrFn<TData, ValidatorType>
|
||||||
@@ -47,8 +48,8 @@ export type FieldInfo<TFormData, ValidatorType> = {
|
|||||||
export type ValidationMeta = {
|
export type ValidationMeta = {
|
||||||
validationCount?: number
|
validationCount?: number
|
||||||
validationAsyncCount?: number
|
validationAsyncCount?: number
|
||||||
validationPromise?: Promise<ValidationError[]>
|
validationPromise?: Promise<ValidationError[] | undefined>
|
||||||
validationResolve?: (errors: ValidationError[]) => void
|
validationResolve?: (errors: ValidationError[] | undefined) => void
|
||||||
validationReject?: (errors: unknown) => void
|
validationReject?: (errors: unknown) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +65,8 @@ export type FormState<TData> = {
|
|||||||
isFormValidating: boolean
|
isFormValidating: boolean
|
||||||
formValidationCount: number
|
formValidationCount: number
|
||||||
isFormValid: boolean
|
isFormValid: boolean
|
||||||
formError?: ValidationError
|
errors: ValidationError[]
|
||||||
|
errorMap: ValidationErrorMap
|
||||||
// Fields
|
// Fields
|
||||||
fieldMeta: Record<DeepKeys<TData>, FieldMeta>
|
fieldMeta: Record<DeepKeys<TData>, FieldMeta>
|
||||||
isFieldsValidating: boolean
|
isFieldsValidating: boolean
|
||||||
@@ -84,6 +86,8 @@ function getDefaultFormState<TData>(
|
|||||||
): FormState<TData> {
|
): FormState<TData> {
|
||||||
return {
|
return {
|
||||||
values: defaultState.values ?? ({} as never),
|
values: defaultState.values ?? ({} as never),
|
||||||
|
errors: defaultState.errors ?? [],
|
||||||
|
errorMap: defaultState.errorMap ?? {},
|
||||||
fieldMeta: defaultState.fieldMeta ?? ({} as never),
|
fieldMeta: defaultState.fieldMeta ?? ({} as never),
|
||||||
canSubmit: defaultState.canSubmit ?? true,
|
canSubmit: defaultState.canSubmit ?? true,
|
||||||
isFieldsValid: defaultState.isFieldsValid ?? false,
|
isFieldsValid: defaultState.isFieldsValid ?? false,
|
||||||
@@ -141,7 +145,10 @@ export class FormApi<TFormData, ValidatorType> {
|
|||||||
const isTouched = fieldMetaValues.some((field) => field?.isTouched)
|
const isTouched = fieldMetaValues.some((field) => field?.isTouched)
|
||||||
|
|
||||||
const isValidating = isFieldsValidating || state.isFormValidating
|
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 isValid = isFieldsValid && isFormValid
|
||||||
const canSubmit =
|
const canSubmit =
|
||||||
(state.submissionAttempts === 0 && !isTouched) ||
|
(state.submissionAttempts === 0 && !isTouched) ||
|
||||||
@@ -169,14 +176,23 @@ export class FormApi<TFormData, ValidatorType> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mount = () => {
|
mount = () => {
|
||||||
if (typeof this.options.onMount === 'function') {
|
const doValidate = () => {
|
||||||
return this.options.onMount(this.state.values, this)
|
if (typeof this.options.onMount === 'function') {
|
||||||
|
return this.options.onMount(this.state.values, this)
|
||||||
|
}
|
||||||
|
if (this.options.validator) {
|
||||||
|
return (this.options.validator as Validator<TFormData>)().validate(
|
||||||
|
this.state.values,
|
||||||
|
this.options.onMount,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (this.options.validator) {
|
const error = doValidate()
|
||||||
return (this.options.validator as Validator<TFormData>)().validate(
|
if (error) {
|
||||||
this.state.values,
|
this.store.setState((prev) => ({
|
||||||
this.options.onMount,
|
...prev,
|
||||||
)
|
errorMap: { ...prev.errorMap, onMount: error },
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +261,177 @@ export class FormApi<TFormData, ValidatorType> {
|
|||||||
return Promise.all(fieldValidationPromises)
|
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<TFormData>)().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<ValidationError[]> => {
|
||||||
|
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<TFormData>)().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<ValidationError[]> => {
|
||||||
|
// 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 () => {
|
handleSubmit = async () => {
|
||||||
// Check to see that the form and all fields have been touched
|
// Check to see that the form and all fields have been touched
|
||||||
// If they have not, touch them all and run validation
|
// If they have not, touch them all and run validation
|
||||||
@@ -279,7 +466,7 @@ export class FormApi<TFormData, ValidatorType> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run validation for the form
|
// Run validation for the form
|
||||||
// await this.validateForm()
|
await this.validate('submit')
|
||||||
|
|
||||||
if (!this.state.isValid) {
|
if (!this.state.isValid) {
|
||||||
done()
|
done()
|
||||||
@@ -428,3 +615,28 @@ export class FormApi<TFormData, ValidatorType> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { expect } from 'vitest'
|
|||||||
|
|
||||||
import { FormApi } from '../FormApi'
|
import { FormApi } from '../FormApi'
|
||||||
import { FieldApi } from '../FieldApi'
|
import { FieldApi } from '../FieldApi'
|
||||||
|
import { sleep } from './utils'
|
||||||
|
|
||||||
describe('form api', () => {
|
describe('form api', () => {
|
||||||
it('should get default form state', () => {
|
it('should get default form state', () => {
|
||||||
@@ -16,6 +17,8 @@ describe('form api', () => {
|
|||||||
isFormValid: true,
|
isFormValid: true,
|
||||||
isFormValidating: false,
|
isFormValidating: false,
|
||||||
isSubmitted: false,
|
isSubmitted: false,
|
||||||
|
errors: [],
|
||||||
|
errorMap: {},
|
||||||
isSubmitting: false,
|
isSubmitting: false,
|
||||||
isTouched: false,
|
isTouched: false,
|
||||||
isValid: true,
|
isValid: true,
|
||||||
@@ -39,6 +42,8 @@ describe('form api', () => {
|
|||||||
fieldMeta: {},
|
fieldMeta: {},
|
||||||
canSubmit: true,
|
canSubmit: true,
|
||||||
isFieldsValid: true,
|
isFieldsValid: true,
|
||||||
|
errors: [],
|
||||||
|
errorMap: {},
|
||||||
isFieldsValidating: false,
|
isFieldsValidating: false,
|
||||||
isFormValid: true,
|
isFormValid: true,
|
||||||
isFormValidating: false,
|
isFormValidating: false,
|
||||||
@@ -62,6 +67,8 @@ describe('form api', () => {
|
|||||||
expect(form.state).toEqual({
|
expect(form.state).toEqual({
|
||||||
values: {},
|
values: {},
|
||||||
fieldMeta: {},
|
fieldMeta: {},
|
||||||
|
errors: [],
|
||||||
|
errorMap: {},
|
||||||
canSubmit: true,
|
canSubmit: true,
|
||||||
isFieldsValid: true,
|
isFieldsValid: true,
|
||||||
isFieldsValidating: false,
|
isFieldsValidating: false,
|
||||||
@@ -97,6 +104,8 @@ describe('form api', () => {
|
|||||||
values: {
|
values: {
|
||||||
name: 'other',
|
name: 'other',
|
||||||
},
|
},
|
||||||
|
errors: [],
|
||||||
|
errorMap: {},
|
||||||
fieldMeta: {},
|
fieldMeta: {},
|
||||||
canSubmit: true,
|
canSubmit: true,
|
||||||
isFieldsValid: true,
|
isFieldsValid: true,
|
||||||
@@ -129,6 +138,8 @@ describe('form api', () => {
|
|||||||
values: {
|
values: {
|
||||||
name: 'test',
|
name: 'test',
|
||||||
},
|
},
|
||||||
|
errors: [],
|
||||||
|
errorMap: {},
|
||||||
fieldMeta: {},
|
fieldMeta: {},
|
||||||
canSubmit: true,
|
canSubmit: true,
|
||||||
isFieldsValid: true,
|
isFieldsValid: true,
|
||||||
@@ -316,4 +327,345 @@ describe('form api', () => {
|
|||||||
expect(form.state.isFieldsValid).toEqual(true)
|
expect(form.state.isFieldsValid).toEqual(true)
|
||||||
expect(form.state.canSubmit).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',
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -157,4 +157,49 @@ describe('useForm', () => {
|
|||||||
await user.click(getByText('Mount form'))
|
await user.click(getByText('Mount form'))
|
||||||
await waitFor(() => expect(getByText('Form mounted')).toBeInTheDocument())
|
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<Person, unknown>()
|
||||||
|
|
||||||
|
function Comp() {
|
||||||
|
const form = formFactory.useForm({
|
||||||
|
onChange() {
|
||||||
|
return error
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form.Provider>
|
||||||
|
<form.Field
|
||||||
|
name="firstName"
|
||||||
|
children={(field) => (
|
||||||
|
<input
|
||||||
|
data-testid="fieldinput"
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<form.Subscribe selector={(state) => state.errorMap}>
|
||||||
|
{(errorMap) => <p>{errorMap.onChange}</p>}
|
||||||
|
</form.Subscribe>
|
||||||
|
</form.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getByTestId, getByText, queryByText } = render(<Comp />)
|
||||||
|
const input = getByTestId('fieldinput')
|
||||||
|
expect(queryByText(error)).not.toBeInTheDocument()
|
||||||
|
await user.type(input, 'other')
|
||||||
|
await waitFor(() => getByText(error))
|
||||||
|
expect(getByText(error)).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function useForm<TData, FormValidator>(
|
|||||||
const api = new FormApi<TData>(opts)
|
const api = new FormApi<TData>(opts)
|
||||||
|
|
||||||
api.Provider = function Provider(props) {
|
api.Provider = function Provider(props) {
|
||||||
useIsomorphicLayoutEffect(formApi.mount, [])
|
useIsomorphicLayoutEffect(api.mount, [])
|
||||||
return <formContext.Provider {...props} value={{ formApi: api }} />
|
return <formContext.Provider {...props} value={{ formApi: api }} />
|
||||||
}
|
}
|
||||||
api.Field = Field as any
|
api.Field = Field as any
|
||||||
|
|||||||
@@ -40,13 +40,14 @@ export function useForm<TData, FormValidator>(
|
|||||||
|
|
||||||
api.Provider = defineComponent(
|
api.Provider = defineComponent(
|
||||||
(_, context) => {
|
(_, context) => {
|
||||||
onMounted(formApi.mount)
|
onMounted(api.mount)
|
||||||
provideFormContext({ formApi: formApi as never })
|
provideFormContext({ formApi: formApi as never })
|
||||||
return () => context.slots.default!()
|
return () => context.slots.default!()
|
||||||
},
|
},
|
||||||
{ name: 'Provider' },
|
{ name: 'Provider' },
|
||||||
)
|
)
|
||||||
api.provideFormContext = () => {
|
api.provideFormContext = () => {
|
||||||
|
onMounted(api.mount)
|
||||||
provideFormContext({ formApi: formApi as never })
|
provideFormContext({ formApi: formApi as never })
|
||||||
}
|
}
|
||||||
api.Field = Field as never
|
api.Field = Field as never
|
||||||
|
|||||||
Reference in New Issue
Block a user