@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/dist/cjs/FieldApi.cjs +89 -23
- package/dist/cjs/FieldApi.cjs.map +1 -1
- package/dist/cjs/FieldApi.d.cts +8 -5
- package/dist/cjs/index.cjs +1 -0
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/utils.cjs +2 -1
- package/dist/cjs/utils.cjs.map +1 -1
- package/dist/cjs/utils.d.cts +3 -2
- package/dist/esm/FieldApi.d.ts +8 -5
- package/dist/esm/FieldApi.js +89 -23
- package/dist/esm/FieldApi.js.map +1 -1
- package/dist/esm/index.js +2 -1
- package/dist/esm/utils.d.ts +3 -2
- package/dist/esm/utils.js +2 -1
- package/dist/esm/utils.js.map +1 -1
- package/package.json +1 -1
- package/src/FieldApi.ts +114 -24
- package/src/tests/FieldApi.spec.ts +158 -0
- package/src/tests/utils.spec.ts +58 -7
- package/src/utils.ts +5 -5
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'
|
|
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 = (
|
|
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
|
-
|
|
496
|
-
|
|
534
|
+
const validateFieldFn = (
|
|
535
|
+
field: FieldApi<any, any, any, any>,
|
|
536
|
+
validateObj: SyncValidator<any>,
|
|
537
|
+
) => {
|
|
497
538
|
const error = normalizeError(
|
|
498
|
-
|
|
539
|
+
field.runValidator({
|
|
499
540
|
validate: validateObj.validate,
|
|
500
|
-
value: { value, fieldApi:
|
|
541
|
+
value: { value: field.getValue(), fieldApi: field },
|
|
501
542
|
type: 'validate',
|
|
502
543
|
}),
|
|
503
544
|
)
|
|
504
545
|
const errorMapKey = getErrorMapKey(validateObj.cause)
|
|
505
|
-
if (
|
|
506
|
-
|
|
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 (
|
|
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
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
613
|
-
results = await Promise.all(
|
|
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(
|
|
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(
|
|
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
|
})
|
package/src/tests/utils.spec.ts
CHANGED
|
@@ -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
|
-
.
|
|
136
|
-
.
|
|
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
|
}
|