chore: replace MoreMenu with DropdownMenu (#17615)

Replace MoreMenu with DropDownMenu component to match update design
patterns.

Note: This was the result of experimentation using Cursor to make the
changes and Claude Code for fixing tests.

One key takeaway is that verbose e2e logging, especially benign
warnings/errors can confuse Claude Code in running playwright and
confirming its work.


<img width="201" alt="Screenshot 2025-05-01 at 00 00 52"
src="https://github.com/user-attachments/assets/4905582e-902e-4b61-adc8-14cab6bd006b"
/>
<img width="257" alt="Screenshot 2025-05-01 at 00 01 07"
src="https://github.com/user-attachments/assets/5befc420-724a-4c57-9a9d-330a39867fae"
/>
<img width="270" alt="Screenshot 2025-05-01 at 00 01 20"
src="https://github.com/user-attachments/assets/9cbf07cb-7d44-4228-ae6f-216e9f2faed0"
/>
<img width="224" alt="Screenshot 2025-05-01 at 00 01 30"
src="https://github.com/user-attachments/assets/9fe95916-3d9d-4600-9b1f-8a620e152a53"
/>
This commit is contained in:
Jaayden Halko
2025-05-01 13:14:11 -04:00
committed by GitHub
parent b7e08ba7c9
commit d9ef6ed8ae
22 changed files with 384 additions and 504 deletions
+2 -3
View File
@@ -33,9 +33,8 @@ test("remove member", async ({ page, baseURL }) => {
await expect(page).toHaveTitle(`${group.display_name} - Coder`);
const userRow = page.getByRole("row", { name: member.username });
await userRow.getByRole("button", { name: "More options" }).click();
const menu = page.locator("#more-options");
await userRow.getByRole("button", { name: "Open menu" }).click();
const menu = page.getByRole("menu");
await menu.getByText("Remove").click({ timeout: 1_000 });
await expect(page.getByText("Member removed successfully.")).toBeVisible();
+4 -2
View File
@@ -79,8 +79,10 @@ test("create group", async ({ page }) => {
await expect(page.getByText("No users found")).toBeVisible();
// Remove someone from the group
await addedRow.getByLabel("More options").click();
await page.getByText("Remove").click();
await addedRow.getByRole("button", { name: "Open menu" }).click();
const menu = page.getByRole("menu");
await menu.getByText("Remove").click();
await expect(addedRow).not.toBeVisible();
// Delete the group
+3 -2
View File
@@ -39,8 +39,9 @@ test("add and remove organization member", async ({ page }) => {
await expect(addedRow.getByText("+1 more")).toBeVisible();
// Remove them from the org
await addedRow.getByLabel("More options").click();
await page.getByText("Remove").click(); // Click the "Remove" option
await addedRow.getByRole("button", { name: "Open menu" }).click();
const menu = page.getByRole("menu");
await menu.getByText("Remove").click();
await page.getByRole("button", { name: "Remove" }).click(); // Click "Remove" in the confirmation dialog
await expect(addedRow).not.toBeVisible();
});
@@ -37,8 +37,8 @@ test.describe("CustomRolesPage", () => {
await expect(roleRow.getByText(customRole.display_name)).toBeVisible();
await expect(roleRow.getByText("organization_member")).toBeVisible();
await roleRow.getByRole("button", { name: "More options" }).click();
const menu = page.locator("#more-options");
await roleRow.getByRole("button", { name: "Open menu" }).click();
const menu = page.getByRole("menu");
await menu.getByText("Edit").click();
await expect(page).toHaveURL(
@@ -118,7 +118,7 @@ test.describe("CustomRolesPage", () => {
// Verify that the more menu (three dots) is not present for built-in roles
await expect(
roleRow.getByRole("button", { name: "More options" }),
roleRow.getByRole("button", { name: "Open menu" }),
).not.toBeVisible();
await deleteOrganization(org.name);
@@ -175,9 +175,9 @@ test.describe("CustomRolesPage", () => {
await page.goto(`/organizations/${org.name}/roles`);
const roleRow = page.getByTestId(`role-${customRole.name}`);
await roleRow.getByRole("button", { name: "More options" }).click();
await roleRow.getByRole("button", { name: "Open menu" }).click();
const menu = page.locator("#more-options");
const menu = page.getByRole("menu");
await menu.getByText("Delete…").click();
const input = page.getByRole("textbox");
+4 -2
View File
@@ -53,8 +53,10 @@ test("add and remove a group", async ({ page }) => {
await expect(row).toBeVisible();
// Now remove the group
await row.getByLabel("More options").click();
await page.getByText("Remove").click();
await row.getByRole("button", { name: "Open menu" }).click();
const menu = page.getByRole("menu");
await menu.getByText("Remove").click();
await expect(page.getByText("Group removed successfully!")).toBeVisible();
await expect(row).not.toBeVisible();
});
+3 -3
View File
@@ -17,9 +17,9 @@ test("remove user", async ({ page, baseURL }) => {
await expect(page).toHaveTitle("Users - Coder");
const userRow = page.getByRole("row", { name: user.email });
await userRow.getByRole("button", { name: "More options" }).click();
const menu = page.locator("#more-options");
await menu.getByText("Delete").click();
await userRow.getByRole("button", { name: "Open menu" }).click();
const menu = page.getByRole("menu");
await menu.getByText("Delete").click();
const dialog = page.getByTestId("dialog");
await dialog.getByLabel("Name of the user to delete").fill(user.username);
@@ -196,7 +196,7 @@ export const DropdownMenuSeparator = forwardRef<
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn(["-mx-1 my-3 h-px bg-border"], className)}
className={cn(["-mx-1 my-2 h-px bg-border"], className)}
{...props}
/>
));
@@ -1,59 +0,0 @@
import GrassIcon from "@mui/icons-material/Grass";
import KitesurfingIcon from "@mui/icons-material/Kitesurfing";
import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react";
import { expect, screen, userEvent, waitFor, within } from "@storybook/test";
import {
MoreMenu,
MoreMenuContent,
MoreMenuItem,
MoreMenuTrigger,
ThreeDotsButton,
} from "./MoreMenu";
const meta: Meta<typeof MoreMenu> = {
title: "components/MoreMenu",
component: MoreMenu,
};
export default meta;
type Story = StoryObj<typeof MoreMenu>;
const Example: Story = {
args: {
children: (
<>
<MoreMenuTrigger>
<ThreeDotsButton />
</MoreMenuTrigger>
<MoreMenuContent>
<MoreMenuItem onClick={action("grass")}>
<GrassIcon />
Touch grass
</MoreMenuItem>
<MoreMenuItem onClick={action("water")}>
<KitesurfingIcon />
Touch water
</MoreMenuItem>
</MoreMenuContent>
</>
),
},
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
await step("Open menu", async () => {
await userEvent.click(
canvas.getByRole("button", { name: "More options" }),
);
await waitFor(() =>
Promise.all([
expect(screen.getByText(/touch grass/i)).toBeInTheDocument(),
expect(screen.getByText(/touch water/i)).toBeInTheDocument(),
]),
);
});
},
};
export { Example as MoreMenu };
-135
View File
@@ -1,135 +0,0 @@
import MoreVertOutlined from "@mui/icons-material/MoreVertOutlined";
import IconButton, { type IconButtonProps } from "@mui/material/IconButton";
import Menu, { type MenuProps } from "@mui/material/Menu";
import MenuItem, { type MenuItemProps } from "@mui/material/MenuItem";
import {
type FC,
type HTMLProps,
type PropsWithChildren,
type ReactElement,
cloneElement,
createContext,
forwardRef,
useContext,
useRef,
useState,
} from "react";
type MoreMenuContextValue = {
triggerRef: React.RefObject<HTMLButtonElement>;
close: () => void;
open: () => void;
isOpen: boolean;
};
const MoreMenuContext = createContext<MoreMenuContextValue | undefined>(
undefined,
);
export const MoreMenu: FC<PropsWithChildren> = ({ children }) => {
const triggerRef = useRef<HTMLButtonElement>(null);
const [isOpen, setIsOpen] = useState(false);
const close = () => {
setIsOpen(false);
};
const open = () => {
setIsOpen(true);
};
return (
<MoreMenuContext.Provider value={{ close, open, triggerRef, isOpen }}>
{children}
</MoreMenuContext.Provider>
);
};
const useMoreMenuContext = () => {
const ctx = useContext(MoreMenuContext);
if (!ctx) {
throw new Error("useMoreMenuContext must be used inside of MoreMenu");
}
return ctx;
};
export const MoreMenuTrigger: FC<HTMLProps<HTMLButtonElement>> = ({
children,
...props
}) => {
const menu = useMoreMenuContext();
return cloneElement(children as ReactElement, {
"aria-haspopup": "true",
...props,
ref: menu.triggerRef,
onClick: menu.open,
});
};
export const ThreeDotsButton = forwardRef<HTMLButtonElement, IconButtonProps>(
(props, ref) => {
return (
<IconButton
aria-controls="more-options"
aria-label="More options"
ref={ref}
{...props}
>
<MoreVertOutlined />
</IconButton>
);
},
);
export const MoreMenuContent: FC<Omit<MenuProps, "open" | "onClose">> = (
props,
) => {
const menu = useMoreMenuContext();
return (
<Menu
id="more-options"
anchorEl={menu.triggerRef.current}
open={menu.isOpen}
onClose={menu.close}
disablePortal
{...props}
/>
);
};
interface MoreMenuItemProps extends MenuItemProps {
closeOnClick?: boolean;
danger?: boolean;
}
export const MoreMenuItem: FC<MoreMenuItemProps> = ({
closeOnClick = true,
danger = false,
...menuItemProps
}) => {
const menu = useMoreMenuContext();
return (
<MenuItem
{...menuItemProps}
css={(theme) => ({
fontSize: 14,
color: danger ? theme.palette.warning.light : undefined,
"& .MuiSvgIcon-root": {
width: 16,
height: 16,
},
})}
onClick={(e) => {
menuItemProps.onClick?.(e);
if (closeOnClick) {
menu.close();
}
}}
/>
);
};
@@ -3,13 +3,14 @@ import Checkbox from "@mui/material/Checkbox";
import TableCell from "@mui/material/TableCell";
import TableRow from "@mui/material/TableRow";
import type { BannerConfig } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
MoreMenu,
MoreMenuContent,
MoreMenuItem,
MoreMenuTrigger,
ThreeDotsButton,
} from "components/MoreMenu/MoreMenu";
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { EllipsisVertical } from "lucide-react";
import type { FC } from "react";
interface AnnouncementBannerItemProps {
@@ -48,17 +49,25 @@ export const AnnouncementBannerItem: FC<AnnouncementBannerItemProps> = ({
</TableCell>
<TableCell>
<MoreMenu>
<MoreMenuTrigger>
<ThreeDotsButton />
</MoreMenuTrigger>
<MoreMenuContent>
<MoreMenuItem onClick={() => onEdit()}>Edit&hellip;</MoreMenuItem>
<MoreMenuItem onClick={() => onDelete()} danger>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon-lg" variant="subtle" aria-label="Open menu">
<EllipsisVertical aria-hidden="true" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit()}>
Edit&hellip;
</DropdownMenuItem>
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={() => onDelete()}
>
Delete&hellip;
</MoreMenuItem>
</MoreMenuContent>
</MoreMenu>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
+25 -17
View File
@@ -20,18 +20,18 @@ import type {
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Avatar } from "components/Avatar/Avatar";
import { AvatarData } from "components/Avatar/AvatarData";
import { Button as ShadcnButton } from "components/Button/Button";
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { EmptyState } from "components/EmptyState/EmptyState";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
import { LastSeen } from "components/LastSeen/LastSeen";
import { Loader } from "components/Loader/Loader";
import {
MoreMenu,
MoreMenuContent,
MoreMenuItem,
MoreMenuTrigger,
ThreeDotsButton,
} from "components/MoreMenu/MoreMenu";
import {
SettingsHeader,
SettingsHeaderDescription,
@@ -51,6 +51,7 @@ import {
TableToolbar,
} from "components/TableToolbar/TableToolbar";
import { MemberAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
import { EllipsisVertical } from "lucide-react";
import { type FC, useState } from "react";
import { Helmet } from "react-helmet-async";
import { useMutation, useQuery, useQueryClient } from "react-query";
@@ -330,20 +331,27 @@ const GroupMemberRow: FC<GroupMemberRowProps> = ({
</TableCell>
<TableCell width="1%">
{canUpdate && (
<MoreMenu>
<MoreMenuTrigger>
<ThreeDotsButton />
</MoreMenuTrigger>
<MoreMenuContent>
<MoreMenuItem
danger
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ShadcnButton
size="icon-lg"
variant="subtle"
aria-label="Open menu"
>
<EllipsisVertical aria-hidden="true" />
<span className="sr-only">Open menu</span>
</ShadcnButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={onRemove}
disabled={group.id === group.organization_id}
>
Remove
</MoreMenuItem>
</MoreMenuContent>
</MoreMenu>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
@@ -4,15 +4,15 @@ import AddOutlined from "@mui/icons-material/AddOutlined";
import Button from "@mui/material/Button";
import Skeleton from "@mui/material/Skeleton";
import type { AssignableRoles, Role } from "api/typesGenerated";
import { Button as ShadcnButton } from "components/Button/Button";
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
import { EmptyState } from "components/EmptyState/EmptyState";
import {
MoreMenu,
MoreMenuContent,
MoreMenuItem,
MoreMenuTrigger,
ThreeDotsButton,
} from "components/MoreMenu/MoreMenu";
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { EmptyState } from "components/EmptyState/EmptyState";
import { Paywall } from "components/Paywall/Paywall";
import { Stack } from "components/Stack/Stack";
import {
@@ -27,6 +27,7 @@ import {
TableLoaderSkeleton,
TableRowSkeleton,
} from "components/TableLoader/TableLoader";
import { EllipsisVertical } from "lucide-react";
import type { FC } from "react";
import { Link as RouterLink, useNavigate } from "react-router-dom";
import { docs } from "utils/docs";
@@ -213,27 +214,33 @@ const RoleRow: FC<RoleRowProps> = ({
<TableCell>
{!role.built_in && (canUpdateOrgRole || canDeleteOrgRole) && (
<MoreMenu>
<MoreMenuTrigger>
<ThreeDotsButton />
</MoreMenuTrigger>
<MoreMenuContent>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ShadcnButton
size="icon-lg"
variant="subtle"
aria-label="Open menu"
>
<EllipsisVertical aria-hidden="true" />
<span className="sr-only">Open menu</span>
</ShadcnButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canUpdateOrgRole && (
<MoreMenuItem
onClick={() => {
navigate(role.name);
}}
>
<DropdownMenuItem onClick={() => navigate(role.name)}>
Edit
</MoreMenuItem>
</DropdownMenuItem>
)}
{canDeleteOrgRole && (
<MoreMenuItem danger onClick={onDelete}>
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={onDelete}
>
Delete&hellip;
</MoreMenuItem>
</DropdownMenuItem>
)}
</MoreMenuContent>
</MoreMenu>
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
@@ -46,15 +46,19 @@ const renderPage = async () => {
const removeMember = async () => {
const user = userEvent.setup();
// Click on the "More options" button to display the "Remove" option
const moreButtons = await screen.findAllByLabelText("More options");
// get MockUser2
const selectedMoreButton = moreButtons[0];
await user.click(selectedMoreButton);
const users = await screen.findAllByText(/.*@coder.com/);
const userRow = users[1].closest("tr");
if (!userRow) {
throw new Error("Error on get the first user row");
}
const menuButton = await within(userRow).findByRole("button", {
name: "Open menu",
});
await user.click(menuButton);
const removeButton = screen.getByText(/Remove/);
await user.click(removeButton);
const removeOption = await screen.findByRole("menuitem", { name: "Remove" });
await user.click(removeOption);
const dialog = await within(document.body).findByRole("dialog");
await user.click(within(dialog).getByRole("button", { name: "Remove" }));
@@ -10,15 +10,15 @@ import type {
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Avatar } from "components/Avatar/Avatar";
import { AvatarData } from "components/Avatar/AvatarData";
import { Button } from "components/Button/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
import { Loader } from "components/Loader/Loader";
import {
MoreMenu,
MoreMenuContent,
MoreMenuItem,
MoreMenuTrigger,
ThreeDotsButton,
} from "components/MoreMenu/MoreMenu";
import { PaginationContainer } from "components/PaginationWidget/PaginationContainer";
import {
SettingsHeader,
@@ -35,7 +35,7 @@ import {
} from "components/Table/Table";
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
import type { PaginationResultInfo } from "hooks/usePaginatedQuery";
import { TriangleAlert } from "lucide-react";
import { EllipsisVertical, TriangleAlert } from "lucide-react";
import { UserGroupsCell } from "pages/UsersPage/UsersTable/UserGroupsCell";
import { type FC, useState } from "react";
import { TableColumnHelpTooltip } from "./UserTable/TableColumnHelpTooltip";
@@ -163,19 +163,26 @@ export const OrganizationMembersPageView: FC<
<UserGroupsCell userGroups={member.groups} />
<TableCell>
{member.user_id !== me.id && canEditMembers && (
<MoreMenu>
<MoreMenuTrigger>
<ThreeDotsButton />
</MoreMenuTrigger>
<MoreMenuContent>
<MoreMenuItem
danger
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon-lg"
variant="subtle"
aria-label="Open menu"
>
<EllipsisVertical aria-hidden="true" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={() => removeMember(member)}
>
Remove
</MoreMenuItem>
</MoreMenuContent>
</MoreMenu>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
@@ -4,7 +4,6 @@ import EditIcon from "@mui/icons-material/EditOutlined";
import CopyIcon from "@mui/icons-material/FileCopyOutlined";
import SettingsIcon from "@mui/icons-material/SettingsOutlined";
import Button from "@mui/material/Button";
import Divider from "@mui/material/Divider";
import { workspaces } from "api/queries/workspaces";
import type {
AuthorizationResponse,
@@ -12,17 +11,18 @@ import type {
TemplateVersion,
} from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { Button as ShadcnButton } from "components/Button/Button";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { Margins } from "components/Margins/Margins";
import { MemoizedInlineMarkdown } from "components/Markdown/Markdown";
import {
MoreMenu,
MoreMenuContent,
MoreMenuItem,
MoreMenuTrigger,
ThreeDotsButton,
} from "components/MoreMenu/MoreMenu";
import {
PageHeader,
PageHeaderSubtitle,
@@ -30,6 +30,7 @@ import {
} from "components/PageHeader/PageHeader";
import { Pill } from "components/Pill/Pill";
import { Stack } from "components/Stack/Stack";
import { EllipsisVertical } from "lucide-react";
import { linkToTemplate, useLinks } from "modules/navigation";
import type { WorkspacePermissions } from "modules/permissions/workspaces";
import type { FC } from "react";
@@ -67,44 +68,48 @@ const TemplateMenu: FC<TemplateMenuProps> = ({
return (
<>
<MoreMenu>
<MoreMenuTrigger>
<ThreeDotsButton />
</MoreMenuTrigger>
<MoreMenuContent>
<MoreMenuItem
onClick={() => {
navigate(`${templateLink}/settings`);
}}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ShadcnButton size="icon-lg" variant="subtle" aria-label="Open menu">
<EllipsisVertical aria-hidden="true" />
<span className="sr-only">Open menu</span>
</ShadcnButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => navigate(`${templateLink}/settings`)}
>
<SettingsIcon />
Settings
</MoreMenuItem>
</DropdownMenuItem>
<MoreMenuItem
onClick={() => {
navigate(`${templateLink}/versions/${templateVersion}/edit`);
}}
<DropdownMenuItem
onClick={() =>
navigate(`${templateLink}/versions/${templateVersion}/edit`)
}
>
<EditIcon />
Edit files
</MoreMenuItem>
</DropdownMenuItem>
<MoreMenuItem
onClick={() => {
navigate(`/templates/new?fromTemplate=${templateId}`);
}}
<DropdownMenuItem
onClick={() =>
navigate(`/templates/new?fromTemplate=${templateId}`)
}
>
<CopyIcon />
Duplicate&hellip;
</MoreMenuItem>
<Divider />
<MoreMenuItem onClick={dialogState.openDeleteConfirmation} danger>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={dialogState.openDeleteConfirmation}
>
<DeleteIcon />
Delete&hellip;
</MoreMenuItem>
</MoreMenuContent>
</MoreMenu>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{safeToDeleteTemplate ? (
<DeleteDialog
@@ -19,18 +19,19 @@ import type {
} from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { AvatarData } from "components/Avatar/AvatarData";
import { Button } from "components/Button/Button";
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
import { EmptyState } from "components/EmptyState/EmptyState";
import {
MoreMenu,
MoreMenuContent,
MoreMenuItem,
MoreMenuTrigger,
ThreeDotsButton,
} from "components/MoreMenu/MoreMenu";
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { EmptyState } from "components/EmptyState/EmptyState";
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader";
import { Stack } from "components/Stack/Stack";
import { TableLoader } from "components/TableLoader/TableLoader";
import { EllipsisVertical } from "lucide-react";
import { type FC, useState } from "react";
import { getGroupSubtitle } from "utils/groups";
import {
@@ -289,19 +290,26 @@ export const TemplatePermissionsPageView: FC<
<TableCell>
{canUpdatePermissions && (
<MoreMenu>
<MoreMenuTrigger>
<ThreeDotsButton />
</MoreMenuTrigger>
<MoreMenuContent>
<MoreMenuItem
danger
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon-lg"
variant="subtle"
aria-label="Open menu"
>
<EllipsisVertical aria-hidden="true" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={() => onRemoveGroup(group)}
>
Remove
</MoreMenuItem>
</MoreMenuContent>
</MoreMenu>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
@@ -338,19 +346,26 @@ export const TemplatePermissionsPageView: FC<
<TableCell>
{canUpdatePermissions && (
<MoreMenu>
<MoreMenuTrigger>
<ThreeDotsButton />
</MoreMenuTrigger>
<MoreMenuContent>
<MoreMenuItem
danger
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon-lg"
variant="subtle"
aria-label="Open menu"
>
<EllipsisVertical aria-hidden="true" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={() => onRemoveUser(user)}
>
Remove
</MoreMenuItem>
</MoreMenuContent>
</MoreMenu>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
@@ -1,7 +1,6 @@
import { useTheme } from "@emotion/react";
import AutorenewIcon from "@mui/icons-material/Autorenew";
import LoadingButton from "@mui/lab/LoadingButton";
import Divider from "@mui/material/Divider";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
@@ -18,16 +17,17 @@ import type {
} from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Avatar } from "components/Avatar/Avatar";
import { Loader } from "components/Loader/Loader";
import { Button } from "components/Button/Button";
import {
MoreMenu,
MoreMenuContent,
MoreMenuItem,
MoreMenuTrigger,
ThreeDotsButton,
} from "components/MoreMenu/MoreMenu";
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { Loader } from "components/Loader/Loader";
import { Stack } from "components/Stack/Stack";
import { TableEmpty } from "components/TableEmpty/TableEmpty";
import { EllipsisVertical } from "lucide-react";
import type { ExternalAuthPollingState } from "pages/CreateWorkspacePage/CreateWorkspacePage";
import { type FC, useCallback, useEffect, useState } from "react";
import { useQuery } from "react-query";
@@ -178,12 +178,15 @@ const ExternalAuthRow: FC<ExternalAuthRowProps> = ({
</LoadingButton>
</TableCell>
<TableCell>
<MoreMenu>
<MoreMenuTrigger>
<ThreeDotsButton size="small" disabled={!authenticated} />
</MoreMenuTrigger>
<MoreMenuContent>
<MoreMenuItem
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon-lg" variant="subtle" aria-label="Open menu">
<EllipsisVertical aria-hidden="true" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={async () => {
onValidateExternalAuth();
// This is kinda jank. It does a refetch of the thing
@@ -194,19 +197,18 @@ const ExternalAuthRow: FC<ExternalAuthRowProps> = ({
}}
>
Test Validate&hellip;
</MoreMenuItem>
<Divider />
<MoreMenuItem
danger
</DropdownMenuItem>
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={async () => {
onUnlinkExternalAuth();
await refetch();
}}
>
Unlink&hellip;
</MoreMenuItem>
</MoreMenuContent>
</MoreMenu>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
+18 -32
View File
@@ -99,10 +99,8 @@ export const SuspendUserSuccess: Story = {
count: 60,
});
await user.click(within(userRow).getByLabelText("More options"));
const suspendButton = await within(userRow).findByText("Suspend", {
exact: false,
});
await user.click(within(userRow).getByLabelText("Open menu"));
const suspendButton = await within(document.body).findByText("Suspend");
await user.click(suspendButton);
const dialog = await within(document.body).findByRole("dialog");
@@ -120,10 +118,8 @@ export const SuspendUserError: Story = {
}
spyOn(API, "suspendUser").mockRejectedValue(undefined);
await user.click(within(userRow).getByLabelText("More options"));
const suspendButton = await within(userRow).findByText("Suspend", {
exact: false,
});
await user.click(within(userRow).getByLabelText("Open menu"));
const suspendButton = await within(document.body).findByText("Suspend");
await user.click(suspendButton);
const dialog = await within(document.body).findByRole("dialog");
@@ -149,10 +145,8 @@ export const DeleteUserSuccess: Story = {
count: 59,
});
await user.click(within(userRow).getByLabelText("More options"));
const deleteButton = await within(userRow).findByText("Delete", {
exact: false,
});
await user.click(within(userRow).getByLabelText("Open menu"));
const deleteButton = await within(document.body).findByText("Delete");
await user.click(deleteButton);
const dialog = await within(document.body).findByRole("dialog");
@@ -172,10 +166,8 @@ export const DeleteUserError: Story = {
}
spyOn(API, "deleteUser").mockRejectedValue({});
await user.click(within(userRow).getByLabelText("More options"));
const deleteButton = await within(userRow).findByText("Delete", {
exact: false,
});
await user.click(within(userRow).getByLabelText("Open menu"));
const deleteButton = await within(document.body).findByText("Delete");
await user.click(deleteButton);
const dialog = await within(document.body).findByRole("dialog");
@@ -220,10 +212,8 @@ export const ActivateUserSuccess: Story = {
count: 60,
});
await user.click(within(userRow).getByLabelText("More options"));
const activateButton = await within(userRow).findByText("Activate", {
exact: false,
});
await user.click(within(userRow).getByLabelText("Open menu"));
const activateButton = await within(document.body).findByText("Activate");
await user.click(activateButton);
const dialog = await within(document.body).findByRole("dialog");
@@ -242,10 +232,8 @@ export const ActivateUserError: Story = {
}
spyOn(API, "activateUser").mockRejectedValue({});
await user.click(within(userRow).getByLabelText("More options"));
const activateButton = await within(userRow).findByText("Activate", {
exact: false,
});
await user.click(within(userRow).getByLabelText("Open menu"));
const activateButton = await within(document.body).findByText("Activate");
await user.click(activateButton);
const dialog = await within(document.body).findByRole("dialog");
@@ -279,10 +267,9 @@ export const ResetUserPasswordSuccess: Story = {
}
spyOn(API, "updateUserPassword").mockResolvedValue();
await user.click(within(userRow).getByLabelText("More options"));
const resetPasswordButton = await within(userRow).findByText(
"Reset password",
{ exact: false },
await user.click(within(userRow).getByLabelText("Open menu"));
const resetPasswordButton = await within(document.body).findByText(
"Reset password",
);
await user.click(resetPasswordButton);
@@ -306,10 +293,9 @@ export const ResetUserPasswordError: Story = {
}
spyOn(API, "updateUserPassword").mockRejectedValue({});
await user.click(within(userRow).getByLabelText("More options"));
const resetPasswordButton = await within(userRow).findByText(
"Reset password",
{ exact: false },
await user.click(within(userRow).getByLabelText("Open menu"));
const resetPasswordButton = await within(document.body).findByText(
"Reset password",
);
await user.click(resetPasswordButton);
@@ -1,26 +1,27 @@
import type { Interpolation, Theme } from "@emotion/react";
import DeleteIcon from "@mui/icons-material/Delete";
import GitHub from "@mui/icons-material/GitHub";
import HideSourceOutlined from "@mui/icons-material/HideSourceOutlined";
import KeyOutlined from "@mui/icons-material/KeyOutlined";
import PasswordOutlined from "@mui/icons-material/PasswordOutlined";
import ShieldOutlined from "@mui/icons-material/ShieldOutlined";
import Divider from "@mui/material/Divider";
import Skeleton from "@mui/material/Skeleton";
import type { GroupsByUserId } from "api/queries/groups";
import type * as TypesGen from "api/typesGenerated";
import { AvatarData } from "components/Avatar/AvatarData";
import { AvatarDataSkeleton } from "components/Avatar/AvatarDataSkeleton";
import { PremiumBadge } from "components/Badges/Badges";
import { Button } from "components/Button/Button";
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { EmptyState } from "components/EmptyState/EmptyState";
import { LastSeen } from "components/LastSeen/LastSeen";
import {
MoreMenu,
MoreMenuContent,
MoreMenuItem,
MoreMenuTrigger,
ThreeDotsButton,
} from "components/MoreMenu/MoreMenu";
import { TableCell, TableRow } from "components/Table/Table";
import {
TableLoaderSkeleton,
@@ -28,6 +29,7 @@ import {
} from "components/TableLoader/TableLoader";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { EllipsisVertical } from "lucide-react";
import type { FC } from "react";
import { UserRoleCell } from "../../OrganizationSettingsPage/UserTable/UserRoleCell";
import { UserGroupsCell } from "./UserGroupsCell";
@@ -180,51 +182,65 @@ export const UsersTableBody: FC<UsersTableBodyProps> = ({
{canEditUsers && (
<TableCell>
<MoreMenu>
<MoreMenuTrigger>
<ThreeDotsButton />
</MoreMenuTrigger>
<MoreMenuContent>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon-lg"
variant="subtle"
aria-label="Open menu"
>
<EllipsisVertical aria-hidden="true" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{user.status === "active" || user.status === "dormant" ? (
<MoreMenuItem
<DropdownMenuItem
data-testid="suspend-button"
onClick={() => {
onSuspendUser(user);
}}
onClick={() => onSuspendUser(user)}
>
Suspend&hellip;
</MoreMenuItem>
</DropdownMenuItem>
) : (
<MoreMenuItem onClick={() => onActivateUser(user)}>
<DropdownMenuItem onClick={() => onActivateUser(user)}>
Activate&hellip;
</MoreMenuItem>
</DropdownMenuItem>
)}
<MoreMenuItem onClick={() => onListWorkspaces(user)}>
<DropdownMenuItem onClick={() => onListWorkspaces(user)}>
View workspaces
</MoreMenuItem>
<MoreMenuItem
onClick={() => onViewActivity(user)}
disabled={!canViewActivity}
>
View activity
{!canViewActivity && <PremiumBadge />}
</MoreMenuItem>
<MoreMenuItem
onClick={() => onResetUserPassword(user)}
disabled={user.login_type !== "password"}
>
Reset password&hellip;
</MoreMenuItem>
<Divider />
<MoreMenuItem
</DropdownMenuItem>
{canViewActivity && (
<DropdownMenuItem
onClick={() => onViewActivity(user)}
disabled={!canViewActivity}
>
View activity {!canViewActivity && <PremiumBadge />}
</DropdownMenuItem>
)}
{user.login_type === "password" && (
<DropdownMenuItem
onClick={() => onResetUserPassword(user)}
disabled={user.login_type !== "password"}
>
Reset password&hellip;
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={() => onDeleteUser(user)}
disabled={user.id === actorID}
danger
>
<DeleteIcon />
Delete&hellip;
</MoreMenuItem>
</MoreMenuContent>
</MoreMenu>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
)}
</TableRow>
@@ -202,9 +202,11 @@ export const OpenDownloadLogs: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "More options" }));
await userEvent.click(canvas.getByText("Download logs", { exact: false }));
await userEvent.click(
canvas.getByRole("button", { name: "Workspace actions" }),
);
const screen = within(document.body);
await userEvent.click(screen.getByText("Download logs…"));
await expect(screen.getByTestId("dialog")).toBeInTheDocument();
},
};
@@ -215,8 +217,11 @@ export const CanDeleteDormantWorkspace: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "More options" }));
const deleteButton = canvas.getByText("Delete…");
await userEvent.click(
canvas.getByRole("button", { name: "Workspace actions" }),
);
const screen = within(document.body);
const deleteButton = screen.getByText("Delete…");
await expect(deleteButton).toBeEnabled();
},
};
@@ -2,17 +2,17 @@ import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import DownloadOutlined from "@mui/icons-material/DownloadOutlined";
import DuplicateIcon from "@mui/icons-material/FileCopyOutlined";
import HistoryIcon from "@mui/icons-material/HistoryOutlined";
import MoreVertOutlined from "@mui/icons-material/MoreVertOutlined";
import SettingsIcon from "@mui/icons-material/SettingsOutlined";
import Divider from "@mui/material/Divider";
import type { Workspace, WorkspaceBuildParameter } from "api/typesGenerated";
import { TopbarIconButton } from "components/FullPageLayout/Topbar";
import { Button } from "components/Button/Button";
import {
MoreMenu,
MoreMenuContent,
MoreMenuItem,
MoreMenuTrigger,
} from "components/MoreMenu/MoreMenu";
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { EllipsisVertical } from "lucide-react";
import { useWorkspaceDuplication } from "pages/CreateWorkspacePage/useWorkspaceDuplication";
import { type FC, Fragment, type ReactNode, useState } from "react";
import { mustUpdateWorkspace } from "utils/workspace";
@@ -177,56 +177,59 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
onToggle={handleToggleFavorite}
/>
<MoreMenu>
<MoreMenuTrigger>
<TopbarIconButton
title="More options"
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon-lg"
variant="subtle"
aria-label="Workspace actions"
data-testid="workspace-options-button"
aria-controls="workspace-options"
disabled={!canAcceptJobs}
>
<MoreVertOutlined />
</TopbarIconButton>
</MoreMenuTrigger>
<EllipsisVertical aria-hidden="true" />
<span className="sr-only">Workspace actions</span>
</Button>
</DropdownMenuTrigger>
<MoreMenuContent id="workspace-options">
<MoreMenuItem onClick={handleSettings}>
<DropdownMenuContent id="workspace-options" align="end">
<DropdownMenuItem onClick={handleSettings}>
<SettingsIcon />
Settings
</MoreMenuItem>
</DropdownMenuItem>
{canChangeVersions && (
<MoreMenuItem onClick={handleChangeVersion}>
<DropdownMenuItem onClick={handleChangeVersion}>
<HistoryIcon />
Change version&hellip;
</MoreMenuItem>
</DropdownMenuItem>
)}
<MoreMenuItem
<DropdownMenuItem
onClick={duplicateWorkspace}
disabled={!isDuplicationReady}
>
<DuplicateIcon />
Duplicate&hellip;
</MoreMenuItem>
</DropdownMenuItem>
<MoreMenuItem onClick={() => setIsDownloadDialogOpen(true)}>
<DropdownMenuItem onClick={() => setIsDownloadDialogOpen(true)}>
<DownloadOutlined />
Download logs&hellip;
</MoreMenuItem>
</DropdownMenuItem>
<Divider />
<DropdownMenuSeparator />
<MoreMenuItem
danger
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={handleDelete}
data-testid="delete-button"
>
<DeleteIcon />
Delete&hellip;
</MoreMenuItem>
</MoreMenuContent>
</MoreMenu>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DownloadLogsDialog
workspace={workspace}
@@ -4,19 +4,19 @@ import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutl
import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined";
import StopOutlined from "@mui/icons-material/StopOutlined";
import LoadingButton from "@mui/lab/LoadingButton";
import Divider from "@mui/material/Divider";
import { hasError, isApiValidationError } from "api/errors";
import type { Template, Workspace } from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Button } from "components/Button/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { EmptyState } from "components/EmptyState/EmptyState";
import { Margins } from "components/Margins/Margins";
import {
MoreMenu,
MoreMenuContent,
MoreMenuItem,
MoreMenuTrigger,
} from "components/MoreMenu/MoreMenu";
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader";
import { PaginationHeader } from "components/PaginationWidget/PaginationHeader";
import { PaginationWidgetBase } from "components/PaginationWidget/PaginationWidgetBase";
@@ -134,8 +134,8 @@ export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({
{workspaces?.length === 1 ? "workspace" : "workspaces"}
</div>
<MoreMenu>
<MoreMenuTrigger>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<LoadingButton
loading={isRunningBatchAction}
loadingPosition="end"
@@ -146,10 +146,9 @@ export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({
>
Actions
</LoadingButton>
</MoreMenuTrigger>
<MoreMenuContent>
<MoreMenuItem
onClick={onStartAll}
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
disabled={
!checkedWorkspaces?.every(
(w) =>
@@ -157,28 +156,32 @@ export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({
!mustUpdateWorkspace(w, canChangeVersions),
)
}
onClick={onStartAll}
>
<PlayArrowOutlined /> Start
</MoreMenuItem>
<MoreMenuItem
onClick={onStopAll}
</DropdownMenuItem>
<DropdownMenuItem
disabled={
!checkedWorkspaces?.every(
(w) => w.latest_build.status === "running",
)
}
onClick={onStopAll}
>
<StopOutlined /> Stop
</MoreMenuItem>
<Divider />
<MoreMenuItem onClick={onUpdateAll}>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onUpdateAll}>
<CloudQueue /> Update&hellip;
</MoreMenuItem>
<MoreMenuItem danger onClick={onDeleteAll}>
</DropdownMenuItem>
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={onDeleteAll}
>
<DeleteOutlined /> Delete&hellip;
</MoreMenuItem>
</MoreMenuContent>
</MoreMenu>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
!invalidPageNumber && (