mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
feat(site): add pause/resume action buttons to tasks table (#21728)
Add the ability to pause and resume tasks directly from the Tasks table, allowing users to manage workspace resources without navigating to individual task pages.
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, userEvent, within } from "storybook/test";
|
||||
import { TaskActionButton } from "./TaskActionButton";
|
||||
|
||||
const meta: Meta<typeof TaskActionButton> = {
|
||||
title: "pages/TasksPage/TaskActionButton",
|
||||
component: TaskActionButton,
|
||||
args: {
|
||||
onClick: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof TaskActionButton>;
|
||||
|
||||
export const Pause: Story = {
|
||||
args: {
|
||||
action: "pause",
|
||||
},
|
||||
};
|
||||
|
||||
export const Resume: Story = {
|
||||
args: {
|
||||
action: "resume",
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
action: "pause",
|
||||
loading: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
action: "pause",
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const ClickHandler: Story = {
|
||||
args: {
|
||||
action: "pause",
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const button = canvas.getByRole("button", { name: /pause task/i });
|
||||
await userEvent.click(button);
|
||||
expect(args.onClick).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Button } from "components/Button/Button";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import { PauseIcon, PlayIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
|
||||
type TaskActionButtonProps = {
|
||||
action: "pause" | "resume";
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const actionConfig = {
|
||||
pause: {
|
||||
icon: PauseIcon,
|
||||
label: "Pause task",
|
||||
tooltip: "Pause the task to save resources. You can resume later.",
|
||||
},
|
||||
resume: {
|
||||
icon: PlayIcon,
|
||||
label: "Resume task",
|
||||
tooltip: "Resuming takes time while the workspace starts.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const TaskActionButton: FC<TaskActionButtonProps> = ({
|
||||
action,
|
||||
disabled,
|
||||
loading,
|
||||
onClick,
|
||||
}) => {
|
||||
const config = actionConfig[action];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon-lg"
|
||||
variant="outline"
|
||||
disabled={disabled || loading}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
<Spinner loading={loading}>
|
||||
<Icon aria-hidden="true" />
|
||||
</Spinner>
|
||||
<span className="sr-only">{config.label}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{config.tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
@@ -2,9 +2,11 @@ import {
|
||||
MockDisplayNameTasks,
|
||||
MockInitializingTasks,
|
||||
MockSystemNotificationTemplates,
|
||||
MockTask,
|
||||
MockTasks,
|
||||
MockTemplate,
|
||||
MockUserOwner,
|
||||
MockWorkspaceBuildStop,
|
||||
mockApiError,
|
||||
} from "testHelpers/entities";
|
||||
import {
|
||||
@@ -288,6 +290,129 @@ export const InitializingTasks: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const AllTaskStatuses: Story = {
|
||||
parameters: {
|
||||
queries: [
|
||||
{
|
||||
key: ["tasks", { owner: MockUserOwner.username }],
|
||||
data: [
|
||||
{
|
||||
...MockTask,
|
||||
id: "active-task",
|
||||
display_name: "Active Task",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
...MockTask,
|
||||
id: "initializing-task",
|
||||
display_name: "Initializing Task",
|
||||
status: "initializing",
|
||||
},
|
||||
{
|
||||
...MockTask,
|
||||
id: "pending-task",
|
||||
display_name: "Pending Task",
|
||||
status: "pending",
|
||||
},
|
||||
{
|
||||
...MockTask,
|
||||
id: "paused-task",
|
||||
display_name: "Paused Task",
|
||||
status: "paused",
|
||||
},
|
||||
{
|
||||
...MockTask,
|
||||
id: "error-task",
|
||||
display_name: "Error Task",
|
||||
status: "error",
|
||||
},
|
||||
{
|
||||
...MockTask,
|
||||
id: "unknown-task",
|
||||
display_name: "Unknown Task",
|
||||
status: "unknown",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: getTemplatesQueryKey({ q: "has-ai-task:true" }),
|
||||
data: [MockTemplate],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const PauseTask: Story = {
|
||||
parameters: {
|
||||
queries: [
|
||||
{
|
||||
key: ["tasks", { owner: MockUserOwner.username }],
|
||||
data: [{ ...MockTask, status: "active" }],
|
||||
},
|
||||
{
|
||||
key: getTemplatesQueryKey({ q: "has-ai-task:true" }),
|
||||
data: [MockTemplate],
|
||||
},
|
||||
],
|
||||
},
|
||||
beforeEach: () => {
|
||||
// Mock APIs for refetch after mutation invalidates queries
|
||||
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
||||
spyOn(API, "getTasks").mockResolvedValue([
|
||||
{ ...MockTask, status: "active" },
|
||||
]);
|
||||
spyOn(API, "stopWorkspace").mockResolvedValue(MockWorkspaceBuildStop);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const pauseButton = await canvas.findByRole("button", {
|
||||
name: /pause task/i,
|
||||
});
|
||||
await userEvent.click(pauseButton);
|
||||
await waitFor(() => {
|
||||
expect(API.stopWorkspace).toHaveBeenCalledWith(MockTask.workspace_id);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const ResumeTask: Story = {
|
||||
parameters: {
|
||||
queries: [
|
||||
{
|
||||
key: ["tasks", { owner: MockUserOwner.username }],
|
||||
data: [{ ...MockTask, status: "paused" }],
|
||||
},
|
||||
{
|
||||
key: getTemplatesQueryKey({ q: "has-ai-task:true" }),
|
||||
data: [MockTemplate],
|
||||
},
|
||||
],
|
||||
},
|
||||
beforeEach: () => {
|
||||
// Mock APIs for refetch after mutation invalidates queries
|
||||
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
||||
spyOn(API, "getTasks").mockResolvedValue([
|
||||
{ ...MockTask, status: "paused" },
|
||||
]);
|
||||
spyOn(API, "startWorkspace").mockResolvedValue(MockWorkspaceBuildStop);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const resumeButton = await canvas.findByRole("button", {
|
||||
name: /resume task/i,
|
||||
});
|
||||
await userEvent.click(resumeButton);
|
||||
await waitFor(() => {
|
||||
expect(API.startWorkspace).toHaveBeenCalledWith(
|
||||
MockTask.workspace_id,
|
||||
MockTask.template_version_id,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const BatchActionsEnabled: Story = {
|
||||
parameters: {
|
||||
features: ["task_batch_actions"],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { API } from "api/api";
|
||||
import { getErrorDetail, getErrorMessage } from "api/errors";
|
||||
import type { Task } from "api/typesGenerated";
|
||||
import type { Task, TaskStatus as TaskStatusType } from "api/typesGenerated";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { AvatarData } from "components/Avatar/AvatarData";
|
||||
import { AvatarDataSkeleton } from "components/Avatar/AvatarDataSkeleton";
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "components/DropdownMenu/DropdownMenu";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
import { Skeleton } from "components/Skeleton/Skeleton";
|
||||
import {
|
||||
Table,
|
||||
@@ -29,9 +31,10 @@ import { EllipsisVertical, RotateCcwIcon, TrashIcon } from "lucide-react";
|
||||
import { TaskDeleteDialog } from "modules/tasks/TaskDeleteDialog/TaskDeleteDialog";
|
||||
import { TaskStatus } from "modules/tasks/TaskStatus/TaskStatus";
|
||||
import { type FC, type ReactNode, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "react-query";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import { relativeTime } from "utils/time";
|
||||
import { TaskActionButton } from "./TaskActionButton";
|
||||
|
||||
type TasksTableProps = {
|
||||
tasks: readonly Task[] | undefined;
|
||||
@@ -170,6 +173,16 @@ const TasksEmpty: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const pauseStatuses: TaskStatusType[] = [
|
||||
"active",
|
||||
"initializing",
|
||||
"pending",
|
||||
"error",
|
||||
"unknown",
|
||||
];
|
||||
const pauseDisabledStatuses: TaskStatusType[] = ["pending", "initializing"];
|
||||
const resumeStatuses: TaskStatusType[] = ["paused", "error", "unknown"];
|
||||
|
||||
type TaskRowProps = {
|
||||
task: Task;
|
||||
checked: boolean;
|
||||
@@ -186,6 +199,46 @@ const TaskRow: FC<TaskRowProps> = ({
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const templateDisplayName = task.template_display_name ?? task.template_name;
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const showPause = pauseStatuses.includes(task.status);
|
||||
const pauseDisabled = pauseDisabledStatuses.includes(task.status);
|
||||
const showResume = resumeStatuses.includes(task.status);
|
||||
|
||||
const pauseMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!task.workspace_id) {
|
||||
throw new Error("Task has no workspace");
|
||||
}
|
||||
return API.stopWorkspace(task.workspace_id);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
displayError(getErrorMessage(error, "Failed to pause task."));
|
||||
},
|
||||
});
|
||||
|
||||
const resumeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!task.workspace_id) {
|
||||
throw new Error("Task has no workspace");
|
||||
}
|
||||
return API.startWorkspace(
|
||||
task.workspace_id,
|
||||
task.template_version_id,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
displayError(getErrorMessage(error, "Failed to resume task."));
|
||||
},
|
||||
});
|
||||
|
||||
const taskPageLink = `/tasks/${task.owner_name}/${task.id}`;
|
||||
// Discard role, breaks Chromatic.
|
||||
@@ -252,30 +305,47 @@ const TaskRow: FC<TaskRowProps> = ({
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon-lg"
|
||||
variant="subtle"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVertical aria-hidden="true" />
|
||||
<span className="sr-only">Show task actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
Delete…
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{showPause && (
|
||||
<TaskActionButton
|
||||
action="pause"
|
||||
disabled={pauseDisabled}
|
||||
loading={pauseMutation.isPending}
|
||||
onClick={pauseMutation.mutate}
|
||||
/>
|
||||
)}
|
||||
{showResume && (
|
||||
<TaskActionButton
|
||||
action="resume"
|
||||
loading={resumeMutation.isPending}
|
||||
onClick={resumeMutation.mutate}
|
||||
/>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon-lg"
|
||||
variant="subtle"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVertical aria-hidden="true" />
|
||||
<span className="sr-only">Show task actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
Delete…
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user