@tanstack/form-core 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/FormApi.ts CHANGED
@@ -21,6 +21,7 @@ type ValidateAsyncFn<TData, ValidatorType> = (
21
21
  export type FormOptions<TData, ValidatorType> = {
22
22
  defaultValues?: TData
23
23
  defaultState?: Partial<FormState<TData>>
24
+ asyncAlways?: boolean
24
25
  asyncDebounceMs?: number
25
26
  validator?: ValidatorType
26
27
  onMount?: ValidateOrFn<TData, ValidatorType>
@@ -47,8 +48,8 @@ export type FieldInfo<TFormData, ValidatorType> = {
47
48
  export type ValidationMeta = {
48
49
  validationCount?: number
49
50
  validationAsyncCount?: number
50
- validationPromise?: Promise<ValidationError[]>
51
- validationResolve?: (errors: ValidationError[]) => void
51
+ validationPromise?: Promise<ValidationError[] | undefined>
52
+ validationResolve?: (errors: ValidationError[] | undefined) => void
52
53
  validationReject?: (errors: unknown) => void
53
54
  }
54
55
 
@@ -64,7 +65,8 @@ export type FormState<TData> = {
64
65
  isFormValidating: boolean
65
66
  formValidationCount: number
66
67
  isFormValid: boolean
67
- formError?: ValidationError
68
+ errors: ValidationError[]
69
+ errorMap: ValidationErrorMap
68
70
  // Fields
69
71
  fieldMeta: Record<DeepKeys<TData>, FieldMeta>
70
72
  isFieldsValidating: boolean
@@ -84,6 +86,8 @@ function getDefaultFormState<TData>(
84
86
  ): FormState<TData> {
85
87
  return {
86
88
  values: defaultState.values ?? ({} as never),
89
+ errors: defaultState.errors ?? [],
90
+ errorMap: defaultState.errorMap ?? {},
87
91
  fieldMeta: defaultState.fieldMeta ?? ({} as never),
88
92
  canSubmit: defaultState.canSubmit ?? true,
89
93
  isFieldsValid: defaultState.isFieldsValid ?? false,
@@ -141,7 +145,10 @@ export class FormApi<TFormData, ValidatorType> {
141
145
  const isTouched = fieldMetaValues.some((field) => field?.isTouched)
142
146
 
143
147
  const isValidating = isFieldsValidating || state.isFormValidating
144
- const isFormValid = !state.formError
148
+ state.errors = Object.values(state.errorMap).filter(
149
+ (val: unknown) => val !== undefined,
150
+ )
151
+ const isFormValid = state.errors.length === 0
145
152
  const isValid = isFieldsValid && isFormValid
146
153
  const canSubmit =
147
154
  (state.submissionAttempts === 0 && !isTouched) ||
@@ -169,14 +176,23 @@ export class FormApi<TFormData, ValidatorType> {
169
176
  }
170
177
 
171
178
  mount = () => {
172
- if (typeof this.options.onMount === 'function') {
173
- return this.options.onMount(this.state.values, this)
179
+ const doValidate = () => {
180
+ if (typeof this.options.onMount === 'function') {
181
+ return this.options.onMount(this.state.values, this)
182
+ }
183
+ if (this.options.validator) {
184
+ return (this.options.validator as Validator<TFormData>)().validate(
185
+ this.state.values,
186
+ this.options.onMount,
187
+ )
188
+ }
174
189
  }
175
- if (this.options.validator) {
176
- return (this.options.validator as Validator<TFormData>)().validate(
177
- this.state.values,
178
- this.options.onMount,
179
- )
190
+ const error = doValidate()
191
+ if (error) {
192
+ this.store.setState((prev) => ({
193
+ ...prev,
194
+ errorMap: { ...prev.errorMap, onMount: error },
195
+ }))
180
196
  }
181
197
  }
182
198
 
@@ -245,6 +261,177 @@ export class FormApi<TFormData, ValidatorType> {
245
261
  return Promise.all(fieldValidationPromises)
246
262
  }
247
263
 
264
+ validateSync = (cause: ValidationCause): void => {
265
+ const { onChange, onBlur } = this.options
266
+ const validate =
267
+ cause === 'change' ? onChange : cause === 'blur' ? onBlur : undefined
268
+ if (!validate) return
269
+
270
+ const errorMapKey = getErrorMapKey(cause)
271
+ const doValidate = () => {
272
+ if (typeof validate === 'function') {
273
+ return validate(this.state.values, this) as ValidationError
274
+ }
275
+ if (this.options.validator && typeof validate !== 'function') {
276
+ return (this.options.validator as Validator<TFormData>)().validate(
277
+ this.state.values,
278
+ validate,
279
+ )
280
+ }
281
+ throw new Error(
282
+ `Form validation for ${errorMapKey} failed. ${errorMapKey} should either be a function, or \`validator\` should be correct.`,
283
+ )
284
+ }
285
+
286
+ const error = normalizeError(doValidate())
287
+ if (this.state.errorMap[errorMapKey] !== error) {
288
+ this.store.setState((prev) => ({
289
+ ...prev,
290
+ errorMap: {
291
+ ...prev.errorMap,
292
+ [errorMapKey]: error,
293
+ },
294
+ }))
295
+ }
296
+
297
+ if (this.state.errorMap[errorMapKey]) {
298
+ this.cancelValidateAsync()
299
+ }
300
+ }
301
+
302
+ __leaseValidateAsync = () => {
303
+ const count = (this.validationMeta.validationAsyncCount || 0) + 1
304
+ this.validationMeta.validationAsyncCount = count
305
+ return count
306
+ }
307
+
308
+ cancelValidateAsync = () => {
309
+ // Lease a new validation count to ignore any pending validations
310
+ this.__leaseValidateAsync()
311
+ // Cancel any pending validation state
312
+ this.store.setState((prev) => ({
313
+ ...prev,
314
+ isFormValidating: false,
315
+ }))
316
+ }
317
+
318
+ validateAsync = async (
319
+ cause: ValidationCause,
320
+ ): Promise<ValidationError[]> => {
321
+ const {
322
+ onChangeAsync,
323
+ onBlurAsync,
324
+ asyncDebounceMs,
325
+ onBlurAsyncDebounceMs,
326
+ onChangeAsyncDebounceMs,
327
+ } = this.options
328
+
329
+ const validate =
330
+ cause === 'change'
331
+ ? onChangeAsync
332
+ : cause === 'blur'
333
+ ? onBlurAsync
334
+ : undefined
335
+
336
+ if (!validate) return []
337
+ const debounceMs =
338
+ (cause === 'change' ? onChangeAsyncDebounceMs : onBlurAsyncDebounceMs) ??
339
+ asyncDebounceMs ??
340
+ 0
341
+
342
+ if (!this.state.isFormValidating) {
343
+ this.store.setState((prev) => ({ ...prev, isFormValidating: true }))
344
+ }
345
+
346
+ // Use the validationCount for all field instances to
347
+ // track freshness of the validation
348
+ const validationAsyncCount = this.__leaseValidateAsync()
349
+
350
+ const checkLatest = () =>
351
+ validationAsyncCount === this.validationMeta.validationAsyncCount
352
+
353
+ if (!this.validationMeta.validationPromise) {
354
+ this.validationMeta.validationPromise = new Promise((resolve, reject) => {
355
+ this.validationMeta.validationResolve = resolve
356
+ this.validationMeta.validationReject = reject
357
+ })
358
+ }
359
+
360
+ if (debounceMs > 0) {
361
+ await new Promise((r) => setTimeout(r, debounceMs))
362
+ }
363
+
364
+ const doValidate = () => {
365
+ if (typeof validate === 'function') {
366
+ return validate(this.state.values, this) as ValidationError
367
+ }
368
+ if (this.options.validator && typeof validate !== 'function') {
369
+ return (this.options.validator as Validator<TFormData>)().validateAsync(
370
+ this.state.values,
371
+ validate,
372
+ )
373
+ }
374
+ const errorMapKey = getErrorMapKey(cause)
375
+ throw new Error(
376
+ `Form validation for ${errorMapKey}Async failed. ${errorMapKey}Async should either be a function, or \`validator\` should be correct.`,
377
+ )
378
+ }
379
+
380
+ // Only kick off validation if this validation is the latest attempt
381
+ if (checkLatest()) {
382
+ const prevErrors = this.state.errors
383
+ try {
384
+ const rawError = await doValidate()
385
+ if (checkLatest()) {
386
+ const error = normalizeError(rawError)
387
+ this.store.setState((prev) => ({
388
+ ...prev,
389
+ isFormValidating: false,
390
+ errorMap: {
391
+ ...prev.errorMap,
392
+ [getErrorMapKey(cause)]: error,
393
+ },
394
+ }))
395
+ this.validationMeta.validationResolve?.([...prevErrors, error])
396
+ }
397
+ } catch (error) {
398
+ if (checkLatest()) {
399
+ this.validationMeta.validationReject?.([...prevErrors, error])
400
+ throw error
401
+ }
402
+ } finally {
403
+ if (checkLatest()) {
404
+ this.store.setState((prev) => ({ ...prev, isFormValidating: false }))
405
+ delete this.validationMeta.validationPromise
406
+ }
407
+ }
408
+ }
409
+ // Always return the latest validation promise to the caller
410
+ return (await this.validationMeta.validationPromise) ?? []
411
+ }
412
+
413
+ validate = (
414
+ cause: ValidationCause,
415
+ ): ValidationError[] | Promise<ValidationError[]> => {
416
+ // Store the previous error for the errorMapKey (eg. onChange, onBlur, onSubmit)
417
+ const errorMapKey = getErrorMapKey(cause)
418
+ const prevError = this.state.errorMap[errorMapKey]
419
+
420
+ // Attempt to sync validate first
421
+ this.validateSync(cause)
422
+
423
+ const newError = this.state.errorMap[errorMapKey]
424
+ if (
425
+ prevError !== newError &&
426
+ !this.options.asyncAlways &&
427
+ !(newError === undefined && prevError !== undefined)
428
+ )
429
+ return this.state.errors
430
+
431
+ // No error? Attempt async validation
432
+ return this.validateAsync(cause)
433
+ }
434
+
248
435
  handleSubmit = async () => {
249
436
  // Check to see that the form and all fields have been touched
250
437
  // If they have not, touch them all and run validation
@@ -279,7 +466,7 @@ export class FormApi<TFormData, ValidatorType> {
279
466
  }
280
467
 
281
468
  // Run validation for the form
282
- // await this.validateForm()
469
+ await this.validate('submit')
283
470
 
284
471
  if (!this.state.isValid) {
285
472
  done()
@@ -428,3 +615,28 @@ export class FormApi<TFormData, ValidatorType> {
428
615
  })
429
616
  }
430
617
  }
618
+
619
+ function normalizeError(rawError?: ValidationError) {
620
+ if (rawError) {
621
+ if (typeof rawError !== 'string') {
622
+ return 'Invalid Form Values'
623
+ }
624
+
625
+ return rawError
626
+ }
627
+
628
+ return undefined
629
+ }
630
+
631
+ function getErrorMapKey(cause: ValidationCause) {
632
+ switch (cause) {
633
+ case 'submit':
634
+ return 'onSubmit'
635
+ case 'change':
636
+ return 'onChange'
637
+ case 'blur':
638
+ return 'onBlur'
639
+ case 'mount':
640
+ return 'onMount'
641
+ }
642
+ }
@@ -2,6 +2,7 @@ import { expect } from 'vitest'
2
2
 
3
3
  import { FormApi } from '../FormApi'
4
4
  import { FieldApi } from '../FieldApi'
5
+ import { sleep } from './utils'
5
6
 
6
7
  describe('form api', () => {
7
8
  it('should get default form state', () => {
@@ -16,6 +17,8 @@ describe('form api', () => {
16
17
  isFormValid: true,
17
18
  isFormValidating: false,
18
19
  isSubmitted: false,
20
+ errors: [],
21
+ errorMap: {},
19
22
  isSubmitting: false,
20
23
  isTouched: false,
21
24
  isValid: true,
@@ -39,6 +42,8 @@ describe('form api', () => {
39
42
  fieldMeta: {},
40
43
  canSubmit: true,
41
44
  isFieldsValid: true,
45
+ errors: [],
46
+ errorMap: {},
42
47
  isFieldsValidating: false,
43
48
  isFormValid: true,
44
49
  isFormValidating: false,
@@ -62,6 +67,8 @@ describe('form api', () => {
62
67
  expect(form.state).toEqual({
63
68
  values: {},
64
69
  fieldMeta: {},
70
+ errors: [],
71
+ errorMap: {},
65
72
  canSubmit: true,
66
73
  isFieldsValid: true,
67
74
  isFieldsValidating: false,
@@ -97,6 +104,8 @@ describe('form api', () => {
97
104
  values: {
98
105
  name: 'other',
99
106
  },
107
+ errors: [],
108
+ errorMap: {},
100
109
  fieldMeta: {},
101
110
  canSubmit: true,
102
111
  isFieldsValid: true,
@@ -129,6 +138,8 @@ describe('form api', () => {
129
138
  values: {
130
139
  name: 'test',
131
140
  },
141
+ errors: [],
142
+ errorMap: {},
132
143
  fieldMeta: {},
133
144
  canSubmit: true,
134
145
  isFieldsValid: true,
@@ -316,4 +327,345 @@ describe('form api', () => {
316
327
  expect(form.state.isFieldsValid).toEqual(true)
317
328
  expect(form.state.canSubmit).toEqual(true)
318
329
  })
330
+
331
+ it('should run validation onChange', () => {
332
+ const form = new FormApi({
333
+ defaultValues: {
334
+ name: 'test',
335
+ },
336
+ onChange: (value) => {
337
+ if (value.name === 'other') return 'Please enter a different value'
338
+ return
339
+ },
340
+ })
341
+
342
+ const field = new FieldApi({
343
+ form,
344
+ name: 'name',
345
+ })
346
+ form.mount()
347
+ field.mount()
348
+
349
+ expect(form.state.errors.length).toBe(0)
350
+ field.setValue('other', { touch: true })
351
+ expect(form.state.errors).toContain('Please enter a different value')
352
+ expect(form.state.errorMap).toMatchObject({
353
+ onChange: 'Please enter a different value',
354
+ })
355
+ })
356
+
357
+ it('should run async validation onChange', async () => {
358
+ vi.useFakeTimers()
359
+
360
+ const form = new FormApi({
361
+ defaultValues: {
362
+ name: 'test',
363
+ },
364
+ onChangeAsync: async (value) => {
365
+ await sleep(1000)
366
+ if (value.name === 'other') return 'Please enter a different value'
367
+ return
368
+ },
369
+ })
370
+ const field = new FieldApi({
371
+ form,
372
+ name: 'name',
373
+ })
374
+ form.mount()
375
+
376
+ field.mount()
377
+
378
+ expect(form.state.errors.length).toBe(0)
379
+ field.setValue('other', { touch: true })
380
+ await vi.runAllTimersAsync()
381
+ expect(form.state.errors).toContain('Please enter a different value')
382
+ expect(form.state.errorMap).toMatchObject({
383
+ onChange: 'Please enter a different value',
384
+ })
385
+ })
386
+
387
+ it('should run async validation onChange with debounce', async () => {
388
+ vi.useFakeTimers()
389
+ const sleepMock = vi.fn().mockImplementation(sleep)
390
+
391
+ const form = new FormApi({
392
+ defaultValues: {
393
+ name: 'test',
394
+ },
395
+ onChangeAsyncDebounceMs: 1000,
396
+ onChangeAsync: async (value) => {
397
+ await sleepMock(1000)
398
+ if (value.name === 'other') return 'Please enter a different value'
399
+ return
400
+ },
401
+ })
402
+ const field = new FieldApi({
403
+ form,
404
+ name: 'name',
405
+ })
406
+ form.mount()
407
+
408
+ field.mount()
409
+
410
+ expect(form.state.errors.length).toBe(0)
411
+ field.setValue('other', { touch: true })
412
+ field.setValue('other')
413
+ await vi.runAllTimersAsync()
414
+ // sleepMock will have been called 2 times without onChangeAsyncDebounceMs
415
+ expect(sleepMock).toHaveBeenCalledTimes(1)
416
+ expect(form.state.errors).toContain('Please enter a different value')
417
+ expect(form.state.errorMap).toMatchObject({
418
+ onChange: 'Please enter a different value',
419
+ })
420
+ })
421
+
422
+ it('should run async validation onChange with asyncDebounceMs', async () => {
423
+ vi.useFakeTimers()
424
+ const sleepMock = vi.fn().mockImplementation(sleep)
425
+
426
+ const form = new FormApi({
427
+ defaultValues: {
428
+ name: 'test',
429
+ },
430
+ asyncDebounceMs: 1000,
431
+ onChangeAsync: async (value) => {
432
+ await sleepMock(1000)
433
+ if (value.name === 'other') return 'Please enter a different value'
434
+ return
435
+ },
436
+ })
437
+ const field = new FieldApi({
438
+ form,
439
+ name: 'name',
440
+ })
441
+
442
+ form.mount()
443
+ field.mount()
444
+
445
+ expect(form.state.errors.length).toBe(0)
446
+ field.setValue('other', { touch: true })
447
+ field.setValue('other')
448
+ await vi.runAllTimersAsync()
449
+ // sleepMock will have been called 2 times without asyncDebounceMs
450
+ expect(sleepMock).toHaveBeenCalledTimes(1)
451
+ expect(form.state.errors).toContain('Please enter a different value')
452
+ expect(form.state.errorMap).toMatchObject({
453
+ onChange: 'Please enter a different value',
454
+ })
455
+ })
456
+
457
+ it('should run validation onBlur', () => {
458
+ const form = new FormApi({
459
+ defaultValues: {
460
+ name: 'other',
461
+ },
462
+ onBlur: (value) => {
463
+ if (value.name === 'other') return 'Please enter a different value'
464
+ return
465
+ },
466
+ })
467
+ const field = new FieldApi({
468
+ form,
469
+ name: 'name',
470
+ })
471
+
472
+ form.mount()
473
+ field.mount()
474
+
475
+ field.setValue('other', { touch: true })
476
+ field.validate('blur')
477
+ expect(form.state.errors).toContain('Please enter a different value')
478
+ expect(form.state.errorMap).toMatchObject({
479
+ onBlur: 'Please enter a different value',
480
+ })
481
+ })
482
+
483
+ it('should run async validation onBlur', async () => {
484
+ vi.useFakeTimers()
485
+
486
+ const form = new FormApi({
487
+ defaultValues: {
488
+ name: 'test',
489
+ },
490
+ onBlurAsync: async (value) => {
491
+ await sleep(1000)
492
+ if (value.name === 'other') return 'Please enter a different value'
493
+ return
494
+ },
495
+ })
496
+ const field = new FieldApi({
497
+ form,
498
+ name: 'name',
499
+ })
500
+
501
+ form.mount()
502
+ field.mount()
503
+
504
+ expect(form.state.errors.length).toBe(0)
505
+ field.setValue('other', { touch: true })
506
+ field.validate('blur')
507
+ await vi.runAllTimersAsync()
508
+ expect(form.state.errors).toContain('Please enter a different value')
509
+ expect(form.state.errorMap).toMatchObject({
510
+ onBlur: 'Please enter a different value',
511
+ })
512
+ })
513
+
514
+ it('should run async validation onBlur with debounce', async () => {
515
+ vi.useFakeTimers()
516
+ const sleepMock = vi.fn().mockImplementation(sleep)
517
+
518
+ const form = new FormApi({
519
+ defaultValues: {
520
+ name: 'test',
521
+ },
522
+ onBlurAsyncDebounceMs: 1000,
523
+ onBlurAsync: async (value) => {
524
+ await sleepMock(10)
525
+ if (value.name === 'other') return 'Please enter a different value'
526
+ return
527
+ },
528
+ })
529
+ const field = new FieldApi({
530
+ form,
531
+ name: 'name',
532
+ })
533
+
534
+ form.mount()
535
+ field.mount()
536
+
537
+ expect(form.state.errors.length).toBe(0)
538
+ field.setValue('other', { touch: true })
539
+ field.validate('blur')
540
+ field.validate('blur')
541
+ await vi.runAllTimersAsync()
542
+ // sleepMock will have been called 2 times without onBlurAsyncDebounceMs
543
+ expect(sleepMock).toHaveBeenCalledTimes(1)
544
+ expect(form.state.errors).toContain('Please enter a different value')
545
+ expect(form.state.errorMap).toMatchObject({
546
+ onBlur: 'Please enter a different value',
547
+ })
548
+ })
549
+
550
+ it('should run async validation onBlur with asyncDebounceMs', async () => {
551
+ vi.useFakeTimers()
552
+ const sleepMock = vi.fn().mockImplementation(sleep)
553
+
554
+ const form = new FormApi({
555
+ defaultValues: {
556
+ name: 'test',
557
+ },
558
+ asyncDebounceMs: 1000,
559
+ onBlurAsync: async (value) => {
560
+ await sleepMock(10)
561
+ if (value.name === 'other') return 'Please enter a different value'
562
+ return
563
+ },
564
+ })
565
+ const field = new FieldApi({
566
+ form,
567
+ name: 'name',
568
+ })
569
+
570
+ form.mount()
571
+ field.mount()
572
+
573
+ expect(form.state.errors.length).toBe(0)
574
+ field.setValue('other', { touch: true })
575
+ field.validate('blur')
576
+ field.validate('blur')
577
+ await vi.runAllTimersAsync()
578
+ // sleepMock will have been called 2 times without asyncDebounceMs
579
+ expect(sleepMock).toHaveBeenCalledTimes(1)
580
+ expect(form.state.errors).toContain('Please enter a different value')
581
+ expect(form.state.errorMap).toMatchObject({
582
+ onBlur: 'Please enter a different value',
583
+ })
584
+ })
585
+
586
+ it('should contain multiple errors when running validation onBlur and onChange', () => {
587
+ const form = new FormApi({
588
+ defaultValues: {
589
+ name: 'other',
590
+ },
591
+ onBlur: (value) => {
592
+ if (value.name === 'other') return 'Please enter a different value'
593
+ return
594
+ },
595
+ onChange: (value) => {
596
+ if (value.name === 'other') return 'Please enter a different value'
597
+ return
598
+ },
599
+ })
600
+ const field = new FieldApi({
601
+ form,
602
+ name: 'name',
603
+ })
604
+
605
+ form.mount()
606
+ field.mount()
607
+
608
+ field.setValue('other', { touch: true })
609
+ field.validate('blur')
610
+ expect(form.state.errors).toStrictEqual([
611
+ 'Please enter a different value',
612
+ 'Please enter a different value',
613
+ ])
614
+ expect(form.state.errorMap).toEqual({
615
+ onBlur: 'Please enter a different value',
616
+ onChange: 'Please enter a different value',
617
+ })
618
+ })
619
+
620
+ it('should reset onChange errors when the issue is resolved', () => {
621
+ const form = new FormApi({
622
+ defaultValues: {
623
+ name: 'other',
624
+ },
625
+ onChange: (value) => {
626
+ if (value.name === 'other') return 'Please enter a different value'
627
+ return
628
+ },
629
+ })
630
+ const field = new FieldApi({
631
+ form,
632
+ name: 'name',
633
+ })
634
+
635
+ form.mount()
636
+ field.mount()
637
+
638
+ field.setValue('other', { touch: true })
639
+ expect(form.state.errors).toStrictEqual(['Please enter a different value'])
640
+ expect(form.state.errorMap).toEqual({
641
+ onChange: 'Please enter a different value',
642
+ })
643
+ field.setValue('test', { touch: true })
644
+ expect(form.state.errors).toStrictEqual([])
645
+ expect(form.state.errorMap).toEqual({})
646
+ })
647
+
648
+ it('should return error onMount', () => {
649
+ const form = new FormApi({
650
+ defaultValues: {
651
+ name: 'other',
652
+ },
653
+ onMount: (value) => {
654
+ if (value.name === 'other') return 'Please enter a different value'
655
+ return
656
+ },
657
+ })
658
+ const field = new FieldApi({
659
+ form,
660
+ name: 'name',
661
+ })
662
+
663
+ form.mount()
664
+ field.mount()
665
+
666
+ expect(form.state.errors).toStrictEqual(['Please enter a different value'])
667
+ expect(form.state.errorMap).toEqual({
668
+ onMount: 'Please enter a different value',
669
+ })
670
+ })
319
671
  })