@tanstack/form-core 0.20.2 → 0.21.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
@@ -370,6 +370,59 @@ export class FormApi<
370
370
  return fieldErrorMapMap.flat()
371
371
  }
372
372
 
373
+ validateArrayFieldsStartingFrom = async <TField extends DeepKeys<TFormData>>(
374
+ field: TField,
375
+ index: number,
376
+ cause: ValidationCause,
377
+ ) => {
378
+ const currentValue = this.getFieldValue(field)
379
+
380
+ const lastIndex = Array.isArray(currentValue)
381
+ ? Math.max(currentValue.length - 1, 0)
382
+ : null
383
+
384
+ // We have to validate all fields that have shifted (at least the current field)
385
+ const fieldKeysToValidate = [`${field}[${index}]`]
386
+ for (let i = index + 1; i <= (lastIndex ?? 0); i++) {
387
+ fieldKeysToValidate.push(`${field}[${i}]`)
388
+ }
389
+
390
+ // We also have to include all fields that are nested in the shifted fields
391
+ const fieldsToValidate = Object.keys(this.fieldInfo).filter((fieldKey) =>
392
+ fieldKeysToValidate.some((key) => fieldKey.startsWith(key)),
393
+ ) as DeepKeys<TFormData>[]
394
+
395
+ // Validate the fields
396
+ const fieldValidationPromises: Promise<ValidationError[]>[] = [] as any
397
+ this.store.batch(() => {
398
+ fieldsToValidate.forEach((nestedField) => {
399
+ fieldValidationPromises.push(
400
+ Promise.resolve().then(() => this.validateField(nestedField, cause)),
401
+ )
402
+ })
403
+ })
404
+
405
+ const fieldErrorMapMap = await Promise.all(fieldValidationPromises)
406
+ return fieldErrorMapMap.flat()
407
+ }
408
+
409
+ validateField = <TField extends DeepKeys<TFormData>>(
410
+ field: TField,
411
+ cause: ValidationCause,
412
+ ) => {
413
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
414
+ const fieldInstance = this.fieldInfo[field]?.instance
415
+ if (!fieldInstance) return []
416
+
417
+ // If the field is not touched (same logic as in validateAllFields)
418
+ if (!fieldInstance.state.meta.isTouched) {
419
+ // Mark it as touched
420
+ fieldInstance.setMeta((prev) => ({ ...prev, isTouched: true }))
421
+ }
422
+
423
+ return fieldInstance.validate(cause)
424
+ }
425
+
373
426
  // TODO: This code is copied from FieldApi, we should refactor to share
374
427
  validateSync = (cause: ValidationCause) => {
375
428
  const validates = getSyncValidatorArray(cause, this.options)
@@ -689,14 +742,39 @@ export class FormApi<
689
742
  : never,
690
743
  opts?: { touch?: boolean },
691
744
  ) => {
692
- return this.setFieldValue(
745
+ this.setFieldValue(
693
746
  field,
694
747
  (prev) => [...(Array.isArray(prev) ? prev : []), value] as any,
695
748
  opts,
696
749
  )
750
+ this.validateField(field, 'change')
751
+ }
752
+
753
+ insertFieldValue = async <TField extends DeepKeys<TFormData>>(
754
+ field: TField,
755
+ index: number,
756
+ value: DeepValue<TFormData, TField> extends any[]
757
+ ? DeepValue<TFormData, TField>[number]
758
+ : never,
759
+ opts?: { touch?: boolean },
760
+ ) => {
761
+ this.setFieldValue(
762
+ field,
763
+ (prev) => {
764
+ return [
765
+ ...(prev as DeepValue<TFormData, TField>[]).slice(0, index),
766
+ value,
767
+ ...(prev as DeepValue<TFormData, TField>[]).slice(index),
768
+ ] as any
769
+ },
770
+ opts,
771
+ )
772
+
773
+ // Validate the whole array + all fields that have shifted
774
+ await this.validateField(field, 'change')
697
775
  }
698
776
 
699
- insertFieldValue = <TField extends DeepKeys<TFormData>>(
777
+ replaceFieldValue = async <TField extends DeepKeys<TFormData>>(
700
778
  field: TField,
701
779
  index: number,
702
780
  value: DeepValue<TFormData, TField> extends any[]
@@ -713,6 +791,10 @@ export class FormApi<
713
791
  },
714
792
  opts,
715
793
  )
794
+
795
+ // Validate the whole array + all fields that have shifted
796
+ await this.validateField(field, 'change')
797
+ await this.validateArrayFieldsStartingFrom(field, index, 'change')
716
798
  }
717
799
 
