mirror of
https://github.com/LukeHagar/form.git
synced 2025-12-09 12:27:44 +00:00
feat(form-core): Change from touched error message and error message to error map and array of errors (#442)
* feature(FieldAPI): Change from touched error message and error message to error map and array of errors BREAKING CHANGE: The touched Error and error field has been removed will be replaced with touched errors array and errors map. * feat: update documentation for updated fields * chore: update Vue adapter as well * fix: update getErrorMapKey to return onChange when change is the validation cause * chore: remove console.log --------- Co-authored-by: Corbin Crutchley <git@crutchcorn.dev>
This commit is contained in:
@@ -83,7 +83,7 @@ An object type representing the options for a field in a form.
|
|||||||
|
|
||||||
A type representing the cause of a validation event.
|
A type representing the cause of a validation event.
|
||||||
|
|
||||||
- 'change' | 'blur' | 'submit'
|
- 'change' | 'blur' | 'submit' | 'mount'
|
||||||
|
|
||||||
### `FieldMeta`
|
### `FieldMeta`
|
||||||
|
|
||||||
@@ -94,13 +94,17 @@ An object type representing the metadata of a field in a form.
|
|||||||
```
|
```
|
||||||
- A flag indicating whether the field has been touched.
|
- A flag indicating whether the field has been touched.
|
||||||
- ```tsx
|
- ```tsx
|
||||||
touchedError?: ValidationError
|
touchedErrors: ValidationError[]
|
||||||
```
|
```
|
||||||
- An optional error related to the touched state of the field.
|
- An array of errors related to the touched state of the field.
|
||||||
- ```tsx
|
- ```tsx
|
||||||
error?: ValidationError
|
errors: ValidationError[]
|
||||||
```
|
```
|
||||||
- An optional error related to the field value.
|
- An array of errors related related to the field value.
|
||||||
|
- ```tsx
|
||||||
|
errorMap: ValidationErrorMap
|
||||||
|
```
|
||||||
|
- A map of errors related related to the field value.
|
||||||
- ```tsx
|
- ```tsx
|
||||||
isValidating: boolean
|
isValidating: boolean
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -262,3 +262,11 @@ An object representing the validation metadata for a field.
|
|||||||
### `ValidationError`
|
### `ValidationError`
|
||||||
|
|
||||||
A type representing a validation error. Possible values are `undefined`, `false`, `null`, or a `string` with an error message.
|
A type representing a validation error. Possible values are `undefined`, `false`, `null`, or a `string` with an error message.
|
||||||
|
|
||||||
|
### `ValidationErrorMapKeys`
|
||||||
|
A type representing the keys used to map to `ValidationError` in `ValidationErrorMap`. It is defined with `on${Capitalize<ValidationCause>}`
|
||||||
|
|
||||||
|
|
||||||
|
### `ValidationErrorMap`
|
||||||
|
|
||||||
|
A type that represents a map with the keys as `ValidationErrorMapKeys` and the values as `ValidationError`
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
"fs-extra": "^11.1.1",
|
"fs-extra": "^11.1.1",
|
||||||
"solid-js": "^1.6.13",
|
"solid-js": "^1.6.13",
|
||||||
"stream-to-array": "^2.3.0",
|
"stream-to-array": "^2.3.0",
|
||||||
"tsup": "^7.1.0",
|
"tsup": "^7.2.0",
|
||||||
"type-fest": "^3.11.0",
|
"type-fest": "^3.11.0",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vitest": "^0.34.3",
|
"vitest": "^0.34.3",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { DeepKeys, DeepValue, Updater } from './utils'
|
import { type DeepKeys, type DeepValue, type Updater } from './utils'
|
||||||
import type { FormApi, ValidationError } from './FormApi'
|
import type { FormApi, ValidationError, ValidationErrorMap } from './FormApi'
|
||||||
import { Store } from '@tanstack/store'
|
import { Store } from '@tanstack/store'
|
||||||
|
|
||||||
export type ValidationCause = 'change' | 'blur' | 'submit'
|
export type ValidationCause = 'change' | 'blur' | 'submit' | 'mount'
|
||||||
|
|
||||||
type ValidateFn<TData, TFormData> = (
|
type ValidateFn<TData, TFormData> = (
|
||||||
value: TData,
|
value: TData,
|
||||||
@@ -52,8 +52,9 @@ export type FieldApiOptions<TData, TFormData> = FieldOptions<
|
|||||||
|
|
||||||
export type FieldMeta = {
|
export type FieldMeta = {
|
||||||
isTouched: boolean
|
isTouched: boolean
|
||||||
touchedError?: ValidationError
|
touchedErrors: ValidationError[]
|
||||||
error?: ValidationError
|
errors: ValidationError[]
|
||||||
|
errorMap: ValidationErrorMap
|
||||||
isValidating: boolean
|
isValidating: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +111,9 @@ export class FieldApi<TData, TFormData> {
|
|||||||
meta: this._getMeta() ?? {
|
meta: this._getMeta() ?? {
|
||||||
isValidating: false,
|
isValidating: false,
|
||||||
isTouched: false,
|
isTouched: false,
|
||||||
|
touchedErrors: [],
|
||||||
|
errors: [],
|
||||||
|
errorMap: {},
|
||||||
...opts.defaultMeta,
|
...opts.defaultMeta,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -117,9 +121,9 @@ export class FieldApi<TData, TFormData> {
|
|||||||
onUpdate: () => {
|
onUpdate: () => {
|
||||||
const state = this.store.state
|
const state = this.store.state
|
||||||
|
|
||||||
state.meta.touchedError = state.meta.isTouched
|
state.meta.touchedErrors = state.meta.isTouched
|
||||||
? state.meta.error
|
? state.meta.errors
|
||||||
: undefined
|
: []
|
||||||
|
|
||||||
this.prevState = state
|
this.prevState = state
|
||||||
this.state = state
|
this.state = state
|
||||||
@@ -203,6 +207,9 @@ export class FieldApi<TData, TFormData> {
|
|||||||
({
|
({
|
||||||
isValidating: false,
|
isValidating: false,
|
||||||
isTouched: false,
|
isTouched: false,
|
||||||
|
touchedErrors: [],
|
||||||
|
errors: [],
|
||||||
|
errorMap: {},
|
||||||
...this.options.defaultMeta,
|
...this.options.defaultMeta,
|
||||||
} as FieldMeta)
|
} as FieldMeta)
|
||||||
|
|
||||||
@@ -239,7 +246,6 @@ export class FieldApi<TData, TFormData> {
|
|||||||
const { onChange, onBlur } = this.options
|
const { onChange, onBlur } = this.options
|
||||||
const validate =
|
const validate =
|
||||||
cause === 'submit' ? undefined : cause === 'change' ? onChange : onBlur
|
cause === 'submit' ? undefined : cause === 'change' ? onChange : onBlur
|
||||||
|
|
||||||
if (!validate) return
|
if (!validate) return
|
||||||
|
|
||||||
// Use the validationCount for all field instances to
|
// Use the validationCount for all field instances to
|
||||||
@@ -247,16 +253,20 @@ export class FieldApi<TData, TFormData> {
|
|||||||
const validationCount = (this.getInfo().validationCount || 0) + 1
|
const validationCount = (this.getInfo().validationCount || 0) + 1
|
||||||
this.getInfo().validationCount = validationCount
|
this.getInfo().validationCount = validationCount
|
||||||
const error = normalizeError(validate(value as never, this as never))
|
const error = normalizeError(validate(value as never, this as never))
|
||||||
|
const errorMapKey = getErrorMapKey(cause)
|
||||||
if (this.state.meta.error !== error) {
|
if (error && this.state.meta.errorMap[errorMapKey] !== error) {
|
||||||
this.setMeta((prev) => ({
|
this.setMeta((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
error,
|
errors: [...prev.errors, error],
|
||||||
|
errorMap: {
|
||||||
|
...prev.errorMap,
|
||||||
|
[getErrorMapKey(cause)]: error,
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// If a sync error is encountered, cancel any async validation
|
// If a sync error is encountered for the errorMapKey (eg. onChange), cancel any async validation
|
||||||
if (this.state.meta.error) {
|
if (this.state.meta.errorMap[errorMapKey]) {
|
||||||
this.cancelValidateAsync()
|
this.cancelValidateAsync()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,9 +303,7 @@ export class FieldApi<TData, TFormData> {
|
|||||||
: cause === 'submit'
|
: cause === 'submit'
|
||||||
? onSubmitAsync
|
? onSubmitAsync
|
||||||
: onBlurAsync
|
: onBlurAsync
|
||||||
|
if (!validate) return []
|
||||||
if (!validate) return
|
|
||||||
|
|
||||||
const debounceMs =
|
const debounceMs =
|
||||||
cause === 'submit'
|
cause === 'submit'
|
||||||
? 0
|
? 0
|
||||||
@@ -328,21 +336,25 @@ export class FieldApi<TData, TFormData> {
|
|||||||
|
|
||||||
// Only kick off validation if this validation is the latest attempt
|
// Only kick off validation if this validation is the latest attempt
|
||||||
if (checkLatest()) {
|
if (checkLatest()) {
|
||||||
|
const prevErrors = this.getMeta().errors
|
||||||
try {
|
try {
|
||||||
const rawError = await validate(value as never, this as never)
|
const rawError = await validate(value as never, this as never)
|
||||||
|
|
||||||
if (checkLatest()) {
|
if (checkLatest()) {
|
||||||
const error = normalizeError(rawError)
|
const error = normalizeError(rawError)
|
||||||
this.setMeta((prev) => ({
|
this.setMeta((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
isValidating: false,
|
isValidating: false,
|
||||||
error,
|
errors: [...prev.errors, error],
|
||||||
|
errorMap: {
|
||||||
|
...prev.errorMap,
|
||||||
|
[getErrorMapKey(cause)]: error,
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
this.getInfo().validationResolve?.(error)
|
this.getInfo().validationResolve?.([...prevErrors, error])
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (checkLatest()) {
|
if (checkLatest()) {
|
||||||
this.getInfo().validationReject?.(error)
|
this.getInfo().validationReject?.([...prevErrors, error])
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -354,26 +366,25 @@ export class FieldApi<TData, TFormData> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Always return the latest validation promise to the caller
|
// Always return the latest validation promise to the caller
|
||||||
return this.getInfo().validationPromise
|
return this.getInfo().validationPromise ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
validate = (
|
validate = (
|
||||||
cause: ValidationCause,
|
cause: ValidationCause,
|
||||||
value?: typeof this._tdata,
|
value?: typeof this._tdata,
|
||||||
): ValidationError | Promise<ValidationError> => {
|
): ValidationError[] | Promise<ValidationError[]> => {
|
||||||
// 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 []
|
||||||
|
|
||||||
// Attempt to sync validate first
|
// Attempt to sync validate first
|
||||||
this.validateSync(value, cause)
|
this.validateSync(value, cause)
|
||||||
|
|
||||||
// If there is an error, return it, do not attempt async validation
|
const errorMapKey = getErrorMapKey(cause)
|
||||||
if (this.state.meta.error) {
|
// If there is an error mapped to the errorMapKey (eg. onChange, onBlur, onSubmit), return the errors array, do not attempt async validation
|
||||||
|
if (this.getMeta().errorMap[errorMapKey]) {
|
||||||
if (!this.options.asyncAlways) {
|
if (!this.options.asyncAlways) {
|
||||||
return this.state.meta.error
|
return this.state.meta.errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No error? Attempt async validation
|
// No error? Attempt async validation
|
||||||
return this.validateAsync(value, cause)
|
return this.validateAsync(value, cause)
|
||||||
}
|
}
|
||||||
@@ -403,3 +414,16 @@ function normalizeError(rawError?: ValidationError) {
|
|||||||
|
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getErrorMapKey(cause: ValidationCause) {
|
||||||
|
switch (cause) {
|
||||||
|
case 'submit':
|
||||||
|
return 'onSubmit'
|
||||||
|
case 'change':
|
||||||
|
return 'onChange'
|
||||||
|
case 'blur':
|
||||||
|
return 'onBlur'
|
||||||
|
case 'mount':
|
||||||
|
return 'onMount'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Store } from '@tanstack/store'
|
import { Store } from '@tanstack/store'
|
||||||
//
|
//
|
||||||
import type { DeepKeys, DeepValue, Updater } from './utils'
|
import type { DeepKeys, DeepValue, Updater } from './utils'
|
||||||
import { functionalUpdate, getBy, setBy } from './utils'
|
import { functionalUpdate, getBy, isNonEmptyArray, setBy } from './utils'
|
||||||
import type { FieldApi, FieldMeta, ValidationCause } from './FieldApi'
|
import type { FieldApi, FieldMeta, ValidationCause } from './FieldApi'
|
||||||
|
|
||||||
export type FormOptions<TData> = {
|
export type FormOptions<TData> = {
|
||||||
@@ -37,13 +37,19 @@ export type FieldInfo<TFormData> = {
|
|||||||
export type ValidationMeta = {
|
export type ValidationMeta = {
|
||||||
validationCount?: number
|
validationCount?: number
|
||||||
validationAsyncCount?: number
|
validationAsyncCount?: number
|
||||||
validationPromise?: Promise<ValidationError>
|
validationPromise?: Promise<ValidationError[]>
|
||||||
validationResolve?: (error: ValidationError) => void
|
validationResolve?: (errors: ValidationError[]) => void
|
||||||
validationReject?: (error: unknown) => void
|
validationReject?: (errors: unknown) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ValidationError = undefined | false | null | string
|
export type ValidationError = undefined | false | null | string
|
||||||
|
|
||||||
|
export type ValidationErrorMapKeys = `on${Capitalize<ValidationCause>}`
|
||||||
|
|
||||||
|
export type ValidationErrorMap = {
|
||||||
|
[K in ValidationErrorMapKeys]?: ValidationError
|
||||||
|
}
|
||||||
|
|
||||||
export type FormState<TData> = {
|
export type FormState<TData> = {
|
||||||
values: TData
|
values: TData
|
||||||
// Form Validation
|
// Form Validation
|
||||||
@@ -117,7 +123,9 @@ export class FormApi<TFormData> {
|
|||||||
(field) => field?.isValidating,
|
(field) => field?.isValidating,
|
||||||
)
|
)
|
||||||
|
|
||||||
const isFieldsValid = !fieldMetaValues.some((field) => field?.error)
|
const isFieldsValid = !fieldMetaValues.some((field) =>
|
||||||
|
isNonEmptyArray(field?.errors),
|
||||||
|
)
|
||||||
|
|
||||||
const isTouched = fieldMetaValues.some((field) => field?.isTouched)
|
const isTouched = fieldMetaValues.some((field) => field?.isTouched)
|
||||||
|
|
||||||
@@ -192,8 +200,7 @@ export class FormApi<TFormData> {
|
|||||||
)
|
)
|
||||||
|
|
||||||
validateAllFields = async (cause: ValidationCause) => {
|
validateAllFields = async (cause: ValidationCause) => {
|
||||||
const fieldValidationPromises: Promise<ValidationError>[] = [] as any
|
const fieldValidationPromises: Promise<ValidationError[]>[] = [] as any
|
||||||
|
|
||||||
this.store.batch(() => {
|
this.store.batch(() => {
|
||||||
void (Object.values(this.fieldInfo) as FieldInfo<any>[]).forEach(
|
void (Object.values(this.fieldInfo) as FieldInfo<any>[]).forEach(
|
||||||
(field) => {
|
(field) => {
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ describe('field api', () => {
|
|||||||
expect(field.getMeta()).toEqual({
|
expect(field.getMeta()).toEqual({
|
||||||
isTouched: false,
|
isTouched: false,
|
||||||
isValidating: false,
|
isValidating: false,
|
||||||
|
touchedErrors: [],
|
||||||
|
errors: [],
|
||||||
|
errorMap: {},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -44,6 +47,9 @@ describe('field api', () => {
|
|||||||
expect(field.getMeta()).toEqual({
|
expect(field.getMeta()).toEqual({
|
||||||
isTouched: true,
|
isTouched: true,
|
||||||
isValidating: false,
|
isValidating: false,
|
||||||
|
touchedErrors: [],
|
||||||
|
errors: [],
|
||||||
|
errorMap: {},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -193,9 +199,12 @@ describe('field api', () => {
|
|||||||
|
|
||||||
field.mount()
|
field.mount()
|
||||||
|
|
||||||
expect(field.getMeta().error).toBeUndefined()
|
expect(field.getMeta().errors.length).toBe(0)
|
||||||
field.setValue('other', { touch: true })
|
field.setValue('other', { touch: true })
|
||||||
expect(field.getMeta().error).toBe('Please enter a different value')
|
expect(field.getMeta().errors).toContain('Please enter a different value')
|
||||||
|
expect(field.getMeta().errorMap).toMatchObject({
|
||||||
|
onChange: 'Please enter a different value',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should run async validation onChange', async () => {
|
it('should run async validation onChange', async () => {
|
||||||
@@ -219,10 +228,13 @@ describe('field api', () => {
|
|||||||
|
|
||||||
field.mount()
|
field.mount()
|
||||||
|
|
||||||
expect(field.getMeta().error).toBeUndefined()
|
expect(field.getMeta().errors.length).toBe(0)
|
||||||
field.setValue('other', { touch: true })
|
field.setValue('other', { touch: true })
|
||||||
await vi.runAllTimersAsync()
|
await vi.runAllTimersAsync()
|
||||||
expect(field.getMeta().error).toBe('Please enter a different value')
|
expect(field.getMeta().errors).toContain('Please enter a different value')
|
||||||
|
expect(field.getMeta().errorMap).toMatchObject({
|
||||||
|
onChange: 'Please enter a different value',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should run async validation onChange with debounce', async () => {
|
it('should run async validation onChange with debounce', async () => {
|
||||||
@@ -248,13 +260,16 @@ describe('field api', () => {
|
|||||||
|
|
||||||
field.mount()
|
field.mount()
|
||||||
|
|
||||||
expect(field.getMeta().error).toBeUndefined()
|
expect(field.getMeta().errors.length).toBe(0)
|
||||||
field.setValue('other', { touch: true })
|
field.setValue('other', { touch: true })
|
||||||
field.setValue('other')
|
field.setValue('other')
|
||||||
await vi.runAllTimersAsync()
|
await vi.runAllTimersAsync()
|
||||||
// sleepMock will have been called 2 times without onChangeAsyncDebounceMs
|
// sleepMock will have been called 2 times without onChangeAsyncDebounceMs
|
||||||
expect(sleepMock).toHaveBeenCalledTimes(1)
|
expect(sleepMock).toHaveBeenCalledTimes(1)
|
||||||
expect(field.getMeta().error).toBe('Please enter a different value')
|
expect(field.getMeta().errors).toContain('Please enter a different value')
|
||||||
|
expect(field.getMeta().errorMap).toMatchObject({
|
||||||
|
onChange: 'Please enter a different value',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should run async validation onChange with asyncDebounceMs', async () => {
|
it('should run async validation onChange with asyncDebounceMs', async () => {
|
||||||
@@ -280,13 +295,16 @@ describe('field api', () => {
|
|||||||
|
|
||||||
field.mount()
|
field.mount()
|
||||||
|
|
||||||
expect(field.getMeta().error).toBeUndefined()
|
expect(field.getMeta().errors.length).toBe(0)
|
||||||
field.setValue('other', { touch: true })
|
field.setValue('other', { touch: true })
|
||||||
field.setValue('other')
|
field.setValue('other')
|
||||||
await vi.runAllTimersAsync()
|
await vi.runAllTimersAsync()
|
||||||
// sleepMock will have been called 2 times without asyncDebounceMs
|
// sleepMock will have been called 2 times without asyncDebounceMs
|
||||||
expect(sleepMock).toHaveBeenCalledTimes(1)
|
expect(sleepMock).toHaveBeenCalledTimes(1)
|
||||||
expect(field.getMeta().error).toBe('Please enter a different value')
|
expect(field.getMeta().errors).toContain('Please enter a different value')
|
||||||
|
expect(field.getMeta().errorMap).toMatchObject({
|
||||||
|
onChange: 'Please enter a different value',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should run validation onBlur', () => {
|
it('should run validation onBlur', () => {
|
||||||
@@ -309,7 +327,10 @@ describe('field api', () => {
|
|||||||
|
|
||||||
field.setValue('other', { touch: true })
|
field.setValue('other', { touch: true })
|
||||||
field.validate('blur')
|
field.validate('blur')
|
||||||
expect(field.getMeta().error).toBe('Please enter a different value')
|
expect(field.getMeta().errors).toContain('Please enter a different value')
|
||||||
|
expect(field.getMeta().errorMap).toMatchObject({
|
||||||
|
onBlur: 'Please enter a different value',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should run async validation onBlur', async () => {
|
it('should run async validation onBlur', async () => {
|
||||||
@@ -333,11 +354,14 @@ describe('field api', () => {
|
|||||||
|
|
||||||
field.mount()
|
field.mount()
|
||||||
|
|
||||||
expect(field.getMeta().error).toBeUndefined()
|
expect(field.getMeta().errors.length).toBe(0)
|
||||||
field.setValue('other', { touch: true })
|
field.setValue('other', { touch: true })
|
||||||
field.validate('blur')
|
field.validate('blur')
|
||||||
await vi.runAllTimersAsync()
|
await vi.runAllTimersAsync()
|
||||||
expect(field.getMeta().error).toBe('Please enter a different value')
|
expect(field.getMeta().errors).toContain('Please enter a different value')
|
||||||
|
expect(field.getMeta().errorMap).toMatchObject({
|
||||||
|
onBlur: 'Please enter a different value',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should run async validation onBlur with debounce', async () => {
|
it('should run async validation onBlur with debounce', async () => {
|
||||||
@@ -363,14 +387,17 @@ describe('field api', () => {
|
|||||||
|
|
||||||
field.mount()
|
field.mount()
|
||||||
|
|
||||||
expect(field.getMeta().error).toBeUndefined()
|
expect(field.getMeta().errors.length).toBe(0)
|
||||||
field.setValue('other', { touch: true })
|
field.setValue('other', { touch: true })
|
||||||
field.validate('blur')
|
field.validate('blur')
|
||||||
field.validate('blur')
|
field.validate('blur')
|
||||||
await vi.runAllTimersAsync()
|
await vi.runAllTimersAsync()
|
||||||
// sleepMock will have been called 2 times without onBlurAsyncDebounceMs
|
// sleepMock will have been called 2 times without onBlurAsyncDebounceMs
|
||||||
expect(sleepMock).toHaveBeenCalledTimes(1)
|
expect(sleepMock).toHaveBeenCalledTimes(1)
|
||||||
expect(field.getMeta().error).toBe('Please enter a different value')
|
expect(field.getMeta().errors).toContain('Please enter a different value')
|
||||||
|
expect(field.getMeta().errorMap).toMatchObject({
|
||||||
|
onBlur: 'Please enter a different value',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should run async validation onBlur with asyncDebounceMs', async () => {
|
it('should run async validation onBlur with asyncDebounceMs', async () => {
|
||||||
@@ -396,14 +423,17 @@ describe('field api', () => {
|
|||||||
|
|
||||||
field.mount()
|
field.mount()
|
||||||
|
|
||||||
expect(field.getMeta().error).toBeUndefined()
|
expect(field.getMeta().errors.length).toBe(0)
|
||||||
field.setValue('other', { touch: true })
|
field.setValue('other', { touch: true })
|
||||||
field.validate('blur')
|
field.validate('blur')
|
||||||
field.validate('blur')
|
field.validate('blur')
|
||||||
await vi.runAllTimersAsync()
|
await vi.runAllTimersAsync()
|
||||||
// sleepMock will have been called 2 times without asyncDebounceMs
|
// sleepMock will have been called 2 times without asyncDebounceMs
|
||||||
expect(sleepMock).toHaveBeenCalledTimes(1)
|
expect(sleepMock).toHaveBeenCalledTimes(1)
|
||||||
expect(field.getMeta().error).toBe('Please enter a different value')
|
expect(field.getMeta().errors).toContain('Please enter a different value')
|
||||||
|
expect(field.getMeta().errorMap).toMatchObject({
|
||||||
|
onBlur: 'Please enter a different value',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should run async validation onSubmit', async () => {
|
it('should run async validation onSubmit', async () => {
|
||||||
@@ -427,11 +457,48 @@ describe('field api', () => {
|
|||||||
|
|
||||||
field.mount()
|
field.mount()
|
||||||
|
|
||||||
expect(field.getMeta().error).toBeUndefined()
|
expect(field.getMeta().errors.length).toBe(0)
|
||||||
field.setValue('other', { touch: true })
|
field.setValue('other', { touch: true })
|
||||||
field.validate('submit')
|
field.validate('submit')
|
||||||
await vi.runAllTimersAsync()
|
await vi.runAllTimersAsync()
|
||||||
expect(field.getMeta().error).toBe('Please enter a different value')
|
expect(field.getMeta().errors).toContain('Please enter a different value')
|
||||||
|
expect(field.getMeta().errorMap).toMatchObject({
|
||||||
|
onSubmit: 'Please enter a different value',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should contain multiple errors when running validation onBlur and onChange', () => {
|
||||||
|
const form = new FormApi({
|
||||||
|
defaultValues: {
|
||||||
|
name: 'other',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const field = new FieldApi({
|
||||||
|
form,
|
||||||
|
name: 'name',
|
||||||
|
onBlur: (value) => {
|
||||||
|
if (value === 'other') return 'Please enter a different value'
|
||||||
|
return
|
||||||
|
},
|
||||||
|
onChange: (value) => {
|
||||||
|
if (value === 'other') return 'Please enter a different value'
|
||||||
|
return
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
field.mount()
|
||||||
|
|
||||||
|
field.setValue('other', { touch: true })
|
||||||
|
field.validate('blur')
|
||||||
|
expect(field.getMeta().errors).toStrictEqual([
|
||||||
|
'Please enter a different value',
|
||||||
|
'Please enter a different value',
|
||||||
|
])
|
||||||
|
expect(field.getMeta().errorMap).toEqual({
|
||||||
|
onBlur: 'Please enter a different value',
|
||||||
|
onChange: 'Please enter a different value',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle default value on field using state.value', async () => {
|
it('should handle default value on field using state.value', async () => {
|
||||||
|
|||||||
@@ -101,6 +101,10 @@ function makePathArray(str: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isNonEmptyArray(obj: any) {
|
||||||
|
return !(Array.isArray(obj) && obj.length === 0)
|
||||||
|
}
|
||||||
|
|
||||||
export type RequiredByKey<T, K extends keyof T> = Omit<T, K> &
|
export type RequiredByKey<T, K extends keyof T> = Omit<T, K> &
|
||||||
Required<Pick<T, K>>
|
Required<Pick<T, K>>
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ describe('useField', () => {
|
|||||||
onBlur={field.handleBlur}
|
onBlur={field.handleBlur}
|
||||||
onChange={(e) => field.setValue(e.target.value)}
|
onChange={(e) => field.setValue(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<p>{field.getMeta().error}</p>
|
<p>{field.getMeta().errors}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -112,7 +112,7 @@ describe('useField', () => {
|
|||||||
onBlur={field.handleBlur}
|
onBlur={field.handleBlur}
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<p>{field.getMeta().error}</p>
|
<p>{field.getMeta().errorMap?.onChange}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -127,6 +127,56 @@ describe('useField', () => {
|
|||||||
expect(getByText(error)).toBeInTheDocument()
|
expect(getByText(error)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should validate on change and on blur', async () => {
|
||||||
|
type Person = {
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
}
|
||||||
|
const onChangeError = 'Please enter a different value (onChangeError)'
|
||||||
|
const onBlurError = 'Please enter a different value (onBlurError)'
|
||||||
|
|
||||||
|
const formFactory = createFormFactory<Person>()
|
||||||
|
|
||||||
|
function Comp() {
|
||||||
|
const form = formFactory.useForm()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form.Provider>
|
||||||
|
<form.Field
|
||||||
|
name="firstName"
|
||||||
|
defaultMeta={{ isTouched: true }}
|
||||||
|
onChange={(value) =>
|
||||||
|
value === 'other' ? onChangeError : undefined
|
||||||
|
}
|
||||||
|
onBlur={(value) => (value === 'other' ? onBlurError : undefined)}
|
||||||
|
children={(field) => (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
data-testid="fieldinput"
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p>{field.getMeta().errorMap?.onChange}</p>
|
||||||
|
<p>{field.getMeta().errorMap?.onBlur}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getByTestId, getByText, queryByText } = render(<Comp />)
|
||||||
|
const input = getByTestId('fieldinput')
|
||||||
|
expect(queryByText(onChangeError)).not.toBeInTheDocument()
|
||||||
|
expect(queryByText(onBlurError)).not.toBeInTheDocument()
|
||||||
|
await user.type(input, 'other')
|
||||||
|
expect(getByText(onChangeError)).toBeInTheDocument()
|
||||||
|
await user.click(document.body)
|
||||||
|
expect(queryByText(onBlurError)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('should validate async on change', async () => {
|
it('should validate async on change', async () => {
|
||||||
type Person = {
|
type Person = {
|
||||||
firstName: string
|
firstName: string
|
||||||
@@ -157,7 +207,7 @@ describe('useField', () => {
|
|||||||
onBlur={field.handleBlur}
|
onBlur={field.handleBlur}
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<p>{field.getMeta().error}</p>
|
<p>{field.getMeta().errorMap?.onChange}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -173,6 +223,63 @@ describe('useField', () => {
|
|||||||
expect(getByText(error)).toBeInTheDocument()
|
expect(getByText(error)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should validate async on change and async on blur', async () => {
|
||||||
|
type Person = {
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
}
|
||||||
|
const onChangeError = 'Please enter a different value (onChangeError)'
|
||||||
|
const onBlurError = 'Please enter a different value (onBlurError)'
|
||||||
|
|
||||||
|
const formFactory = createFormFactory<Person>()
|
||||||
|
|
||||||
|
function Comp() {
|
||||||
|
const form = formFactory.useForm()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form.Provider>
|
||||||
|
<form.Field
|
||||||
|
name="firstName"
|
||||||
|
defaultMeta={{ isTouched: true }}
|
||||||
|
onChangeAsync={async () => {
|
||||||
|
await sleep(10)
|
||||||
|
return onChangeError
|
||||||
|
}}
|
||||||
|
onBlurAsync={async () => {
|
||||||
|
await sleep(10)
|
||||||
|
return onBlurError
|
||||||
|
}}
|
||||||
|
children={(field) => (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
data-testid="fieldinput"
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p>{field.getMeta().errorMap?.onChange}</p>
|
||||||
|
<p>{field.getMeta().errorMap?.onBlur}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getByTestId, getByText, queryByText } = render(<Comp />)
|
||||||
|
const input = getByTestId('fieldinput')
|
||||||
|
|
||||||
|
expect(queryByText(onChangeError)).not.toBeInTheDocument()
|
||||||
|
expect(queryByText(onBlurError)).not.toBeInTheDocument()
|
||||||
|
await user.type(input, 'other')
|
||||||
|
await waitFor(() => getByText(onChangeError))
|
||||||
|
expect(getByText(onChangeError)).toBeInTheDocument()
|
||||||
|
await user.click(document.body)
|
||||||
|
await waitFor(() => getByText(onBlurError))
|
||||||
|
expect(getByText(onBlurError)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('should validate async on change with debounce', async () => {
|
it('should validate async on change with debounce', async () => {
|
||||||
type Person = {
|
type Person = {
|
||||||
firstName: string
|
firstName: string
|
||||||
@@ -205,7 +312,7 @@ describe('useField', () => {
|
|||||||
onBlur={field.handleBlur}
|
onBlur={field.handleBlur}
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<p>{field.getMeta().error}</p>
|
<p>{field.getMeta().errors}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ describe('useField', () => {
|
|||||||
field.setValue((e.target as HTMLInputElement).value)
|
field.setValue((e.target as HTMLInputElement).value)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<p>{field.getMeta().error}</p>
|
<p>{field.getMeta().errors}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
@@ -122,7 +122,7 @@ describe('useField', () => {
|
|||||||
field.handleChange((e.target as HTMLInputElement).value)
|
field.handleChange((e.target as HTMLInputElement).value)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<p>{field.getMeta().error}</p>
|
<p>{field.getMeta().errors}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
@@ -170,7 +170,7 @@ describe('useField', () => {
|
|||||||
field.handleChange((e.target as HTMLInputElement).value)
|
field.handleChange((e.target as HTMLInputElement).value)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<p>{field.getMeta().error}</p>
|
<p>{field.getMeta().errors}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
@@ -222,7 +222,7 @@ describe('useField', () => {
|
|||||||
field.handleChange((e.target as HTMLInputElement).value)
|
field.handleChange((e.target as HTMLInputElement).value)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<p>{field.getMeta().error}</p>
|
<p>{field.getMeta().errors}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
|
|||||||
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
@@ -197,7 +197,7 @@ importers:
|
|||||||
specifier: ^2.3.0
|
specifier: ^2.3.0
|
||||||
version: 2.3.0
|
version: 2.3.0
|
||||||
tsup:
|
tsup:
|
||||||
specifier: ^7.1.0
|
specifier: ^7.2.0
|
||||||
version: 7.2.0(typescript@5.2.2)
|
version: 7.2.0(typescript@5.2.2)
|
||||||
type-fest:
|
type-fest:
|
||||||
specifier: ^3.11.0
|
specifier: ^3.11.0
|
||||||
|
|||||||
Reference in New Issue
Block a user