diff --git a/.changeset/clean-spoons-travel.md b/.changeset/clean-spoons-travel.md new file mode 100644 index 0000000000..bff209b78c --- /dev/null +++ b/.changeset/clean-spoons-travel.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Ensure only one email action is open within `UserProfile` at a time. diff --git a/packages/clerk-js/src/ui/components/UserProfile/EmailsSection.tsx b/packages/clerk-js/src/ui/components/UserProfile/EmailsSection.tsx index 3f4558e717..013b77dc33 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/EmailsSection.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/EmailsSection.tsx @@ -1,5 +1,6 @@ import { useUser } from '@clerk/shared/react'; -import type { EmailAddressResource } from '@clerk/types'; +import type { EmailAddressResource, UserResource } from '@clerk/types'; +import { useEffect, useState } from 'react'; import { sortIdentificationBasedOnVerification } from '../../components/UserProfile/utils'; import { Badge, Flex, localizationKeys, Text } from '../../customizables'; @@ -37,47 +38,28 @@ const EmailScreen = (props: EmailScreenProps) => { export const EmailsSection = ({ shouldAllowCreation = true }) => { const { user } = useUser(); + const [actionRootValue, setActionRootValue] = useState(null); + if (!user) return null; return ( - + - {sortIdentificationBasedOnVerification(user?.emailAddresses, user?.primaryEmailAddressId).map(email => ( - - - ({ overflow: 'hidden', gap: t.space.$1 })}> - ({ color: t.colors.$colorText })} - truncate - > - {email.emailAddress} - - {user?.primaryEmailAddressId === email.id && ( - - )} - {email.verification.status !== 'verified' && ( - - )} - - - - - - - - - - - - - - - - + {sortIdentificationBasedOnVerification(user.emailAddresses, user.primaryEmailAddressId).map(email => ( + ))} {shouldAllowCreation && ( <> @@ -100,7 +82,67 @@ export const EmailsSection = ({ shouldAllowCreation = true }) => { ); }; -const EmailMenu = ({ email }: { email: EmailAddressResource }) => { +const EmailRow = ({ + user, + email, + actionRootValue, + setActionRootValue, +}: { + user: UserResource; + email: EmailAddressResource; + actionRootValue?: string | null; + setActionRootValue: (value: string | null) => void; +}) => { + const [internalValue, setInternalValue] = useState(null); + + useEffect(() => { + if (actionRootValue === 'add') { + setInternalValue(null); + } + }, [actionRootValue]); + + return ( + + + ({ overflow: 'hidden', gap: t.space.$1 })}> + ({ color: t.colors.$colorText })} + truncate + > + {email.emailAddress} + + {user?.primaryEmailAddressId === email.id && } + {email.verification.status !== 'verified' && ( + + )} + + { + setActionRootValue(null); + }} + /> + + + + + + + + + + + + + + + ); +}; + +const EmailMenu = ({ email, removeOnClick }: { email: EmailAddressResource; removeOnClick?: () => void }) => { const card = useCardState(); const { user } = useUser(); const { open } = useActionContext(); @@ -133,7 +175,10 @@ const EmailMenu = ({ email }: { email: EmailAddressResource }) => { { label: localizationKeys('userProfile.start.emailAddressesSection.destructiveAction'), isDestructive: true, - onClick: () => open('remove'), + onClick: () => { + open('remove'); + removeOnClick?.(); + }, }, ] satisfies (PropsOfComponent['actions'][0] | null)[] ).filter(a => a !== null) as PropsOfComponent['actions']; diff --git a/packages/clerk-js/src/ui/components/UserProfile/__tests__/EmailsSection.test.tsx b/packages/clerk-js/src/ui/components/UserProfile/__tests__/EmailsSection.test.tsx index acbd48ac55..7cbd7e1952 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/__tests__/EmailsSection.test.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/__tests__/EmailsSection.test.tsx @@ -187,4 +187,62 @@ describe('EmailSection', () => { }); }); }); + + describe('Handles opening/closing actions', () => { + it('closes add email form when remove an email address action is clicked', async () => { + const { wrapper, fixtures } = await createFixtures(withEmails); + const { getByText, userEvent, getByRole, queryByRole } = render( + + + , + { wrapper }, + ); + + fixtures.clerk.user?.emailAddresses[0].destroy.mockResolvedValue(); + + await userEvent.click(getByRole('button', { name: 'Add email address' })); + await waitFor(() => getByRole('heading', { name: /Add email address/i })); + + const item = getByText(emails[0]); + const menuButton = getMenuItemFromText(item); + await act(async () => { + await userEvent.click(menuButton!); + }); + + getByRole('menuitem', { name: /remove email/i }); + await userEvent.click(getByRole('menuitem', { name: /remove email/i })); + await waitFor(() => getByRole('heading', { name: /Remove email address/i })); + + await waitFor(() => expect(queryByRole('heading', { name: /Remove email address/i })).toBeInTheDocument()); + await waitFor(() => expect(queryByRole('heading', { name: /Add email address/i })).not.toBeInTheDocument()); + }); + + it('closes remove email address form when add email address action is clicked', async () => { + const { wrapper, fixtures } = await createFixtures(withEmails); + const { getByText, userEvent, getByRole, queryByRole } = render( + + + , + { wrapper }, + ); + + fixtures.clerk.user?.emailAddresses[0].destroy.mockResolvedValue(); + + const item = getByText(emails[0]); + const menuButton = getMenuItemFromText(item); + await act(async () => { + await userEvent.click(menuButton!); + }); + + getByRole('menuitem', { name: /remove email/i }); + await userEvent.click(getByRole('menuitem', { name: /remove email/i })); + await waitFor(() => getByRole('heading', { name: /Remove email address/i })); + + await userEvent.click(getByRole('button', { name: 'Add email address' })); + await waitFor(() => getByRole('heading', { name: /Add email address/i })); + + await waitFor(() => expect(queryByRole('heading', { name: /Remove email address/i })).not.toBeInTheDocument()); + await waitFor(() => expect(queryByRole('heading', { name: /Add email address/i })).toBeInTheDocument()); + }); + }); }); diff --git a/packages/clerk-js/src/ui/elements/Action/ActionRoot.tsx b/packages/clerk-js/src/ui/elements/Action/ActionRoot.tsx index 1c83c70342..85fe48c86e 100644 --- a/packages/clerk-js/src/ui/elements/Action/ActionRoot.tsx +++ b/packages/clerk-js/src/ui/elements/Action/ActionRoot.tsx @@ -4,7 +4,11 @@ import { useCallback, useState } from 'react'; import { Animated } from '..'; -type ActionRootProps = PropsWithChildren<{ animate?: boolean }>; +type ActionRootProps = PropsWithChildren<{ + animate?: boolean; + value?: string | null; + onChange?: (value: string | null) => void; +}>; type ActionOpen = (value: string) => void; @@ -15,16 +19,29 @@ export const [ActionContext, useActionContext, _] = createContextAndHook<{ }>('ActionContext'); export const ActionRoot = (props: ActionRootProps) => { - const { animate = true, children } = props; - const [active, setActive] = useState(null); + const { animate = true, children, value: controlledValue, onChange } = props; + const [internalValue, setInternalValue] = useState(null); - const close = useCallback(() => { - setActive(null); - }, []); + const active = controlledValue !== undefined ? controlledValue : internalValue; - const open: ActionOpen = useCallback(value => { - setActive(value); - }, []); + const close = useCallback(() => { + if (onChange) { + onChange(null); + } else { + setInternalValue(null); + } + }, [onChange]); + + const open: ActionOpen = useCallback( + newValue => { + if (onChange) { + onChange(newValue); + } else { + setInternalValue(newValue); + } + }, + [onChange], + ); const body = {children};