Skip to content

Commit ee58f40

Browse files
authored
feat(site): add bulk delete for tasks (#20905)
This change implements bulk delete for tasks, closely copying UI and components from workspaces batch actions. Fixes coder/internal#1088
1 parent 21efebe commit ee58f40

File tree

9 files changed

+676
-31
lines changed

9 files changed

+676
-31
lines changed

codersdk/deployment.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ const (
8080
FeatureWorkspaceProxy FeatureName = "workspace_proxy"
8181
FeatureExternalTokenEncryption FeatureName = "external_token_encryption"
8282
FeatureWorkspaceBatchActions FeatureName = "workspace_batch_actions"
83+
FeatureTaskBatchActions FeatureName = "task_batch_actions"
8384
FeatureAccessControl FeatureName = "access_control"
8485
FeatureControlSharedPorts FeatureName = "control_shared_ports"
8586
FeatureCustomRoles FeatureName = "custom_roles"
@@ -111,6 +112,7 @@ var (
111112
FeatureUserRoleManagement,
112113
FeatureExternalTokenEncryption,
113114
FeatureWorkspaceBatchActions,
115+
FeatureTaskBatchActions,
114116
FeatureAccessControl,
115117
FeatureControlSharedPorts,
116118
FeatureCustomRoles,
@@ -157,6 +159,7 @@ func (n FeatureName) AlwaysEnable() bool {
157159
FeatureExternalProvisionerDaemons: true,
158160
FeatureAppearance: true,
159161
FeatureWorkspaceBatchActions: true,
162+
FeatureTaskBatchActions: true,
160163
FeatureHighAvailability: true,
161164
FeatureCustomRoles: true,
162165
FeatureMultipleOrganizations: true,

site/src/api/typesGenerated.ts

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { chromatic } from "testHelpers/chromatic";
2+
import { MockTask } from "testHelpers/entities";
3+
import type { Meta, StoryObj } from "@storybook/react-vite";
4+
import { action } from "storybook/actions";
5+
import { userEvent, within } from "storybook/test";
6+
import { BatchDeleteConfirmation } from "./BatchDeleteConfirmation";
7+
8+
const meta: Meta<typeof BatchDeleteConfirmation> = {
9+
title: "pages/TasksPage/BatchDeleteConfirmation",
10+
parameters: { chromatic },
11+
component: BatchDeleteConfirmation,
12+
args: {
13+
onClose: action("onClose"),
14+
onConfirm: action("onConfirm"),
15+
open: true,
16+
isLoading: false,
17+
checkedTasks: [
18+
MockTask,
19+
{
20+
...MockTask,
21+
id: "task-2",
22+
name: "task-test-456",
23+
display_name: "Add API Tests",
24+
initial_prompt: "Add comprehensive tests for the API endpoints",
25+
// Different owner to test admin bulk delete of other users' tasks
26+
owner_name: "bob",
27+
created_at: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
28+
updated_at: new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString(),
29+
},
30+
{
31+
...MockTask,
32+
id: "task-3",
33+
name: "task-docs-789",
34+
display_name: "Update Documentation",
35+
initial_prompt: "Update documentation for the new features",
36+
// Intentionally null to test that only 2 workspaces are shown in review resources stage
37+
workspace_id: null,
38+
created_at: new Date(
39+
Date.now() - 3 * 24 * 60 * 60 * 1000,
40+
).toISOString(),
41+
updated_at: new Date(
42+
Date.now() - 2 * 24 * 60 * 60 * 1000,
43+
).toISOString(),
44+
},
45+
],
46+
workspaceCount: 2,
47+
},
48+
};
49+
50+
export default meta;
51+
type Story = StoryObj<typeof BatchDeleteConfirmation>;
52+
53+
export const Consequences: Story = {};
54+
55+
export const ReviewTasks: Story = {
56+
play: async ({ canvasElement, step }) => {
57+
const body = within(canvasElement.ownerDocument.body);
58+
59+
await step("Advance to stage 2: Review tasks", async () => {
60+
const confirmButton = await body.findByRole("button", {
61+
name: /review selected tasks/i,
62+
});
63+
await userEvent.click(confirmButton);
64+
});
65+
},
66+
};
67+
68+
export const ReviewResources: Story = {
69+
play: async ({ canvasElement, step }) => {
70+
const body = within(canvasElement.ownerDocument.body);
71+
72+
await step("Advance to stage 2: Review tasks", async () => {
73+
const confirmButton = await body.findByRole("button", {
74+
name: /review selected tasks/i,
75+
});
76+
await userEvent.click(confirmButton);
77+
});
78+
79+
await step("Advance to stage 3: Review resources", async () => {
80+
const confirmButton = await body.findByRole("button", {
81+
name: /confirm.*tasks/i,
82+
});
83+
await userEvent.click(confirmButton);
84+
});
85+
},
86+
};
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import type { Task } from "api/typesGenerated";
2+
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
3+
import dayjs from "dayjs";
4+
import relativeTime from "dayjs/plugin/relativeTime";
5+
import { ClockIcon, ServerIcon, UserIcon } from "lucide-react";
6+
import { type FC, type ReactNode, useState } from "react";
7+
8+
dayjs.extend(relativeTime);
9+
10+
type BatchDeleteConfirmationProps = {
11+
checkedTasks: readonly Task[];
12+
workspaceCount: number;
13+
open: boolean;
14+
isLoading: boolean;
15+
onClose: () => void;
16+
onConfirm: () => void;
17+
};
18+
19+
export const BatchDeleteConfirmation: FC<BatchDeleteConfirmationProps> = ({
20+
checkedTasks,
21+
workspaceCount,
22+
open,
23+
onClose,
24+
onConfirm,
25+
isLoading,
26+
}) => {
27+
const [stage, setStage] = useState<"consequences" | "tasks" | "resources">(
28+
"consequences",
29+
);
30+
31+
const onProceed = () => {
32+
switch (stage) {
33+
case "resources":
34+
onConfirm();
35+
break;
36+
case "tasks":
37+
setStage("resources");
38+
break;
39+
case "consequences":
40+
setStage("tasks");
41+
break;
42+
}
43+
};
44+
45+
const taskCount = `${checkedTasks.length} ${
46+
checkedTasks.length === 1 ? "task" : "tasks"
47+
}`;
48+
49+
let confirmText: ReactNode = <>Review selected tasks&hellip;</>;
50+
if (stage === "tasks") {
51+
confirmText = <>Confirm {taskCount}&hellip;</>;
52+
}
53+
if (stage === "resources") {
54+
const workspaceCountText = `${workspaceCount} ${
55+
workspaceCount === 1 ? "workspace" : "workspaces"
56+
}`;
57+
confirmText = (
58+
<>
59+
Delete {taskCount} and {workspaceCountText}
60+
</>
61+
);
62+
}
63+
64+
return (
65+
<ConfirmDialog
66+
type="delete"
67+
open={open}
68+
onClose={() => {
69+
setStage("consequences");
70+
onClose();
71+
}}
72+
title={`Delete ${taskCount}`}
73+
hideCancel
74+
confirmLoading={isLoading}
75+
confirmText={confirmText}
76+
onConfirm={onProceed}
77+
description={
78+
<>
79+
{stage === "consequences" && <Consequences />}
80+
{stage === "tasks" && <Tasks tasks={checkedTasks} />}
81+
{stage === "resources" && (
82+
<Resources tasks={checkedTasks} workspaceCount={workspaceCount} />
83+
)}
84+
{/* Preload ServerIcon to prevent flicker on stage 3 */}
85+
<ServerIcon className="sr-only" aria-hidden />
86+
</>
87+
}
88+
/>
89+
);
90+
};
91+
92+
interface TasksStageProps {
93+
tasks: readonly Task[];
94+
}
95+
96+
interface ResourcesStageProps {
97+
tasks: readonly Task[];
98+
workspaceCount: number;
99+
}
100+
101+
const Consequences: FC = () => {
102+
return (
103+
<>
104+
<p>Deleting tasks is irreversible!</p>
105+
<ul className="flex flex-col gap-2 pl-4 mb-0">
106+
<li>
107+
Tasks with associated workspaces will have those workspaces deleted.
108+
</li>
109+
<li>Any data stored in task workspaces will be permanently deleted.</li>
110+
</ul>
111+
</>
112+
);
113+
};
114+
115+
const Tasks: FC<TasksStageProps> = ({ tasks }) => {
116+
const mostRecent = tasks.reduce(
117+
(latestSoFar, against) => {
118+
if (!latestSoFar) {
119+
return against;
120+
}
121+
122+
return new Date(against.updated_at).getTime() >
123+
new Date(latestSoFar.updated_at).getTime()
124+
? against
125+
: latestSoFar;
126+
},
127+
undefined as Task | undefined,
128+
);
129+
130+
const ownersCount = new Set(tasks.map((it) => it.owner_name)).size;
131+
const ownersCountDisplay = `${ownersCount} ${ownersCount === 1 ? "owner" : "owners"}`;
132+
133+
return (
134+
<>
135+
<ul className="list-none p-0 border border-solid border-zinc-200 dark:border-zinc-700 rounded-lg overflow-x-hidden overflow-y-auto max-h-[184px]">
136+
{tasks.map((task) => (
137+
<li
138+
key={task.id}
139+
className="py-2 px-4 border-solid border-0 border-b border-zinc-200 dark:border-zinc-700 last:border-b-0"
140+
>
141+
<div className="flex items-center justify-between gap-6">
142+
<span className="font-medium text-content-primary max-w-[400px] overflow-hidden text-ellipsis whitespace-nowrap">
143+
{task.display_name}
144+
</span>
145+
146+
<div className="flex flex-col text-sm items-end">
147+
<div className="flex items-center gap-2">
148+
<span className="whitespace-nowrap">{task.owner_name}</span>
149+
<UserIcon className="size-icon-sm -m-px" />
150+
</div>
151+
<div className="flex items-center gap-2">
152+
<span className="whitespace-nowrap">
153+
{dayjs(task.updated_at).fromNow()}
154+
</span>
155+
<ClockIcon className="size-icon-xs" />
156+
</div>
157+
</div>
158+
</div>
159+
</li>
160+
))}
161+
</ul>
162+
<div className="flex flex-wrap justify-center gap-x-5 gap-y-1.5 text-sm">
163+
<div className="flex items-center gap-2">
164+
<UserIcon className="size-icon-sm -m-px" />
165+
<span>{ownersCountDisplay}</span>
166+
</div>
167+
{mostRecent && (
168+
<div className="flex items-center gap-2">
169+
<ClockIcon className="size-icon-xs" />
170+
<span>Last updated {dayjs(mostRecent.updated_at).fromNow()}</span>
171+
</div>
172+
)}
173+
</div>
174+
</>
175+
);
176+
};
177+
178+
const Resources: FC<ResourcesStageProps> = ({ tasks, workspaceCount }) => {
179+
const taskCount = tasks.length;
180+
181+
return (
182+
<div className="flex flex-col gap-4">
183+
<p>
184+
Deleting {taskCount === 1 ? "this task" : "these tasks"} will also
185+
permanently destroy&hellip;
186+
</p>
187+
<div className="flex flex-wrap justify-center gap-x-5 gap-y-1.5 text-sm">
188+
<div className="flex items-center gap-2">
189+
<ServerIcon className="size-icon-sm" />
190+
<span>
191+
{workspaceCount} {workspaceCount === 1 ? "workspace" : "workspaces"}
192+
</span>
193+
</div>
194+
</div>
195+
</div>
196+
);
197+
};

0 commit comments

Comments
 (0)