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
This commit is contained in:
Mathias Fredriksson
2025-11-27 16:05:17 +00:00
committed by GitHub
parent 21efebeadc
commit ee58f40cad
9 changed files with 676 additions and 31 deletions
+3
View File
@@ -80,6 +80,7 @@ const (
FeatureWorkspaceProxy FeatureName = "workspace_proxy"
FeatureExternalTokenEncryption FeatureName = "external_token_encryption"
FeatureWorkspaceBatchActions FeatureName = "workspace_batch_actions"
FeatureTaskBatchActions FeatureName = "task_batch_actions"
FeatureAccessControl FeatureName = "access_control"
FeatureControlSharedPorts FeatureName = "control_shared_ports"
FeatureCustomRoles FeatureName = "custom_roles"
@@ -111,6 +112,7 @@ var (
FeatureUserRoleManagement,
FeatureExternalTokenEncryption,
FeatureWorkspaceBatchActions,
FeatureTaskBatchActions,
FeatureAccessControl,
FeatureControlSharedPorts,
FeatureCustomRoles,
@@ -157,6 +159,7 @@ func (n FeatureName) AlwaysEnable() bool {
FeatureExternalProvisionerDaemons: true,
FeatureAppearance: true,
FeatureWorkspaceBatchActions: true,
FeatureTaskBatchActions: true,
FeatureHighAvailability: true,
FeatureCustomRoles: true,
FeatureMultipleOrganizations: true,
+2
View File
@@ -2103,6 +2103,7 @@ export type FeatureName =
| "multiple_external_auth"
| "multiple_organizations"
| "scim"
| "task_batch_actions"
| "template_rbac"
| "user_limit"
| "user_role_management"
@@ -2128,6 +2129,7 @@ export const FeatureNames: FeatureName[] = [
"multiple_external_auth",
"multiple_organizations",
"scim",
"task_batch_actions",
"template_rbac",
"user_limit",
"user_role_management",
@@ -0,0 +1,86 @@
import { chromatic } from "testHelpers/chromatic";
import { MockTask } from "testHelpers/entities";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { action } from "storybook/actions";
import { userEvent, within } from "storybook/test";
import { BatchDeleteConfirmation } from "./BatchDeleteConfirmation";
const meta: Meta<typeof BatchDeleteConfirmation> = {
title: "pages/TasksPage/BatchDeleteConfirmation",
parameters: { chromatic },
component: BatchDeleteConfirmation,
args: {
onClose: action("onClose"),
onConfirm: action("onConfirm"),
open: true,
isLoading: false,
checkedTasks: [
MockTask,
{
...MockTask,
id: "task-2",
name: "task-test-456",
display_name: "Add API Tests",
initial_prompt: "Add comprehensive tests for the API endpoints",
// Different owner to test admin bulk delete of other users' tasks
owner_name: "bob",
created_at: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
updated_at: new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString(),
},
{
...MockTask,
id: "task-3",
name: "task-docs-789",
display_name: "Update Documentation",
initial_prompt: "Update documentation for the new features",
// Intentionally null to test that only 2 workspaces are shown in review resources stage
workspace_id: null,
created_at: new Date(
Date.now() - 3 * 24 * 60 * 60 * 1000,
).toISOString(),
updated_at: new Date(
Date.now() - 2 * 24 * 60 * 60 * 1000,
).toISOString(),
},
],
workspaceCount: 2,
},
};
export default meta;
type Story = StoryObj<typeof BatchDeleteConfirmation>;
export const Consequences: Story = {};
export const ReviewTasks: Story = {
play: async ({ canvasElement, step }) => {
const body = within(canvasElement.ownerDocument.body);
await step("Advance to stage 2: Review tasks", async () => {
const confirmButton = await body.findByRole("button", {
name: /review selected tasks/i,
});
await userEvent.click(confirmButton);
});
},
};
export const ReviewResources: Story = {
play: async ({ canvasElement, step }) => {
const body = within(canvasElement.ownerDocument.body);
await step("Advance to stage 2: Review tasks", async () => {
const confirmButton = await body.findByRole("button", {
name: /review selected tasks/i,
});
await userEvent.click(confirmButton);
});
await step("Advance to stage 3: Review resources", async () => {
const confirmButton = await body.findByRole("button", {
name: /confirm.*tasks/i,
});
await userEvent.click(confirmButton);
});
},
};
@@ -0,0 +1,197 @@
import type { Task } from "api/typesGenerated";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { ClockIcon, ServerIcon, UserIcon } from "lucide-react";
import { type FC, type ReactNode, useState } from "react";
dayjs.extend(relativeTime);
type BatchDeleteConfirmationProps = {
checkedTasks: readonly Task[];
workspaceCount: number;
open: boolean;
isLoading: boolean;
onClose: () => void;
onConfirm: () => void;
};
export const BatchDeleteConfirmation: FC<BatchDeleteConfirmationProps> = ({
checkedTasks,
workspaceCount,
open,
onClose,
onConfirm,
isLoading,
}) => {
const [stage, setStage] = useState<"consequences" | "tasks" | "resources">(
"consequences",
);
const onProceed = () => {
switch (stage) {
case "resources":
onConfirm();
break;
case "tasks":
setStage("resources");
break;
case "consequences":
setStage("tasks");
break;
}
};
const taskCount = `${checkedTasks.length} ${
checkedTasks.length === 1 ? "task" : "tasks"
}`;
let confirmText: ReactNode = <>Review selected tasks&hellip;</>;
if (stage === "tasks") {
confirmText = <>Confirm {taskCount}&hellip;</>;
}
if (stage === "resources") {
const workspaceCountText = `${workspaceCount} ${
workspaceCount === 1 ? "workspace" : "workspaces"
}`;
confirmText = (
<>
Delete {taskCount} and {workspaceCountText}
</>
);
}
return (
<ConfirmDialog
type="delete"
open={open}
onClose={() => {
setStage("consequences");
onClose();
}}
title={`Delete ${taskCount}`}
hideCancel
confirmLoading={isLoading}
confirmText={confirmText}
onConfirm={onProceed}
description={
<>
{stage === "consequences" && <Consequences />}
{stage === "tasks" && <Tasks tasks={checkedTasks} />}
{stage === "resources" && (
<Resources tasks={checkedTasks} workspaceCount={workspaceCount} />
)}
{/* Preload ServerIcon to prevent flicker on stage 3 */}
<ServerIcon className="sr-only" aria-hidden />
</>
}
/>
);
};
interface TasksStageProps {
tasks: readonly Task[];
}
interface ResourcesStageProps {
tasks: readonly Task[];
workspaceCount: number;
}
const Consequences: FC = () => {
return (
<>
<p>Deleting tasks is irreversible!</p>
<ul className="flex flex-col gap-2 pl-4 mb-0">
<li>
Tasks with associated workspaces will have those workspaces deleted.
</li>
<li>Any data stored in task workspaces will be permanently deleted.</li>
</ul>
</>
);
};
const Tasks: FC<TasksStageProps> = ({ tasks }) => {
const mostRecent = tasks.reduce(
(latestSoFar, against) => {
if (!latestSoFar) {
return against;
}
return new Date(against.updated_at).getTime() >
new Date(latestSoFar.updated_at).getTime()
? against
: latestSoFar;
},
undefined as Task | undefined,
);
const ownersCount = new Set(tasks.map((it) => it.owner_name)).size;
const ownersCountDisplay = `${ownersCount} ${ownersCount === 1 ? "owner" : "owners"}`;
return (
<>
<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]">
{tasks.map((task) => (
<li
key={task.id}
className="py-2 px-4 border-solid border-0 border-b border-zinc-200 dark:border-zinc-700 last:border-b-0"
>
<div className="flex items-center justify-between gap-6">
<span className="font-medium text-content-primary max-w-[400px] overflow-hidden text-ellipsis whitespace-nowrap">
{task.display_name}
</span>
<div className="flex flex-col text-sm items-end">
<div className="flex items-center gap-2">
<span className="whitespace-nowrap">{task.owner_name}</span>
<UserIcon className="size-icon-sm -m-px" />
</div>
<div className="flex items-center gap-2">
<span className="whitespace-nowrap">
{dayjs(task.updated_at).fromNow()}
</span>
<ClockIcon className="size-icon-xs" />
</div>
</div>
</div>
</li>
))}
</ul>
<div className="flex flex-wrap justify-center gap-x-5 gap-y-1.5 text-sm">
<div className="flex items-center gap-2">
<UserIcon className="size-icon-sm -m-px" />
<span>{ownersCountDisplay}</span>
</div>
{mostRecent && (
<div className="flex items-center gap-2">
<ClockIcon className="size-icon-xs" />
<span>Last updated {dayjs(mostRecent.updated_at).fromNow()}</span>
</div>
)}
</div>
</>
);
};
const Resources: FC<ResourcesStageProps> = ({ tasks, workspaceCount }) => {
const taskCount = tasks.length;
return (
<div className="flex flex-col gap-4">
<p>
Deleting {taskCount === 1 ? "this task" : "these tasks"} will also
permanently destroy&hellip;
</p>
<div className="flex flex-wrap justify-center gap-x-5 gap-y-1.5 text-sm">
<div className="flex items-center gap-2">
<ServerIcon className="size-icon-sm" />
<span>
{workspaceCount} {workspaceCount === 1 ? "workspace" : "workspaces"}
</span>
</div>
</div>
</div>
);
};
+108 -2
View File
@@ -6,7 +6,11 @@ import {
MockUserOwner,
mockApiError,
} from "testHelpers/entities";
import { withAuthProvider, withProxyProvider } from "testHelpers/storybook";
import {
withAuthProvider,
withDashboardProvider,
withProxyProvider,
} from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import { MockUsers } from "pages/UsersPage/storybookData/users";
@@ -17,7 +21,7 @@ import TasksPage from "./TasksPage";
const meta: Meta<typeof TasksPage> = {
title: "pages/TasksPage",
component: TasksPage,
decorators: [withAuthProvider, withProxyProvider()],
decorators: [withAuthProvider, withDashboardProvider, withProxyProvider()],
parameters: {
user: MockUserOwner,
permissions: {
@@ -255,3 +259,105 @@ export const InitializingTasks: Story = {
],
},
};
export const BatchActionsEnabled: Story = {
parameters: {
features: ["task_batch_actions"],
queries: [
{
key: ["tasks", { owner: MockUserOwner.username }],
data: MockTasks,
},
{
key: getTemplatesQueryKey({ q: "has-ai-task:true" }),
data: [MockTemplate],
},
],
},
};
export const BatchActionsSomeSelected: Story = {
parameters: {
features: ["task_batch_actions"],
queries: [
{
key: ["tasks", { owner: MockUserOwner.username }],
data: MockTasks,
},
{
key: getTemplatesQueryKey({ q: "has-ai-task:true" }),
data: [MockTemplate],
},
],
},
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
await step("Select first two tasks", async () => {
await canvas.findByRole("table");
const checkboxes = await canvas.findAllByRole("checkbox");
// Skip the "select all" checkbox (first one) and select the next two
await userEvent.click(checkboxes[1]);
await userEvent.click(checkboxes[2]);
});
},
};
export const BatchActionsAllSelected: Story = {
parameters: {
features: ["task_batch_actions"],
queries: [
{
key: ["tasks", { owner: MockUserOwner.username }],
data: MockTasks,
},
{
key: getTemplatesQueryKey({ q: "has-ai-task:true" }),
data: [MockTemplate],
},
],
},
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
await step("Select all tasks using header checkbox", async () => {
await canvas.findByRole("table");
const checkboxes = await canvas.findAllByRole("checkbox");
// Click the first checkbox (select all)
await userEvent.click(checkboxes[0]);
});
},
};
export const BatchActionsDropdownOpen: Story = {
parameters: {
features: ["task_batch_actions"],
queries: [
{
key: ["tasks", { owner: MockUserOwner.username }],
data: MockTasks,
},
{
key: getTemplatesQueryKey({ q: "has-ai-task:true" }),
data: [MockTemplate],
},
],
},
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
await step("Select some tasks", async () => {
await canvas.findByRole("table");
const checkboxes = await canvas.findAllByRole("checkbox");
await userEvent.click(checkboxes[1]);
await userEvent.click(checkboxes[2]);
});
await step("Open bulk actions dropdown", async () => {
const bulkActionsButton = await canvas.findByRole("button", {
name: /bulk actions/i,
});
await userEvent.click(bulkActionsButton);
});
},
};
+121 -3
View File
@@ -1,21 +1,34 @@
import { API } from "api/api";
import { templates } from "api/queries/templates";
import type { TasksFilter } from "api/typesGenerated";
import { Badge } from "components/Badge/Badge";
import { Button, type ButtonProps } from "components/Button/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { Margins } from "components/Margins/Margins";
import {
PageHeader,
PageHeaderSubtitle,
PageHeaderTitle,
} from "components/PageHeader/PageHeader";
import { Spinner } from "components/Spinner/Spinner";
import { TableToolbar } from "components/TableToolbar/TableToolbar";
import { useAuthenticated } from "hooks";
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
import { ChevronDownIcon, TrashIcon } from "lucide-react";
import { useDashboard } from "modules/dashboard/useDashboard";
import { TaskPrompt } from "modules/tasks/TaskPrompt/TaskPrompt";
import type { FC } from "react";
import { type FC, useState } from "react";
import { useQuery } from "react-query";
import { cn } from "utils/cn";
import { pageTitle } from "utils/page";
import { BatchDeleteConfirmation } from "./BatchDeleteConfirmation";
import { useBatchTaskActions } from "./batchActions";
import { TasksTable } from "./TasksTable";
import { UsersCombobox } from "./UsersCombobox";
@@ -49,6 +62,40 @@ const TasksPage: FC = () => {
const displayedTasks =
tab.value === "waiting-for-input" ? idleTasks : tasksQuery.data;
const [checkedTaskIds, setCheckedTaskIds] = useState<Set<string>>(new Set());
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const checkedTasks =
displayedTasks?.filter((t) => checkedTaskIds.has(t.id)) ?? [];
const batchActions = useBatchTaskActions({
onSuccess: async () => {
await tasksQuery.refetch();
setCheckedTaskIds(new Set());
setIsDeleteDialogOpen(false);
},
});
const handleCheckChange = (newIds: Set<string>) => {
setCheckedTaskIds(newIds);
};
const handleBatchDelete = () => {
setIsDeleteDialogOpen(true);
};
const handleConfirmDelete = async () => {
await batchActions.delete(checkedTasks);
};
const { entitlements } = useDashboard();
const canCheckTasks = entitlements.features.task_batch_actions.enabled;
// Count workspaces that will be deleted with the selected tasks.
const workspaceCount = checkedTasks.filter(
(t) => t.workspace_id !== null,
).length;
return (
<>
<title>{pageTitle("AI Tasks")}</title>
@@ -76,14 +123,20 @@ const TasksPage: FC = () => {
<div className="flex items-center bg-surface-secondary rounded p-1">
<PillButton
active={tab.value === "all"}
onClick={() => tab.setValue("all")}
onClick={() => {
tab.setValue("all");
setCheckedTaskIds(new Set());
}}
>
All tasks
</PillButton>
<PillButton
disabled={!idleTasks || idleTasks.length === 0}
active={tab.value === "waiting-for-input"}
onClick={() => tab.setValue("waiting-for-input")}
onClick={() => {
tab.setValue("waiting-for-input");
setCheckedTaskIds(new Set());
}}
>
Waiting for input
{idleTasks && idleTasks.length > 0 && (
@@ -100,19 +153,84 @@ const TasksPage: FC = () => {
ownerFilter.setValue(
username === ownerFilter.value ? "" : username,
);
setCheckedTaskIds(new Set());
}}
/>
</section>
)}
<div className="mt-6">
<TableToolbar>
{checkedTasks.length > 0 ? (
<>
<div>
Selected <strong>{checkedTasks.length}</strong> of{" "}
<strong>{displayedTasks?.length}</strong>{" "}
{displayedTasks?.length === 1 ? "task" : "tasks"}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
disabled={batchActions.isProcessing}
variant="outline"
size="sm"
className="ml-auto"
>
Bulk actions
<Spinner loading={batchActions.isProcessing}>
<ChevronDownIcon className="size-4" />
</Spinner>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={handleBatchDelete}
>
<TrashIcon /> Delete&hellip;
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<div>
Showing{" "}
{displayedTasks && displayedTasks.length > 0 ? (
<>
<strong>1</strong> to{" "}
<strong>{displayedTasks.length}</strong> of{" "}
<strong>{displayedTasks.length}</strong>
</>
) : (
<strong>0</strong>
)}{" "}
{displayedTasks?.length === 1 ? "task" : "tasks"}
</div>
)}
</TableToolbar>
</div>
<TasksTable
tasks={displayedTasks}
error={tasksQuery.error}
onRetry={tasksQuery.refetch}
checkedTaskIds={checkedTaskIds}
onCheckChange={handleCheckChange}
canCheckTasks={canCheckTasks}
/>
</section>
)}
</main>
<BatchDeleteConfirmation
open={isDeleteDialogOpen}
checkedTasks={checkedTasks}
workspaceCount={workspaceCount}
isLoading={batchActions.isProcessing}
onClose={() => setIsDeleteDialogOpen(false)}
onConfirm={handleConfirmDelete}
/>
</Margins>
</>
);
+119 -26
View File
@@ -1,3 +1,4 @@
import Checkbox from "@mui/material/Checkbox";
import { getErrorDetail, getErrorMessage } from "api/errors";
import type { Task } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
@@ -29,38 +30,97 @@ import { TaskDeleteDialog } from "modules/tasks/TaskDeleteDialog/TaskDeleteDialo
import { TaskStatus } from "modules/tasks/TaskStatus/TaskStatus";
import { type FC, type ReactNode, useState } from "react";
import { useNavigate } from "react-router";
import { relativeTime } from "utils/time";
type TasksTableProps = {
tasks: readonly Task[] | undefined;
error: unknown;
onRetry: () => void;
checkedTaskIds?: Set<string>;
onCheckChange?: (checkedTaskIds: Set<string>) => void;
canCheckTasks?: boolean;
};
export const TasksTable: FC<TasksTableProps> = ({ tasks, error, onRetry }) => {
export const TasksTable: FC<TasksTableProps> = ({
tasks,
error,
onRetry,
checkedTaskIds = new Set(),
onCheckChange,
canCheckTasks = false,
}) => {
let body: ReactNode = null;
if (error) {
body = <TasksErrorBody error={error} onRetry={onRetry} />;
} else if (!tasks) {
body = <TasksSkeleton />;
body = <TasksSkeleton canCheckTasks={canCheckTasks} />;
} else if (tasks.length === 0) {
body = <TasksEmpty />;
} else {
body = tasks.map((task) => <TaskRow key={task.id} task={task} />);
body = tasks.map((task) => {
const checked = checkedTaskIds.has(task.id);
return (
<TaskRow
key={task.id}
task={task}
checked={checked}
onCheckChange={(taskId, checked) => {
if (!onCheckChange) return;
const newIds = new Set(checkedTaskIds);
if (checked) {
newIds.add(taskId);
} else {
newIds.delete(taskId);
}
onCheckChange(newIds);
}}
canCheck={canCheckTasks}
/>
);
});
}
return (
<Table className="mt-4">
<Table>
<TableHeader>
<TableRow>
<TableHead>Task</TableHead>
<TableHead className="w-1/3">
<div className="flex items-center gap-2">
{canCheckTasks && (
<Checkbox
className="-my-[9px]"
disabled={!tasks || tasks.length === 0}
checked={
tasks &&
tasks.length > 0 &&
checkedTaskIds.size === tasks.length
}
size="xsmall"
onChange={(_, checked) => {
if (!tasks || !onCheckChange) {
return;
}
if (!checked) {
onCheckChange(new Set());
} else {
onCheckChange(new Set(tasks.map((t) => t.id)));
}
}}
aria-label="Select all tasks"
/>
)}
Task
</div>
</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created by</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>{body}</TableBody>
<TableBody className="[&_td]:h-[72px]">{body}</TableBody>
</Table>
);
};
@@ -96,7 +156,7 @@ const TasksErrorBody: FC<TasksErrorBodyProps> = ({ error, onRetry }) => {
const TasksEmpty: FC = () => {
return (
<TableRow>
<TableCell colSpan={999} className="text-center">
<TableCell colSpan={4} className="text-center">
<div className="w-full min-h-80 p-4 flex items-center justify-center">
<div className="flex flex-col items-center">
<h3 className="m-0 font-medium text-content-primary text-base">
@@ -112,9 +172,19 @@ const TasksEmpty: FC = () => {
);
};
type TaskRowProps = { task: Task };
type TaskRowProps = {
task: Task;
checked: boolean;
onCheckChange: (taskId: string, checked: boolean) => void;
canCheck: boolean;
};
const TaskRow: FC<TaskRowProps> = ({ task }) => {
const TaskRow: FC<TaskRowProps> = ({
task,
checked,
onCheckChange,
canCheck,
}) => {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const templateDisplayName = task.template_display_name ?? task.template_name;
const navigate = useNavigate();
@@ -133,22 +203,38 @@ const TaskRow: FC<TaskRowProps> = ({ task }) => {
{...clickableRowProps}
>
<TableCell>
<AvatarData
title={
<span className="block max-w-[520px] truncate">
{task.display_name}
</span>
}
subtitle={templateDisplayName}
avatar={
<Avatar
size="lg"
variant="icon"
src={task.template_icon}
fallback={templateDisplayName}
<div className="flex items-center gap-2">
{canCheck && (
<Checkbox
data-testid={`checkbox-${task.id}`}
size="xsmall"
checked={checked}
onClick={(e) => {
e.stopPropagation();
}}
onChange={(e) => {
onCheckChange(task.id, e.currentTarget.checked);
}}
aria-label={`Select task ${task.initial_prompt}`}
/>
}
/>
)}
<AvatarData
title={
<span className="block max-w-[520px] truncate">
{task.display_name}
</span>
}
subtitle={templateDisplayName}
avatar={
<Avatar
size="lg"
variant="icon"
src={task.template_icon}
fallback={templateDisplayName}
/>
}
/>
</div>
</TableCell>
<TableCell>
<TaskStatus
@@ -201,12 +287,19 @@ const TaskRow: FC<TaskRowProps> = ({ task }) => {
);
};
const TasksSkeleton: FC = () => {
type TasksSkeletonProps = {
canCheckTasks: boolean;
};
const TasksSkeleton: FC<TasksSkeletonProps> = ({ canCheckTasks }) => {
return (
<TableLoaderSkeleton>
<TableRowSkeleton>
<TableCell>
<AvatarDataSkeleton />
<div className="flex items-center gap-2">
{canCheckTasks && <Checkbox size="small" disabled />}
<AvatarDataSkeleton />
</div>
</TableCell>
<TableCell>
<Skeleton className="w-[100px] h-6" />
+36
View File
@@ -0,0 +1,36 @@
import { API } from "api/api";
import type { Task } from "api/typesGenerated";
import { displayError } from "components/GlobalSnackbar/utils";
import { useMutation } from "react-query";
interface UseBatchTaskActionsOptions {
onSuccess: () => Promise<void>;
}
type UseBatchTaskActionsResult = Readonly<{
isProcessing: boolean;
delete: (tasks: readonly Task[]) => Promise<void>;
}>;
export function useBatchTaskActions(
options: UseBatchTaskActionsOptions,
): UseBatchTaskActionsResult {
const { onSuccess } = options;
const deleteAllMutation = useMutation({
mutationFn: async (tasks: readonly Task[]): Promise<void> => {
await Promise.all(
tasks.map((task) => API.deleteTask(task.owner_name, task.id)),
);
},
onSuccess,
onError: () => {
displayError("Failed to delete some tasks");
},
});
return {
delete: deleteAllMutation.mutateAsync,
isProcessing: deleteAllMutation.isPending,
};
}
+4
View File
@@ -2461,6 +2461,10 @@ export const MockEntitlements: TypesGen.Entitlements = {
enabled: true,
entitlement: "entitled",
},
task_batch_actions: {
enabled: true,
entitlement: "entitled",
},
}),
require_telemetry: false,
trial: false,