Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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.

82 changes: 82 additions & 0 deletions site/src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
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",
initial_prompt: "Add comprehensive tests for the API endpoints",
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",
initial_prompt: "Update documentation for the new features",
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 owners = new Set(tasks.map((it) => it.owner_name)).size;
const ownersCount = `${owners} ${owners === 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>{ownersCount}</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>
);
};
8 changes: 6 additions & 2 deletions site/src/pages/TasksPage/TasksPage.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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: {
Expand Down
Loading
Loading