Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions codersdk/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -111,6 +112,7 @@ var (
FeatureUserRoleManagement,
FeatureExternalTokenEncryption,
FeatureWorkspaceBatchActions,
FeatureTaskBatchActions,
FeatureAccessControl,
FeatureControlSharedPorts,
FeatureCustomRoles,
Expand Down Expand Up @@ -157,6 +159,7 @@ func (n FeatureName) AlwaysEnable() bool {
FeatureExternalProvisionerDaemons: true,
FeatureAppearance: true,
FeatureWorkspaceBatchActions: true,
FeatureTaskBatchActions: true,
FeatureHighAvailability: true,
FeatureCustomRoles: true,
FeatureMultipleOrganizations: true,
Expand Down
2 changes: 2 additions & 0 deletions site/src/api/typesGenerated.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

86 changes: 86 additions & 0 deletions site/src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
},
};
197 changes: 197 additions & 0 deletions site/src/pages/TasksPage/BatchDeleteConfirmation.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};
Loading
Loading