Skip to content

feat(site): add stop and start batch actions #10565

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Nov 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 36 additions & 14 deletions site/src/components/MoreMenu/MoreMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
import { useRef, useState, createContext, useContext, ReactNode } from "react";
import {
useRef,
useState,
createContext,
useContext,
ReactNode,
cloneElement,
HTMLProps,
forwardRef,
ReactElement,
} from "react";
import MoreVertOutlined from "@mui/icons-material/MoreVertOutlined";
import Menu, { MenuProps } from "@mui/material/Menu";
import MenuItem, { MenuItemProps } from "@mui/material/MenuItem";
Expand Down Expand Up @@ -44,23 +54,35 @@ const useMoreMenuContext = () => {
return ctx;
};

export const MoreMenuTrigger = (props: IconButtonProps) => {
export const MoreMenuTrigger = ({
children,
...props
}: HTMLProps<HTMLButtonElement>) => {
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>
);
return cloneElement(children as ReactElement, {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this type-safe? My worry is that it looks like there's nothing restricting the type of children at the type level, so if someone accidentally passes in a string or a primitive as a prop, they would get a more cryptic runtime error

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I swear I tried my best to get a correct type for this without success :(

"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 = (props: Omit<MenuProps, "open" | "onClose">) => {
const menu = useMoreMenuContext();

Expand Down
5 changes: 4 additions & 1 deletion site/src/pages/GroupsPage/GroupPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
MoreMenuContent,
MoreMenuItem,
MoreMenuTrigger,
ThreeDotsButton,
} from "components/MoreMenu/MoreMenu";

export const GroupPage: FC = () => {
Expand Down Expand Up @@ -287,7 +288,9 @@ const GroupMemberRow = (props: {
<TableCell width="1%">
{canUpdate && (
<MoreMenu>
<MoreMenuTrigger />
<MoreMenuTrigger>
<ThreeDotsButton />
</MoreMenuTrigger>
<MoreMenuContent>
<MoreMenuItem
danger
Expand Down
5 changes: 4 additions & 1 deletion site/src/pages/TemplatePage/TemplatePageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
MoreMenuContent,
MoreMenuItem,
MoreMenuTrigger,
ThreeDotsButton,
} from "components/MoreMenu/MoreMenu";
import Divider from "@mui/material/Divider";

Expand Down Expand Up @@ -59,7 +60,9 @@ const TemplateMenu: FC<TemplateMenuProps> = ({
return (
<>
<MoreMenu>
<MoreMenuTrigger />
<MoreMenuTrigger>
<ThreeDotsButton />
</MoreMenuTrigger>
<MoreMenuContent>
<MoreMenuItem
onClick={() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
MoreMenuContent,
MoreMenuItem,
MoreMenuTrigger,
ThreeDotsButton,
} from "components/MoreMenu/MoreMenu";

type AddTemplateUserOrGroupProps = {
Expand Down Expand Up @@ -287,7 +288,9 @@ export const TemplatePermissionsPageView: FC<
<TableCell>
{canUpdatePermissions && (
<MoreMenu>
<MoreMenuTrigger />
<MoreMenuTrigger>
<ThreeDotsButton />
</MoreMenuTrigger>
<MoreMenuContent>
<MoreMenuItem
danger
Expand Down Expand Up @@ -334,7 +337,9 @@ export const TemplatePermissionsPageView: FC<
<TableCell>
{canUpdatePermissions && (
<MoreMenu>
<MoreMenuTrigger />
<MoreMenuTrigger>
<ThreeDotsButton />
</MoreMenuTrigger>
<MoreMenuContent>
<MoreMenuItem
danger
Expand Down
5 changes: 4 additions & 1 deletion site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
MoreMenuTrigger,
MoreMenuContent,
MoreMenuItem,
ThreeDotsButton,
} from "components/MoreMenu/MoreMenu";
import Divider from "@mui/material/Divider";

Expand Down Expand Up @@ -183,7 +184,9 @@ export const UsersTableBody: FC<
{canEditUsers && (
<TableCell>
<MoreMenu>
<MoreMenuTrigger />
<MoreMenuTrigger>
<ThreeDotsButton />
</MoreMenuTrigger>
<MoreMenuContent>
{user.status === "active" || user.status === "dormant" ? (
<MoreMenuItem
Expand Down
17 changes: 10 additions & 7 deletions site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
MoreMenuContent,
MoreMenuItem,
MoreMenuTrigger,
ThreeDotsButton,
} from "components/MoreMenu/MoreMenu";

export interface WorkspaceActionsProps {
Expand Down Expand Up @@ -132,13 +133,15 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({

{canCancel && <CancelButton handleAction={handleCancel} />}
<MoreMenu>
<MoreMenuTrigger
title="More options"
size="small"
data-testid="workspace-options-button"
aria-controls="workspace-options"
disabled={!canAcceptJobs}
/>
<MoreMenuTrigger>
<ThreeDotsButton
title="More options"
size="small"
data-testid="workspace-options-button"
aria-controls="workspace-options"
disabled={!canAcceptJobs}
/>
</MoreMenuTrigger>

<MoreMenuContent id="workspace-options">
<MoreMenuItem onClick={handleSettings}>
Expand Down
142 changes: 142 additions & 0 deletions site/src/pages/WorkspacesPage/BatchActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import TextField from "@mui/material/TextField";
import { Box } from "@mui/system";
import { deleteWorkspace, startWorkspace, stopWorkspace } from "api/api";
import { Workspace } from "api/typesGenerated";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { displayError } from "components/GlobalSnackbar/utils";
import { useState } from "react";
import { useMutation } from "react-query";
import { MONOSPACE_FONT_FAMILY } from "theme/constants";

export const useBatchActions = (options: {
onSuccess: () => Promise<void>;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be typed as () => (void | Promise<void>), just so components are free to use sync or async logic when it makes sense for them

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This component has a very specific use case and should only be used in one place so I would not try to make its interface generic to be re-used

}) => {
const { onSuccess } = options;

const startAllMutation = useMutation({
mutationFn: async (workspaces: Workspace[]) => {
return Promise.all(
workspaces.map((w) =>
startWorkspace(w.id, w.latest_build.template_version_id),
),
);
},
onSuccess,
onError: () => {
displayError("Failed to start workspaces");
},
});

const stopAllMutation = useMutation({
mutationFn: async (workspaces: Workspace[]) => {
return Promise.all(workspaces.map((w) => stopWorkspace(w.id)));
},
onSuccess,
onError: () => {
displayError("Failed to stop workspaces");
},
});

const deleteAllMutation = useMutation({
mutationFn: async (workspaces: Workspace[]) => {
return Promise.all(workspaces.map((w) => deleteWorkspace(w.id)));
},
onSuccess,
onError: () => {
displayError("Failed to delete workspaces");
},
});

return {
startAll: startAllMutation.mutateAsync,
stopAll: stopAllMutation.mutateAsync,
deleteAll: deleteAllMutation.mutateAsync,
isLoading:
startAllMutation.isLoading ||
stopAllMutation.isLoading ||
deleteAllMutation.isLoading,
};
};

type BatchDeleteConfirmationProps = {
checkedWorkspaces: Workspace[];
open: boolean;
isLoading: boolean;
onClose: () => void;
onConfirm: () => void;
};

export const BatchDeleteConfirmation = (
props: BatchDeleteConfirmationProps,
) => {
const { checkedWorkspaces, open, onClose, onConfirm, isLoading } = props;
const [confirmation, setConfirmation] = useState({ value: "", error: false });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What values is value expected to have right now? So far, it looks like "" or DELETE?
I'm wondering if value could have its own type, if we're only going to be supporting a discrete set of operations

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a good idea but it does not work well for "dynamic" values since it can be whatever the user types and not only the "final" result. Makes sense? The values can be "D", "DE", "DEL", "DELE" or anything like "OTHER".


const confirmDeletion = () => {
setConfirmation((c) => ({ ...c, error: false }));

if (confirmation.value !== "DELETE") {
setConfirmation((c) => ({ ...c, error: true }));
return;
}

onConfirm();
};

return (
<ConfirmDialog
type="delete"
open={open}
confirmLoading={isLoading}
onConfirm={confirmDeletion}
onClose={() => {
onClose();
setConfirmation({ value: "", error: false });
}}
title={`Delete ${checkedWorkspaces?.length} ${
checkedWorkspaces.length === 1 ? "workspace" : "workspaces"
}`}
description={
<form
onSubmit={async (e) => {
e.preventDefault();
confirmDeletion();
}}
>
<Box>
Deleting these workspaces is irreversible! Are you sure you want to
proceed? Type{" "}
<Box
component="code"
sx={{
fontFamily: MONOSPACE_FONT_FAMILY,
color: (theme) => theme.palette.text.primary,
fontWeight: 600,
}}
>
`DELETE`
</Box>{" "}
to confirm.
</Box>
<TextField
value={confirmation.value}
required
autoFocus
fullWidth
inputProps={{
"aria-label": "Type DELETE to confirm",
}}
placeholder="Type DELETE to confirm"
sx={{ mt: 2 }}
onChange={(e) => {
const value = e.currentTarget?.value;
setConfirmation((c) => ({ ...c, value }));
}}
error={confirmation.error}
helperText={confirmation.error && "Please type DELETE to confirm"}
/>
</form>
}
/>
);
};
Loading