Skip to content
Merged
108 changes: 108 additions & 0 deletions site/src/components/MoreMenu/MoreMenu.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>;
close: () => void;
open: () => void;
isOpen: boolean;
};

const MoreMenuContext = createContext<MoreMenuContextValue | undefined>(
undefined,
);

export const MoreMenu = (props: { children: ReactNode }) => {
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 }}>
{props.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 = (props: IconButtonProps) => {
const menu = useMoreMenuContext();

return (
<IconButton
aria-controls="more-options"
aria-label="More options"
aria-haspopup="true"
onClick={menu.open}
ref={menu.triggerRef}
{...props}
>
<MoreVertOutlined />
</IconButton>
);
};

export const MoreMenuContent = (props: Omit<MenuProps, "open" | "onClose">) => {
const menu = useMoreMenuContext();

return (
<Menu
id="more-options"
anchorEl={menu.triggerRef.current}
open={menu.isOpen}
onClose={menu.close}
disablePortal
{...props}
/>
);
};

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 (
<MenuItem
{...menuItemProps}
css={(theme) => ({
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();
}
}}
/>
);
};
24 changes: 0 additions & 24 deletions site/src/components/TableRowMenu/TableRowMenu.stories.tsx

This file was deleted.

63 changes: 0 additions & 63 deletions site/src/components/TableRowMenu/TableRowMenu.tsx

This file was deleted.

31 changes: 19 additions & 12 deletions site/src/pages/GroupsPage/GroupPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 };
Expand Down Expand Up @@ -281,12 +286,12 @@ const GroupMemberRow = (props: {
</TableCell>
<TableCell width="1%">
{canUpdate && (
<TableRowMenu
data={member}
menuItems={[
{
label: "Remove",
onClick: async () => {
<MoreMenu>
<MoreMenuTrigger />
<MoreMenuContent>
<MoreMenuItem
danger
onClick={async () => {
try {
await removeMemberMutation.mutateAsync({
groupId: group.id,
Expand All @@ -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
</MoreMenuItem>
</MoreMenuContent>
</MoreMenu>
)}
</TableCell>
</TableRow>
Expand Down
86 changes: 31 additions & 55 deletions site/src/pages/TemplatePage/TemplatePageHeader.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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;
Expand All @@ -46,80 +48,54 @@ const TemplateMenu: FC<TemplateMenuProps> = ({
onDelete,
}) => {
const dialogState = useDeletionDialogState(templateId, onDelete);
const menuTriggerRef = useRef<HTMLButtonElement>(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 (
<>
<div>
<IconButton
aria-controls="template-options"
aria-haspopup="true"
onClick={() => setIsMenuOpen(true)}
ref={menuTriggerRef}
arial-label="More options"
>
<MoreVertOutlined />
</IconButton>

<Menu
id="template-options"
anchorEl={menuTriggerRef.current}
open={isMenuOpen}
onClose={() => setIsMenuOpen(false)}
>
<MenuItem
onClick={onMenuItemClick(() =>
navigate(`/templates/${templateName}/settings`),
)}
<MoreMenu>
<MoreMenuTrigger />
<MoreMenuContent>
<MoreMenuItem
onClick={() => {
navigate(`/templates/${templateName}/settings`);
}}
>
<SettingsIcon />
Settings
</MenuItem>
</MoreMenuItem>

<MenuItem
onClick={onMenuItemClick(() =>
<MoreMenuItem
onClick={() => {
navigate(
`/templates/${templateName}/versions/${templateVersion}/edit`,
),
)}
);
}}
>
<EditIcon />
Edit files
</MenuItem>
</MoreMenuItem>

<MenuItem
onClick={onMenuItemClick(() =>
navigate(`/templates/new?fromTemplate=${templateName}`),
)}
<MoreMenuItem
onClick={() => {
navigate(`/templates/new?fromTemplate=${templateName}`);
}}
>
<CopyIcon />
Duplicate&hellip;
</MenuItem>

<MenuItem
onClick={onMenuItemClick(dialogState.openDeleteConfirmation)}
>
</MoreMenuItem>
<Divider />
<MoreMenuItem onClick={dialogState.openDeleteConfirmation} danger>
<DeleteIcon />
Delete&hellip;
</MenuItem>
</Menu>
</div>
</MoreMenuItem>
</MoreMenuContent>
</MoreMenu>

{safeToDeleteTemplate ? (
<DeleteDialog
Expand Down
Loading