diff --git a/src/__tests__/toNestErrors.ts b/src/__tests__/toNestErrors.ts index d16b8bb0..80d9d7e5 100644 --- a/src/__tests__/toNestErrors.ts +++ b/src/__tests__/toNestErrors.ts @@ -68,6 +68,67 @@ test('transforms flat object to nested object with names option', () => { }); }); +test('uses the first element of `refs` as `ref` for radio/checkbox fields (#630)', () => { + const checkboxEl = { name: 'isTosAccepted', type: 'checkbox' }; + + const result = toNestErrors( + { + isTosAccepted: { type: 'oneOf', message: 'must accept tos' }, + }, + { + fields: { + // react-hook-form stores a placeholder object as `ref` for + // radio/checkbox fields and keeps the actual DOM elements in `refs`. + isTosAccepted: { + name: 'isTosAccepted', + ref: { name: 'isTosAccepted', type: 'checkbox' }, + refs: [checkboxEl], + }, + } as any as Record, + shouldUseNativeValidation: false, + }, + ); + + expect(result.isTosAccepted?.ref).toBe(checkboxEl); +}); + +test('uses the first element of `refs` for a radio group with multiple options', () => { + const firstRadioEl = { name: 'plan', type: 'radio', value: 'basic' }; + const secondRadioEl = { name: 'plan', type: 'radio', value: 'pro' }; + + const result = toNestErrors( + { plan: { type: 'required', message: 'plan is required' } }, + { + fields: { + plan: { + name: 'plan', + ref: { name: 'plan', type: 'radio' }, + refs: [firstRadioEl, secondRadioEl], + }, + } as any as Record, + shouldUseNativeValidation: false, + }, + ); + + expect(result.plan?.ref).toBe(firstRadioEl); +}); + +test('falls back to `ref` when the field has no `refs` (non radio/checkbox fields)', () => { + const inputEl = { name: 'username', type: 'text' }; + + const result = toNestErrors( + { username: { type: 'required', message: 'username is required' } }, + { + fields: { + username: { name: 'username', ref: inputEl }, + } as any as Record, + shouldUseNativeValidation: false, + }, + ); + + expect(result.username?.ref).toBe(inputEl); +}); + test('transforms flat object to nested object with root error for field array', () => { const result = toNestErrors( { diff --git a/src/toNestErrors.ts b/src/toNestErrors.ts index 6b7f1a8e..4326bce4 100644 --- a/src/toNestErrors.ts +++ b/src/toNestErrors.ts @@ -18,8 +18,11 @@ export const toNestErrors = ( const fieldErrors = {} as FieldErrors; for (const path in errors) { const field = get(options.fields, path) as Field['_f'] | undefined; + // Radio/checkbox fields keep a placeholder `ref` (`{ name, type }`) and + // store the actual DOM elements in `refs`, matching react-hook-form's + // own `validateField` behavior for built-in validation. const error = Object.assign(errors[path] || {}, { - ref: field && field.ref, + ref: field && field.refs ? field.refs[0] : field && field.ref, }); if (isNameInFieldArray(options.names || Object.keys(errors), path)) { diff --git a/yup/src/__tests__/Form.tsx b/yup/src/__tests__/Form.tsx index 75f82d9f..5b6c9802 100644 --- a/yup/src/__tests__/Form.tsx +++ b/yup/src/__tests__/Form.tsx @@ -50,3 +50,29 @@ test("form's validation with Yup and TypeScript's integration", async () => { expect(screen.getByText(/password is a required field/i)).toBeInTheDocument(); expect(handleSubmit).not.toHaveBeenCalled(); }); + +const checkboxSchema = Yup.object({ + isTosAccepted: Yup.boolean().oneOf([true], 'must accept tos').required(), +}); + +test('errors..ref is the checkbox input element, not validation metadata (#630)', async () => { + let latestErrors: ReturnType['formState']['errors'] = {}; + function Wrapper() { + const methods = useForm({ resolver: yupResolver(checkboxSchema) }); + latestErrors = methods.formState.errors; + + return ( +
{})}> + + +
+ ); + } + + render(); + + const checkbox = screen.getByRole('checkbox'); + await user.click(screen.getByText(/submit/i)); + + expect(latestErrors.isTosAccepted?.ref).toBe(checkbox); +});