diff --git a/.changeset/fix-5001-radio-same-name.md b/.changeset/fix-5001-radio-same-name.md new file mode 100644 index 000000000..676a2f54c --- /dev/null +++ b/.changeset/fix-5001-radio-same-name.md @@ -0,0 +1,5 @@ +--- +"vee-validate": patch +--- + +Fix radio button same name validation errors (#5001) diff --git a/packages/vee-validate/src/useField.ts b/packages/vee-validate/src/useField.ts index 423683d1b..e85159a6f 100644 --- a/packages/vee-validate/src/useField.ts +++ b/packages/vee-validate/src/useField.ts @@ -386,10 +386,7 @@ function _useField( flags.pendingUnmount[field.id] = true; const pathState = form.getPathState(path); - const matchesId = - Array.isArray(pathState?.id) && pathState?.multiple - ? pathState?.id.includes(field.id) - : pathState?.id === field.id; + const matchesId = Array.isArray(pathState?.id) ? pathState?.id.includes(field.id) : pathState?.id === field.id; if (!matchesId) { return; } @@ -405,7 +402,7 @@ function _useField( if (Array.isArray(pathState.id)) { pathState.id.splice(pathState.id.indexOf(field.id), 1); } - } else { + } else if (pathState?.multiple || pathState?.fieldsCount <= 1) { form.unsetPathValue(toValue(name)); } diff --git a/packages/vee-validate/src/useForm.ts b/packages/vee-validate/src/useForm.ts index f5e7329b8..5f6ca0fce 100644 --- a/packages/vee-validate/src/useForm.ts +++ b/packages/vee-validate/src/useForm.ts @@ -262,10 +262,14 @@ export function useForm< config?: Partial>, ): PathState { const initialValue = computed(() => getFromPath(initialValues.value, toValue(path))); - const pathStateExists = pathStateLookup.value[toValue(path)]; + const pathValue = toValue(path); + const pathStateExists = pathStateLookup.value[pathValue]; const isCheckboxOrRadio = config?.type === 'checkbox' || config?.type === 'radio'; - if (pathStateExists && isCheckboxOrRadio) { - pathStateExists.multiple = true; + if (pathStateExists && normalizeFormPath(toValue(pathStateExists.path)) === normalizeFormPath(pathValue)) { + if (isCheckboxOrRadio) { + pathStateExists.multiple = true; + } + const id = FIELD_ID_COUNTER++; if (Array.isArray(pathStateExists.id)) { pathStateExists.id.push(id); @@ -280,7 +284,6 @@ export function useForm< } const currentValue = computed(() => getFromPath(formValues, toValue(path))); - const pathValue = toValue(path); const unsetBatchIndex = UNSET_BATCH.findIndex(_path => _path === pathValue); if (unsetBatchIndex !== -1) { @@ -576,7 +579,7 @@ export function useForm< validateField(path, { mode: 'silent', warn: false }); }); - if (pathState.multiple && pathState.fieldsCount) { + if (pathState.fieldsCount) { pathState.fieldsCount--; } @@ -589,7 +592,7 @@ export function useForm< delete pathState.__flags.pendingUnmount[id]; } - if (!pathState.multiple || pathState.fieldsCount <= 0) { + if (pathState.fieldsCount <= 0) { pathStates.value.splice(idx, 1); unsetInitialValue(path); rebuildPathLookup(); diff --git a/packages/vee-validate/tests/Form.spec.ts b/packages/vee-validate/tests/Form.spec.ts index 7e0e43cda..8de7db0a2 100644 --- a/packages/vee-validate/tests/Form.spec.ts +++ b/packages/vee-validate/tests/Form.spec.ts @@ -3182,16 +3182,14 @@ test('removes proper pathState when field is unmounting', async () => { await flushPromises(); expect(form.meta.value.valid).toBe(false); - expect(form.getAllPathStates()).toMatchObject([ - { id: 0, path: 'foo' }, - { id: 1, path: 'foo' }, - ]); + // Both fields share the same pathState with an array of ids + expect(form.getAllPathStates()).toMatchObject([{ id: [0, 1], path: 'foo', fieldsCount: 2 }]); renderTemplateField.value = false; await flushPromises(); expect(form.meta.value.valid).toBe(true); - expect(form.getAllPathStates()).toMatchObject([{ id: 0, path: 'foo' }]); + expect(form.getAllPathStates()).toMatchObject([{ id: [0], path: 'foo', fieldsCount: 1 }]); }); test('handles onSubmit with generic object from zod schema', async () => { @@ -3241,3 +3239,105 @@ test('handles onSubmit with generic object from zod schema', async () => { expect.anything(), ); }); + +// #5001 - radio buttons without explicit type on Field should share errors +test('radio buttons with same name should all expose errors even without type=radio on Field', async () => { + const REQUIRED_MSG = 'This field is required'; + defineRule('required', (value: unknown) => { + if (!value) { + return REQUIRED_MSG; + } + return true; + }); + + const wrapper = mountWithHoc({ + template: ` + + + + {{ errors[0] }} + + + + {{ errors[0] }} + + + + {{ errors[0] }} + + + {{ formErrors.drink }} + + + `, + }); + + const errCoffee = wrapper.$el.querySelector('.err-coffee'); + const errTea = wrapper.$el.querySelector('.err-tea'); + const errCoke = wrapper.$el.querySelector('.err-coke'); + const formErr = wrapper.$el.querySelector('#form-err'); + + // Submit without selecting a radio button to trigger required error + wrapper.$el.querySelector('button').click(); + await flushPromises(); + + // All radio buttons should show the same error, not just the last one + expect(formErr.textContent).toBe(REQUIRED_MSG); + expect(errCoffee.textContent).toBe(REQUIRED_MSG); + expect(errTea.textContent).toBe(REQUIRED_MSG); + expect(errCoke.textContent).toBe(REQUIRED_MSG); +}); + +// #5001 - radio buttons with explicit type=radio on Field should share errors +test('radio buttons with type=radio and same name should all expose errors', async () => { + const REQUIRED_MSG = 'This field is required'; + defineRule('required', (value: unknown) => { + if (!value) { + return REQUIRED_MSG; + } + return true; + }); + + const wrapper = mountWithHoc({ + setup() { + const schema = { + drink: 'required', + }; + + return { + schema, + }; + }, + template: ` + + + + {{ errors[0] }} + + + + {{ errors[0] }} + + + + {{ errors[0] }} + + + + + `, + }); + + const errCoffee = wrapper.$el.querySelector('.err-coffee'); + const errTea = wrapper.$el.querySelector('.err-tea'); + const errCoke = wrapper.$el.querySelector('.err-coke'); + + // Submit without selecting a radio button to trigger required error + wrapper.$el.querySelector('button').click(); + await flushPromises(); + + // All radio buttons should show the same error + expect(errCoffee.textContent).toBe(REQUIRED_MSG); + expect(errTea.textContent).toBe(REQUIRED_MSG); + expect(errCoke.textContent).toBe(REQUIRED_MSG); +});