mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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,
|
||||
|
||||
Generated
+2
@@ -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…</>;
|
||||
if (stage === "tasks") {
|
||||
confirmText = <>Confirm {taskCount}…</>;
|
||||
}
|
||||
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…
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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…
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user