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:
Corbin Crutchley
2023-11-05 05:14:29 -08:00
committed by GitHub
parent 5d3f0fd946
commit 2fc941ed76
6 changed files with 632 additions and 18 deletions

View File

@@ -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]

View File

@@ -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'
}
}

View File

@@ -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',
})
})
}) })

View File

@@ -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()
})
}) })

View File

@@ -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

View File

@@ -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