Skip to content
Prev Previous commit
Next Next commit
fix: change emotion to Tailwind
  • Loading branch information
mafredri committed Nov 26, 2025
commit 41ea0dea8c048b27d54bf6ec498bced0ef578e2e
23 changes: 5 additions & 18 deletions site/src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { chromatic } from "testHelpers/chromatic";
import { MockTask, MockWorkspace } from "testHelpers/entities";
import { MockTask } from "testHelpers/entities";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { action } from "storybook/actions";
import { userEvent, within } from "storybook/test";
Expand Down Expand Up @@ -39,23 +39,16 @@ const meta: Meta<typeof BatchDeleteConfirmation> = {
).toISOString(),
},
],
workspaces: [
MockWorkspace,
{
...MockWorkspace,
id: "workspace-2",
name: "bob-workspace",
},
],
workspaceCount: 2,
},
};

export default meta;
type Story = StoryObj<typeof BatchDeleteConfirmation>;

const Stage1_Consequences: Story = {};
export const Consequences: Story = {};

const Stage2_ReviewTasks: Story = {
export const ReviewTasks: Story = {
play: async ({ canvasElement, step }) => {
const body = within(canvasElement.ownerDocument.body);

Expand All @@ -68,7 +61,7 @@ const Stage2_ReviewTasks: Story = {
},
};

const Stage3_ReviewResources: Story = {
export const ReviewResources: Story = {
play: async ({ canvasElement, step }) => {
const body = within(canvasElement.ownerDocument.body);

Expand All @@ -87,9 +80,3 @@ const Stage3_ReviewResources: Story = {
});
},
};

export {
Stage1_Consequences as Consequences,
Stage2_ReviewTasks as ReviewTasks,
Stage3_ReviewResources as ReviewResources,
};
207 changes: 44 additions & 163 deletions site/src/pages/TasksPage/BatchDeleteConfirmation.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
import { visuallyHidden } from "@mui/utils";
import type { Task, Workspace } from "api/typesGenerated";
import type { Task } from "api/typesGenerated";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { Stack } from "components/Stack/Stack";
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";
import { getResourceIconPath } from "utils/workspace";

dayjs.extend(relativeTime);

type BatchDeleteConfirmationProps = {
checkedTasks: readonly Task[];
workspaces: readonly Workspace[];
workspaceCount: number;
open: boolean;
isLoading: boolean;
onClose: () => void;
Expand All @@ -23,7 +18,7 @@ type BatchDeleteConfirmationProps = {

export const BatchDeleteConfirmation: FC<BatchDeleteConfirmationProps> = ({
checkedTasks,
workspaces,
workspaceCount,
open,
onClose,
onConfirm,
Expand Down Expand Up @@ -56,38 +51,16 @@ export const BatchDeleteConfirmation: FC<BatchDeleteConfirmationProps> = ({
confirmText = <>Confirm {taskCount}&hellip;</>;
}
if (stage === "resources") {
const workspaceCount = workspaces.length;
const resources = workspaces
.map((workspace) => workspace.latest_build.resources.length)
.reduce((a, b) => a + b, 0);
const resourceCount = `${resources} ${
resources === 1 ? "resource" : "resources"
}`;
const workspaceCountText = `${workspaceCount} ${
workspaceCount === 1 ? "workspace" : "workspaces"
}`;
confirmText = (
<>
Delete {taskCount}, {workspaceCountText} and {resourceCount}
Delete {taskCount} and {workspaceCountText}
</>
);
}

// The flicker of these icons is quite noticeable if they aren't
// loaded in advance, so we insert them into the document without
// actually displaying them yet.
const resourceIconPreloads = [
...new Set(
workspaces.flatMap((workspace) =>
workspace.latest_build.resources.map(
(resource) => resource.icon || getResourceIconPath(resource.type),
),
),
),
].map((url) => (
<img key={url} alt="" aria-hidden css={{ ...visuallyHidden }} src={url} />
));

return (
<ConfirmDialog
type="delete"
Expand All @@ -106,9 +79,10 @@ export const BatchDeleteConfirmation: FC<BatchDeleteConfirmationProps> = ({
{stage === "consequences" && <Consequences />}
{stage === "tasks" && <Tasks tasks={checkedTasks} />}
{stage === "resources" && (
<Resources tasks={checkedTasks} workspaces={workspaces} />
<Resources tasks={checkedTasks} workspaceCount={workspaceCount} />
)}
{resourceIconPreloads}
{/* Preload ServerIcon to prevent flicker on stage 3 */}
<ServerIcon className="sr-only" aria-hidden />
</>
}
/>
Expand All @@ -121,27 +95,24 @@ interface TasksStageProps {

interface ResourcesStageProps {
tasks: readonly Task[];
workspaces: readonly Workspace[];
workspaceCount: number;
}

const Consequences: FC = () => {
return (
<>
<p>Deleting tasks is irreversible!</p>
<ul css={styles.consequences}>
<ul className="flex flex-col gap-2 pl-4 mb-0">
<li>
Tasks with associated workspaces will have those workspaces deleted.
</li>
<li>Terraform resources in task workspaces will be destroyed.</li>
<li>Any data stored in task workspaces will be permanently deleted.</li>
</ul>
</>
);
};

const Tasks: FC<TasksStageProps> = ({ tasks }) => {
const theme = useTheme();

const mostRecent = tasks.reduce(
(latestSoFar, against) => {
if (!latestSoFar) {
Expand All @@ -161,156 +132,66 @@ const Tasks: FC<TasksStageProps> = ({ tasks }) => {

return (
<>
<ul css={styles.tasksList}>
<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} css={styles.task}>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
spacing={3}
>
<span
css={{
fontWeight: 500,
color: theme.experimental.l1.text,
maxWidth: 400,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{task.initial_prompt}
<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>

<Stack css={{ gap: 0, fontSize: 14 }} justifyContent="flex-end">
<Stack
direction="row"
alignItems="center"
justifyContent="flex-end"
spacing={1}
>
<span css={{ whiteSpace: "nowrap" }}>{task.owner_name}</span>
<PersonIcon />
</Stack>
<Stack
direction="row"
alignItems="center"
spacing={1}
justifyContent="flex-end"
>
<span css={{ whiteSpace: "nowrap" }}>
<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.created_at).fromNow()}
</span>
<ClockIcon className="size-icon-xs" />
</Stack>
</Stack>
</Stack>
</div>
</div>
</div>
</li>
))}
</ul>
<Stack
justifyContent="center"
direction="row"
wrap="wrap"
css={{ gap: "6px 20px", fontSize: 14 }}
>
<Stack direction="row" alignItems="center" spacing={1}>
<PersonIcon />
<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>
</Stack>
</div>
{mostRecent && (
<Stack direction="row" alignItems="center" spacing={1}>
<div className="flex items-center gap-2">
<ClockIcon className="size-icon-xs" />
<span>Last created {dayjs(mostRecent.created_at).fromNow()}</span>
</Stack>
</div>
)}
</Stack>
</div>
</>
);
};

const Resources: FC<ResourcesStageProps> = ({ tasks, workspaces }) => {
const resources: Record<string, { count: number; icon: string }> = {};
for (const workspace of workspaces) {
for (const resource of workspace.latest_build.resources) {
if (!resources[resource.type]) {
resources[resource.type] = {
count: 0,
icon: resource.icon || getResourceIconPath(resource.type),
};
}

resources[resource.type].count++;
}
}
const Resources: FC<ResourcesStageProps> = ({ tasks, workspaceCount }) => {
const taskCount = tasks.length;

return (
<Stack>
<div className="flex flex-col gap-4">
<p>
Deleting {tasks.length === 1 ? "this task" : "these tasks"} will also
Deleting {taskCount === 1 ? "this task" : "these tasks"} will also
permanently destroy&hellip;
</p>
<Stack
direction="row"
justifyContent="center"
wrap="wrap"
css={{ gap: "6px 20px", fontSize: 14 }}
>
<Stack direction="row" alignItems="center" spacing={1}>
<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>
{workspaces.length}{" "}
{workspaces.length === 1 ? "workspace" : "workspaces"}
{workspaceCount} {workspaceCount === 1 ? "workspace" : "workspaces"}
</span>
</Stack>
{Object.entries(resources).map(([type, summary]) => (
<Stack key={type} direction="row" alignItems="center" spacing={1}>
<ExternalImage
src={summary.icon}
width={styles.summaryIcon.width}
height={styles.summaryIcon.height}
/>
<span>
{summary.count} <code>{type}</code>
</span>
</Stack>
))}
</Stack>
</Stack>
</div>
</div>
</div>
);
};

const PersonIcon: FC = () => {
return <UserIcon className="size-icon-sm" css={{ margin: -1 }} />;
};

const styles = {
summaryIcon: { width: 16, height: 16 },

consequences: {
display: "flex",
flexDirection: "column",
gap: 8,
paddingLeft: 16,
marginBottom: 0,
},

tasksList: (theme) => ({
listStyleType: "none",
padding: 0,
border: `1px solid ${theme.palette.divider}`,
borderRadius: 8,
overflow: "hidden auto",
maxHeight: 184,
}),

task: (theme) => ({
padding: "8px 16px",
borderBottom: `1px solid ${theme.palette.divider}`,

"&:last-child": {
border: "none",
},
}),
} satisfies Record<string, Interpolation<Theme>>;
18 changes: 6 additions & 12 deletions site/src/pages/TasksPage/TasksPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { API } from "api/api";
import { templates } from "api/queries/templates";
import { workspaces } from "api/queries/workspaces";

import type { TasksFilter } from "api/typesGenerated";
import { Badge } from "components/Badge/Badge";
import { Button, type ButtonProps } from "components/Button/Button";
Expand Down Expand Up @@ -91,16 +91,10 @@ const TasksPage: FC = () => {
const { entitlements } = useDashboard();
const canCheckTasks = entitlements.features.task_batch_actions.enabled;

// Workspaces are fetched lazily only when dialog opens because tasks
// don't always have associated workspaces and we need full resource data
// for the confirmation dialog.
const workspaceIds = checkedTasks
.map((t) => t.workspace_id)
.filter((id): id is string => id !== null);
const workspacesQuery = useQuery({
...workspaces({ q: `id:${workspaceIds.join(",")}` }),
enabled: workspaceIds.length > 0 && isDeleteDialogOpen,
});
// Count workspaces that will be deleted with the selected tasks.
const workspaceCount = checkedTasks.filter(
(t) => t.workspace_id !== null,
).length;

// Clear selections when switching tabs/filters to avoid confusing UX
// where selected tasks might no longer be visible.
Expand Down Expand Up @@ -232,7 +226,7 @@ const TasksPage: FC = () => {
<BatchDeleteConfirmation
open={isDeleteDialogOpen}
checkedTasks={checkedTasks}
workspaces={workspacesQuery.data?.workspaces ?? []}
workspaceCount={workspaceCount}
isLoading={batchActions.isProcessing}
onClose={() => setIsDeleteDialogOpen(false)}
onConfirm={handleConfirmDelete}
Expand Down