mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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]);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
Reference in New Issue
Block a user