Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(clerk-js): Ensure only one action is present within EmailSection UI #5023

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clean-spoons-travel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Ensure only one email action is open within `UserProfile` at a time.
117 changes: 81 additions & 36 deletions packages/clerk-js/src/ui/components/UserProfile/EmailsSection.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -37,47 +38,28 @@ const EmailScreen = (props: EmailScreenProps) => {

export const EmailsSection = ({ shouldAllowCreation = true }) => {
const { user } = useUser();
const [actionRootValue, setActionRootValue] = useState<string | null>(null);

if (!user) return null;
return (
<ProfileSection.Root
title={localizationKeys('userProfile.start.emailAddressesSection.title')}
centered={false}
id='emailAddresses'
>
<Action.Root>
<Action.Root
value={actionRootValue}
onChange={setActionRootValue}
>
<ProfileSection.ItemList id='emailAddresses'>
{sortIdentificationBasedOnVerification(user?.emailAddresses, user?.primaryEmailAddressId).map(email => (
<Action.Root key={email.emailAddress}>
<ProfileSection.Item id='emailAddresses'>
<Flex sx={t => ({ overflow: 'hidden', gap: t.space.$1 })}>
<Text
sx={t => ({ color: t.colors.$colorText })}
truncate
>
{email.emailAddress}
</Text>
{user?.primaryEmailAddressId === email.id && (
<Badge localizationKey={localizationKeys('badge__primary')} />
)}
{email.verification.status !== 'verified' && (
<Badge localizationKey={localizationKeys('badge__unverified')} />
)}
</Flex>
<EmailMenu email={email} />
</ProfileSection.Item>

<Action.Open value='remove'>
<Action.Card variant='destructive'>
<RemoveEmailScreen emailId={email.id} />
</Action.Card>
</Action.Open>

<Action.Open value='verify'>
<Action.Card>
<EmailScreen emailId={email.id} />
</Action.Card>
</Action.Open>
</Action.Root>
{sortIdentificationBasedOnVerification(user.emailAddresses, user.primaryEmailAddressId).map(email => (
<EmailRow
key={email.emailAddress}
email={email}
user={user}
actionRootValue={actionRootValue}
setActionRootValue={setActionRootValue}
/>
))}
{shouldAllowCreation && (
<>
Expand All @@ -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<string | null>(null);

useEffect(() => {
if (actionRootValue === 'add') {
setInternalValue(null);
}
}, [actionRootValue]);

return (
<Action.Root
value={internalValue}
onChange={setInternalValue}
>
<ProfileSection.Item id='emailAddresses'>
<Flex sx={t => ({ overflow: 'hidden', gap: t.space.$1 })}>
<Text
sx={t => ({ color: t.colors.$colorText })}
truncate
>
{email.emailAddress}
</Text>
{user?.primaryEmailAddressId === email.id && <Badge localizationKey={localizationKeys('badge__primary')} />}
{email.verification.status !== 'verified' && (
<Badge localizationKey={localizationKeys('badge__unverified')} />
)}
</Flex>
<EmailMenu
email={email}
removeOnClick={() => {
setActionRootValue(null);
}}
/>
</ProfileSection.Item>

<Action.Open value='remove'>
<Action.Card variant='destructive'>
<RemoveEmailScreen emailId={email.id} />
</Action.Card>
</Action.Open>

<Action.Open value='verify'>
<Action.Card>
<EmailScreen emailId={email.id} />
</Action.Card>
</Action.Open>
</Action.Root>
);
};

const EmailMenu = ({ email, removeOnClick }: { email: EmailAddressResource; removeOnClick?: () => void }) => {
const card = useCardState();
const { user } = useUser();
const { open } = useActionContext();
Expand Down Expand Up @@ -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<typeof ThreeDotsMenu>['actions'][0] | null)[]
).filter(a => a !== null) as PropsOfComponent<typeof ThreeDotsMenu>['actions'];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<CardStateProvider>
<EmailsSection />
</CardStateProvider>,
{ 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(
<CardStateProvider>
<EmailsSection />
</CardStateProvider>,
{ 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());
});
});
});
35 changes: 26 additions & 9 deletions packages/clerk-js/src/ui/elements/Action/ActionRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}>;
Comment on lines +7 to +11
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Introduce options to be able to make Action.Root a controlled component.


type ActionOpen = (value: string) => void;

Expand All @@ -15,16 +19,29 @@ export const [ActionContext, useActionContext, _] = createContextAndHook<{
}>('ActionContext');

export const ActionRoot = (props: ActionRootProps) => {
const { animate = true, children } = props;
const [active, setActive] = useState<string | null>(null);
const { animate = true, children, value: controlledValue, onChange } = props;
const [internalValue, setInternalValue] = useState<string | null>(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 = <ActionContext.Provider value={{ value: { active, open, close } }}>{children}</ActionContext.Provider>;

Expand Down
Loading