mirror of
https://github.com/LukeHagar/form.git
synced 2025-12-06 12:27:45 +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 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]
|
||||
|
||||
@@ -21,6 +21,7 @@ type ValidateAsyncFn<TData, ValidatorType> = (
|
||||
export type FormOptions<TData, ValidatorType> = {
|
||||
defaultValues?: TData
|
||||
defaultState?: Partial<FormState<TData>>
|
||||
asyncAlways?: boolean
|
||||
asyncDebounceMs?: number
|
||||
validator?: ValidatorType
|
||||
onMount?: ValidateOrFn<TData, ValidatorType>
|
||||
@@ -47,8 +48,8 @@ export type FieldInfo<TFormData, ValidatorType> = {
|
||||
export type ValidationMeta = {
|
||||
validationCount?: number
|
||||
validationAsyncCount?: number
|
||||
validationPromise?: Promise<ValidationError[]>
|
||||
validationResolve?: (errors: ValidationError[]) => void
|
||||
validationPromise?: Promise<ValidationError[] | undefined>
|
||||
validationResolve?: (errors: ValidationError[] | undefined) => void
|
||||
validationReject?: (errors: unknown) => void
|
||||
}
|
||||
|
||||
@@ -64,7 +65,8 @@ export type FormState<TData> = {
|
||||
isFormValidating: boolean
|
||||
formValidationCount: number
|
||||
isFormValid: boolean
|
||||
formError?: ValidationError
|
||||
errors: ValidationError[]
|
||||
errorMap: ValidationErrorMap
|
||||
// Fields
|
||||
fieldMeta: Record<DeepKeys<TData>, FieldMeta>
|
||||
isFieldsValidating: boolean
|
||||
@@ -84,6 +86,8 @@ function getDefaultFormState<TData>(
|
||||
): FormState<TData> {
|
||||
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<TFormData, ValidatorType> {
|
||||
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<TFormData, ValidatorType> {
|
||||
}
|
||||
|
||||
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<TFormData>)().validate(
|
||||
this.state.values,
|
||||
this.options.onMount,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (this.options.validator) {
|
||||
return (this.options.validator as Validator<TFormData>)().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<TFormData, ValidatorType> {
|
||||
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 () => {
|
||||
// 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<TFormData, ValidatorType> {
|
||||
}
|
||||
|
||||
// Run validation for the form
|
||||
// await this.validateForm()
|
||||
await this.validate('submit')
|
||||
|
||||
if (!this.state.isValid) {
|
||||
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 { 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<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)
|
||||
|
||||
api.Provider = function Provider(props) {
|
||||
useIsomorphicLayoutEffect(formApi.mount, [])
|
||||
useIsomorphicLayoutEffect(api.mount, [])
|
||||
return <formContext.Provider {...props} value={{ formApi: api }} />
|
||||
}
|
||||
api.Field = Field as any
|
||||
|
||||
@@ -40,13 +40,14 @@ export function useForm<TData, FormValidator>(
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user