@tanstack/form-core 0.18.0 → 0.19.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/FieldApi.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  import { Store } from '@tanstack/store'
2
2
  import { getAsyncValidatorArray, getSyncValidatorArray } from './utils'
3
- import type { FormApi } from './FormApi'
3
+ import type { FieldInfo, FormApi } from './FormApi'
4
4
  import type {
5
5
  ValidationCause,
6
6
  ValidationError,
7
7
  ValidationErrorMap,
8
8
  Validator,
9
9
  } from './types'
10
- import type { Updater } from './utils'
10
+ import type { AsyncValidator, SyncValidator, Updater } from './utils'
11
11
  import type { DeepKeys, DeepValue, NoInfer } from './util-types'
12
12
 
13
13
  export type FieldValidateFn<
@@ -150,6 +150,7 @@ export interface FieldValidators<
150
150
  TData
151
151
  >
152
152
  onChangeAsyncDebounceMs?: number
153
+ onChangeListenTo?: DeepKeys<TParentData>[]
153
154
  onBlur?: FieldValidateOrFn<
154
155
  TParentData,
155
156
  TName,
@@ -165,6 +166,7 @@ export interface FieldValidators<
165
166
  TData
166
167
  >
167
168
  onBlurAsyncDebounceMs?: number
169
+ onBlurListenTo?: DeepKeys<TParentData>[]
168
170
  onSubmit?: FieldValidateOrFn<
169
171
  TParentData,
170
172
  TName,
@@ -447,7 +449,7 @@ export class FieldApi<
447
449
  options?: { touch?: boolean; notify?: boolean },
448
450
  ) => {
449
451
  this.form.setFieldValue(this.name, updater as never, options)
450
- this.validate('change', this.state.value)
452
+ this.validate('change')
451
453
  }
452
454
 
453
455
  _getMeta = () => this.form.getFieldMeta(this.name)
@@ -482,28 +484,67 @@ export class FieldApi<
482
484
  swapValues = (aIndex: number, bIndex: number) =>
483
485
  this.form.swapFieldValues(this.name, aIndex, bIndex)
484
486
 
487
+ getLinkedFields = (cause: ValidationCause) => {
488
+ const fields = Object.values(this.form.fieldInfo) as FieldInfo<
489
+ any,
490
+ TFormValidator
491
+ >[]
492
+
493
+ const linkedFields: FieldApi<any, any, any, any>[] = []
494
+ for (const field of fields) {
495
+ if (!field.instance) continue
496
+ const { onChangeListenTo, onBlurListenTo } =
497
+ field.instance.options.validators || {}
498
+ if (
499
+ cause === 'change' &&
500
+ onChangeListenTo?.includes(this.name as string)
501
+ ) {
502
+ linkedFields.push(field.instance)
503
+ }
504
+ if (cause === 'blur' && onBlurListenTo?.includes(this.name as string)) {
505
+ linkedFields.push(field.instance)
506
+ }
507
+ }
508
+
509
+ return linkedFields
510
+ }
511
+
485
512
  moveValue = (aIndex: number, bIndex: number) =>
486
513
  this.form.moveFieldValues(this.name, aIndex, bIndex)
487
514
 
