Skip to content

Commit fa4014a

Browse files
logaretmclaude
andcommitted
fix: fieldArray mutations now mark form as touched (#5113)
FieldArray mutation operations (push, remove, insert, prepend, swap, move, replace, update) now set the form's meta.touched to true, since these operations represent user interaction with the form. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7d8cc52 commit fa4014a

3 files changed

Lines changed: 345 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"vee-validate": patch
3+
---
4+
5+
FieldArray mutation operations (push, remove, etc.) now set the form as touched (#5113)

packages/vee-validate/src/useFieldArray.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,27 @@ export function useFieldArray<TValue = unknown>(arrayPath: MaybeRefOrGetter<stri
110110

111111
function afterMutation() {
112112
updateEntryFlags();
113+
// Marking the field array path states as touched since any mutation is a user interaction #5113
114+
markTouched();
113115
// Should trigger a silent validation since a field may not do that #4096
114116
form?.validate({ mode: 'silent' });
115117
}
116118

119+
function markTouched() {
120+
const pathName = toValue(arrayPath);
121+
const pathState = form.getPathState(pathName as any);
122+
if (pathState) {
123+
pathState.touched = true;
124+
}
125+
126+
const allStates = form.getAllPathStates();
127+
allStates
128+
.filter(s => toValue(s.path).startsWith(pathName + '['))
129+
.forEach(s => {
130+
s.touched = true;
131+
});
132+
}
133+
117134
function remove(idx: number) {
118135
const pathName = toValue(arrayPath);
119136
const pathValue = getFromPath<TValue[]>(form?.values, pathName);
@@ -168,7 +185,7 @@ export function useFieldArray<TValue = unknown>(arrayPath: MaybeRefOrGetter<stri
168185
newFields[indexB] = tempEntry;
169186
setInPath(form.values, pathName, newValue);
170187
fields.value = newFields;
171-
updateEntryFlags();
188+
afterMutation();
172189
}
173190

174191
function insert(idx: number, initialValue: TValue) {
@@ -205,6 +222,8 @@ export function useFieldArray<TValue = unknown>(arrayPath: MaybeRefOrGetter<stri
205222
}
206223

207224
setInPath(form.values, `${pathName}[${idx}]`, value);
225+
// Marking the field array path states as touched since any mutation is a user interaction #5113
226+
markTouched();
208227
form?.validate({ mode: 'validated-only' });
209228
}
210229

packages/vee-validate/tests/useFieldArray.spec.ts

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,3 +577,323 @@ test('errors are available to the newly inserted items', async () => {
577577
await flushPromises();
578578
expect(spanAt(1).textContent).toBeTruthy();
579579
});
580+
581+
// #5113
582+
test('push should set the form as touched', async () => {
583+
let form!: FormContext;
584+
let arr!: FieldArrayContext;
585+
const InputText = defineComponent({
586+
props: { name: { type: String, required: true } },
587+
setup(props) {
588+
const { value } = useField(() => props.name);
589+
return { value };
590+
},
591+
template: '<input v-model="value" />',
592+
});
593+
594+
mountWithHoc({
595+
components: { InputText },
596+
setup() {
597+
form = useForm<any>({
598+
initialValues: {
599+
users: ['one'],
600+
},
601+
});
602+
603+
arr = useFieldArray('users');
604+
605+
return { fields: arr.fields };
606+
},
607+
template: `
608+
<div>
609+
<InputText v-for="(field, idx) in fields" :key="field.key" :name="'users[' + idx + ']'" />
610+
</div>
611+
`,
612+
});
613+
614+
await flushPromises();
615+
expect(form.meta.value.touched).toBe(false);
616+
arr.push('two');
617+
await flushPromises();
618+
expect(form.meta.value.touched).toBe(true);
619+
});
620+
621+
// #5113
622+
test('remove should set the form as touched', async () => {
623+
let form!: FormContext;
624+
let arr!: FieldArrayContext;
625+
const InputText = defineComponent({
626+
props: { name: { type: String, required: true } },
627+
setup(props) {
628+
const { value } = useField(() => props.name);
629+
return { value };
630+
},
631+
template: '<input v-model="value" />',
632+
});
633+
634+
mountWithHoc({
635+
components: { InputText },
636+
setup() {
637+
form = useForm<any>({
638+
initialValues: {
639+
users: ['one', 'two'],
640+
},
641+
});
642+
643+
arr = useFieldArray('users');
644+
645+
return { fields: arr.fields };
646+
},
647+
template: `
648+
<div>
649+
<InputText v-for="(field, idx) in fields" :key="field.key" :name="'users[' + idx + ']'" />
650+
</div>
651+
`,
652+
});
653+
654+
await flushPromises();
655+
expect(form.meta.value.touched).toBe(false);
656+
arr.remove(0);
657+
await flushPromises();
658+
expect(form.meta.value.touched).toBe(true);
659+
});
660+
661+
// #5113
662+
test('insert should set the form as touched', async () => {
663+
let form!: FormContext;
664+
let arr!: FieldArrayContext;
665+
const InputText = defineComponent({
666+
props: { name: { type: String, required: true } },
667+
setup(props) {
668+
const { value } = useField(() => props.name);
669+
return { value };
670+
},
671+
template: '<input v-model="value" />',
672+
});
673+
674+
mountWithHoc({
675+
components: { InputText },
676+
setup() {
677+
form = useForm<any>({
678+
initialValues: {
679+
users: ['one', 'two'],
680+
},
681+
});
682+
683+
arr = useFieldArray('users');
684+
685+
return { fields: arr.fields };
686+
},
687+
template: `
688+
<div>
689+
<InputText v-for="(field, idx) in fields" :key="field.key" :name="'users[' + idx + ']'" />
690+
</div>
691+
`,
692+
});
693+
694+
await flushPromises();
695+
expect(form.meta.value.touched).toBe(false);
696+
arr.insert(1, 'inserted');
697+
await flushPromises();
698+
expect(form.meta.value.touched).toBe(true);
699+
});
700+
701+
// #5113
702+
test('prepend should set the form as touched', async () => {
703+
let form!: FormContext;
704+
let arr!: FieldArrayContext;
705+
const InputText = defineComponent({
706+
props: { name: { type: String, required: true } },
707+
setup(props) {
708+
const { value } = useField(() => props.name);
709+
return { value };
710+
},
711+
template: '<input v-model="value" />',
712+
});
713+
714+
mountWithHoc({
715+
components: { InputText },
716+
setup() {
717+
form = useForm<any>({
718+
initialValues: {
719+
users: ['one'],
720+
},
721+
});
722+
723+
arr = useFieldArray('users');
724+
725+
return { fields: arr.fields };
726+
},
727+
template: `
728+
<div>
729+
<InputText v-for="(field, idx) in fields" :key="field.key" :name="'users[' + idx + ']'" />
730+
</div>
731+
`,
732+
});
733+
734+
await flushPromises();
735+
expect(form.meta.value.touched).toBe(false);
736+
arr.prepend('zero');
737+
await flushPromises();
738+
expect(form.meta.value.touched).toBe(true);
739+
});
740+
741+
// #5113
742+
test('swap should set the form as touched', async () => {
743+
let form!: FormContext;
744+
let arr!: FieldArrayContext;
745+
const InputText = defineComponent({
746+
props: { name: { type: String, required: true } },
747+
setup(props) {
748+
const { value } = useField(() => props.name);
749+
return { value };
750+
},
751+
template: '<input v-model="value" />',
752+
});
753+
754+
mountWithHoc({
755+
components: { InputText },
756+
setup() {
757+
form = useForm<any>({
758+
initialValues: {
759+
users: ['one', 'two'],
760+
},
761+
});
762+
763+
arr = useFieldArray('users');
764+
765+
return { fields: arr.fields };
766+
},
767+
template: `
768+
<div>
769+
<InputText v-for="(field, idx) in fields" :key="field.key" :name="'users[' + idx + ']'" />
770+
</div>
771+
`,
772+
});
773+
774+
await flushPromises();
775+
expect(form.meta.value.touched).toBe(false);
776+
arr.swap(0, 1);
777+
await flushPromises();
778+
expect(form.meta.value.touched).toBe(true);
779+
});
780+
781+
// #5113
782+
test('move should set the form as touched', async () => {
783+
let form!: FormContext;
784+
let arr!: FieldArrayContext;
785+
const InputText = defineComponent({
786+
props: { name: { type: String, required: true } },
787+
setup(props) {
788+
const { value } = useField(() => props.name);
789+
return { value };
790+
},
791+
template: '<input v-model="value" />',
792+
});
793+
794+
mountWithHoc({
795+
components: { InputText },
796+
setup() {
797+
form = useForm<any>({
798+
initialValues: {
799+
users: ['one', 'two', 'three'],
800+
},
801+
});
802+
803+
arr = useFieldArray('users');
804+
805+
return { fields: arr.fields };
806+
},
807+
template: `
808+
<div>
809+
<InputText v-for="(field, idx) in fields" :key="field.key" :name="'users[' + idx + ']'" />
810+
</div>
811+
`,
812+
});
813+
814+
await flushPromises();
815+
expect(form.meta.value.touched).toBe(false);
816+
arr.move(0, 2);
817+
await flushPromises();
818+
expect(form.meta.value.touched).toBe(true);
819+
});
820+
821+
// #5113
822+
test('replace should set the form as touched', async () => {
823+
let form!: FormContext;
824+
let arr!: FieldArrayContext;
825+
const InputText = defineComponent({
826+
props: { name: { type: String, required: true } },
827+
setup(props) {
828+
const { value } = useField(() => props.name);
829+
return { value };
830+
},
831+
template: '<input v-model="value" />',
832+
});
833+
834+
mountWithHoc({
835+
components: { InputText },
836+
setup() {
837+
form = useForm<any>({
838+
initialValues: {
839+
users: ['one'],
840+
},
841+
});
842+
843+
arr = useFieldArray('users');
844+
845+
return { fields: arr.fields };
846+
},
847+
template: `
848+
<div>
849+
<InputText v-for="(field, idx) in fields" :key="field.key" :name="'users[' + idx + ']'" />
850+
</div>
851+
`,
852+
});
853+
854+
await flushPromises();
855+
expect(form.meta.value.touched).toBe(false);
856+
arr.replace(['a', 'b']);
857+
await flushPromises();
858+
expect(form.meta.value.touched).toBe(true);
859+
});
860+
861+
// #5113
862+
test('update should set the form as touched', async () => {
863+
let form!: FormContext;
864+
let arr!: FieldArrayContext;
865+
const InputText = defineComponent({
866+
props: { name: { type: String, required: true } },
867+
setup(props) {
868+
const { value } = useField(() => props.name);
869+
return { value };
870+
},
871+
template: '<input v-model="value" />',
872+
});
873+
874+
mountWithHoc({
875+
components: { InputText },
876+
setup() {
877+
form = useForm<any>({
878+
initialValues: {
879+
users: ['one'],
880+
},
881+
});
882+
883+
arr = useFieldArray('users');
884+
885+
return { fields: arr.fields };
886+
},
887+
template: `
888+
<div>
889+
<InputText v-for="(field, idx) in fields" :key="field.key" :name="'users[' + idx + ']'" />
890+
</div>
891+
`,
892+
});
893+
894+
await flushPromises();
895+
expect(form.meta.value.touched).toBe(false);
896+
arr.update(0, 'updated');
897+
await flushPromises();
898+
expect(form.meta.value.touched).toBe(true);
899+
});

0 commit comments

Comments
 (0)