718
800
  removeFieldValue = async <TField extends DeepKeys<TFormData>>(
@@ -746,30 +828,54 @@ export class FormApi<
746
828
  fieldsToDelete.forEach((f) => this.deleteField(f as TField))
747
829
  }
748
830
 
749
- await this.validateAllFields('change')
831
+ // Validate the whole array + all fields that have shifted
832
+ await this.validateField(field, 'change')
833
+ await this.validateArrayFieldsStartingFrom(field, index, 'change')
750
834
  }
751
835
 
752
836
  swapFieldValues = <TField extends DeepKeys<TFormData>>(
753
837
  field: TField,
754
838
  index1: number,
755
839
  index2: number,
840
+ opts?: { touch?: boolean },
756
841
  ) => {
757
- this.setFieldValue(field, (prev: any) => {
758
- const prev1 = prev[index1]!
759
- const prev2 = prev[index2]!
760
- return setBy(setBy(prev, `${index1}`, prev2), `${index2}`, prev1)
761
- })
842
+ this.setFieldValue(
843
+ field,
844
+ (prev: any) => {
845
+ const prev1 = prev[index1]!
846
+ const prev2 = prev[index2]!
847
+ return setBy(setBy(prev, `${index1}`, prev2), `${index2}`, prev1)
848
+ },
849
+ opts,
850
+ )
851
+
852
+ // Validate the whole array
853
+ this.validateField(field, 'change')
854
+ // Validate the swapped fields
855
+ this.validateField(`${field}[${index1}]` as DeepKeys<TFormData>, 'change')
856
+ this.validateField(`${field}[${index2}]` as DeepKeys<TFormData>, 'change')
762
857
  }
763
858
 
764
859
  moveFieldValues = <TField extends DeepKeys<TFormData>>(
765
860
  field: TField,
766
861
  index1: number,
767
862
  index2: number,
863
+ opts?: { touch?: boolean },
768
864
  ) => {
769
- this.setFieldValue(field, (prev: any) => {
770
- prev.splice(index2, 0, prev.splice(index1, 1)[0])
771
- return prev
772
- })
865
+ this.setFieldValue(
866
+ field,
867
+ (prev: any) => {
868
+ prev.splice(index2, 0, prev.splice(index1, 1)[0])
869
+ return prev
870
+ },
871
+ opts,
872
+ )
873
+
874
+ // Validate the whole array
875
+ this.validateField(field, 'change')
876
+ // Validate the moved fields
877
+ this.validateField(`${field}[${index1}]` as DeepKeys<TFormData>, 'change')
878
+ this.validateField(`${field}[${index2}]` as DeepKeys<TFormData>, 'change')
773
879
  }
774
880
  }
775
881
 
@@ -107,6 +107,35 @@ describe('field api', () => {
107
107
  expect(field.getValue()).toStrictEqual(['one', 'other'])
108
108
  })
109
109
 
