Skip to content

Commit

Permalink
onChange + onBlur
Browse files Browse the repository at this point in the history
  • Loading branch information
aadito123 committed Oct 31, 2023
1 parent 52b6245 commit e1e8aba
Show file tree
Hide file tree
Showing 3 changed files with 494 additions and 339 deletions.
10 changes: 7 additions & 3 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 @@ -504,6 +504,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
209 changes: 175 additions & 34 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,9 @@ type ValidateAsyncFn<TData, ValidatorType> = (
export type FormOptions<TData, ValidatorType> = {
defaultValues?: TData
defaultState?: Partial<FormState<TData>>
asyncAlways?: boolean
asyncDebounceMs?: number
validator?: ValidatorType
validateFn?: (
values: TData,
formApi: FormApi<TData, ValidatorType>,
) => Promise<ValidationError[] | void> | ValidationError[] | void
onMount?: ValidateOrFn<TData, ValidatorType>
onMountAsync?: ValidateAsyncFn<TData, ValidatorType>
onMountAsyncDebounceMs?: number
Expand Down Expand Up @@ -70,7 +67,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 @@ -90,6 +88,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 @@ -147,7 +147,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 @@ -239,26 +242,96 @@ export class FormApi<TFormData, ValidatorType> {
return Promise.all(fieldValidationPromises)
}

validateForm = async () => {
const { validateFn } = this.options
validateSync = (cause: ValidationCause): void => {
const { onChange, onBlur } = this.options
const validate =
cause === 'change' ? onChange : cause === 'blur' ? onBlur : undefined
if (!validate) return

if (!validateFn) {
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.`,
)
}

// Use the formValidationCount for all field instances to
// track freshness of the validation
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()
//??=0) Cancel any pending validation state
this.store.setState((prev) => ({
...prev,
isValidating: true,
isFormValidating: true,
formValidationCount: prev.formValidationCount + 1,
isFormValidating: false,
}))
}

const formValidationCount = this.state.formValidationCount
validateAsync = async (
cause: ValidationCause,
): Promise<ValidationError[]> => {
console.log('validateAsync')
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) {
console.log('isFormValidating true')
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 = () =>
formValidationCount === this.state.formValidationCount
validationAsyncCount === this.state.formValidationCount

if (!this.validationMeta.validationPromise) {
this.validationMeta.validationPromise = new Promise((resolve, reject) => {
Expand All @@ -267,34 +340,79 @@ export class FormApi<TFormData, ValidatorType> {
})
}

const doValidation = async () => {
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 error = await validateFn(this.state.values, this)
console.log('Error: ', error)
const rawError = await doValidate()
if (checkLatest()) {
const error = normalizeError(rawError)
this.store.setState((prev) => ({
...prev,
isValidating: false,
isFormValidating: false,
formError: error ? 'Invalid Form Values' : false,
errorMap: {
...prev.errorMap,
[getErrorMapKey(cause)]: error,
},
}))

this.validationMeta.validationResolve?.(
error as ValidationError[] | undefined,
)
this.validationMeta.validationResolve?.([...prevErrors, error])
}
} catch (err) {
} catch (error) {
if (checkLatest()) {
this.validationMeta.validationReject?.(err)
this.validationMeta.validationReject?.([...prevErrors, error])
throw error
}
} finally {
delete this.validationMeta.validationPromise
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) ?? []
}

doValidation()
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

return this.validationMeta.validationPromise
// No error? Attempt async validation
return this.validateAsync(cause)
}

handleSubmit = async () => {
Expand Down Expand Up @@ -331,7 +449,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 @@ -408,8 +526,6 @@ export class FormApi<TFormData, ValidatorType> {
values: setBy(prev.values, field, updater),
}
})

this.validateForm()
})
}

Expand Down Expand Up @@ -479,3 +595,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 e1e8aba

Please sign in to comment.