From 75c2b15711c52fa79f2b8805c3d7cbb7a09e393f Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 3 Nov 2023 15:04:26 +0200 Subject: [PATCH 1/2] chore(clerk-js): Introduce Form.RadioGroup --- .changeset/slow-wombats-battle.md | 5 + .../VerifiedDomainPage.tsx | 91 ++--- .../clerk-js/src/ui/elements/FieldControl.tsx | 2 + packages/clerk-js/src/ui/elements/Form.tsx | 70 ++-- .../clerk-js/src/ui/elements/RadioGroup.tsx | 93 ++++- .../ui/elements/__tests__/RadioGroup.test.tsx | 351 ++++++++++++++++++ 6 files changed, 545 insertions(+), 67 deletions(-) create mode 100644 .changeset/slow-wombats-battle.md create mode 100644 packages/clerk-js/src/ui/elements/__tests__/RadioGroup.test.tsx diff --git a/.changeset/slow-wombats-battle.md b/.changeset/slow-wombats-battle.md new file mode 100644 index 00000000000..7438ec655c1 --- /dev/null +++ b/.changeset/slow-wombats-battle.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Refactor of internal radio input in forms. diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/VerifiedDomainPage.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/VerifiedDomainPage.tsx index 02be6245755..085b28b05c3 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/VerifiedDomainPage.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/VerifiedDomainPage.tsx @@ -51,6 +51,51 @@ const useCalloutLabel = ( ]; }; +const useEnrollmentOptions = () => { + const { organizationSettings } = useEnvironment(); + + const options = (() => { + const _options = []; + if (organizationSettings.domains.enrollmentModes.includes('manual_invitation')) { + _options.push({ + value: 'manual_invitation', + label: localizationKeys('organizationProfile.verifiedDomainPage.enrollmentTab.manualInvitationOption__label'), + description: localizationKeys( + 'organizationProfile.verifiedDomainPage.enrollmentTab.manualInvitationOption__description', + ), + }); + } + + if (organizationSettings.domains.enrollmentModes.includes('automatic_invitation')) { + _options.push({ + value: 'automatic_invitation', + label: localizationKeys( + 'organizationProfile.verifiedDomainPage.enrollmentTab.automaticInvitationOption__label', + ), + description: localizationKeys( + 'organizationProfile.verifiedDomainPage.enrollmentTab.automaticInvitationOption__description', + ), + }); + } + + if (organizationSettings.domains.enrollmentModes.includes('automatic_suggestion')) { + _options.push({ + value: 'automatic_suggestion', + label: localizationKeys( + 'organizationProfile.verifiedDomainPage.enrollmentTab.automaticSuggestionOption__label', + ), + description: localizationKeys( + 'organizationProfile.verifiedDomainPage.enrollmentTab.automaticSuggestionOption__description', + ), + }); + } + + return _options; + })(); + + return options; +}; + export const VerifiedDomainPage = withCardStateProvider(() => { const card = useCardState(); const { organizationSettings } = useEnvironment(); @@ -71,49 +116,11 @@ export const VerifiedDomainPage = withCardStateProvider(() => { const breadcrumbTitle = localizationKeys('organizationProfile.profilePage.domainSection.title'); const allowsEdit = mode === 'edit'; + const enrollmentOptions = useEnrollmentOptions(); const enrollmentMode = useFormControl('enrollmentMode', '', { type: 'radio', - radioOptions: [ - ...(organizationSettings.domains.enrollmentModes.includes('manual_invitation') - ? [ - { - value: 'manual_invitation', - label: localizationKeys( - 'organizationProfile.verifiedDomainPage.enrollmentTab.manualInvitationOption__label', - ), - description: localizationKeys( - 'organizationProfile.verifiedDomainPage.enrollmentTab.manualInvitationOption__description', - ), - }, - ] - : []), - ...(organizationSettings.domains.enrollmentModes.includes('automatic_invitation') - ? [ - { - value: 'automatic_invitation', - label: localizationKeys( - 'organizationProfile.verifiedDomainPage.enrollmentTab.automaticInvitationOption__label', - ), - description: localizationKeys( - 'organizationProfile.verifiedDomainPage.enrollmentTab.automaticInvitationOption__description', - ), - }, - ] - : []), - ...(organizationSettings.domains.enrollmentModes.includes('automatic_suggestion') - ? [ - { - value: 'automatic_suggestion', - label: localizationKeys( - 'organizationProfile.verifiedDomainPage.enrollmentTab.automaticSuggestionOption__label', - ), - description: localizationKeys( - 'organizationProfile.verifiedDomainPage.enrollmentTab.automaticSuggestionOption__description', - ), - }, - ] - : []), - ], + radioOptions: enrollmentOptions, + isRequired: true, }); const deletePending = useFormControl('deleteExistingInvitationsSuggestions', '', { @@ -252,7 +259,7 @@ export const VerifiedDomainPage = withCardStateProvider(() => { gap={6} > - + {allowsEdit && ( diff --git a/packages/clerk-js/src/ui/elements/FieldControl.tsx b/packages/clerk-js/src/ui/elements/FieldControl.tsx index 55d293dcaf6..cebfec38198 100644 --- a/packages/clerk-js/src/ui/elements/FieldControl.tsx +++ b/packages/clerk-js/src/ui/elements/FieldControl.tsx @@ -22,6 +22,7 @@ import { useFormControlFeedback } from '../utils'; import { useCardState } from './contexts'; import type { FormFeedbackProps } from './FormControl'; import { FormFeedback } from './FormControl'; +import { RadioItem } from './RadioGroup'; type FormControlProps = Omit, 'label' | 'placeholder' | 'disabled' | 'required'> & ReturnType>['props']; @@ -218,6 +219,7 @@ export const Field = { Label: FieldLabel, LabelRow: FieldLabelRow, Input: InputElement, + RadioItem: RadioItem, Action: FieldAction, AsOptional: FieldOptionalLabel, LabelIcon: FieldLabelIcon, diff --git a/packages/clerk-js/src/ui/elements/Form.tsx b/packages/clerk-js/src/ui/elements/Form.tsx index 90f6d433838..aded9dfa076 100644 --- a/packages/clerk-js/src/ui/elements/Form.tsx +++ b/packages/clerk-js/src/ui/elements/Form.tsx @@ -4,7 +4,7 @@ import type { PropsWithChildren } from 'react'; import React, { useState } from 'react'; import type { LocalizationKey } from '../customizables'; -import { Button, descriptors, Flex, Form as FormPrim, localizationKeys } from '../customizables'; +import { Button, Col, descriptors, Flex, Form as FormPrim, localizationKeys } from '../customizables'; import { useLoadingStatus } from '../hooks'; import type { PropsOfComponent } from '../styledSystem'; import { useCardState } from './contexts'; @@ -76,35 +76,31 @@ const FormRoot = (props: FormProps): JSX.Element => { const FormSubmit = (props: PropsOfComponent) => { const { isLoading, isDisabled } = useFormState(); return ( - <> - + + ); + }); + + return { + Field: MockFieldWrapper, + }; +}; + +// TODO: Remove this once FormControl is no longer used +const createFormControl = (...params: Parameters) => { + const MockFieldWrapper = withCardStateProvider((props: Partial[0]>) => { + const field = useFormControl(...params); + + return ( + <> + {/* @ts-ignore*/} + + + + ); + }); + + return { + Field: MockFieldWrapper, + }; +}; + +describe('RadioGroup', () => { + it('renders the component', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createField('some-radio', ''); + + const { getAllByRole } = render( + , + { wrapper }, + ); + + const radios = getAllByRole('radio'); + expect(radios[0]).toHaveAttribute('value', 'one'); + expect(radios[0].nextSibling).toHaveTextContent('One'); + expect(radios[1]).toHaveAttribute('value', 'two'); + expect(radios[1].nextSibling).toHaveTextContent('Two'); + + radios.forEach(radio => { + expect(radio).not.toBeChecked(); + expect(radio).toHaveAttribute('name', 'some-radio'); + expect(radio).not.toHaveAttribute('required'); + expect(radio).not.toHaveAttribute('disabled'); + }); + + expect(radios[1]).not.toBeChecked(); + }); + + it('renders the component with default value', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createField('some-radio', 'two'); + + const { getAllByRole } = render( + , + { wrapper }, + ); + + const radios = getAllByRole('radio'); + expect(radios[0]).toHaveAttribute('value', 'one'); + expect(radios[0].nextSibling).toHaveTextContent('One'); + expect(radios[1]).toHaveAttribute('value', 'two'); + expect(radios[1].nextSibling).toHaveTextContent('Two'); + + radios.forEach(radio => { + expect(radio).toHaveAttribute('type', 'radio'); + expect(radio).not.toHaveAttribute('required'); + expect(radio).not.toHaveAttribute('disabled'); + expect(radio).toHaveAttribute('aria-required', 'false'); + expect(radio).toHaveAttribute('aria-disabled', 'false'); + }); + + expect(radios[0]).not.toBeChecked(); + expect(radios[1]).toBeChecked(); + }); + + it('disabled', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createField('some-radio', 'two'); + + const { getAllByRole } = render( + , + { wrapper }, + ); + + const radios = getAllByRole('radio'); + radios.forEach(radio => { + expect(radio).not.toHaveAttribute('required'); + expect(radio).toHaveAttribute('disabled'); + expect(radio).toHaveAttribute('aria-disabled', 'true'); + }); + }); + + it('required', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createField('some-radio', 'two'); + + const { getAllByRole } = render( + , + { wrapper }, + ); + + const radios = getAllByRole('radio'); + radios.forEach(radio => { + expect(radio).toHaveAttribute('required'); + expect(radio).toHaveAttribute('aria-required', 'true'); + }); + }); + + it('with error', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createField('some-radio', 'two'); + + const { getAllByRole, getByRole, getByText } = render( + , + { wrapper }, + ); + + await act(() => userEvent.click(getByRole('button', { name: /set error/i }))); + + await waitFor(() => { + const radios = getAllByRole('radio'); + radios.forEach(radio => { + expect(radio).toHaveAttribute('aria-invalid', 'true'); + expect(radio).toHaveAttribute('aria-describedby', 'error-some-radio'); + }); + expect(getByText('some error')).toBeInTheDocument(); + }); + }); + + it('with info', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createField('some-radio', '', { + type: 'radio', + radioOptions: [ + { value: 'one', label: 'One' }, + { value: 'two', label: 'Two' }, + ], + infoText: 'some info', + }); + + const { getByLabelText, getByText } = render(, { wrapper }); + + await act(() => fireEvent.focus(getByLabelText('One'))); + await waitFor(() => { + expect(getByText('some info')).toBeInTheDocument(); + }); + }); +}); + +/** + * This tests ensure that the deprecated FormControl and RadioGroup continue to behave the same and nothing broke during the refactoring. + */ +describe('Form control as text', () => { + it('renders the component', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createFormControl('some-radio', '', { + type: 'radio', + radioOptions: [ + { value: 'one', label: 'One' }, + { value: 'two', label: 'Two' }, + ], + }); + + const { getAllByRole } = render(, { wrapper }); + + const radios = getAllByRole('radio'); + expect(radios[0]).toHaveAttribute('value', 'one'); + expect(radios[0].nextSibling).toHaveTextContent('One'); + expect(radios[1]).toHaveAttribute('value', 'two'); + expect(radios[1].nextSibling).toHaveTextContent('Two'); + + radios.forEach(radio => { + expect(radio).not.toBeChecked(); + expect(radio).toHaveAttribute('name', 'some-radio'); + expect(radio).not.toHaveAttribute('required'); + expect(radio).not.toHaveAttribute('disabled'); + }); + + expect(radios[1]).not.toBeChecked(); + }); + + it('renders the component with default value', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createFormControl('some-radio', 'two', { + type: 'radio', + radioOptions: [ + { value: 'one', label: 'One' }, + { value: 'two', label: 'Two' }, + ], + }); + + const { getAllByRole } = render(, { wrapper }); + + const radios = getAllByRole('radio'); + expect(radios[0]).toHaveAttribute('value', 'one'); + expect(radios[0].nextSibling).toHaveTextContent('One'); + expect(radios[1]).toHaveAttribute('value', 'two'); + expect(radios[1].nextSibling).toHaveTextContent('Two'); + + radios.forEach(radio => { + expect(radio).toHaveAttribute('type', 'radio'); + expect(radio).not.toHaveAttribute('required'); + expect(radio).not.toHaveAttribute('disabled'); + expect(radio).toHaveAttribute('aria-required', 'false'); + expect(radio).toHaveAttribute('aria-disabled', 'false'); + }); + + expect(radios[0]).not.toBeChecked(); + expect(radios[1]).toBeChecked(); + }); + + it('disabled', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createFormControl('some-radio', 'two', { + type: 'radio', + radioOptions: [ + { value: 'one', label: 'One' }, + { value: 'two', label: 'Two' }, + ], + }); + + const { getAllByRole } = render(, { wrapper }); + + const radios = getAllByRole('radio'); + radios.forEach(radio => { + expect(radio).not.toHaveAttribute('required'); + expect(radio).toHaveAttribute('disabled'); + expect(radio).toHaveAttribute('aria-disabled', 'true'); + }); + }); + + it('required', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createFormControl('some-radio', 'two', { + type: 'radio', + radioOptions: [ + { value: 'one', label: 'One' }, + { value: 'two', label: 'Two' }, + ], + }); + + const { getAllByRole } = render(, { wrapper }); + + const radios = getAllByRole('radio'); + radios.forEach(radio => { + expect(radio).toHaveAttribute('required'); + expect(radio).toHaveAttribute('aria-required', 'true'); + }); + }); + + it('with error', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createFormControl('some-radio', 'two', { + type: 'radio', + radioOptions: [ + { value: 'one', label: 'One' }, + { value: 'two', label: 'Two' }, + ], + }); + + const { getAllByRole, getByRole, getByText } = render(, { wrapper }); + + await act(() => userEvent.click(getByRole('button', { name: /set error/i }))); + + await waitFor(() => { + const radios = getAllByRole('radio'); + radios.forEach(radio => { + expect(radio).toHaveAttribute('aria-invalid', 'true'); + expect(radio).toHaveAttribute('aria-describedby', 'error-some-radio'); + }); + expect(getByText('some error')).toBeInTheDocument(); + }); + }); + + it('with info', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createFormControl('some-radio', '', { + type: 'radio', + radioOptions: [ + { value: 'one', label: 'One' }, + { value: 'two', label: 'Two' }, + ], + infoText: 'some info', + }); + + const { getByLabelText, getByText } = render(, { wrapper }); + + await act(() => fireEvent.focus(getByLabelText('One'))); + await waitFor(() => { + expect(getByText('some info')).toBeInTheDocument(); + }); + }); +}); From 719e901a788fcab37cb5ffa4946476180a883355 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 7 Nov 2023 12:04:32 +0200 Subject: [PATCH 2/2] chore(clerk-js): Address PR comments --- .../VerifiedDomainPage.tsx | 79 +++++++++---------- 1 file changed, 39 insertions(+), 40 deletions(-) diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/VerifiedDomainPage.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/VerifiedDomainPage.tsx index 085b28b05c3..2dc351302bf 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/VerifiedDomainPage.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/VerifiedDomainPage.tsx @@ -1,4 +1,8 @@ -import type { OrganizationDomainResource, OrganizationEnrollmentMode } from '@clerk/types'; +import type { + OrganizationDomainResource, + OrganizationEnrollmentMode, + OrganizationSettingsResource, +} from '@clerk/types'; import { CalloutWithAction, useGate } from '../../common'; import { useCoreOrganization, useEnvironment } from '../../contexts'; @@ -51,49 +55,44 @@ const useCalloutLabel = ( ]; }; -const useEnrollmentOptions = () => { - const { organizationSettings } = useEnvironment(); - - const options = (() => { - const _options = []; - if (organizationSettings.domains.enrollmentModes.includes('manual_invitation')) { - _options.push({ - value: 'manual_invitation', - label: localizationKeys('organizationProfile.verifiedDomainPage.enrollmentTab.manualInvitationOption__label'), - description: localizationKeys( - 'organizationProfile.verifiedDomainPage.enrollmentTab.manualInvitationOption__description', - ), - }); - } +const buildEnrollmentOptions = (settings: OrganizationSettingsResource) => { + const _options = []; + if (settings.domains.enrollmentModes.includes('manual_invitation')) { + _options.push({ + value: 'manual_invitation', + label: localizationKeys('organizationProfile.verifiedDomainPage.enrollmentTab.manualInvitationOption__label'), + description: localizationKeys( + 'organizationProfile.verifiedDomainPage.enrollmentTab.manualInvitationOption__description', + ), + }); + } - if (organizationSettings.domains.enrollmentModes.includes('automatic_invitation')) { - _options.push({ - value: 'automatic_invitation', - label: localizationKeys( - 'organizationProfile.verifiedDomainPage.enrollmentTab.automaticInvitationOption__label', - ), - description: localizationKeys( - 'organizationProfile.verifiedDomainPage.enrollmentTab.automaticInvitationOption__description', - ), - }); - } + if (settings.domains.enrollmentModes.includes('automatic_invitation')) { + _options.push({ + value: 'automatic_invitation', + label: localizationKeys('organizationProfile.verifiedDomainPage.enrollmentTab.automaticInvitationOption__label'), + description: localizationKeys( + 'organizationProfile.verifiedDomainPage.enrollmentTab.automaticInvitationOption__description', + ), + }); + } - if (organizationSettings.domains.enrollmentModes.includes('automatic_suggestion')) { - _options.push({ - value: 'automatic_suggestion', - label: localizationKeys( - 'organizationProfile.verifiedDomainPage.enrollmentTab.automaticSuggestionOption__label', - ), - description: localizationKeys( - 'organizationProfile.verifiedDomainPage.enrollmentTab.automaticSuggestionOption__description', - ), - }); - } + if (settings.domains.enrollmentModes.includes('automatic_suggestion')) { + _options.push({ + value: 'automatic_suggestion', + label: localizationKeys('organizationProfile.verifiedDomainPage.enrollmentTab.automaticSuggestionOption__label'), + description: localizationKeys( + 'organizationProfile.verifiedDomainPage.enrollmentTab.automaticSuggestionOption__description', + ), + }); + } - return _options; - })(); + return _options; +}; - return options; +const useEnrollmentOptions = () => { + const { organizationSettings } = useEnvironment(); + return buildEnrollmentOptions(organizationSettings); }; export const VerifiedDomainPage = withCardStateProvider(() => {