Skip to content

Commit

Permalink
feat: add back form validation (#505)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
Co-authored-by: aadito123 <[email protected]>
  • Loading branch information
3 people authored Nov 5, 2023
1 parent 5d3f0fd commit 2fc941e
Show file tree
Hide file tree
Showing 6 changed files with 632 additions and 18 deletions.
12 changes: 8 additions & 4 deletions packages/form-core/src/FieldApi.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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 = (
Expand All @@ -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]
Expand Down
236 changes: 224 additions & 12 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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
}

Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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) ||
Expand Down Expand Up @@ -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 },
}))
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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'
}
}
Loading

0 comments on commit 2fc941e

Please sign in to comment.