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:
Ray Liu
2023-09-08 14:14:54 -04:00
committed by GitHub
parent 05aedcea20
commit c2f9957046
10 changed files with 289 additions and 68 deletions

View File

@@ -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.
- 'change' | 'blur' | 'submit'
- 'change' | 'blur' | 'submit' | 'mount'
### `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.
- ```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
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
isValidating: boolean
```

View File

@@ -262,3 +262,11 @@ An object representing the validation metadata for a field.
### `ValidationError`
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`

View File

@@ -92,7 +92,7 @@
"fs-extra": "^11.1.1",
"solid-js": "^1.6.13",
"stream-to-array": "^2.3.0",
"tsup": "^7.1.0",
"tsup": "^7.2.0",
"type-fest": "^3.11.0",
"typescript": "^5.2.2",
"vitest": "^0.34.3",

View File

@@ -1,8 +1,8 @@
import type { DeepKeys, DeepValue, Updater } from './utils'
import type { FormApi, ValidationError } from './FormApi'
import { type DeepKeys, type DeepValue, type Updater } from './utils'
import type { FormApi, ValidationError, ValidationErrorMap } from './FormApi'
import { Store } from '@tanstack/store'
export type ValidationCause = 'change' | 'blur' | 'submit'
export type ValidationCause = 'change' | 'blur' | 'submit' | 'mount'
type ValidateFn<TData, TFormData> = (
value: TData,
@@ -52,8 +52,9 @@ export type FieldApiOptions<TData, TFormData> = FieldOptions<
export type FieldMeta = {
isTouched: boolean
touchedError?: ValidationError
error?: ValidationError
touchedErrors: ValidationError[]
errors: ValidationError[]
errorMap: ValidationErrorMap
isValidating: boolean
}
@@ -110,6 +111,9 @@ export class FieldApi<TData, TFormData> {
meta: this._getMeta() ?? {
isValidating: false,
isTouched: false,
touchedErrors: [],
errors: [],
errorMap: {},
...opts.defaultMeta,
},
},
@@ -117,9 +121,9 @@ export class FieldApi<TData, TFormData> {
onUpdate: () => {
const state = this.store.state
state.meta.touchedError = state.meta.isTouched
? state.meta.error
: undefined
state.meta.touchedErrors = state.meta.isTouched
? state.meta.errors
: []
this.prevState = state
this.state = state
@@ -203,6 +207,9 @@ export class FieldApi<TData, TFormData> {
({
isValidating: false,
isTouched: false,
touchedErrors: [],
errors: [],
errorMap: {},
...this.options.defaultMeta,
} as FieldMeta)
@@ -239,7 +246,6 @@ export class FieldApi<TData, TFormData> {
const { onChange, onBlur } = this.options
const validate =
cause === 'submit' ? undefined : cause === 'change' ? onChange : onBlur
if (!validate) return
// Use the validationCount for all field instances to
@@ -247,16 +253,20 @@ export class FieldApi<TData, TFormData> {
const validationCount = (this.getInfo().validationCount || 0) + 1
this.getInfo().validationCount = validationCount
const error = normalizeError(validate(value as never, this as never))
if (this.state.meta.error !== error) {
const errorMapKey = getErrorMapKey(cause)
if (error && this.state.meta.errorMap[errorMapKey] !== error) {
this.setMeta((prev) => ({
...prev,
error,
errors: [...prev.errors, error],
errorMap: {
...prev.errorMap,
[getErrorMapKey(cause)]: error,
},
}))
}
// If a sync error is encountered, cancel any async validation
if (this.state.meta.error) {
// If a sync error is encountered for the errorMapKey (eg. onChange), cancel any async validation
if (this.state.meta.errorMap[errorMapKey]) {
this.cancelValidateAsync()
}
}
@@ -293,9 +303,7 @@ export class FieldApi<TData, TFormData> {
: cause === 'submit'
? onSubmitAsync
: onBlurAsync
if (!validate) return
if (!validate) return []
const debounceMs =
cause === 'submit'
? 0
@@ -328,21 +336,25 @@ export class FieldApi<TData, TFormData> {
// Only kick off validation if this validation is the latest attempt
if (checkLatest()) {
const prevErrors = this.getMeta().errors
try {
const rawError = await validate(value as never, this as never)
if (checkLatest()) {
const error = normalizeError(rawError)
this.setMeta((prev) => ({
...prev,
isValidating: false,
error,
errors: [...prev.errors, error],
errorMap: {
...prev.errorMap,
[getErrorMapKey(cause)]: error,
},
}))
this.getInfo().validationResolve?.(error)
this.getInfo().validationResolve?.([...prevErrors, error])
}
} catch (error) {
if (checkLatest()) {
this.getInfo().validationReject?.(error)
this.getInfo().validationReject?.([...prevErrors, error])
throw error
}
} finally {
@@ -354,26 +366,25 @@ export class FieldApi<TData, TFormData> {
}
// Always return the latest validation promise to the caller
return this.getInfo().validationPromise
return this.getInfo().validationPromise ?? []
}
validate = (
cause: ValidationCause,
value?: typeof this._tdata,
): ValidationError | Promise<ValidationError> => {
): ValidationError[] | Promise<ValidationError[]> => {
// 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
this.validateSync(value, cause)
// If there is an error, return it, do not attempt async validation
if (this.state.meta.error) {
const errorMapKey = getErrorMapKey(cause)
// 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) {
return this.state.meta.error
return this.state.meta.errors
}
}
// No error? Attempt async validation
return this.validateAsync(value, cause)
}
@@ -403,3 +414,16 @@ function normalizeError(rawError?: ValidationError) {
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

@@ -1,7 +1,7 @@
import { Store } from '@tanstack/store'
//
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'
export type FormOptions<TData> = {
@@ -37,13 +37,19 @@ export type FieldInfo<TFormData> = {
export type ValidationMeta = {
validationCount?: number
validationAsyncCount?: number
validationPromise?: Promise<ValidationError>
validationResolve?: (error: ValidationError) => void
validationReject?: (error: unknown) => void
validationPromise?: Promise<ValidationError[]>
validationResolve?: (errors: ValidationError[]) => void
validationReject?: (errors: unknown) => void
}
export type ValidationError = undefined | false | null | string
export type ValidationErrorMapKeys = `on${Capitalize<ValidationCause>}`
export type ValidationErrorMap = {
[K in ValidationErrorMapKeys]?: ValidationError
}
export type FormState<TData> = {
values: TData
// Form Validation
@@ -117,7 +123,9 @@ export class FormApi<TFormData> {
(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)
@@ -192,8 +200,7 @@ export class FormApi<TFormData> {
)
validateAllFields = async (cause: ValidationCause) => {
const fieldValidationPromises: Promise<ValidationError>[] = [] as any
const fieldValidationPromises: Promise<ValidationError[]>[] = [] as any
this.store.batch(() => {
void (Object.values(this.fieldInfo) as FieldInfo<any>[]).forEach(
(field) => {

View File

@@ -30,6 +30,9 @@ describe('field api', () => {
expect(field.getMeta()).toEqual({
isTouched: false,
isValidating: false,
touchedErrors: [],
errors: [],
errorMap: {},
})
})
@@ -44,6 +47,9 @@ describe('field api', () => {
expect(field.getMeta()).toEqual({
isTouched: true,
isValidating: false,
touchedErrors: [],
errors: [],
errorMap: {},
})
})
@@ -193,9 +199,12 @@ describe('field api', () => {
field.mount()
expect(field.getMeta().error).toBeUndefined()
expect(field.getMeta().errors.length).toBe(0)
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 () => {
@@ -219,10 +228,13 @@ describe('field api', () => {
field.mount()
expect(field.getMeta().error).toBeUndefined()
expect(field.getMeta().errors.length).toBe(0)
field.setValue('other', { touch: true })
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 () => {
@@ -248,13 +260,16 @@ describe('field api', () => {
field.mount()
expect(field.getMeta().error).toBeUndefined()
expect(field.getMeta().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(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 () => {
@@ -280,13 +295,16 @@ describe('field api', () => {
field.mount()
expect(field.getMeta().error).toBeUndefined()
expect(field.getMeta().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(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', () => {
@@ -309,7 +327,10 @@ describe('field api', () => {
field.setValue('other', { touch: true })
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 () => {
@@ -333,11 +354,14 @@ describe('field api', () => {
field.mount()
expect(field.getMeta().error).toBeUndefined()
expect(field.getMeta().errors.length).toBe(0)
field.setValue('other', { touch: true })
field.validate('blur')
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 () => {
@@ -363,14 +387,17 @@ describe('field api', () => {
field.mount()
expect(field.getMeta().error).toBeUndefined()
expect(field.getMeta().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(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 () => {
@@ -396,14 +423,17 @@ describe('field api', () => {
field.mount()
expect(field.getMeta().error).toBeUndefined()
expect(field.getMeta().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(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 () => {
@@ -427,11 +457,48 @@ describe('field api', () => {
field.mount()
expect(field.getMeta().error).toBeUndefined()
expect(field.getMeta().errors.length).toBe(0)
field.setValue('other', { touch: true })
field.validate('submit')
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 () => {

View File

@@ -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> &
Required<Pick<T, K>>

View File

@@ -71,7 +71,7 @@ describe('useField', () => {
onBlur={field.handleBlur}
onChange={(e) => field.setValue(e.target.value)}
/>
<p>{field.getMeta().error}</p>
<p>{field.getMeta().errors}</p>
</div>
)}
/>
@@ -112,7 +112,7 @@ describe('useField', () => {
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
<p>{field.getMeta().error}</p>
<p>{field.getMeta().errorMap?.onChange}</p>
</div>
)}
/>
@@ -127,6 +127,56 @@ describe('useField', () => {
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 () => {
type Person = {
firstName: string
@@ -157,7 +207,7 @@ describe('useField', () => {
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
<p>{field.getMeta().error}</p>
<p>{field.getMeta().errorMap?.onChange}</p>
</div>
)}
/>
@@ -173,6 +223,63 @@ describe('useField', () => {
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 () => {
type Person = {
firstName: string
@@ -205,7 +312,7 @@ describe('useField', () => {
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
<p>{field.getMeta().error}</p>
<p>{field.getMeta().errors}</p>
</div>
)}
/>

View File

@@ -79,7 +79,7 @@ describe('useField', () => {
field.setValue((e.target as HTMLInputElement).value)
}
/>
<p>{field.getMeta().error}</p>
<p>{field.getMeta().errors}</p>
</div>
)}
</form.Field>
@@ -122,7 +122,7 @@ describe('useField', () => {
field.handleChange((e.target as HTMLInputElement).value)
}
/>
<p>{field.getMeta().error}</p>
<p>{field.getMeta().errors}</p>
</div>
)}
</form.Field>
@@ -170,7 +170,7 @@ describe('useField', () => {
field.handleChange((e.target as HTMLInputElement).value)
}
/>
<p>{field.getMeta().error}</p>
<p>{field.getMeta().errors}</p>
</div>
)}
</form.Field>
@@ -222,7 +222,7 @@ describe('useField', () => {
field.handleChange((e.target as HTMLInputElement).value)
}
/>
<p>{field.getMeta().error}</p>
<p>{field.getMeta().errors}</p>
</div>
)}
</form.Field>

2
pnpm-lock.yaml generated
View File

@@ -197,7 +197,7 @@ importers:
specifier: ^2.3.0
version: 2.3.0
tsup:
specifier: ^7.1.0
specifier: ^7.2.0
version: 7.2.0(typescript@5.2.2)
type-fest:
specifier: ^3.11.0