@tanstack/form-core
Advanced tools
Comparing version 0.7.2 to 0.8.0
export { FieldApi, FieldApiOptions, FieldMeta, FieldOptions, FieldState, ResolveName, ValidationCause } from './index.js'; | ||
import './utils.js'; | ||
import '@tanstack/store'; | ||
import './types.js'; | ||
import './utils.js'; |
@@ -201,3 +201,3 @@ // src/FieldApi.ts | ||
} | ||
return this.getInfo().validationPromise ?? []; | ||
return await this.getInfo().validationPromise ?? []; | ||
}; | ||
@@ -207,2 +207,6 @@ this.validate = (cause, value) => { | ||
return []; | ||
try { | ||
this.form.validate(cause); | ||
} catch (_) { | ||
} | ||
const errorMapKey = getErrorMapKey(cause); | ||
@@ -209,0 +213,0 @@ const prevError = this.getMeta().errorMap[errorMapKey]; |
@@ -7,2 +7,4 @@ // src/FormApi.ts | ||
values: defaultState.values ?? {}, | ||
errors: defaultState.errors ?? [], | ||
errorMap: defaultState.errorMap ?? {}, | ||
fieldMeta: defaultState.fieldMeta ?? {}, | ||
@@ -30,11 +32,20 @@ canSubmit: defaultState.canSubmit ?? true, | ||
this.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().validate( | ||
this.state.values, | ||
this.options.onMount | ||
); | ||
} | ||
}; | ||
const error = doValidate(); | ||
if (error) { | ||
this.store.setState((prev) => ({ | ||
...prev, | ||
errorMap: { ...prev.errorMap, onMount: error } | ||
})); | ||
} | ||
if (this.options.validator) { | ||
return this.options.validator().validate( | ||
this.state.values, | ||
this.options.onMount | ||
); | ||
} | ||
}; | ||
@@ -89,2 +100,129 @@ this.update = (options) => { | ||
}; | ||
this.validateSync = (cause) => { | ||
const { onChange, onBlur } = this.options; | ||
const validate = cause === "change" ? onChange : cause === "blur" ? onBlur : void 0; | ||
if (!validate) | ||
return; | ||
const errorMapKey = getErrorMapKey(cause); | ||
const doValidate = () => { | ||
if (typeof validate === "function") { | ||
return validate(this.state.values, this); | ||
} | ||
if (this.options.validator && typeof validate !== "function") { | ||
return this.options.validator().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(); | ||
} | ||
}; | ||
this.__leaseValidateAsync = () => { | ||
const count = (this.validationMeta.validationAsyncCount || 0) + 1; | ||
this.validationMeta.validationAsyncCount = count; | ||
return count; | ||
}; | ||
this.cancelValidateAsync = () => { | ||
this.__leaseValidateAsync(); | ||
this.store.setState((prev) => ({ | ||
...prev, | ||
isFormValidating: false | ||
})); | ||
}; | ||
this.validateAsync = async (cause) => { | ||
var _a, _b, _c, _d; | ||
const { | ||
onChangeAsync, | ||
onBlurAsync, | ||
asyncDebounceMs, | ||
onBlurAsyncDebounceMs, | ||
onChangeAsyncDebounceMs | ||
} = this.options; | ||
const validate = cause === "change" ? onChangeAsync : cause === "blur" ? onBlurAsync : void 0; | ||
if (!validate) | ||
return []; | ||
const debounceMs = (cause === "change" ? onChangeAsyncDebounceMs : onBlurAsyncDebounceMs) ?? asyncDebounceMs ?? 0; | ||
if (!this.state.isFormValidating) { | ||
this.store.setState((prev) => ({ ...prev, isFormValidating: true })); | ||
} | ||
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); | ||
} | ||
if (this.options.validator && typeof validate !== "function") { | ||
return this.options.validator().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.` | ||
); | ||
}; | ||
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 | ||
} | ||
})); | ||
(_b = (_a = this.validationMeta).validationResolve) == null ? void 0 : _b.call(_a, [...prevErrors, error]); | ||
} | ||
} catch (error) { | ||
if (checkLatest()) { | ||
(_d = (_c = this.validationMeta).validationReject) == null ? void 0 : _d.call(_c, [...prevErrors, error]); | ||
throw error; | ||
} | ||
} finally { | ||
if (checkLatest()) { | ||
this.store.setState((prev) => ({ ...prev, isFormValidating: false })); | ||
delete this.validationMeta.validationPromise; | ||
} | ||
} | ||
} | ||
return await this.validationMeta.validationPromise ?? []; | ||
}; | ||
this.validate = (cause) => { | ||
const errorMapKey = getErrorMapKey(cause); | ||
const prevError = this.state.errorMap[errorMapKey]; | ||
this.validateSync(cause); | ||
const newError = this.state.errorMap[errorMapKey]; | ||
if (prevError !== newError && !this.options.asyncAlways && !(newError === void 0 && prevError !== void 0)) | ||
return this.state.errors; | ||
return this.validateAsync(cause); | ||
}; | ||
this.handleSubmit = async () => { | ||
@@ -111,2 +249,3 @@ var _a, _b, _c, _d, _e, _f; | ||
} | ||
await this.validate("submit"); | ||
if (!this.state.isValid) { | ||
@@ -227,3 +366,6 @@ done(); | ||
const isValidating = isFieldsValidating || state.isFormValidating; | ||
const isFormValid = !state.formError; | ||
state.errors = Object.values(state.errorMap).filter( | ||
(val) => val !== void 0 | ||
); | ||
const isFormValid = state.errors.length === 0; | ||
const isValid = isFieldsValid && isFormValid; | ||
@@ -249,2 +391,23 @@ const canSubmit = state.submissionAttempts === 0 && !isTouched || !isValidating && !state.isSubmitting && isValid; | ||
}; | ||
function normalizeError(rawError) { | ||
if (rawError) { | ||
if (typeof rawError !== "string") { | ||
return "Invalid Form Values"; | ||
} | ||
return rawError; | ||
} | ||
return void 0; | ||
} | ||
function getErrorMapKey(cause) { | ||
switch (cause) { | ||
case "submit": | ||
return "onSubmit"; | ||
case "change": | ||
return "onChange"; | ||
case "blur": | ||
return "onBlur"; | ||
case "mount": | ||
return "onMount"; | ||
} | ||
} | ||
export { | ||
@@ -251,0 +414,0 @@ FormApi |
@@ -12,2 +12,3 @@ import { Store } from '@tanstack/store'; | ||
defaultState?: Partial<FormState<TData>>; | ||
asyncAlways?: boolean; | ||
asyncDebounceMs?: number; | ||
@@ -31,4 +32,4 @@ validator?: ValidatorType; | ||
validationAsyncCount?: number; | ||
validationPromise?: Promise<ValidationError[]>; | ||
validationResolve?: (errors: ValidationError[]) => void; | ||
validationPromise?: Promise<ValidationError[] | undefined>; | ||
validationResolve?: (errors: ValidationError[] | undefined) => void; | ||
validationReject?: (errors: unknown) => void; | ||
@@ -45,3 +46,4 @@ }; | ||
isFormValid: boolean; | ||
formError?: ValidationError; | ||
errors: ValidationError[]; | ||
errorMap: ValidationErrorMap; | ||
fieldMeta: Record<DeepKeys<TData>, FieldMeta>; | ||
@@ -66,6 +68,11 @@ isFieldsValidating: boolean; | ||
constructor(opts?: FormOptions<TFormData, ValidatorType>); | ||
mount: () => any; | ||
mount: () => void; | ||
update: (options?: FormOptions<TFormData, ValidatorType>) => void; | ||
reset: () => void; | ||
validateAllFields: (cause: ValidationCause) => Promise<ValidationError[][]>; | ||
validateSync: (cause: ValidationCause) => void; | ||
__leaseValidateAsync: () => number; | ||
cancelValidateAsync: () => void; | ||
validateAsync: (cause: ValidationCause) => Promise<ValidationError[]>; | ||
validate: (cause: ValidationCause) => ValidationError[] | Promise<ValidationError[]>; | ||
handleSubmit: () => Promise<void>; | ||
@@ -72,0 +79,0 @@ getFieldValue: <TField extends DeepKeys<TFormData>>(field: TField) => DeepValue<TFormData, TField>; |
export { FieldApi, FieldApiOptions, FieldMeta, FieldOptions, FieldState, ResolveName, ValidationCause } from './index.js'; | ||
import './utils.js'; | ||
import '@tanstack/store'; | ||
import './types.js'; | ||
import './utils.js'; |
@@ -198,3 +198,3 @@ // src/FieldApi.ts | ||
} | ||
return this.getInfo().validationPromise ?? []; | ||
return await this.getInfo().validationPromise ?? []; | ||
}; | ||
@@ -204,2 +204,6 @@ this.validate = (cause, value) => { | ||
return []; | ||
try { | ||
this.form.validate(cause); | ||
} catch (_) { | ||
} | ||
const errorMapKey = getErrorMapKey(cause); | ||
@@ -206,0 +210,0 @@ const prevError = this.getMeta().errorMap[errorMapKey]; |
@@ -7,2 +7,4 @@ // src/FormApi.ts | ||
values: defaultState.values ?? {}, | ||
errors: defaultState.errors ?? [], | ||
errorMap: defaultState.errorMap ?? {}, | ||
fieldMeta: defaultState.fieldMeta ?? {}, | ||
@@ -30,11 +32,20 @@ canSubmit: defaultState.canSubmit ?? true, | ||
this.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().validate( | ||
this.state.values, | ||
this.options.onMount | ||
); | ||
} | ||
}; | ||
const error = doValidate(); | ||
if (error) { | ||
this.store.setState((prev) => ({ | ||
...prev, | ||
errorMap: { ...prev.errorMap, onMount: error } | ||
})); | ||
} | ||
if (this.options.validator) { | ||
return this.options.validator().validate( | ||
this.state.values, | ||
this.options.onMount | ||
); | ||
} | ||
}; | ||
@@ -86,2 +97,128 @@ this.update = (options) => { | ||
}; | ||
this.validateSync = (cause) => { | ||
const { onChange, onBlur } = this.options; | ||
const validate = cause === "change" ? onChange : cause === "blur" ? onBlur : void 0; | ||
if (!validate) | ||
return; | ||
const errorMapKey = getErrorMapKey(cause); | ||
const doValidate = () => { | ||
if (typeof validate === "function") { | ||
return validate(this.state.values, this); | ||
} | ||
if (this.options.validator && typeof validate !== "function") { | ||
return this.options.validator().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(); | ||
} | ||
}; | ||
this.__leaseValidateAsync = () => { | ||
const count = (this.validationMeta.validationAsyncCount || 0) + 1; | ||
this.validationMeta.validationAsyncCount = count; | ||
return count; | ||
}; | ||
this.cancelValidateAsync = () => { | ||
this.__leaseValidateAsync(); | ||
this.store.setState((prev) => ({ | ||
...prev, | ||
isFormValidating: false | ||
})); | ||
}; | ||
this.validateAsync = async (cause) => { | ||
const { | ||
onChangeAsync, | ||
onBlurAsync, | ||
asyncDebounceMs, | ||
onBlurAsyncDebounceMs, | ||
onChangeAsyncDebounceMs | ||
} = this.options; | ||
const validate = cause === "change" ? onChangeAsync : cause === "blur" ? onBlurAsync : void 0; | ||
if (!validate) | ||
return []; | ||
const debounceMs = (cause === "change" ? onChangeAsyncDebounceMs : onBlurAsyncDebounceMs) ?? asyncDebounceMs ?? 0; | ||
if (!this.state.isFormValidating) { | ||
this.store.setState((prev) => ({ ...prev, isFormValidating: true })); | ||
} | ||
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); | ||
} | ||
if (this.options.validator && typeof validate !== "function") { | ||
return this.options.validator().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.` | ||
); | ||
}; | ||
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; | ||
} | ||
} | ||
} | ||
return await this.validationMeta.validationPromise ?? []; | ||
}; | ||
this.validate = (cause) => { | ||
const errorMapKey = getErrorMapKey(cause); | ||
const prevError = this.state.errorMap[errorMapKey]; | ||
this.validateSync(cause); | ||
const newError = this.state.errorMap[errorMapKey]; | ||
if (prevError !== newError && !this.options.asyncAlways && !(newError === void 0 && prevError !== void 0)) | ||
return this.state.errors; | ||
return this.validateAsync(cause); | ||
}; | ||
this.handleSubmit = async () => { | ||
@@ -107,2 +244,3 @@ this.store.setState((old) => ({ | ||
} | ||
await this.validate("submit"); | ||
if (!this.state.isValid) { | ||
@@ -221,3 +359,6 @@ done(); | ||
const isValidating = isFieldsValidating || state.isFormValidating; | ||
const isFormValid = !state.formError; | ||
state.errors = Object.values(state.errorMap).filter( | ||
(val) => val !== void 0 | ||
); | ||
const isFormValid = state.errors.length === 0; | ||
const isValid = isFieldsValid && isFormValid; | ||
@@ -243,2 +384,23 @@ const canSubmit = state.submissionAttempts === 0 && !isTouched || !isValidating && !state.isSubmitting && isValid; | ||
}; | ||
function normalizeError(rawError) { | ||
if (rawError) { | ||
if (typeof rawError !== "string") { | ||
return "Invalid Form Values"; | ||
} | ||
return rawError; | ||
} | ||
return void 0; | ||
} | ||
function getErrorMapKey(cause) { | ||
switch (cause) { | ||
case "submit": | ||
return "onSubmit"; | ||
case "change": | ||
return "onChange"; | ||
case "blur": | ||
return "onBlur"; | ||
case "mount": | ||
return "onMount"; | ||
} | ||
} | ||
export { | ||
@@ -245,0 +407,0 @@ FormApi |
@@ -12,2 +12,3 @@ import { Store } from '@tanstack/store'; | ||
defaultState?: Partial<FormState<TData>>; | ||
asyncAlways?: boolean; | ||
asyncDebounceMs?: number; | ||
@@ -31,4 +32,4 @@ validator?: ValidatorType; | ||
validationAsyncCount?: number; | ||
validationPromise?: Promise<ValidationError[]>; | ||
validationResolve?: (errors: ValidationError[]) => void; | ||
validationPromise?: Promise<ValidationError[] | undefined>; | ||
validationResolve?: (errors: ValidationError[] | undefined) => void; | ||
validationReject?: (errors: unknown) => void; | ||
@@ -45,3 +46,4 @@ }; | ||
isFormValid: boolean; | ||
formError?: ValidationError; | ||
errors: ValidationError[]; | ||
errorMap: ValidationErrorMap; | ||
fieldMeta: Record<DeepKeys<TData>, FieldMeta>; | ||
@@ -66,6 +68,11 @@ isFieldsValidating: boolean; | ||
constructor(opts?: FormOptions<TFormData, ValidatorType>); | ||
mount: () => any; | ||
mount: () => void; | ||
update: (options?: FormOptions<TFormData, ValidatorType>) => void; | ||
reset: () => void; | ||
validateAllFields: (cause: ValidationCause) => Promise<ValidationError[][]>; | ||
validateSync: (cause: ValidationCause) => void; | ||
__leaseValidateAsync: () => number; | ||
cancelValidateAsync: () => void; | ||
validateAsync: (cause: ValidationCause) => Promise<ValidationError[]>; | ||
validate: (cause: ValidationCause) => ValidationError[] | Promise<ValidationError[]>; | ||
handleSubmit: () => Promise<void>; | ||
@@ -72,0 +79,0 @@ getFieldValue: <TField extends DeepKeys<TFormData>>(field: TField) => DeepValue<TFormData, TField>; |
{ | ||
"name": "@tanstack/form-core", | ||
"version": "0.7.2", | ||
"version": "0.8.0", | ||
"description": "Powerful, type-safe, framework agnostic forms.", | ||
@@ -5,0 +5,0 @@ "author": "tannerlinsley", |
@@ -1,5 +0,5 @@ | ||
import { type DeepKeys, type DeepValue, type Updater } from './utils' | ||
import { Store } from '@tanstack/store' | ||
import type { FormApi, ValidationErrorMap } from './FormApi' | ||
import { Store } from '@tanstack/store' | ||
import type { Validator, ValidationError } from './types' | ||
import type { ValidationError, Validator } from './types' | ||
import type { DeepKeys, DeepValue, Updater } from './utils' | ||
@@ -498,3 +498,3 @@ export type ValidationCause = 'change' | 'blur' | 'submit' | 'mount' | ||
// Always return the latest validation promise to the caller | ||
return this.getInfo().validationPromise ?? [] | ||
return (await this.getInfo().validationPromise) ?? [] | ||
} | ||
@@ -509,2 +509,6 @@ | ||
try { | ||
this.form.validate(cause) | ||
} catch (_) {} | ||
// Store the previous error for the errorMapKey (eg. onChange, onBlur, onSubmit) | ||
@@ -511,0 +515,0 @@ const errorMapKey = getErrorMapKey(cause) |
@@ -24,2 +24,3 @@ import { Store } from '@tanstack/store' | ||
defaultState?: Partial<FormState<TData>> | ||
asyncAlways?: boolean | ||
asyncDebounceMs?: number | ||
@@ -51,4 +52,4 @@ validator?: ValidatorType | ||
validationAsyncCount?: number | ||
validationPromise?: Promise<ValidationError[]> | ||
validationResolve?: (errors: ValidationError[]) => void | ||
validationPromise?: Promise<ValidationError[] | undefined> | ||
validationResolve?: (errors: ValidationError[] | undefined) => void | ||
validationReject?: (errors: unknown) => void | ||
@@ -69,3 +70,4 @@ } | ||
isFormValid: boolean | ||
formError?: ValidationError | ||
errors: ValidationError[] | ||
errorMap: ValidationErrorMap | ||
// Fields | ||
@@ -90,2 +92,4 @@ fieldMeta: Record<DeepKeys<TData>, FieldMeta> | ||
values: defaultState.values ?? ({} as never), | ||
errors: defaultState.errors ?? [], | ||
errorMap: defaultState.errorMap ?? {}, | ||
fieldMeta: defaultState.fieldMeta ?? ({} as never), | ||
@@ -148,3 +152,6 @@ canSubmit: defaultState.canSubmit ?? true, | ||
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 | ||
@@ -177,10 +184,19 @@ const canSubmit = | ||
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 }, | ||
})) | ||
} | ||
@@ -254,2 +270,173 @@ } | ||
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 () => { | ||
@@ -289,3 +476,3 @@ // Check to see that the form and all fields have been touched | ||
// Run validation for the form | ||
// await this.validateForm() | ||
await this.validate('submit') | ||
@@ -439,1 +626,26 @@ if (!this.state.isValid) { | ||
} | ||
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' | ||
} | ||
} |
@@ -5,2 +5,3 @@ import { expect } from 'vitest' | ||
import { FieldApi } from '../FieldApi' | ||
import { sleep } from './utils' | ||
@@ -20,2 +21,4 @@ describe('form api', () => { | ||
isSubmitted: false, | ||
errors: [], | ||
errorMap: {}, | ||
isSubmitting: false, | ||
@@ -44,2 +47,4 @@ isTouched: false, | ||
isFieldsValid: true, | ||
errors: [], | ||
errorMap: {}, | ||
isFieldsValidating: false, | ||
@@ -68,2 +73,4 @@ isFormValid: true, | ||
fieldMeta: {}, | ||
errors: [], | ||
errorMap: {}, | ||
canSubmit: true, | ||
@@ -104,2 +111,4 @@ isFieldsValid: true, | ||
}, | ||
errors: [], | ||
errorMap: {}, | ||
fieldMeta: {}, | ||
@@ -137,2 +146,4 @@ canSubmit: true, | ||
}, | ||
errors: [], | ||
errorMap: {}, | ||
fieldMeta: {}, | ||
@@ -325,2 +336,343 @@ canSubmit: 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', | ||
}) | ||
}) | ||
}) |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
473082
6135