feat: add remove task button into the tasks list (#20036)

**Demo:**


https://github.com/user-attachments/assets/eca91a46-41fb-412c-b476-0cf91c0b69b8

Closes https://github.com/coder/coder/issues/19525
This commit is contained in:
Bruno Quaresma
2025-10-01 15:37:11 -03:00
committed by GitHub
parent 0fbe21e574
commit f23a6a1140
6 changed files with 189 additions and 106 deletions
@@ -0,0 +1,50 @@
import { MockTasks, MockWorkspace } from "testHelpers/entities";
import { withGlobalSnackbar } from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
import { TaskDeleteDialog } from "./TaskDeleteDialog";
const meta: Meta<typeof TaskDeleteDialog> = {
title: "modules/tasks/TaskDeleteDialog",
component: TaskDeleteDialog,
decorators: [withGlobalSnackbar],
};
export default meta;
type Story = StoryObj<typeof TaskDeleteDialog>;
export const DeleteTaskSuccess: Story = {
decorators: [withGlobalSnackbar],
args: {
open: true,
task: { prompt: "My Task", workspace: MockWorkspace },
onClose: () => {},
},
parameters: {
chromatic: {
disableSnapshot: false,
},
},
beforeEach: () => {
spyOn(API.experimental, "deleteTask").mockResolvedValue();
},
play: async ({ canvasElement, step }) => {
const body = within(canvasElement.ownerDocument.body);
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,
);
});
});
});
},
};
@@ -0,0 +1,58 @@
import { API } from "api/api";
import { getErrorDetail, getErrorMessage } from "api/errors";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
import type { FC } from "react";
import { QueryClient, useMutation } from "react-query";
import type { Task } from "../tasks";
type TaskDeleteDialogProps = {
open: boolean;
task: Task;
onClose: () => void;
onSuccess?: () => void;
};
export const TaskDeleteDialog: FC<TaskDeleteDialogProps> = ({
task,
onSuccess,
...props
}) => {
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 (
<ConfirmDialog
{...props}
type="delete"
confirmLoading={deleteTaskMutation.isPending}
title="Delete task"
onConfirm={async () => {
try {
await deleteTaskMutation.mutateAsync();
displaySuccess("Task deleted successfully");
onSuccess?.();
} catch (error) {
displayError(
getErrorMessage(error, "Failed to delete task"),
getErrorDetail(error),
);
} finally {
props.onClose();
}
}}
description={
<p>
This action is irreversible and removes all workspace resources and
data.
</p>
}
/>
);
};
@@ -1,9 +1,9 @@
import { MockTasks, MockUserOwner, mockApiError } from "testHelpers/entities";
import { withAuthProvider, withGlobalSnackbar } from "testHelpers/storybook";
import { withAuthProvider } from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import { MockUsers } from "pages/UsersPage/storybookData/users";
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
import { spyOn, userEvent, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { TasksSidebar } from "./TasksSidebar";
@@ -93,7 +93,7 @@ export const OpenOptionsMenu: Story = {
},
};
export const DeleteTaskDialog: Story = {
export const OpenDeleteDialog: Story = {
beforeEach: () => {
spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks);
},
@@ -114,49 +114,3 @@ export const DeleteTaskDialog: Story = {
});
},
};
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,8 +1,7 @@
import { API } from "api/api";
import { getErrorDetail, getErrorMessage } from "api/errors";
import { 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,
@@ -10,7 +9,6 @@ import {
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";
@@ -25,9 +23,10 @@ import { useSearchParamsKey } from "hooks/useSearchParamsKey";
import { EditIcon, EllipsisIcon, PanelLeftIcon, TrashIcon } from "lucide-react";
import type { Task } from "modules/tasks/tasks";
import { type FC, useState } from "react";
import { QueryClient, useMutation, useQuery } from "react-query";
import { useQuery } from "react-query";
import { Link as RouterLink, useNavigate, useParams } from "react-router";
import { cn } from "utils/cn";
import { TaskDeleteDialog } from "../TaskDeleteDialog/TaskDeleteDialog";
import { UserCombobox } from "./UserCombobox";
export const TasksSidebar: FC = () => {
@@ -180,14 +179,6 @@ const TaskSidebarMenuItem: FC<TaskSidebarMenuItemProps> = ({ task }) => {
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 (
<>
@@ -249,36 +240,17 @@ const TaskSidebarMenuItem: FC<TaskSidebarMenuItemProps> = ({ task }) => {
</RouterLink>
</Button>
<ConfirmDialog
type="delete"
confirmLoading={deleteTaskMutation.isPending}
<TaskDeleteDialog
open={isDeleteDialogOpen}
title="Delete task"
onClose={(): void => {
task={task}
onClose={() => {
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);
onSuccess={() => {
if (isActive) {
navigate("/tasks");
}
}}
description={
<p>
This action is irreversible and removes all workspace resources and
data.
</p>
}
/>
</>
);
@@ -424,3 +424,17 @@ export const NonAdmin: Story = {
});
},
};
export const OpenDeleteDialog: Story = {
beforeEach: () => {
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const deleteButtons = await canvas.findAllByRole("button", {
name: /delete task/i,
});
await userEvent.click(deleteButtons[0]);
},
};
+55 -20
View File
@@ -16,10 +16,17 @@ import {
TableLoaderSkeleton,
TableRowSkeleton,
} from "components/TableLoader/TableLoader";
import { RotateCcwIcon } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { RotateCcwIcon, TrashIcon } from "lucide-react";
import { TaskDeleteDialog } from "modules/tasks/TaskDeleteDialog/TaskDeleteDialog";
import type { Task } from "modules/tasks/tasks";
import { WorkspaceAppStatus } from "modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus";
import type { FC, ReactNode } from "react";
import { type FC, type ReactNode, useState } from "react";
import { Link as RouterLink } from "react-router";
import { relativeTime } from "utils/time";
@@ -39,7 +46,7 @@ export const TasksTable: FC<TasksTableProps> = ({ tasks, error, onRetry }) => {
} else if (tasks.length === 0) {
body = <TasksEmpty />;
} else {
body = <Tasks tasks={tasks} />;
body = tasks.map((task) => <TaskRow key={task.workspace.id} task={task} />);
}
return (
@@ -49,6 +56,7 @@ export const TasksTable: FC<TasksTableProps> = ({ tasks, error, onRetry }) => {
<TableHead>Task</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created by</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>{body}</TableBody>
@@ -103,24 +111,25 @@ const TasksEmpty: FC = () => {
);
};
type TasksProps = { tasks: Task[] };
type TaskRowProps = { task: Task };
const Tasks: FC<TasksProps> = ({ tasks }) => {
return tasks.map(({ workspace, prompt }) => {
const templateDisplayName =
workspace.template_display_name ?? workspace.template_name;
const TaskRow: FC<TaskRowProps> = ({ task }) => {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const templateDisplayName =
task.workspace.template_display_name ?? task.workspace.template_name;
return (
<TableRow key={workspace.id} className="relative" hover>
return (
<>
<TableRow key={task.workspace.id} className="relative" hover>
<TableCell>
<AvatarData
title={
<>
<span className="block max-w-[520px] overflow-hidden text-ellipsis whitespace-nowrap">
{prompt}
{task.prompt}
</span>
<RouterLink
to={`/tasks/${workspace.owner_name}/${workspace.name}`}
to={`/tasks/${task.workspace.owner_name}/${task.workspace.name}`}
className="absolute inset-0"
>
<span className="sr-only">Access task</span>
@@ -132,7 +141,7 @@ const Tasks: FC<TasksProps> = ({ tasks }) => {
<Avatar
size="lg"
variant="icon"
src={workspace.template_icon}
src={task.workspace.template_icon}
fallback={templateDisplayName}
/>
}
@@ -140,24 +149,50 @@ const Tasks: FC<TasksProps> = ({ tasks }) => {
</TableCell>
<TableCell>
<WorkspaceAppStatus
disabled={workspace.latest_build.status !== "running"}
status={workspace.latest_app_status}
disabled={task.workspace.latest_build.status !== "running"}
status={task.workspace.latest_app_status}
/>
</TableCell>
<TableCell>
<AvatarData
title={workspace.owner_name}
title={task.workspace.owner_name}
subtitle={
<span className="block first-letter:uppercase">
{relativeTime(new Date(workspace.created_at))}
{relativeTime(new Date(task.workspace.created_at))}
</span>
}
src={workspace.owner_avatar_url}
src={task.workspace.owner_avatar_url}
/>
</TableCell>
<TableCell className="text-right">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
className="relative z-50"
onClick={() => setIsDeleteDialogOpen(true)}
>
<span className="sr-only">Delete task</span>
<TrashIcon />
</Button>
</TooltipTrigger>
<TooltipContent>Delete task</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableCell>
</TableRow>
);
});
<TaskDeleteDialog
task={task}
open={isDeleteDialogOpen}
onClose={() => {
setIsDeleteDialogOpen(false);
}}
/>
</>
);
};
const TasksSkeleton: FC = () => {