mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix: during workspace bulk start/stop, skip workspaces already in target state (#27108)
Previously, bulk start required every selected workspace to be stopped, and bulk stop required every selected workspace to be running. Mixed selections disabled both buttons entirely. - Change the disabled checks on bulk start/stop from `every()` to `some()` so the buttons are enabled when at least one workspace is eligible. - Filter workspaces by status in the mutation functions so only eligible workspaces are sent to the API, matching the pattern used by other batch mutations (update, favorite, unfavorite). - Update docs to reflect the new behavior. > [!NOTE] > Generated by Coder Agents. [View session](https://coder.com/). <details> <summary>Implementation plan</summary> ## Problem When an admin selects multiple workspaces and opens the "Bulk actions" dropdown, the **Start** menu item is disabled unless *every* selected workspace has `latest_build.status === "stopped"`. If even one workspace is already running (or in any other non-stopped state), the Start button is grayed out and unusable. Same issue applies to **Stop**. ## Changes ### 1. Relax disabled condition (`WorkspacesPageView.tsx`) Changed `every()` to `some()` for both Start and Stop dropdown items. The buttons are now enabled when at least one selected workspace is in the target state. ### 2. Filter in mutations (`batchActions.ts`) Added `.filter()` before `.map()` in both `startAllMutation` and `stopAllMutation` so only eligible workspaces hit the API. This matches the existing pattern in `updateAllMutation`, `favoriteAllMutation`, and `unfavoriteAllMutation`. ### 3. Update documentation (`docs/user-guides/workspace-management.md`) Replaced "can only be applied to a set of workspaces which are all in the same state" with "apply to eligible workspaces in the selection, skipping workspaces that are already in the target state." ## Testing Four new Storybook stories: | Story | What it tests | |-------|---------------| | `StartIgnoresAlreadyRunningWorkspaces` | Mixed selection; only stopped workspaces get `startWorkspace` calls | | `StopIgnoresAlreadyStoppedWorkspaces` | Mixed selection; only running workspaces get `stopWorkspace` calls | | `StartDisabledWhenNoWorkspacesAreStartable` | All running; Start button is disabled | | `StopDisabledWhenNoWorkspacesAreStoppable` | All stopped; Stop button is disabled | </details>
This commit is contained in:
@@ -107,8 +107,10 @@ Admins may apply bulk operations (update, delete, start, stop) in the
|
||||
checkboxes on the left, then use the top-right **Actions** dropdown to apply the
|
||||
operation.
|
||||
|
||||
The start and stop operations can only be applied to a set of workspaces which
|
||||
are all in the same state. For update and delete, the user will be prompted for
|
||||
The start and stop operations can be applied even when the selected workspaces
|
||||
are not all in the same state. Bulk start will only apply to selected workspaces
|
||||
that are currently stopped, and bulk stop will only apply to selected workspaces
|
||||
that are currently running. For update and delete, the user will be prompted for
|
||||
confirmation before any action is taken.
|
||||
|
||||

|
||||
|
||||
@@ -462,6 +462,12 @@ const stoppedWorkspaces: Workspace[] = [
|
||||
{ ...MockStoppedWorkspace, id: "3" },
|
||||
];
|
||||
|
||||
const mixedStateWorkspaces: Workspace[] = [
|
||||
{ ...MockStoppedWorkspace, id: "1" },
|
||||
{ ...MockWorkspace, id: "2" },
|
||||
{ ...MockStoppedWorkspace, id: "3" },
|
||||
];
|
||||
|
||||
export const StartsOnlySelectedWorkspaces: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getWorkspaces").mockResolvedValue({
|
||||
@@ -491,6 +497,103 @@ export const StartsOnlySelectedWorkspaces: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const StartIgnoresAlreadyRunningWorkspaces: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getWorkspaces").mockResolvedValue({
|
||||
workspaces: mixedStateWorkspaces,
|
||||
count: mixedStateWorkspaces.length,
|
||||
});
|
||||
spyOn(API, "startWorkspace").mockResolvedValue(MockWorkspaceBuild);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const body = within(document.body);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await selectWorkspaces(canvas, user, ["1", "2", "3"]);
|
||||
await openBulkActions(canvas, user);
|
||||
|
||||
const startItem = await body.findByRole("menuitem", { name: /start/i });
|
||||
expect(startItem).not.toHaveAttribute("data-disabled");
|
||||
await user.click(startItem);
|
||||
|
||||
await waitFor(() => expect(API.startWorkspace).toHaveBeenCalledTimes(2));
|
||||
expect(API.startWorkspace).toHaveBeenCalledWith(
|
||||
"1",
|
||||
MockStoppedWorkspace.latest_build.template_version_id,
|
||||
);
|
||||
expect(API.startWorkspace).toHaveBeenCalledWith(
|
||||
"3",
|
||||
MockStoppedWorkspace.latest_build.template_version_id,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const StopIgnoresAlreadyStoppedWorkspaces: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getWorkspaces").mockResolvedValue({
|
||||
workspaces: mixedStateWorkspaces,
|
||||
count: mixedStateWorkspaces.length,
|
||||
});
|
||||
spyOn(API, "stopWorkspace").mockResolvedValue(MockWorkspaceBuild);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const body = within(document.body);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await selectWorkspaces(canvas, user, ["1", "2", "3"]);
|
||||
await openBulkActions(canvas, user);
|
||||
|
||||
const stopItem = await body.findByRole("menuitem", { name: /stop/i });
|
||||
expect(stopItem).not.toHaveAttribute("data-disabled");
|
||||
await user.click(stopItem);
|
||||
|
||||
await waitFor(() => expect(API.stopWorkspace).toHaveBeenCalledTimes(1));
|
||||
expect(API.stopWorkspace).toHaveBeenCalledWith("2");
|
||||
},
|
||||
};
|
||||
|
||||
export const StartDisabledWhenNoWorkspacesAreStartable: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getWorkspaces").mockResolvedValue({
|
||||
workspaces: runningWorkspaces,
|
||||
count: runningWorkspaces.length,
|
||||
});
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const body = within(document.body);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await selectWorkspaces(canvas, user, ["1", "2", "3"]);
|
||||
await openBulkActions(canvas, user);
|
||||
|
||||
const startItem = await body.findByRole("menuitem", { name: /start/i });
|
||||
expect(startItem).toHaveAttribute("data-disabled");
|
||||
},
|
||||
};
|
||||
|
||||
export const StopDisabledWhenNoWorkspacesAreStoppable: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getWorkspaces").mockResolvedValue({
|
||||
workspaces: stoppedWorkspaces,
|
||||
count: stoppedWorkspaces.length,
|
||||
});
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const body = within(document.body);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await selectWorkspaces(canvas, user, ["1", "2", "3"]);
|
||||
await openBulkActions(canvas, user);
|
||||
|
||||
const stopItem = await body.findByRole("menuitem", { name: /stop/i });
|
||||
expect(stopItem).toHaveAttribute("data-disabled");
|
||||
},
|
||||
};
|
||||
|
||||
const appHealthStatuses: [WorkspaceAppHealth, boolean][] = [
|
||||
["healthy", true],
|
||||
["disabled", true],
|
||||
|
||||
@@ -145,7 +145,7 @@ export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
disabled={
|
||||
!checkedWorkspaces?.every(
|
||||
!checkedWorkspaces?.some(
|
||||
(w) =>
|
||||
w.latest_build.status === "stopped" &&
|
||||
!mustUpdateWorkspace(w, canChangeVersions),
|
||||
@@ -157,7 +157,7 @@ export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={
|
||||
!checkedWorkspaces?.every(
|
||||
!checkedWorkspaces?.some(
|
||||
(w) => w.latest_build.status === "running",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,9 +33,11 @@ export function useBatchActions(
|
||||
const startAllMutation = useMutation({
|
||||
mutationFn: (workspaces: readonly Workspace[]) => {
|
||||
return Promise.all(
|
||||
workspaces.map((w) =>
|
||||
API.startWorkspace(w.id, w.latest_build.template_version_id),
|
||||
),
|
||||
workspaces
|
||||
.filter((w) => w.latest_build.status === "stopped")
|
||||
.map((w) =>
|
||||
API.startWorkspace(w.id, w.latest_build.template_version_id),
|
||||
),
|
||||
);
|
||||
},
|
||||
onSuccess,
|
||||
@@ -48,7 +50,11 @@ export function useBatchActions(
|
||||
|
||||
const stopAllMutation = useMutation({
|
||||
mutationFn: (workspaces: readonly Workspace[]) => {
|
||||
return Promise.all(workspaces.map((w) => API.stopWorkspace(w.id)));
|
||||
return Promise.all(
|
||||
workspaces
|
||||
.filter((w) => w.latest_build.status === "running")
|
||||
.map((w) => API.stopWorkspace(w.id)),
|
||||
);
|
||||
},
|
||||
onSuccess,
|
||||
onError: (error) => {
|
||||
|
||||
Reference in New Issue
Block a user