Skip to content

Commit c2f9957

Browse files
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>
1 parent 05aedce commit c2f9957

File tree

10 files changed

+289
-68
lines changed

10 files changed

+289
-68
lines changed

docs/reference/fieldApi.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ An object type representing the options for a field in a form.
8383

8484
A type representing the cause of a validation event.
8585

86-
- 'change' | 'blur' | 'submit'
86+
- 'change' | 'blur' | 'submit' | 'mount'
8787

8888
### `FieldMeta`
8989

@@ -94,13 +94,17 @@ An object type representing the metadata of a field in a form.
9494
```
9595
- A flag indicating whether the field has been touched.
9696
- ```tsx
97-
touchedError?: ValidationError
97+
touchedErrors: ValidationError[]
9898
```
99-
- An optional error related to the touched state of the field.
99+
- An array of errors related to the touched state of the field.
100100
- ```tsx
101-
error?: ValidationError
101+
errors: ValidationError[]
102102
```
103-
- An optional error related to the field value.
103+
- An array of errors related related to the field value.
104+
- ```tsx
105+
errorMap: ValidationErrorMap
106+
```
107+
- A map of errors related related to the field value.
104108
- ```tsx
105109
isValidating: boolean
106110
```

docs/reference/formApi.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,3 +262,11 @@ An object representing the validation metadata for a field.
262262
### `ValidationError`
263263

264264
A type representing a validation error. Possible values are `undefined`, `false`, `null`, or a `string` with an error message.
265+
266+
### `ValidationErrorMapKeys`
267+
A type representing the keys used to map to `ValidationError` in `ValidationErrorMap`. It is defined with `on${Capitalize<ValidationCause>}`
268+
269+
270+
### `ValidationErrorMap`
271+
272+
A type that represents a map with the keys as `ValidationErrorMapKeys` and the values as `ValidationError`

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@
9292
"fs-extra": "^11.1.1",
9393
"solid-js": "^1.6.13",
9494
"stream-to-array": "^2.3.0",
95-
"tsup": "^7.1.0",
95+
"tsup": "^7.2.0",
9696
"type-fest": "^3.11.0",
9797
"typescript": "^5.2.2",
9898
"vitest": "^0.34.3",

packages/form-core/src/FieldApi.ts

Lines changed: 53 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import type { DeepKeys, DeepValue, Updater } from './utils'
2-
import type { FormApi, ValidationError } from './FormApi'
1+
import { type DeepKeys, type DeepValue, type Updater } from './utils'
2+
import type { FormApi, ValidationError, ValidationErrorMap } from './FormApi'
33
import { Store } from '@tanstack/store'
44

5-
export type ValidationCause = 'change' | 'blur' | 'submit'
5+
export type ValidationCause = 'change' | 'blur' | 'submit' | 'mount'
66

77
type ValidateFn<TData, TFormData> = (
88
value: TData,
@@ -52,8 +52,9 @@ export type FieldApiOptions<TData, TFormData> = FieldOptions<
5252

5353
export type FieldMeta = {
5454
isTouched: boolean
55-
touchedError?: ValidationError
56-
error?: ValidationError
55+
touchedErrors: ValidationError[]
56+
errors: ValidationError[]
57+
errorMap: ValidationErrorMap
5758
isValidating: boolean
5859
}
5960

@@ -110,16 +111,19 @@ export class FieldApi<TData, TFormData> {
110111
meta: this._getMeta() ?? {
111112
isValidating: false,
112113
isTouched: false,
114+
touchedErrors: [],
115+
errors: [],
116+
errorMap: {},
113117
...opts.defaultMeta,
114118
},
115119
},
116120
{
117121
onUpdate: () => {
118122
const state = this.store.state
119123

120-
state.meta.touchedError = state.meta.isTouched
121-
? state.meta.error
122-
: undefined
124+
state.meta.touchedErrors = state.meta.isTouched
125+
? state.meta.errors
126+
: []
123127

124128
this.prevState = state
125129
this.state = state
@@ -203,6 +207,9 @@ export class FieldApi<TData, TFormData> {
203207
({
204208
isValidating: false,
205209
isTouched: false,
210+
touchedErrors: [],
211+
errors: [],
212+
errorMap: {},
206213
...this.options.defaultMeta,
207214
} as FieldMeta)
208215

@@ -239,24 +246,27 @@ export class FieldApi<TData, TFormData> {
239246
const { onChange, onBlur } = this.options
240247
const validate =
241248
cause === 'submit' ? undefined : cause === 'change' ? onChange : onBlur
242-
243249
if (!validate) return
244250

245251
// Use the validationCount for all field instances to
246252
// track freshness of the validation
247253
const validationCount = (this.getInfo().validationCount || 0) + 1
248254
this.getInfo().validationCount = validationCount
249255
const error = normalizeError(validate(value as never, this as never))
250-
251-
if (this.state.meta.error !== error) {
256+
const errorMapKey = getErrorMapKey(cause)
257+
if (error && this.state.meta.errorMap[errorMapKey] !== error) {
252258
this.setMeta((prev) => ({
253259
...prev,
254-
error,
260+
errors: [...prev.errors, error],
261+
errorMap: {
262+
...prev.errorMap,
263+
[getErrorMapKey(cause)]: error,
264+
},
255265
}))
256266
}
257267

258-
// If a sync error is encountered, cancel any async validation
259-
if (this.state.meta.error) {
268+
// If a sync error is encountered for the errorMapKey (eg. onChange), cancel any async validation
269+
if (this.state.meta.errorMap[errorMapKey]) {
260270
this.cancelValidateAsync()
261271
}
262272
}
@@ -293,9 +303,7 @@ export class FieldApi<TData, TFormData> {
293303
: cause === 'submit'
294304
? onSubmitAsync
295305
: onBlurAsync
296-
297-
if (!validate) return
298-
306+
if (!validate) return []
299307
const debounceMs =
300308
cause === 'submit'
301309
? 0
@@ -328,21 +336,25 @@ export class FieldApi<TData, TFormData> {
328336

329337
// Only kick off validation if this validation is the latest attempt
330338
if (checkLatest()) {
339+
const prevErrors = this.getMeta().errors
331340
try {
332341
const rawError = await validate(value as never, this as never)
333-
334342
if (checkLatest()) {
335343
const error = normalizeError(rawError)
336344
this.setMeta((prev) => ({
337345
...prev,
338346
isValidating: false,
339-
error,
347+
errors: [...prev.errors, error],
348+
errorMap: {
349+
...prev.errorMap,
350+
[getErrorMapKey(cause)]: error,
351+
},
340352
}))
341-
this.getInfo().validationResolve?.(error)
353+
this.getInfo().validationResolve?.([...prevErrors, error])
342354
}
343355
} catch (error) {
344356
if (checkLatest()) {
345-
this.getInfo().validationReject?.(error)
357+
this.getInfo().validationReject?.([...prevErrors, error])
346358
throw error
347359
}
348360
} finally {
@@ -354,26 +366,25 @@ export class FieldApi<TData, TFormData> {
354366
}
355367

356368
// Always return the latest validation promise to the caller
357-
return this.getInfo().validationPromise
369+
return this.getInfo().validationPromise ?? []
358370
}
359371

360372
validate = (
361373
cause: ValidationCause,
362374
value?: typeof this._tdata,
363-
): ValidationError | Promise<ValidationError> => {
375+
): ValidationError[] | Promise<ValidationError[]> => {
364376
// If the field is pristine and validatePristine is false, do not validate
365-
if (!this.state.meta.isTouched) return
366-
377+
if (!this.state.meta.isTouched) return []
367378
// Attempt to sync validate first
368379
this.validateSync(value, cause)
369380

370-
// If there is an error, return it, do not attempt async validation
371-
if (this.state.meta.error) {
381+
const errorMapKey = getErrorMapKey(cause)
382+
// If there is an error mapped to the errorMapKey (eg. onChange, onBlur, onSubmit), return the errors array, do not attempt async validation
383+
if (this.getMeta().errorMap[errorMapKey]) {
372384
if (!this.options.asyncAlways) {
373-
return this.state.meta.error
385+
return this.state.meta.errors
374386
}
375387
}
376-
377388
// No error? Attempt async validation
378389
return this.validateAsync(value, cause)
379390
}
@@ -403,3 +414,16 @@ function normalizeError(rawError?: ValidationError) {
403414

404415
return undefined
405416
}
417+
418+
function getErrorMapKey(cause: ValidationCause) {
419+
switch (cause) {
420+
case 'submit':
421+
return 'onSubmit'
422+
case 'change':
423+
return 'onChange'
424+
case 'blur':
425+
return 'onBlur'
426+
case 'mount':
427+
return 'onMount'
428+
}
429+
}

packages/form-core/src/FormApi.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Store } from '@tanstack/store'
22
//
33
import type { DeepKeys, DeepValue, Updater } from './utils'
4-
import { functionalUpdate, getBy, setBy } from './utils'
4+
import { functionalUpdate, getBy, isNonEmptyArray, setBy } from './utils'
55
import type { FieldApi, FieldMeta, ValidationCause } from './FieldApi'
66

77
export type FormOptions<TData> = {
@@ -37,13 +37,19 @@ export type FieldInfo<TFormData> = {
3737
export type ValidationMeta = {
3838
validationCount?: number
3939
validationAsyncCount?: number
40-
validationPromise?: Promise<ValidationError>
41-
validationResolve?: (error: ValidationError) => void
42-
validationReject?: (error: unknown) => void
40+
validationPromise?: Promise<ValidationError[]>
41+
validationResolve?: (errors: ValidationError[]) => void
42+
validationReject?: (errors: unknown) => void
4343
}
4444

4545
export type ValidationError = undefined | false | null | string
4646

47+
export type ValidationErrorMapKeys = `on${Capitalize<ValidationCause>}`
48+
49+
export type ValidationErrorMap = {
50+
[K in ValidationErrorMapKeys]?: ValidationError
51+
}
52+
4753
export type FormState<TData> = {
4854
values: TData
4955
// Form Validation
@@ -117,7 +123,9 @@ export class FormApi<TFormData> {
117123
(field) => field?.isValidating,
118124
)
119125

120-
const isFieldsValid = !fieldMetaValues.some((field) => field?.error)
126+
const isFieldsValid = !fieldMetaValues.some((field) =>
127+
isNonEmptyArray(field?.errors),
128+
)
121129

122130
const isTouched = fieldMetaValues.some((field) => field?.isTouched)
123131

@@ -192,8 +200,7 @@ export class FormApi<TFormData> {
192200
)
193201

194202
validateAllFields = async (cause: ValidationCause) => {
195-
const fieldValidationPromises: Promise<ValidationError>[] = [] as any
196-
203+
const fieldValidationPromises: Promise<ValidationError[]>[] = [] as any
197204
this.store.batch(() => {
198205
void (Object.values(this.fieldInfo) as FieldInfo<any>[]).forEach(
199206
(field) => {

0 commit comments

Comments
 (0)