mirror of
https://github.com/coder/coder.git
synced 2026-09-21 12:44:32 +08:00
feat: delete task from sidebar (#20023)
This adds a way to delete a task from the sidebar. Once this PR is merged, I’ll also add the option to the table list. https://github.com/user-attachments/assets/75d714ef-afe1-4d9c-b907-1eab3a59f26b Right now, after deleting a task, the data still shows—that’s a separate issue related to how task data is loaded in the FE. A proper tasks endpoint is now available, and that should be addressed as part of [https://github.com/coder/internal/issues/904](https://github.com/coder/internal/issues/904). Related to https://github.com/coder/coder/issues/19525
This commit is contained in:
@@ -2728,6 +2728,10 @@ class ExperimentalApiMethods {
|
||||
prompt: prompts.prompts[workspace.latest_build.id],
|
||||
}));
|
||||
};
|
||||
|
||||
deleteTask = async (user: string, id: string): Promise<void> => {
|
||||
await this.axios.delete(`/api/experimental/tasks/${user}/${id}`);
|
||||
};
|
||||
}
|
||||
|
||||
// This is a hard coded CSRF token/cookie pair for local development. In prod,
|
||||
|
||||
@@ -20,7 +20,7 @@ export const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const _DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
export const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const _DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { MockTasks, MockUserOwner, mockApiError } from "testHelpers/entities";
|
||||
import { withAuthProvider } from "testHelpers/storybook";
|
||||
import { withAuthProvider, withGlobalSnackbar } from "testHelpers/storybook";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { API } from "api/api";
|
||||
import { MockUsers } from "pages/UsersPage/storybookData/users";
|
||||
import { spyOn, userEvent, within } from "storybook/test";
|
||||
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
|
||||
import { reactRouterParameters } from "storybook-addon-remix-react-router";
|
||||
import { TasksSidebar } from "./TasksSidebar";
|
||||
|
||||
@@ -17,6 +17,18 @@ const meta: Meta<typeof TasksSidebar> = {
|
||||
permissions: {
|
||||
viewAllUsers: true,
|
||||
},
|
||||
reactRouter: reactRouterParameters({
|
||||
location: {
|
||||
path: `/tasks/${MockTasks[0].workspace.name}`,
|
||||
pathParams: {
|
||||
workspace: MockTasks[0].workspace.name,
|
||||
},
|
||||
},
|
||||
routing: [
|
||||
{ path: "/tasks/:workspace", useStoryElement: true },
|
||||
{ path: "/tasks", element: <div>Tasks Index Page</div> },
|
||||
],
|
||||
}),
|
||||
},
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getUsers").mockResolvedValue({
|
||||
@@ -49,16 +61,6 @@ export const Loaded: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks);
|
||||
},
|
||||
parameters: {
|
||||
reactRouter: reactRouterParameters({
|
||||
location: {
|
||||
pathParams: {
|
||||
workspace: MockTasks[0].workspace.name,
|
||||
},
|
||||
},
|
||||
routing: { path: "/tasks/:workspace" },
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
@@ -71,19 +73,90 @@ export const Closed: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks);
|
||||
},
|
||||
parameters: {
|
||||
reactRouter: reactRouterParameters({
|
||||
location: {
|
||||
pathParams: {
|
||||
workspace: MockTasks[0].workspace.name,
|
||||
},
|
||||
},
|
||||
routing: { path: "/tasks/:workspace" },
|
||||
}),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const button = canvas.getByRole("button", { name: /close sidebar/i });
|
||||
await userEvent.click(button);
|
||||
},
|
||||
};
|
||||
|
||||
export const OpenOptionsMenu: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const optionButtons = await canvas.findAllByRole("button", {
|
||||
name: /task options/i,
|
||||
});
|
||||
await userEvent.click(optionButtons[0]);
|
||||
},
|
||||
};
|
||||
|
||||
export const DeleteTaskDialog: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks);
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
await step("Open menu", async () => {
|
||||
const canvas = within(canvasElement);
|
||||
const optionButtons = await canvas.findAllByRole("button", {
|
||||
name: /task options/i,
|
||||
});
|
||||
await userEvent.click(optionButtons[0]);
|
||||
});
|
||||
await step("Open delete dialog", async () => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const deleteButton = await body.findByRole("menuitem", {
|
||||
name: /delete/i,
|
||||
});
|
||||
await userEvent.click(deleteButton);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const DeleteTaskSuccess: Story = {
|
||||
decorators: [withGlobalSnackbar],
|
||||
parameters: {
|
||||
chromatic: {
|
||||
disableSnapshot: false,
|
||||
},
|
||||
},
|
||||
beforeEach: () => {
|
||||
spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks);
|
||||
spyOn(API.experimental, "deleteTask").mockResolvedValue();
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await step("Open menu", async () => {
|
||||
const optionButtons = await canvas.findAllByRole("button", {
|
||||
name: /task options/i,
|
||||
});
|
||||
await userEvent.click(optionButtons[0]);
|
||||
});
|
||||
|
||||
await step("Open delete dialog", async () => {
|
||||
const deleteButton = await body.findByRole("menuitem", {
|
||||
name: /delete/i,
|
||||
});
|
||||
await userEvent.click(deleteButton);
|
||||
});
|
||||
|
||||
await step("Confirm delete", async () => {
|
||||
const confirmButton = await body.findByRole("button", {
|
||||
name: /delete/i,
|
||||
});
|
||||
await userEvent.click(confirmButton);
|
||||
await step("Confirm delete", async () => {
|
||||
await waitFor(() => {
|
||||
expect(API.experimental.deleteTask).toHaveBeenCalledWith(
|
||||
MockTasks[0].workspace.owner_name,
|
||||
MockTasks[0].workspace.id,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { API } from "api/api";
|
||||
import { getErrorMessage } from "api/errors";
|
||||
import { getErrorDetail, getErrorMessage } from "api/errors";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "components/DropdownMenu/DropdownMenu";
|
||||
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { CoderIcon } from "components/Icons/CoderIcon";
|
||||
import { ScrollArea } from "components/ScrollArea/ScrollArea";
|
||||
import { Skeleton } from "components/Skeleton/Skeleton";
|
||||
@@ -13,11 +22,11 @@ import {
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import { useAuthenticated } from "hooks";
|
||||
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
|
||||
import { EditIcon, PanelLeftIcon } from "lucide-react";
|
||||
import { EditIcon, EllipsisIcon, PanelLeftIcon, TrashIcon } from "lucide-react";
|
||||
import type { Task } from "modules/tasks/tasks";
|
||||
import { type FC, useState } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { Link as RouterLink, useParams } from "react-router";
|
||||
import { QueryClient, useMutation, useQuery } from "react-query";
|
||||
import { Link as RouterLink, useNavigate, useParams } from "react-router";
|
||||
import { cn } from "utils/cn";
|
||||
import { UserCombobox } from "./UserCombobox";
|
||||
|
||||
@@ -33,84 +42,83 @@ export const TasksSidebar: FC = () => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-full flex flex-col flex-1 min-h-0 gap-6 bg-surface-secondary max-w-80",
|
||||
"border-solid border-0 border-r transition-all p-3",
|
||||
"h-full bg-surface-secondary w-full max-w-80",
|
||||
"border-solid border-0 border-r transition-all",
|
||||
{ "max-w-14": isCollapsed },
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center place-content-between">
|
||||
{!isCollapsed && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="subtle"
|
||||
className={cn(["size-8 p-0 transition-[margin,opacity]"])}
|
||||
>
|
||||
<CoderIcon className="fill-content-primary !size-6 !p-0" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="p-3 flex flex-col flex-1 gap-6">
|
||||
<div className="flex items-center place-content-between">
|
||||
{!isCollapsed && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="subtle"
|
||||
className={cn(["size-8 p-0 transition-[margin,opacity]"])}
|
||||
>
|
||||
<CoderIcon className="fill-content-primary !size-6 !p-0" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="subtle"
|
||||
onClick={() => setIsCollapsed((v) => !v)}
|
||||
className="[&_svg]:p-0"
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">
|
||||
{isCollapsed ? "Open" : "Close"} Sidebar
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" align="center">
|
||||
{isCollapsed ? "Open" : "Close"} Sidebar
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="subtle"
|
||||
onClick={() => setIsCollapsed((v) => !v)}
|
||||
className="[&_svg]:p-0"
|
||||
variant={isCollapsed ? "subtle" : "default"}
|
||||
size={isCollapsed ? "icon" : "sm"}
|
||||
asChild={true}
|
||||
className={cn({
|
||||
"[&_svg]:p-0": isCollapsed,
|
||||
})}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">
|
||||
{isCollapsed ? "Open" : "Close"} Sidebar
|
||||
</span>
|
||||
<RouterLink to="/tasks">
|
||||
<span className={isCollapsed ? "hidden" : ""}>New Task</span>{" "}
|
||||
<EditIcon />
|
||||
</RouterLink>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" align="center">
|
||||
{isCollapsed ? "Open" : "Close"} Sidebar
|
||||
New task
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{!isCollapsed && permissions.viewAllUsers && (
|
||||
<UserCombobox
|
||||
value={usernameParam.value}
|
||||
onValueChange={(username) => {
|
||||
if (username === usernameParam.value) {
|
||||
usernameParam.setValue("");
|
||||
return;
|
||||
}
|
||||
usernameParam.setValue(username);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={isCollapsed ? "subtle" : "default"}
|
||||
size={isCollapsed ? "icon" : "sm"}
|
||||
asChild={true}
|
||||
className={cn({
|
||||
"[&_svg]:p-0": isCollapsed,
|
||||
})}
|
||||
>
|
||||
<RouterLink to="/tasks">
|
||||
<span className={isCollapsed ? "hidden" : ""}>New Task</span>{" "}
|
||||
<EditIcon />
|
||||
</RouterLink>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" align="center">
|
||||
New task
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
{permissions.viewAllUsers && (
|
||||
<UserCombobox
|
||||
value={usernameParam.value}
|
||||
onValueChange={(username) => {
|
||||
if (username === usernameParam.value) {
|
||||
usernameParam.setValue("");
|
||||
return;
|
||||
}
|
||||
usernameParam.setValue(username);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<TasksSidebarGroup username={usernameParam.value} />
|
||||
</>
|
||||
)}
|
||||
{!isCollapsed && <TasksSidebarGroup username={usernameParam.value} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -129,7 +137,7 @@ const TasksSidebarGroup: FC<TasksSidebarGroupProps> = ({ username }) => {
|
||||
|
||||
return (
|
||||
<ScrollArea>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<div className="text-content-secondary text-xs">Tasks</div>
|
||||
<div className="flex flex-col flex-1 gap-1">
|
||||
{tasksQuery.data ? (
|
||||
@@ -166,30 +174,109 @@ type TaskSidebarMenuItemProps = {
|
||||
const TaskSidebarMenuItem: FC<TaskSidebarMenuItemProps> = ({ task }) => {
|
||||
const { workspace } = useParams<{ workspace: string }>();
|
||||
const isActive = task.workspace.name === workspace;
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const queryClient = new QueryClient();
|
||||
const deleteTaskMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
API.experimental.deleteTask(task.workspace.owner_name, task.workspace.id),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
className={cn(
|
||||
"w-full justify-start text-content-secondary hover:bg-surface-tertiary gap-2",
|
||||
{
|
||||
"text-content-primary bg-surface-quaternary pointer-events-none":
|
||||
isActive,
|
||||
},
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<RouterLink
|
||||
to={{
|
||||
pathname: `/tasks/${task.workspace.owner_name}/${task.workspace.name}`,
|
||||
search: window.location.search,
|
||||
}}
|
||||
<>
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
className={cn(
|
||||
"overflow-visible group w-full justify-start text-content-secondary",
|
||||
"transition-none hover:bg-surface-tertiary gap-2 has-[[data-state=open]]:bg-surface-tertiary",
|
||||
{
|
||||
"text-content-primary bg-surface-quaternary hover:bg-surface-quaternary has-[[data-state=open]]:bg-surface-quaternary":
|
||||
isActive,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<TaskSidebarMenuItemStatus task={task} />
|
||||
{task.workspace.name}
|
||||
</RouterLink>
|
||||
</Button>
|
||||
<RouterLink
|
||||
to={{
|
||||
pathname: `/tasks/${task.workspace.owner_name}/${task.workspace.name}`,
|
||||
search: window.location.search,
|
||||
}}
|
||||
>
|
||||
<TaskSidebarMenuItemStatus task={task} />
|
||||
<span className="truncate">{task.workspace.name}</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="subtle"
|
||||
className={`
|
||||
ml-auto -mr-2 opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100
|
||||
focus-visible:opacity-100 data-[state=open]:opacity-100
|
||||
`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<EllipsisIcon />
|
||||
<span className="sr-only">Task options</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</RouterLink>
|
||||
</Button>
|
||||
|
||||
<ConfirmDialog
|
||||
type="delete"
|
||||
confirmLoading={deleteTaskMutation.isPending}
|
||||
open={isDeleteDialogOpen}
|
||||
title="Delete task"
|
||||
onClose={(): void => {
|
||||
setIsDeleteDialogOpen(false);
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await deleteTaskMutation.mutateAsync();
|
||||
displaySuccess("Task deleted successfully");
|
||||
if (isActive) {
|
||||
navigate("/tasks");
|
||||
}
|
||||
} catch (error) {
|
||||
displayError(
|
||||
getErrorMessage(error, "Failed to delete task"),
|
||||
getErrorDetail(error),
|
||||
);
|
||||
} finally {
|
||||
setIsDeleteDialogOpen(false);
|
||||
}
|
||||
}}
|
||||
description={
|
||||
<p>
|
||||
This action is irreversible and removes all workspace resources and
|
||||
data.
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user