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:
Bruno Quaresma
2025-09-30 10:02:02 -03:00
committed by GitHub
parent bf021e627e
commit e96d69ba95
4 changed files with 274 additions and 110 deletions
+4
View File
@@ -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>
}
/>
</>
);
};