mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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>>;
|
||||
|
||||
Reference in New Issue
Block a user