feat: refactor <UserDropdown /> (#21809)

This pull-request takes our `<UserDropdown />` component and converts it
to a `<DropdownMenu />`. This is done so that we can more easily
standardise the content among multiple Dropdown's, and as an added bonus
helps us to remove MUI dependencies (win win).

<img
src="https://github.com/user-attachments/assets/1168ece2-b514-4b91-8cfd-4baf2744eb38"
/>


> [!NOTE]  
> I removed the avatar here whilst we debate internally on how we show
the user account. This differs from the screenshot below 🙂

| Old | New |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/d1fe8bcc-bdbb-4366-9ceb-39a63bd09da3"
/> | <img
src="https://github.com/user-attachments/assets/89252765-4203-433e-8b25-3087fd2fd754"
/> |
This commit is contained in:
Jake Howell
2026-02-04 13:08:10 +11:00
committed by GitHub
parent 62ba27b08f
commit 014693ba34
4 changed files with 114 additions and 180 deletions
-2
View File
@@ -18,8 +18,6 @@ export const Popover = PopoverPrimitive.Root;
export const PopoverTrigger = PopoverPrimitive.Trigger;
export const PopoverClose = PopoverPrimitive.PopoverClose;
export const PopoverContent = forwardRef<
ElementRef<typeof PopoverPrimitive.Content>,
ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
@@ -1,10 +1,10 @@
import type * as TypesGen from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/Popover/Popover";
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import type { FC } from "react";
import { UserDropdownContent } from "./UserDropdownContent";
@@ -22,28 +22,24 @@ export const UserDropdown: FC<UserDropdownProps> = ({
onSignOut,
}) => {
return (
<Popover>
<PopoverTrigger asChild>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="bg-transparent border-0 cursor-pointer p-0"
>
<Avatar fallback={user.username} src={user.avatar_url} size="lg" />
</button>
</PopoverTrigger>
</DropdownMenuTrigger>
<PopoverContent
align="end"
className="min-w-auto w-[260px] bg-surface-secondary border-surface-quaternary"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuContent align="end" className="min-w-auto w-[260px]">
<UserDropdownContent
user={user}
buildInfo={buildInfo}
supportLinks={supportLinks}
onSignOut={onSignOut}
/>
</PopoverContent>
</Popover>
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -1,20 +1,31 @@
import { MockUserOwner } from "testHelpers/entities";
import { render, waitForLoaderToBeRemoved } from "testHelpers/renderHelpers";
import { screen } from "@testing-library/react";
import { Popover } from "components/Popover/Popover";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { Language, UserDropdownContent } from "./UserDropdownContent";
const renderUserDropdownContent = (props: { onSignOut: () => void }) => {
return render(
<DropdownMenu defaultOpen>
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
<DropdownMenuContent>
<UserDropdownContent
user={MockUserOwner}
onSignOut={props.onSignOut}
supportLinks={[]}
/>
</DropdownMenuContent>
</DropdownMenu>,
);
};
describe("UserDropdownContent", () => {
it("has the correct link for the account item", async () => {
render(
<Popover>
<UserDropdownContent
user={MockUserOwner}
onSignOut={vi.fn()}
supportLinks={[]}
/>
</Popover>,
);
renderUserDropdownContent({ onSignOut: vi.fn() });
await waitForLoaderToBeRemoved();
const link = screen.getByText(Language.accountLabel).closest("a");
@@ -27,15 +38,7 @@ describe("UserDropdownContent", () => {
it("calls the onSignOut function", async () => {
const onSignOut = vi.fn();
render(
<Popover>
<UserDropdownContent
user={MockUserOwner}
onSignOut={onSignOut}
supportLinks={[]}
/>
</Popover>,
);
renderUserDropdownContent({ onSignOut });
await waitForLoaderToBeRemoved();
screen.getByText(Language.signOutLabel).click();
expect(onSignOut).toBeCalledTimes(1);
@@ -1,22 +1,18 @@
import {
type CSSObject,
css,
type Interpolation,
type Theme,
} from "@emotion/react";
import Divider from "@mui/material/Divider";
import MenuItem from "@mui/material/MenuItem";
import type * as TypesGen from "api/typesGenerated";
import { CopyButton } from "components/CopyButton/CopyButton";
import { PopoverClose } from "components/Popover/Popover";
import { Stack } from "components/Stack/Stack";
import {
DropdownMenuItem,
DropdownMenuSeparator,
} from "components/DropdownMenu/DropdownMenu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { useClipboard } from "hooks/useClipboard";
import {
CheckIcon,
CircleUserIcon,
CopyIcon,
LogOutIcon,
MonitorDownIcon,
SquareArrowOutUpRightIcon,
@@ -44,153 +40,94 @@ export const UserDropdownContent: FC<UserDropdownContentProps> = ({
supportLinks,
onSignOut,
}) => {
const { showCopiedSuccess, copyToClipboard } = useClipboard();
return (
<div>
<Stack css={styles.info} spacing={0}>
<span css={styles.userName}>{user.username}</span>
<span css={styles.userEmail}>{user.email}</span>
</Stack>
<Divider css={{ marginBottom: 8 }} />
<Link to="/install" css={styles.link}>
<PopoverClose asChild>
<MenuItem css={styles.menuItem}>
<MonitorDownIcon className="size-5 text-content-secondary" />
<span css={styles.menuItemText}>Install CLI</span>
</MenuItem>
</PopoverClose>
</Link>
<Link to="/settings/account" css={styles.link}>
<PopoverClose asChild>
<MenuItem css={styles.menuItem}>
<CircleUserIcon className="size-5 text-content-secondary" />
<span css={styles.menuItemText}>{Language.accountLabel}</span>
</MenuItem>
</PopoverClose>
</Link>
<MenuItem css={styles.menuItem} onClick={onSignOut}>
<LogOutIcon className="size-5 text-content-secondary" />
<span css={styles.menuItemText}>{Language.signOutLabel}</span>
</MenuItem>
{supportLinks && (
<>
<DropdownMenuItem
className="flex items-center gap-3 [&_img]:w-full [&_img]:h-full"
asChild
>
<Link to="/settings/account">
<div className="flex flex-col">
<span className="text-white">{user.username}</span>
<span className="text-xs font-semibold">{user.email}</span>
</div>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/install">
<MonitorDownIcon />
<span>Install CLI</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/settings/account">
<CircleUserIcon />
<span>Account</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={onSignOut}>
<LogOutIcon />
<span>Sign Out</span>
</DropdownMenuItem>
{supportLinks && supportLinks.length > 0 && (
<>
<Divider />
<DropdownMenuSeparator />
{supportLinks.map((link) => (
<a
href={link.target}
key={link.name}
target="_blank"
rel="noreferrer"
css={styles.link}
>
<PopoverClose asChild>
<MenuItem css={styles.menuItem}>
{link.icon && (
<SupportIcon
icon={link.icon}
className="size-5 text-content-secondary"
/>
)}
<span css={styles.menuItemText}>{link.name}</span>
</MenuItem>
</PopoverClose>
</a>
<DropdownMenuItem key={link.name} asChild>
<a href={link.target} target="_blank" rel="noreferrer">
{link.icon && <SupportIcon icon={link.icon} />}
<span>{link.name}</span>
</a>
</DropdownMenuItem>
))}
</>
)}
<Divider css={{ marginBottom: "0 !important" }} />
<Stack css={styles.info} spacing={0}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuSeparator />
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<DropdownMenuItem className="text-xs" asChild>
<a
css={[styles.footerText, styles.buildInfo]}
href={buildInfo?.external_url}
className="flex items-center gap-2"
target="_blank"
rel="noreferrer"
>
{buildInfo?.version} <SquareArrowOutUpRightIcon />
<span className="flex-1">{buildInfo?.version}</span>
<SquareArrowOutUpRightIcon className="!size-icon-xs" />
</a>
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent side="bottom">Browse the source code</TooltipContent>
</Tooltip>
{buildInfo?.deployment_id && (
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<DropdownMenuItem
className="text-xs"
onSelect={(e) => {
e.preventDefault();
copyToClipboard(buildInfo.deployment_id);
}}
>
<span className="truncate flex-1">{buildInfo.deployment_id}</span>
{showCopiedSuccess ? (
<CheckIcon className="!size-icon-xs ml-auto" />
) : (
<CopyIcon className="!size-icon-xs ml-auto" />
)}
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent side="bottom">Browse the source code</TooltipContent>
<TooltipContent side="bottom">
{showCopiedSuccess ? "Copied!" : "Copy deployment ID"}
</TooltipContent>
</Tooltip>
{buildInfo?.deployment_id && (
<div className="flex items-center text-xs">
<Tooltip>
<TooltipTrigger asChild>
<span className="whitespace-nowrap overflow-hidden text-ellipsis">
{buildInfo.deployment_id}
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
Deployment Identifier
</TooltipContent>
</Tooltip>
<CopyButton
text={buildInfo.deployment_id}
label="Copy deployment ID"
/>
</div>
)}
<div css={styles.footerText}>{Language.copyrightText}</div>
</Stack>
</div>
)}
<DropdownMenuItem className="text-xs" disabled>
<span>{Language.copyrightText}</span>
</DropdownMenuItem>
</>
);
};
const styles = {
info: (theme) => [
theme.typography.body2 as CSSObject,
{
padding: 20,
},
],
userName: {
fontWeight: 600,
},
userEmail: (theme) => ({
color: theme.palette.text.secondary,
width: "100%",
textOverflow: "ellipsis",
overflow: "hidden",
}),
link: {
textDecoration: "none",
color: "inherit",
},
menuItem: (theme) => css`
gap: 20px;
padding: 8px 20px;
&:hover {
background-color: ${theme.palette.action.hover};
transition: background-color 0.3s ease;
}
`,
menuItemText: {
fontSize: 14,
},
footerText: (theme) => css`
font-size: 12px;
text-decoration: none;
color: ${theme.palette.text.secondary};
display: flex;
align-items: center;
gap: 4px;
& svg {
width: 12px;
height: 12px;
}
`,
buildInfo: (theme) => ({
color: theme.palette.text.primary,
}),
} satisfies Record<string, Interpolation<Theme>>;