diff --git a/site/src/components/MoreMenu/MoreMenu.tsx b/site/src/components/MoreMenu/MoreMenu.tsx new file mode 100644 index 0000000000000..fbececc20f0b4 --- /dev/null +++ b/site/src/components/MoreMenu/MoreMenu.tsx @@ -0,0 +1,108 @@ +import { useRef, useState, createContext, useContext, ReactNode } from "react"; +import MoreVertOutlined from "@mui/icons-material/MoreVertOutlined"; +import Menu, { MenuProps } from "@mui/material/Menu"; +import MenuItem, { MenuItemProps } from "@mui/material/MenuItem"; +import IconButton, { IconButtonProps } from "@mui/material/IconButton"; + +type MoreMenuContextValue = { + triggerRef: React.RefObject; + close: () => void; + open: () => void; + isOpen: boolean; +}; + +const MoreMenuContext = createContext( + undefined, +); + +export const MoreMenu = (props: { children: ReactNode }) => { + const triggerRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + + const close = () => { + setIsOpen(false); + }; + + const open = () => { + setIsOpen(true); + }; + + return ( + + {props.children} + + ); +}; + +const useMoreMenuContext = () => { + const ctx = useContext(MoreMenuContext); + + if (!ctx) { + throw new Error("useMoreMenuContext must be used inside of MoreMenu"); + } + + return ctx; +}; + +export const MoreMenuTrigger = (props: IconButtonProps) => { + const menu = useMoreMenuContext(); + + return ( + + + + ); +}; + +export const MoreMenuContent = (props: Omit) => { + const menu = useMoreMenuContext(); + + return ( + + ); +}; + +export const MoreMenuItem = ( + props: MenuItemProps & { closeOnClick?: boolean; danger?: boolean }, +) => { + const { closeOnClick = true, danger = false, ...menuItemProps } = props; + const ctx = useContext(MoreMenuContext); + + if (!ctx) { + throw new Error("MoreMenuItem must be used inside of MoreMenu"); + } + + return ( + ({ + fontSize: 14, + color: danger ? theme.palette.error.light : undefined, + "& .MuiSvgIcon-root": { + width: theme.spacing(2), + height: theme.spacing(2), + }, + })} + onClick={(e) => { + menuItemProps.onClick && menuItemProps.onClick(e); + if (closeOnClick) { + ctx.close(); + } + }} + /> + ); +}; diff --git a/site/src/components/TableRowMenu/TableRowMenu.stories.tsx b/site/src/components/TableRowMenu/TableRowMenu.stories.tsx deleted file mode 100644 index 53991b0245728..0000000000000 --- a/site/src/components/TableRowMenu/TableRowMenu.stories.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { TableRowMenu } from "./TableRowMenu"; -import type { Meta, StoryObj } from "@storybook/react"; - -const meta: Meta = { - title: "components/TableRowMenu", - component: TableRowMenu, -}; - -export default meta; -type Story = StoryObj>; - -const Example: Story = { - args: { - data: { id: "123" }, - menuItems: [ - { label: "Suspend", onClick: (data) => alert(data.id), disabled: false }, - { label: "Update", onClick: (data) => alert(data.id), disabled: false }, - { label: "Delete", onClick: (data) => alert(data.id), disabled: false }, - { label: "Explode", onClick: (data) => alert(data.id), disabled: true }, - ], - }, -}; - -export { Example as TableRowMenu }; diff --git a/site/src/components/TableRowMenu/TableRowMenu.tsx b/site/src/components/TableRowMenu/TableRowMenu.tsx deleted file mode 100644 index e4e81954b71b5..0000000000000 --- a/site/src/components/TableRowMenu/TableRowMenu.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import IconButton from "@mui/material/IconButton"; -import Menu, { MenuProps } from "@mui/material/Menu"; -import MenuItem from "@mui/material/MenuItem"; -import MoreVertIcon from "@mui/icons-material/MoreVert"; -import { MouseEvent, useState } from "react"; - -export interface TableRowMenuProps { - data: TData; - menuItems: Array<{ - label: React.ReactNode; - disabled: boolean; - onClick: (data: TData) => void; - }>; -} - -export const TableRowMenu = ({ - data, - menuItems, -}: TableRowMenuProps): JSX.Element => { - const [anchorEl, setAnchorEl] = useState(null); - - const handleClick = (event: MouseEvent) => { - setAnchorEl(event.currentTarget); - }; - - const handleClose = () => { - setAnchorEl(null); - }; - - return ( - <> - - - - - {menuItems.map((item, index) => ( - { - handleClose(); - item.onClick(data); - }} - > - {item.label} - - ))} - - - ); -}; diff --git a/site/src/pages/GroupsPage/GroupPage.tsx b/site/src/pages/GroupsPage/GroupPage.tsx index 8a69777104d1c..41d6238dfb487 100644 --- a/site/src/pages/GroupsPage/GroupPage.tsx +++ b/site/src/pages/GroupsPage/GroupPage.tsx @@ -20,7 +20,6 @@ import { PageHeaderTitle, } from "components/PageHeader/PageHeader"; import { Stack } from "components/Stack/Stack"; -import { TableRowMenu } from "components/TableRowMenu/TableRowMenu"; import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; import { type FC, useState } from "react"; import { Helmet } from "react-helmet-async"; @@ -46,6 +45,12 @@ import Box from "@mui/material/Box"; import { LastSeen } from "components/LastSeen/LastSeen"; import { type Interpolation, type Theme } from "@emotion/react"; import LoadingButton from "@mui/lab/LoadingButton"; +import { + MoreMenu, + MoreMenuContent, + MoreMenuItem, + MoreMenuTrigger, +} from "components/MoreMenu/MoreMenu"; export const GroupPage: FC = () => { const { groupId } = useParams() as { groupId: string }; @@ -281,12 +286,12 @@ const GroupMemberRow = (props: { {canUpdate && ( - { + + + + { try { await removeMemberMutation.mutateAsync({ groupId: group.id, @@ -298,11 +303,13 @@ const GroupMemberRow = (props: { getErrorMessage(error, "Failed to remove member."), ); } - }, - disabled: group.id === group.organization_id, - }, - ]} - /> + }} + disabled={group.id === group.organization_id} + > + Remove + + + )} diff --git a/site/src/pages/TemplatePage/TemplatePageHeader.tsx b/site/src/pages/TemplatePage/TemplatePageHeader.tsx index ec2bae83bd852..8168a861d783e 100644 --- a/site/src/pages/TemplatePage/TemplatePageHeader.tsx +++ b/site/src/pages/TemplatePage/TemplatePageHeader.tsx @@ -1,4 +1,4 @@ -import { type FC, useRef, useState } from "react"; +import { type FC } from "react"; import { Link as RouterLink, useNavigate } from "react-router-dom"; import { useDeletionDialogState } from "./useDeletionDialogState"; @@ -20,17 +20,19 @@ import { PageHeaderTitle, PageHeaderSubtitle, } from "components/PageHeader/PageHeader"; - import Button from "@mui/material/Button"; -import MoreVertOutlined from "@mui/icons-material/MoreVertOutlined"; -import Menu from "@mui/material/Menu"; -import MenuItem from "@mui/material/MenuItem"; -import IconButton from "@mui/material/IconButton"; import AddIcon from "@mui/icons-material/AddOutlined"; import SettingsIcon from "@mui/icons-material/SettingsOutlined"; import DeleteIcon from "@mui/icons-material/DeleteOutlined"; import EditIcon from "@mui/icons-material/EditOutlined"; import CopyIcon from "@mui/icons-material/FileCopyOutlined"; +import { + MoreMenu, + MoreMenuContent, + MoreMenuItem, + MoreMenuTrigger, +} from "components/MoreMenu/MoreMenu"; +import Divider from "@mui/material/Divider"; type TemplateMenuProps = { templateName: string; @@ -46,80 +48,54 @@ const TemplateMenu: FC = ({ onDelete, }) => { const dialogState = useDeletionDialogState(templateId, onDelete); - const menuTriggerRef = useRef(null); - const [isMenuOpen, setIsMenuOpen] = useState(false); const navigate = useNavigate(); - const queryText = `template:${templateName}`; const workspaceCountQuery = useQuery({ ...workspaces({ q: queryText }), select: (res) => res.count, }); - - // Returns a function that will execute the action and close the menu - const onMenuItemClick = (actionFn: () => void) => () => { - setIsMenuOpen(false); - actionFn(); - }; - const safeToDeleteTemplate = workspaceCountQuery.data === 0; return ( <> -
- setIsMenuOpen(true)} - ref={menuTriggerRef} - arial-label="More options" - > - - - - setIsMenuOpen(false)} - > - - navigate(`/templates/${templateName}/settings`), - )} + + + + { + navigate(`/templates/${templateName}/settings`); + }} > Settings - + - + { navigate( `/templates/${templateName}/versions/${templateVersion}/edit`, - ), - )} + ); + }} > Edit files - + - - navigate(`/templates/new?fromTemplate=${templateName}`), - )} + { + navigate(`/templates/new?fromTemplate=${templateName}`); + }} > Duplicate… - - - + + + Delete… - - -
+ + + {safeToDeleteTemplate ? ( {canUpdatePermissions && ( - onRemoveGroup(group), - disabled: false, - }, - ]} - /> + + + + onRemoveGroup(group)} + > + Remove + + + )} @@ -327,16 +333,17 @@ export const TemplatePermissionsPageView: FC< {canUpdatePermissions && ( - onRemoveUser(user), - disabled: false, - }, - ]} - /> + + + + onRemoveUser(user)} + > + Remove + + + )} diff --git a/site/src/pages/UsersPage/UsersPage.test.tsx b/site/src/pages/UsersPage/UsersPage.test.tsx index 540e6bc439d49..bbe29a30d4992 100644 --- a/site/src/pages/UsersPage/UsersPage.test.tsx +++ b/site/src/pages/UsersPage/UsersPage.test.tsx @@ -21,12 +21,11 @@ const renderPage = () => { const suspendUser = async () => { const user = userEvent.setup(); // Get the first user in the table - const moreButtons = await screen.findAllByLabelText("more"); + const moreButtons = await screen.findAllByLabelText("More options"); const firstMoreButton = moreButtons[0]; await user.click(firstMoreButton); - const menu = await screen.findByRole("menu"); - const suspendButton = within(menu).getByText(/Suspend/); + const suspendButton = screen.getByTestId("suspend-button"); await user.click(suspendButton); // Check if the confirm message is displayed @@ -39,17 +38,15 @@ const suspendUser = async () => { const deleteUser = async () => { const user = userEvent.setup(); - // Click on the "more" button to display the "Delete" option + // Click on the "More options" button to display the "Delete" option // Needs to await fetching users and fetching permissions, because they're needed to see the more button - const moreButtons = await screen.findAllByLabelText("more"); + const moreButtons = await screen.findAllByLabelText("More options"); // get MockUser2 const selectedMoreButton = moreButtons[1]; await user.click(selectedMoreButton); - const menu = await screen.findByRole("menu"); - const deleteButton = within(menu).getByText(/Delete/); - + const deleteButton = screen.getByText(/Delete/); await user.click(deleteButton); // Check if the confirm message is displayed @@ -67,12 +64,11 @@ const deleteUser = async () => { }; const activateUser = async () => { - const moreButtons = await screen.findAllByLabelText("more"); + const moreButtons = await screen.findAllByLabelText("More options"); const suspendedMoreButton = moreButtons[2]; fireEvent.click(suspendedMoreButton); - const menu = screen.getByRole("menu"); - const activateButton = within(menu).getByText(/Activate/); + const activateButton = screen.getByText(/Activate/); fireEvent.click(activateButton); // Check if the confirm message is displayed @@ -86,14 +82,11 @@ const activateUser = async () => { }; const resetUserPassword = async (setupActionSpies: () => void) => { - const moreButtons = await screen.findAllByLabelText("more"); + const moreButtons = await screen.findAllByLabelText("More options"); const firstMoreButton = moreButtons[0]; - fireEvent.click(firstMoreButton); - const menu = screen.getByRole("menu"); - const resetPasswordButton = within(menu).getByText(/Reset password/); - + const resetPasswordButton = screen.getByText(/Reset password/); fireEvent.click(resetPasswordButton); // Check if the confirm message is displayed @@ -135,6 +128,8 @@ const updateUserRole = async (role: Role) => { }; }; +jest.spyOn(console, "error").mockImplementation(() => {}); + describe("UsersPage", () => { describe("suspend user", () => { describe("when it is success", () => { diff --git a/site/src/pages/UsersPage/UsersTable/UsersTable.tsx b/site/src/pages/UsersPage/UsersTable/UsersTable.tsx index 84028c79a9016..7c00c470b23b8 100644 --- a/site/src/pages/UsersPage/UsersTable/UsersTable.tsx +++ b/site/src/pages/UsersPage/UsersTable/UsersTable.tsx @@ -67,7 +67,7 @@ export const UsersTable: FC> = ({ }) => { return ( - +
{Language.usernameLabel} diff --git a/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx b/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx index 559fdc9c5ed93..d27ac135cff57 100644 --- a/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx +++ b/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx @@ -15,7 +15,6 @@ import { TableLoaderSkeleton, TableRowSkeleton, } from "components/TableLoader/TableLoader"; -import { TableRowMenu } from "components/TableRowMenu/TableRowMenu"; import { EnterpriseBadge } from "components/DeploySettingsLayout/Badges"; import HideSourceOutlined from "@mui/icons-material/HideSourceOutlined"; import KeyOutlined from "@mui/icons-material/KeyOutlined"; @@ -26,6 +25,13 @@ import { LastSeen } from "components/LastSeen/LastSeen"; import { UserRoleCell } from "./UserRoleCell"; import { type GroupsByUserId } from "api/queries/groups"; import { UserGroupsCell } from "./UserGroupsCell"; +import { + MoreMenu, + MoreMenuTrigger, + MoreMenuContent, + MoreMenuItem, +} from "components/MoreMenu/MoreMenu"; +import Divider from "@mui/material/Divider"; dayjs.extend(relativeTime); @@ -176,48 +182,49 @@ export const UsersTableBody: FC< {canEditUsers && ( - Suspend…, - onClick: onSuspendUser, - disabled: false, - } - : { - label: <>Activate…, - onClick: onActivateUser, - disabled: false, - }, - { - label: <>Delete…, - onClick: onDeleteUser, - disabled: user.id === actorID, - }, - { - label: <>Reset password…, - onClick: onResetUserPassword, - disabled: user.login_type !== "password", - }, - { - label: "View workspaces", - onClick: onListWorkspaces, - disabled: false, - }, - { - label: ( - <> - View activity - {!canViewActivity && } - - ), - onClick: onViewActivity, - disabled: !canViewActivity, - }, - ]} - /> + + + + {user.status === "active" || user.status === "dormant" ? ( + { + onSuspendUser(user); + }} + > + Suspend… + + ) : ( + onActivateUser(user)}> + Activate… + + )} + onListWorkspaces(user)}> + View workspaces + + onViewActivity(user)} + disabled={!canViewActivity} + > + View activity + {!canViewActivity && } + + onResetUserPassword(user)} + disabled={user.login_type !== "password"} + > + Reset password… + + + onDeleteUser(user)} + disabled={user.id === actorID} + danger + > + Delete… + + + )} diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx index 6a9931d6cb0af..e36b2161bbba1 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx @@ -1,8 +1,5 @@ -import MenuItem from "@mui/material/MenuItem"; -import Menu from "@mui/material/Menu"; -import MoreVertOutlined from "@mui/icons-material/MoreVertOutlined"; -import { type FC, Fragment, type ReactNode, useRef, useState } from "react"; -import type { Workspace, WorkspaceBuildParameter } from "api/typesGenerated"; +import { FC, Fragment, ReactNode } from "react"; +import { Workspace, WorkspaceBuildParameter } from "api/typesGenerated"; import { ActionLoadingButton, CancelButton, @@ -21,7 +18,13 @@ import { import SettingsOutlined from "@mui/icons-material/SettingsOutlined"; import HistoryOutlined from "@mui/icons-material/HistoryOutlined"; import DeleteOutlined from "@mui/icons-material/DeleteOutlined"; -import IconButton from "@mui/material/IconButton"; +import { + MoreMenu, + MoreMenuContent, + MoreMenuItem, + MoreMenuTrigger, +} from "components/MoreMenu/MoreMenu"; +import Divider from "@mui/material/Divider"; export interface WorkspaceActionsProps { workspace: Workspace; @@ -65,8 +68,6 @@ export const WorkspaceActions: FC = ({ canChangeVersions, ); const canBeUpdated = workspace.outdated && canAcceptJobs; - const menuTriggerRef = useRef(null); - const [isMenuOpen, setIsMenuOpen] = useState(false); // A mapping of button type to the corresponding React component const buttonMapping: ButtonMapping = { @@ -106,12 +107,6 @@ export const WorkspaceActions: FC = ({ ), }; - // Returns a function that will execute the action and close the menu - const onMenuItemClick = (actionFn: () => void) => () => { - setIsMenuOpen(false); - actionFn(); - }; - return (
({ @@ -131,44 +126,36 @@ export const WorkspaceActions: FC = ({ {buttonMapping[action]} ))} {canCancel && } -
- + setIsMenuOpen(true)} - > - - - setIsMenuOpen(false)} - > - + /> + + Settings - + {canChangeVersions && ( - + Change version… - + )} - + Delete… - - -
+ + +
); }; diff --git a/site/src/theme/theme.ts b/site/src/theme/theme.ts index 7ebaec29c01fc..5f9ba828b524a 100644 --- a/site/src/theme/theme.ts +++ b/site/src/theme/theme.ts @@ -342,6 +342,13 @@ dark = createTheme(dark, { padding: "4px 0", minWidth: 160, }, + root: { + // It should be the same as the menu padding + "& .MuiDivider-root": { + marginTop: 4, + marginBottom: 4, + }, + }, }, }, MuiMenuItem: {