mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): confirm before batch stopping workspaces (#27631)
> 🤖 This PR was written by Coder Agents on behalf of Jake Howell.
## What
Bulk stopping workspaces from the workspaces table currently fires
immediately with no confirmation, whereas the single-row **Stop** action
and the bulk **Delete** / **Update** actions all show a dialog first.
This adds a confirmation dialog to the bulk **Stop** action so it
matches the rest.
## How
- Added `BatchStopConfirmation`, a small `ConfirmDialog` wrapper
mirroring the wording of the single-workspace stop confirmation but
pluralized for the selected count.
- Wired it into `WorkspacesPage`: `onBatchStopTransition` now opens the
dialog (`setActiveBatchAction("stop")`) instead of calling
`batchActions.stop(...)` directly, and the actual stop runs on confirm.
Added `"stop"` to the `BatchAction` union.
No change to the underlying `batchActions.stop` behavior (still only
stops `running` workspaces).
<details>
<summary>Reviewer notes</summary>
Before: `onBatchStopTransition={() =>
batchActions.stop(checkedWorkspaces)}` — no confirmation.
After: opens `BatchStopConfirmation`; confirm calls
`batchActions.stop(checkedWorkspaces)` then clears the active action,
consistent with how `BatchDeleteConfirmation` and `BatchUpdateModalForm`
are handled.
</details>
---
_Opened as a draft. Disclosure: this PR was generated by Coder Agents on
behalf of @jakehwll._
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
import type { Workspace } from "#/api/typesGenerated";
|
||||||
|
import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog";
|
||||||
|
|
||||||
|
type BatchStopConfirmationProps = {
|
||||||
|
workspacesToStop: readonly Workspace[];
|
||||||
|
open: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BatchStopConfirmation: FC<BatchStopConfirmationProps> = ({
|
||||||
|
workspacesToStop,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
isLoading,
|
||||||
|
}) => {
|
||||||
|
const workspaceCount = `${workspacesToStop.length} ${
|
||||||
|
workspacesToStop.length === 1 ? "workspace" : "workspaces"
|
||||||
|
}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmDialog
|
||||||
|
type="delete"
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={`Stop ${workspaceCount}`}
|
||||||
|
confirmLoading={isLoading}
|
||||||
|
confirmText="Stop"
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
description={`Are you sure you want to stop ${workspaceCount}? This will terminate all running processes and disconnect any active sessions.`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -433,6 +433,9 @@ export const StopsOnlySelectedWorkspaces: Story = {
|
|||||||
await openBulkActions(canvas, user);
|
await openBulkActions(canvas, user);
|
||||||
await user.click(await body.findByRole("menuitem", { name: /stop/i }));
|
await user.click(await body.findByRole("menuitem", { name: /stop/i }));
|
||||||
|
|
||||||
|
const dialog = await body.findByRole("dialog");
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: "Stop" }));
|
||||||
|
|
||||||
await waitFor(() => expect(API.stopWorkspace).toHaveBeenCalledTimes(2));
|
await waitFor(() => expect(API.stopWorkspace).toHaveBeenCalledTimes(2));
|
||||||
expect(API.stopWorkspace).toHaveBeenCalledWith("1");
|
expect(API.stopWorkspace).toHaveBeenCalledWith("1");
|
||||||
expect(API.stopWorkspace).toHaveBeenCalledWith("2");
|
expect(API.stopWorkspace).toHaveBeenCalledWith("2");
|
||||||
@@ -532,6 +535,9 @@ export const StopIgnoresAlreadyStoppedWorkspaces: Story = {
|
|||||||
expect(stopItem).not.toHaveAttribute("data-disabled");
|
expect(stopItem).not.toHaveAttribute("data-disabled");
|
||||||
await user.click(stopItem);
|
await user.click(stopItem);
|
||||||
|
|
||||||
|
const dialog = await body.findByRole("dialog");
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: "Stop" }));
|
||||||
|
|
||||||
await waitFor(() => expect(API.stopWorkspace).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(API.stopWorkspace).toHaveBeenCalledTimes(1));
|
||||||
expect(API.stopWorkspace).toHaveBeenCalledWith("2");
|
expect(API.stopWorkspace).toHaveBeenCalledWith("2");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { useOrganizationsFilterMenu } from "#/modules/tableFiltering/options";
|
|||||||
import { ACTIVE_BUILD_STATUSES } from "#/modules/workspaces/status";
|
import { ACTIVE_BUILD_STATUSES } from "#/modules/workspaces/status";
|
||||||
import { pageTitle } from "#/utils/page";
|
import { pageTitle } from "#/utils/page";
|
||||||
import { BatchDeleteConfirmation } from "./BatchDeleteConfirmation";
|
import { BatchDeleteConfirmation } from "./BatchDeleteConfirmation";
|
||||||
|
import { BatchStopConfirmation } from "./BatchStopConfirmation";
|
||||||
import { BatchUpdateModalForm } from "./BatchUpdateModalForm";
|
import { BatchUpdateModalForm } from "./BatchUpdateModalForm";
|
||||||
import { useBatchActions } from "./batchActions";
|
import { useBatchActions } from "./batchActions";
|
||||||
import { useStatusFilterMenu, useTemplateFilterMenu } from "./filter/menus";
|
import { useStatusFilterMenu, useTemplateFilterMenu } from "./filter/menus";
|
||||||
@@ -52,7 +53,7 @@ function useSafeSearchParams() {
|
|||||||
>;
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type BatchAction = "delete" | "update";
|
type BatchAction = "delete" | "stop" | "update";
|
||||||
|
|
||||||
const WorkspacesPage: FC = () => {
|
const WorkspacesPage: FC = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -163,6 +164,13 @@ const WorkspacesPage: FC = () => {
|
|||||||
const checkedWorkspaces =
|
const checkedWorkspaces =
|
||||||
data?.workspaces.filter((w) => checkedWorkspaceIds.has(w.id)) ?? [];
|
data?.workspaces.filter((w) => checkedWorkspaceIds.has(w.id)) ?? [];
|
||||||
|
|
||||||
|
// Bulk stop only affects running workspaces, so the confirmation dialog and
|
||||||
|
// the mutation should both operate on that subset to avoid over-reporting
|
||||||
|
// how many workspaces will actually be stopped.
|
||||||
|
const workspacesToStop = checkedWorkspaces.filter(
|
||||||
|
(w) => w.latest_build.status === "running",
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<title>{pageTitle("Workspaces")}</title>
|
<title>{pageTitle("Workspaces")}</title>
|
||||||
@@ -197,7 +205,7 @@ const WorkspacesPage: FC = () => {
|
|||||||
isRunningBatchAction={batchActions.isProcessing}
|
isRunningBatchAction={batchActions.isProcessing}
|
||||||
onBatchDeleteTransition={() => setActiveBatchAction("delete")}
|
onBatchDeleteTransition={() => setActiveBatchAction("delete")}
|
||||||
onBatchStartTransition={() => batchActions.start(checkedWorkspaces)}
|
onBatchStartTransition={() => batchActions.start(checkedWorkspaces)}
|
||||||
onBatchStopTransition={() => batchActions.stop(checkedWorkspaces)}
|
onBatchStopTransition={() => setActiveBatchAction("stop")}
|
||||||
onBatchUpdateTransition={() => {
|
onBatchUpdateTransition={() => {
|
||||||
// Just because batch-updating can be really dangerous
|
// Just because batch-updating can be really dangerous
|
||||||
// action for running workspaces, we're going to invalidate
|
// action for running workspaces, we're going to invalidate
|
||||||
@@ -241,6 +249,17 @@ const WorkspacesPage: FC = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<BatchStopConfirmation
|
||||||
|
isLoading={batchActions.isProcessing}
|
||||||
|
workspacesToStop={workspacesToStop}
|
||||||
|
open={activeBatchAction === "stop"}
|
||||||
|
onClose={() => setActiveBatchAction(undefined)}
|
||||||
|
onConfirm={async () => {
|
||||||
|
await batchActions.stop(workspacesToStop);
|
||||||
|
setActiveBatchAction(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<BatchUpdateModalForm
|
<BatchUpdateModalForm
|
||||||
open={activeBatchAction === "update"}
|
open={activeBatchAction === "update"}
|
||||||
workspacesToUpdate={checkedWorkspaces}
|
workspacesToUpdate={checkedWorkspaces}
|
||||||
|
|||||||
Reference in New Issue
Block a user