110
+ it('should run onChange validation when pushing an array fields value', async () => {
111
+ const form = new FormApi({
112
+ defaultValues: {
113
+ names: ['test'],
114
+ },
115
+ })
116
+ form.mount()
117
+
118
+ const field = new FieldApi({
119
+ form,
120
+ name: 'names',
121
+ validators: {
122
+ onChange: ({ value }) => {
123
+ if (value.length < 3) {
124
+ return 'At least 3 names are required'
125
+ }
126
+ return
127
+ },
128
+ },
129
+ })
130
+ field.mount()
131
+
132
+ field.pushValue('other')
133
+
134
+ expect(field.getMeta().errors).toStrictEqual([
135
+ 'At least 3 names are required',
136
+ ])
137
+ })
138
+
110
139
  it('should insert a value into an array value correctly', () => {
111
140
  const form = new FormApi({
112
141
  defaultValues: {
@@ -121,7 +150,73 @@ describe('field api', () => {
121
150
 
122
151
  field.insertValue(1, 'other')
123
152
 
124
- expect(field.getValue()).toStrictEqual(['one', 'other'])
153
+ expect(field.getValue()).toStrictEqual(['one', 'other', 'two'])
154
+ })
155
+
156
+ it('should replace a value into an array correctly', () => {
157
+ const form = new FormApi({
158
+ defaultValues: {
159
+ names: ['one', 'two', 'three'],
160
+ },
161
+ })
162
+
163
+ const field = new FieldApi({
164
+ form,
165
+ name: 'names',
166
+ })
167
+
168
+ field.replaceValue(1, 'other')
169
+
170
+ expect(field.getValue()).toStrictEqual(['one', 'other', 'three'])
171
+ })
172
+
173
+ it('should do nothing when replacing a value into an array at an index that does not exist', () => {
174
+ const form = new FormApi({
175
+ defaultValues: {
176
+ names: ['one', 'two', 'three'],
177
+ },
178
+ })
179
+
180
+ const field = new FieldApi({
181
+ form,
182
+ name: 'names',
183
+ })
184
+
185
+ field.replaceValue(10, 'other')
186
+
187
+ expect(field.getValue()).toStrictEqual(['one', 'two', 'three'])
188
+ })
189
+
190
+ it('should run onChange validation when inserting an array fields value', () => {
191
+ const form = new FormApi({
192
+ defaultValues: {
193
+ names: ['test'],
194
+ },
195
+ })
196
+ form.mount()
197
+
198
+ const field = new FieldApi({
199
+ form,
200
+ name: 'names',
201
+ validators: {
202
+ onChange: ({ value }) => {
203
+ if (value.length < 3) {
204
+ return 'At least 3 names are required'
205
+ }
206
+ return
207
+ },
208
+ },
209
+ defaultMeta: {
210
+ isTouched: true,
211
+ },
212
+ })
213
+ field.mount()
214
+
215
+ field.insertValue(1, 'other')
216
+
217
+ expect(field.getMeta().errors).toStrictEqual([
218
+ 'At least 3 names are required',
219
+ ])
125
220
  })
126
221
 
127
222
  it('should remove a value from an array value correctly', () => {
@@ -141,6 +236,38 @@ describe('field api', () => {
141
236
  expect(field.getValue()).toStrictEqual(['one'])
142
237
  })
143
238
 
239
+ it('should run onChange validation when removing an array fields value', async () => {
240
+ const form = new FormApi({
241
+ defaultValues: {
242
+ names: ['test'],
243
+ },
244
+ })
245
+ form.mount()
246
+
247
+ const field = new FieldApi({
248
+ form,
249
+ name: 'names',
250
+ validators: {
251
+ onChange: ({ value }) => {
252
+ if (value.length < 3) {
253
+ return 'At least 3 names are required'
254
+ }
255
+ return
256
+ },
257
+ },
258
+ defaultMeta: {
259
+ isTouched: true,
260
+ },
261
+ })
262
+ field.mount()
263
+
264
+ await field.removeValue(0)
265
+
266
+ expect(field.getMeta().errors).toStrictEqual([
267
+ 'At least 3 names are required',
268
+ ])
269
+ })
270
+
144
271
  it('should remove a subfield from an array field correctly', async () => {
145
272
  const form = new FormApi({
146
273
  defaultValues: {
@@ -269,6 +396,38 @@ describe('field api', () => {
269
396
  expect(field.getValue()).toStrictEqual(['two', 'one'])
270
397
  })
271
398
 
399
+ it('should run onChange validation when swapping an array fields value', () => {
400
+ const form = new FormApi({
401
+ defaultValues: {
402
+ names: ['test', 'test2'],
403
+ },
404
+ })
405
+ form.mount()
406
+
407
+ const field = new FieldApi({
408
+ form,
409
+ name: 'names',
410
+ validators: {
411
+ onChange: ({ value }) => {
412
+ if (value.length < 3) {
413
+ return 'At least 3 names are required'
414
+ }
415
+ return
416
+ },
417
+ },
418
+ defaultMeta: {
419
+ isTouched: true,
420
+ },
421
+ })
422
+ field.mount()
423
+
424
+ field.swapValues(0, 1)
425
+
426
+ expect(field.getMeta().errors).toStrictEqual([
427
+ 'At least 3 names are required',
428
+ ])
429
+ })
430
+
272
431
  it('should move a value from an array value correctly', () => {
273
432
  const form = new FormApi({
274
433
  defaultValues: {
@@ -286,6 +445,38 @@ describe('field api', () => {
286
445
  expect(field.getValue()).toStrictEqual(['three', 'one', 'two', 'four'])
287
446
  })
288
447
 
448
+ it('should run onChange validation when moving an array fields value', () => {
449
+ const form = new FormApi({
450
+ defaultValues: {
451
+ names: ['test', 'test2'],
452
+ },
453
+ })
454
+ form.mount()
455
+
456
+ const field = new FieldApi({
457
+ form,
458
+ name: 'names',
459
+ validators: {
460
+ onChange: ({ value }) => {
461
+ if (value.length < 3) {
462
+ return 'At least 3 names are required'
463
+ }
464
+ return
465
+ },
466
+ },
467
+ defaultMeta: {
468
+ isTouched: true,
469
+ },
470
+ })
471
+ field.mount()
472
+
473
+ field.moveValue(0, 1)
474
+
475
+ expect(field.getMeta().errors).toStrictEqual([
476
+ 'At least 3 names are required',
477
+ ])
478
+ })
479
+
289
480
  it('should not throw errors when no meta info is stored on a field and a form re-renders', async () => {
290
481
  const form = new FormApi({
291
482
  defaultValues: {