488
- validateSync = (value = this.state.value, cause: ValidationCause) => {
515
+ validateSync = (cause: ValidationCause) => {
489
516
  const validates = getSyncValidatorArray(cause, this.options)
490
517
 
518
+ const linkedFields = this.getLinkedFields(cause)
519
+ const linkedFieldValidates = linkedFields.reduce(
520
+ (acc, field) => {
521
+ const fieldValidates = getSyncValidatorArray(cause, field.options)
522
+ fieldValidates.forEach((validate) => {
523
+ ;(validate as any).field = field
524
+ })
525
+ return acc.concat(fieldValidates as never)
526
+ },
527
+ [] as Array<SyncValidator<any> & { field: FieldApi<any, any, any, any> }>,
528
+ )
529
+
491
530
  // Needs type cast as eslint errantly believes this is always falsy
492
531
  let hasErrored = false as boolean
493
532
 
494
533
  this.form.store.batch(() => {
495
- for (const validateObj of validates) {
496
- if (!validateObj.validate) continue
534
+ const validateFieldFn = (
535
+ field: FieldApi<any, any, any, any>,
536
+ validateObj: SyncValidator<any>,
537
+ ) => {
497
538
  const error = normalizeError(
498
- this.runValidator({
539
+ field.runValidator({
499
540
  validate: validateObj.validate,
500
- value: { value, fieldApi: this },
541
+ value: { value: field.getValue(), fieldApi: field },
501
542
  type: 'validate',
502
543
  }),
503
544
  )
504
545
  const errorMapKey = getErrorMapKey(validateObj.cause)
505
- if (this.state.meta.errorMap[errorMapKey] !== error) {
506
- this.setMeta((prev) => ({
546
+ if (field.state.meta.errorMap[errorMapKey] !== error) {
547
+ field.setMeta((prev) => ({
507
548
  ...prev,
508
549
  errorMap: {
509
550
  ...prev.errorMap,
@@ -515,6 +556,15 @@ export class FieldApi<
515
556
  hasErrored = true
516
557
  }
517
558
  }
559
+
560
+ for (const validateObj of validates) {
561
+ if (!validateObj.validate) continue
562
+ validateFieldFn(this, validateObj)
563
+ }
564
+ for (const fieldValitateObj of linkedFieldValidates) {
565
+ if (!fieldValitateObj.validate) continue
566
+ validateFieldFn(fieldValitateObj.field, fieldValitateObj)
567
+ }
518
568
  })
519
569
 
520
570
  /**
@@ -539,23 +589,45 @@ export class FieldApi<
539
589
  return { hasErrored }
540
590
  }
541
591
 
542
- validateAsync = async (value = this.state.value, cause: ValidationCause) => {
592
+ validateAsync = async (cause: ValidationCause) => {
543
593
  const validates = getAsyncValidatorArray(cause, this.options)
544
594
 
595
+ const linkedFields = this.getLinkedFields(cause)
596
+ const linkedFieldValidates = linkedFields.reduce(
597
+ (acc, field) => {
598
+ const fieldValidates = getAsyncValidatorArray(cause, field.options)
599
+ fieldValidates.forEach((validate) => {
600
+ ;(validate as any).field = field
601
+ })
602
+ return acc.concat(fieldValidates as never)
603
+ },
604
+ [] as Array<
605
+ AsyncValidator<any> & { field: FieldApi<any, any, any, any> }
606
+ >,
607
+ )
608
+
545
609
  if (!this.state.meta.isValidating) {
546
610
  this.setMeta((prev) => ({ ...prev, isValidating: true }))
547
611
  }
548
612
 
613
+ for (const linkedField of linkedFields) {
614
+ linkedField.setMeta((prev) => ({ ...prev, isValidating: true }))
615
+ }
616
+
549
617
  /**
550
618
  * We have to use a for loop and generate our promises this way, otherwise it won't be sync
551
619
  * when there are no validators needed to be run
552
620
  */
553
- const promises: Promise<ValidationError | undefined>[] = []
554
-
555
- for (const validateObj of validates) {
556
- if (!validateObj.validate) continue
621
+ const validatesPromises: Promise<ValidationError | undefined>[] = []
622
+ const linkedPromises: Promise<ValidationError | undefined>[] = []
623
+
624
+ const validateFieldAsyncFn = (
625
+ field: FieldApi<any, any, any, any>,
626
+ validateObj: AsyncValidator<any>,
627
+ promises: Promise<ValidationError | undefined>[],
628
+ ) => {
557
629
  const key = getErrorMapKey(validateObj.cause)
558
- const fieldValidatorMeta = this.getInfo().validationMetaMap[key]
630
+ const fieldValidatorMeta = field.getInfo().validationMetaMap[key]
559
631
 
560
632
  fieldValidatorMeta?.lastAbortController.abort()
561
633
  const controller = new AbortController()
@@ -576,8 +648,8 @@ export class FieldApi<
576
648
  await this.runValidator({
577
649
  validate: validateObj.validate,
578
650
  value: {
579
- value,
580
- fieldApi: this,
651
+ value: field.getValue(),
652
+ fieldApi: field,
581
653
  signal: controller.signal,
582
654
  },
583
655
  type: 'validateAsync',
@@ -592,7 +664,7 @@ export class FieldApi<
592
664
  rawError = e as ValidationError
593
665
  }
594
666
  const error = normalizeError(rawError)
595
- this.setMeta((prev) => {
667
+ field.setMeta((prev) => {
596
668
  return {
597
669
  ...prev,
598
670
  errorMap: {
@@ -608,19 +680,37 @@ export class FieldApi<
608
680
  )
609
681
  }
610
682
 
683
+ // TODO: Dedupe this logic to reduce bundle size
684
+ for (const validateObj of validates) {
685
+ if (!validateObj.validate) continue
686
+ validateFieldAsyncFn(this, validateObj, validatesPromises)
687
+ }
688
+ for (const fieldValitateObj of linkedFieldValidates) {
689
+ if (!fieldValitateObj.validate) continue
690
+ validateFieldAsyncFn(
691
+ fieldValitateObj.field,
692
+ fieldValitateObj,
693
+ linkedPromises,
694
+ )
695
+ }
696
+
611
697
  let results: ValidationError[] = []
612
- if (promises.length) {
613
- results = await Promise.all(promises)
698
+ if (validatesPromises.length || linkedPromises.length) {
699
+ results = await Promise.all(validatesPromises)
700
+ await Promise.all(linkedPromises)
614
701
  }
615
702
 
616
703
  this.setMeta((prev) => ({ ...prev, isValidating: false }))
617
704
 
705
+ for (const linkedField of linkedFields) {
706
+ linkedField.setMeta((prev) => ({ ...prev, isValidating: false }))
707
+ }
708
+
618
709
  return results.filter(Boolean)
619
710
  }
620
711
 
621
712
  validate = (
622
713
  cause: ValidationCause,
623
- value?: TData,
624
714
  ): ValidationError[] | Promise<ValidationError[]> => {
625
715
  // If the field is pristine and validatePristine is false, do not validate
626
716
  if (!this.state.meta.isTouched) return []
@@ -630,13 +720,13 @@ export class FieldApi<
630
720
  } catch (_) {}
631
721
 
632
722
  // Attempt to sync validate first
633
- const { hasErrored } = this.validateSync(value, cause)
723
+ const { hasErrored } = this.validateSync(cause)
634
724
 
635
725
  if (hasErrored && !this.options.asyncAlways) {
636
726
  return this.state.meta.errors
637
727
  }
638
728
  // No error? Attempt async validation
639
- return this.validateAsync(value, cause)
729
+ return this.validateAsync(cause)
640
730
  }
641
731
 
642
732
  handleChange = (updater: Updater<TData>) => {
@@ -721,4 +721,162 @@ describe('field api', () => {
721
721
  await sleep(1)
722
722
  expect(fn).toHaveBeenCalledTimes(1)
723
723
  })
724
+
725
+ it('should run onChange on a linked field', () => {
726
+ const form = new FormApi({
727
+ defaultValues: {
728
+ password: '',
729
+ confirm_password: '',
730
+ },
731
+ })
732
+
733
+ const passField = new FieldApi({
734
+ form,
735
+ name: 'password',
736
+ })
737
+
738
+ const passconfirmField = new FieldApi({
739
+ form,
740
+ name: 'confirm_password',
741
+ validators: {
742
+ onChangeListenTo: ['password'],
743
+ onChange: ({ value, fieldApi }) => {
744
+ if (value !== fieldApi.form.getFieldValue('password')) {
745
+ return 'Passwords do not match'
746
+ }
747
+ return undefined
748
+ },
749
+ },
750
+ })
751
+
752
+ passField.mount()
753
+ passconfirmField.mount()
754
+
755
+ passField.setValue('one', { touch: true })
756
+ expect(passconfirmField.state.meta.errors).toStrictEqual([
757
+ 'Passwords do not match',
758
+ ])
759
+ passconfirmField.setValue('one', { touch: true })
760
+ expect(passconfirmField.state.meta.errors).toStrictEqual([])
761
+ passField.setValue('two', { touch: true })
762
+ expect(passconfirmField.state.meta.errors).toStrictEqual([
763
+ 'Passwords do not match',
764
+ ])
765
+ })
766
+
767
+ it('should run onBlur on a linked field', () => {
768
+ const form = new FormApi({
769
+ defaultValues: {
770
+ password: '',
771
+ confirm_password: '',
772
+ },
773
+ })
774
+
775
+ const passField = new FieldApi({
776
+ form,
777
+ name: 'password',
778
+ })
779
+
780
+ const passconfirmField = new FieldApi({
781
+ form,
782
+ name: 'confirm_password',
783
+ validators: {
784
+ onBlurListenTo: ['password'],
785
+ onBlur: ({ value, fieldApi }) => {
786
+ if (value !== fieldApi.form.getFieldValue('password')) {
787
+ return 'Passwords do not match'
788
+ }
789
+ return undefined
790
+ },
791
+ },
792
+ })
793
+
794
+ passField.mount()
795
+ passconfirmField.mount()
796
+
797
+ passField.setValue('one', { touch: true })
798
+ expect(passconfirmField.state.meta.errors).toStrictEqual([])
799
+ passField.handleBlur()
800
+ expect(passconfirmField.state.meta.errors).toStrictEqual([
801
+ 'Passwords do not match',
802
+ ])
803
+ passconfirmField.setValue('one', { touch: true })
804
+ expect(passconfirmField.state.meta.errors).toStrictEqual([
805
+ 'Passwords do not match',
806
+ ])
807
+ passField.handleBlur()
808
+ expect(passconfirmField.state.meta.errors).toStrictEqual([])
809
+ passField.setValue('two', { touch: true })
810
+ passField.handleBlur()
811
+ expect(passconfirmField.state.meta.errors).toStrictEqual([
812
+ 'Passwords do not match',
813
+ ])
814
+ })
815
+
816
+ it('should run onChangeAsync on a linked field', async () => {
817
+ vi.useRealTimers()
818
+ let resolve!: () => void
819
+ let promise = new Promise((r) => {
820
+ resolve = r as never
821
+ })
822
+
823
+ const fn = vi.fn()
824
+
825
+ const form = new FormApi({
826
+ defaultValues: {
827
+ password: '',
828
+ confirm_password: '',
829
+ },
830
+ })
831
+
832
+ const passField = new FieldApi({
833
+ form,
834
+ name: 'password',
835
+ })
836
+
837
+ const passconfirmField = new FieldApi({
838
+ form,
839
+ name: 'confirm_password',
840
+ validators: {
841
+ onChangeListenTo: ['password'],
842
+ onChangeAsync: async ({ value, fieldApi }) => {
843
+ await promise
844
+ fn()
845
+ if (value !== fieldApi.form.getFieldValue('password')) {
846
+ return 'Passwords do not match'
847
+ }
848
+ return undefined
849
+ },
850
+ },
851
+ })
852
+
853
+ passField.mount()
854
+ passconfirmField.mount()
855
+
856
+ passField.setValue('one', { touch: true })
857
+ resolve()
858
+ // Allow for a micro-tick to allow the promise to resolve
859
+ await sleep(1)
860
+ expect(passconfirmField.state.meta.errors).toStrictEqual([
861
+ 'Passwords do not match',
862
+ ])
863
+ promise = new Promise((r) => {
864
+ resolve = r as never
865
+ })
866
+ passconfirmField.setValue('one', { touch: true })
867
+ resolve()
868
+ // Allow for a micro-tick to allow the promise to resolve
869
+ await sleep(1)
870
+ expect(passconfirmField.state.meta.errors).toStrictEqual([])
871
+ promise = new Promise((r) => {
872
+ resolve = r as never
873
+ })
874
+ passField.setValue('two', { touch: true })
875
+ resolve()
876
+ // Allow for a micro-tick to allow the promise to resolve
877
+ await sleep(1)
878
+ expect(passconfirmField.state.meta.errors).toStrictEqual([
879
+ 'Passwords do not match',
880
+ ])
881
+ })
724
882
  })
@@ -1,12 +1,12 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { deleteBy, getBy, setBy } from '../utils'
2
+ import { deleteBy, getBy, makePathArray, setBy } from '../utils'
3
3
 
4
4
  describe('getBy', () => {
5
5
  const structure = {
6
6
  name: 'Marc',
7
7
  kids: [
8
- { name: 'Stephen', age: 10 },
9
- { name: 'Taylor', age: 15 },
8
+ { name: 'Stephen', age: 10, hobbies: ['soccer', 'reading'] },
9
+ { name: 'Taylor', age: 15, hobbies: ['swimming', 'gaming'] },
10
10
  ],
11
11
  mother: {
12
12
  name: 'Lisa',
@@ -22,14 +22,23 @@ describe('getBy', () => {
22
22
  expect(getBy(structure, 'kids[0].name')).toBe(structure.kids[0]!.name)
23
23
  expect(getBy(structure, 'kids[0].age')).toBe(structure.kids[0]!.age)
24
24
  })
25
+
26
+ it('should get nested array subfields by path', () => {
27
+ expect(getBy(structure, 'kids[0].hobbies[0]')).toBe(
28
+ structure.kids[0]!.hobbies[0],
29
+ )
30
+ expect(getBy(structure, 'kids[0].hobbies[1]')).toBe(
31
+ structure.kids[0]!.hobbies[1],
32
+ )
33
+ })
25
34
  })
26
35
 
27
36
  describe('setBy', () => {
28
37
  const structure = {
29
38
  name: 'Marc',
30
39
  kids: [
31
- { name: 'Stephen', age: 10 },
32
- { name: 'Taylor', age: 15 },
40
+ { name: 'Stephen', age: 10, hobbies: ['soccer', 'reading'] },
41
+ { name: 'Taylor', age: 15, hobbies: ['swimming', 'gaming'] },
33
42
  ],
34
43
  mother: {
35
44
  name: 'Lisa',
@@ -47,14 +56,23 @@ describe('setBy', () => {
47
56
  )
48
57
  expect(setBy(structure, 'kids[0].age', 20).kids[0].age).toBe(20)
49
58
  })
59
+
60
+ it('should set nested array subfields by path', () => {
61
+ expect(
62
+ setBy(structure, 'kids[0].hobbies[0]', 'swimming').kids[0].hobbies[0],
63
+ ).toBe('swimming')
64
+ expect(
65
+ setBy(structure, 'kids[0].hobbies[1]', 'gaming').kids[0].hobbies[1],
66
+ ).toBe('gaming')
67
+ })
50
68
  })
51
69
 
52
70
  describe('deleteBy', () => {
53
71
  const structure = {
54
72
  name: 'Marc',
55
73
  kids: [
56
- { name: 'Stephen', age: 10 },
57
- { name: 'Taylor', age: 15 },
74
+ { name: 'Stephen', age: 10, hobbies: ['soccer', 'reading'] },
75
+ { name: 'Taylor', age: 15, hobbies: ['swimming', 'gaming'] },
58
76
  ],
59
77
  mother: {
60
78
  name: 'Lisa',
@@ -71,6 +89,15 @@ describe('deleteBy', () => {
71
89
  expect(deleteBy(structure, 'kids[0].age').kids[0].age).not.toBeDefined()
72
90
  })
73
91
 
92
+ it('should delete nested array subfields by path', () => {
93
+ expect(deleteBy(structure, 'kids[0].hobbies[0]').kids[0].hobbies[0]).toBe(
94
+ 'reading',
95
+ )
96
+ expect(
97
+ deleteBy(structure, 'kids[0].hobbies[1]').kids[0].hobbies[1],
98
+ ).not.toBeDefined()
99
+ })
100
+
74
101
  it('should delete non-existent paths like a noop', () => {
75
102
  expect(deleteBy(structure, 'nonexistent')).toEqual(structure)
76
103
  expect(deleteBy(structure, 'nonexistent.nonexistent')).toEqual(structure)
@@ -78,3 +105,27 @@ describe('deleteBy', () => {
78
105
  expect(deleteBy(structure, 'nonexistent[3].nonexistent')).toEqual(structure)
79
106
  })
80
107
  })
108
+
109
+ describe('makePathArray', () => {
110
+ it('should convert dot notation to array', () => {
111
+ expect(makePathArray('name')).toEqual(['name'])
112
+ expect(makePathArray('mother.name')).toEqual(['mother', 'name'])
113
+ expect(makePathArray('kids[0].name')).toEqual(['kids', 0, 'name'])
114
+ expect(makePathArray('kids[0].name[1]')).toEqual(['kids', 0, 'name', 1])
115
+ expect(makePathArray('kids[0].name[1].age')).toEqual([
116
+ 'kids',
117
+ 0,
118
+ 'name',
119
+ 1,
120
+ 'age',
121
+ ])
122
+ expect(makePathArray('kids[0].name[1].age[2]')).toEqual([
123
+ 'kids',
124
+ 0,
125
+ 'name',
126
+ 1,
127
+ 'age',
128
+ 2,
129
+ ])
130
+ })
131
+ })
package/src/utils.ts CHANGED
@@ -126,14 +126,14 @@ const reFindMultiplePeriods = /\.{2,}/gm
126
126
  const intPrefix = '__int__'
127
127
  const intReplace = `${intPrefix}$1`
128
128
 
129
- function makePathArray(str: string) {
129
+ export function makePathArray(str: string) {
130
130
  if (typeof str !== 'string') {
131
131
  throw new Error('Path must be a string.')
132
132
  }
133
133
 
134
134
  return str
135
- .replace('[', '.')
136
- .replace(']', '')
135
+ .replaceAll('[', '.')
136
+ .replaceAll(']', '')
137
137
  .replace(reFindNumbers0, intReplace)
138
138
  .replace(reFindNumbers1, `.${intReplace}.`)
139
139
  .replace(reFindNumbers2, `${intReplace}.`)
@@ -157,7 +157,7 @@ interface AsyncValidatorArrayPartialOptions<T> {
157
157
  asyncDebounceMs?: number
158
158
  }
159
159
 
160
- interface AsyncValidator<T> {
160
+ export interface AsyncValidator<T> {
161
161
  cause: ValidationCause
162
162
  validate: T
163
163
  debounceMs: number
@@ -226,7 +226,7 @@ interface SyncValidatorArrayPartialOptions<T> {
226
226
  validators?: T
227
227
  }
228
228
 
229
- interface SyncValidator<T> {
229
+ export interface SyncValidator<T> {
230
230
  cause: ValidationCause
231
231
  validate: T
232
232
  }