diff --git a/codersdk/deployment.go b/codersdk/deployment.go index a51e3e9247e58..29081b4bf6076 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -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, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 5597abae9eacc..d31b069dce921 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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", diff --git a/site/src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx b/site/src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx new file mode 100644 index 0000000000000..bea8e1796e5be --- /dev/null +++ b/site/src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx @@ -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 = { + 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; + +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); + }); + }, +}; diff --git a/site/src/pages/TasksPage/BatchDeleteConfirmation.tsx b/site/src/pages/TasksPage/BatchDeleteConfirmation.tsx new file mode 100644 index 0000000000000..9e903f898571a --- /dev/null +++ b/site/src/pages/TasksPage/BatchDeleteConfirmation.tsx @@ -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 = ({ + 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 ( + { + setStage("consequences"); + onClose(); + }} + title={`Delete ${taskCount}`} + hideCancel + confirmLoading={isLoading} + confirmText={confirmText} + onConfirm={onProceed} + description={ + <> + {stage === "consequences" && } + {stage === "tasks" && } + {stage === "resources" && ( + + )} + {/* Preload ServerIcon to prevent flicker on stage 3 */} + + + } + /> + ); +}; + +interface TasksStageProps { + tasks: readonly Task[]; +} + +interface ResourcesStageProps { + tasks: readonly Task[]; + workspaceCount: number; +} + +const Consequences: FC = () => { + return ( + <> +

Deleting tasks is irreversible!

+
    +
  • + Tasks with associated workspaces will have those workspaces deleted. +
  • +
  • Any data stored in task workspaces will be permanently deleted.
  • +
+ + ); +}; + +const Tasks: FC = ({ 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 ( + <> +
    + {tasks.map((task) => ( +
  • +
    + + {task.display_name} + + +
    +
    + {task.owner_name} + +
    +
    + + {dayjs(task.updated_at).fromNow()} + + +
    +
    +
    +
  • + ))} +
+
+
+ + {ownersCountDisplay} +
+ {mostRecent && ( +
+ + Last updated {dayjs(mostRecent.updated_at).fromNow()} +
+ )} +
+ + ); +}; + +const Resources: FC = ({ tasks, workspaceCount }) => { + const taskCount = tasks.length; + + return ( +
+

+ Deleting {taskCount === 1 ? "this task" : "these tasks"} will also + permanently destroy… +

+
+
+ + + {workspaceCount} {workspaceCount === 1 ? "workspace" : "workspaces"} + +
+
+
+ ); +}; diff --git a/site/src/pages/TasksPage/TasksPage.stories.tsx b/site/src/pages/TasksPage/TasksPage.stories.tsx index 20fe21ee147cb..67f350c8aada2 100644 --- a/site/src/pages/TasksPage/TasksPage.stories.tsx +++ b/site/src/pages/TasksPage/TasksPage.stories.tsx @@ -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 = { 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); + }); + }, +}; diff --git a/site/src/pages/TasksPage/TasksPage.tsx b/site/src/pages/TasksPage/TasksPage.tsx index 7b37c0371e950..66aebdac26917 100644 --- a/site/src/pages/TasksPage/TasksPage.tsx +++ b/site/src/pages/TasksPage/TasksPage.tsx @@ -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>(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) => { + 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 ( <> {pageTitle("AI Tasks")} @@ -76,14 +123,20 @@ const TasksPage: FC = () => {
tab.setValue("all")} + onClick={() => { + tab.setValue("all"); + setCheckedTaskIds(new Set()); + }} > All tasks 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()); }} /> )} +
+ + {checkedTasks.length > 0 ? ( + <> +
+ Selected {checkedTasks.length} of{" "} + {displayedTasks?.length}{" "} + {displayedTasks?.length === 1 ? "task" : "tasks"} +
+ + + + + + + + Delete… + + + + + ) : ( +
+ Showing{" "} + {displayedTasks && displayedTasks.length > 0 ? ( + <> + 1 to{" "} + {displayedTasks.length} of{" "} + {displayedTasks.length} + + ) : ( + 0 + )}{" "} + {displayedTasks?.length === 1 ? "task" : "tasks"} +
+ )} +
+
+ )} + + setIsDeleteDialogOpen(false)} + onConfirm={handleConfirmDelete} + /> ); diff --git a/site/src/pages/TasksPage/TasksTable.tsx b/site/src/pages/TasksPage/TasksTable.tsx index 5ea40bea710eb..392c0b7ed4146 100644 --- a/site/src/pages/TasksPage/TasksTable.tsx +++ b/site/src/pages/TasksPage/TasksTable.tsx @@ -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; + onCheckChange?: (checkedTaskIds: Set) => void; + canCheckTasks?: boolean; }; -export const TasksTable: FC = ({ tasks, error, onRetry }) => { +export const TasksTable: FC = ({ + tasks, + error, + onRetry, + checkedTaskIds = new Set(), + onCheckChange, + canCheckTasks = false, +}) => { let body: ReactNode = null; if (error) { body = ; } else if (!tasks) { - body = ; + body = ; } else if (tasks.length === 0) { body = ; } else { - body = tasks.map((task) => ); + body = tasks.map((task) => { + const checked = checkedTaskIds.has(task.id); + return ( + { + if (!onCheckChange) return; + const newIds = new Set(checkedTaskIds); + if (checked) { + newIds.add(taskId); + } else { + newIds.delete(taskId); + } + onCheckChange(newIds); + }} + canCheck={canCheckTasks} + /> + ); + }); } return ( - +
- Task + +
+ {canCheckTasks && ( + 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 +
+
Status Created by
- {body} + {body}
); }; @@ -96,7 +156,7 @@ const TasksErrorBody: FC = ({ error, onRetry }) => { const TasksEmpty: FC = () => { return ( - +

@@ -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 = ({ task }) => { +const TaskRow: FC = ({ + 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 = ({ task }) => { {...clickableRowProps} > - - {task.display_name} - - } - subtitle={templateDisplayName} - avatar={ - + {canCheck && ( + { + e.stopPropagation(); + }} + onChange={(e) => { + onCheckChange(task.id, e.currentTarget.checked); + }} + aria-label={`Select task ${task.initial_prompt}`} /> - } - /> + )} + + {task.display_name} + + } + subtitle={templateDisplayName} + avatar={ + + } + /> +

= ({ task }) => { ); }; -const TasksSkeleton: FC = () => { +type TasksSkeletonProps = { + canCheckTasks: boolean; +}; + +const TasksSkeleton: FC = ({ canCheckTasks }) => { return ( - +
+ {canCheckTasks && } + +
diff --git a/site/src/pages/TasksPage/batchActions.ts b/site/src/pages/TasksPage/batchActions.ts new file mode 100644 index 0000000000000..dfd06b903f094 --- /dev/null +++ b/site/src/pages/TasksPage/batchActions.ts @@ -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; +} + +type UseBatchTaskActionsResult = Readonly<{ + isProcessing: boolean; + delete: (tasks: readonly Task[]) => Promise; +}>; + +export function useBatchTaskActions( + options: UseBatchTaskActionsOptions, +): UseBatchTaskActionsResult { + const { onSuccess } = options; + + const deleteAllMutation = useMutation({ + mutationFn: async (tasks: readonly Task[]): Promise => { + 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, + }; +} diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 6c89deb873023..4b8657c593ee8 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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,