From e96d69ba950f2ba40ddf6908e5d3c6e7f143a22c Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 30 Sep 2025 10:02:02 -0300 Subject: [PATCH] feat: delete task from sidebar (#20023) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- site/src/api/api.ts | 4 + .../components/DropdownMenu/DropdownMenu.tsx | 2 +- .../TasksSidebar/TasksSidebar.stories.tsx | 117 ++++++-- .../tasks/TasksSidebar/TasksSidebar.tsx | 261 ++++++++++++------ 4 files changed, 274 insertions(+), 110 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index a6c9aeb479..f5b47ed824 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2728,6 +2728,10 @@ class ExperimentalApiMethods { prompt: prompts.prompts[workspace.latest_build.id], })); }; + + deleteTask = async (user: string, id: string): Promise => { + await this.axios.delete(`/api/experimental/tasks/${user}/${id}`); + }; } // This is a hard coded CSRF token/cookie pair for local development. In prod, diff --git a/site/src/components/DropdownMenu/DropdownMenu.tsx b/site/src/components/DropdownMenu/DropdownMenu.tsx index 8e0e1fb628..4aa4dbd7e0 100644 --- a/site/src/components/DropdownMenu/DropdownMenu.tsx +++ b/site/src/components/DropdownMenu/DropdownMenu.tsx @@ -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; diff --git a/site/src/modules/tasks/TasksSidebar/TasksSidebar.stories.tsx b/site/src/modules/tasks/TasksSidebar/TasksSidebar.stories.tsx index ce49156530..333b3db13c 100644 --- a/site/src/modules/tasks/TasksSidebar/TasksSidebar.stories.tsx +++ b/site/src/modules/tasks/TasksSidebar/TasksSidebar.stories.tsx @@ -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 = { 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:
Tasks Index Page
}, + ], + }), }, 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, + ); + }); + }); + }); + }, +}; diff --git a/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx b/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx index 8b56fe6828..6e7bb60bd4 100644 --- a/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx +++ b/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx @@ -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 (
-
- {!isCollapsed && ( - - )} +
+
+ {!isCollapsed && ( + + )} + + + + + + + + {isCollapsed ? "Open" : "Close"} Sidebar + + + +
- {isCollapsed ? "Open" : "Close"} Sidebar + New task + + {!isCollapsed && permissions.viewAllUsers && ( + { + if (username === usernameParam.value) { + usernameParam.setValue(""); + return; + } + usernameParam.setValue(username); + }} + /> + )}
- - - - - - - New task - - - - - {!isCollapsed && ( - <> - {permissions.viewAllUsers && ( - { - if (username === usernameParam.value) { - usernameParam.setValue(""); - return; - } - usernameParam.setValue(username); - }} - /> - )} - - - )} + {!isCollapsed && }
); }; @@ -129,7 +137,7 @@ const TasksSidebarGroup: FC = ({ username }) => { return ( -
+
Tasks
{tasksQuery.data ? ( @@ -166,30 +174,109 @@ type TaskSidebarMenuItemProps = { const TaskSidebarMenuItem: FC = ({ 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 ( - + + + {task.workspace.name} + + + + + + + + { + e.stopPropagation(); + setIsDeleteDialogOpen(true); + }} + > + + Delete + + + + + + + + { + 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={ +

+ This action is irreversible and removes all workspace resources and + data. +

+ } + /> + ); };