fix(site): revamp UI for batch-updating workspaces (#18895)

Closes https://github.com/coder/coder/issues/18879
Builds on https://github.com/coder/coder/pull/18895

## Changes made
- Deleted `BatchUpdateConfirmation` component, replacing it with
`BatchUpdateModalForm`
- Added stories for the new component, trying to capture every variant I
could think of

## Screenshots
<img width="840" height="1059" alt="image"
src="https://github.com/user-attachments/assets/0fdac28e-19e8-4a14-a20a-fc1fd3758c81"
/>


## Notes
- There's too many problems to list, but look at the issue to see all
the problems we had with the old implementation
- It's definitely helpful to look at the stories to see all the things
the component is meant to cover
This commit is contained in:
Michael Smith
2025-09-17 20:46:38 -04:00
committed by GitHub
parent 759746cbf1
commit 8a6852f095
7 changed files with 991 additions and 671 deletions
@@ -1,86 +0,0 @@
import { chromatic } from "testHelpers/chromatic";
import {
MockDormantOutdatedWorkspace,
MockOutdatedWorkspace,
MockRunningOutdatedWorkspace,
MockTemplateVersion,
MockUserMember,
MockWorkspace,
} from "testHelpers/entities";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { Workspace } from "api/typesGenerated";
import { useQueryClient } from "react-query";
import { action } from "storybook/actions";
import {
BatchUpdateConfirmation,
type Update,
} from "./BatchUpdateConfirmation";
const workspaces: Workspace[] = [
{ ...MockRunningOutdatedWorkspace, id: "1" },
{ ...MockDormantOutdatedWorkspace, id: "2" },
{ ...MockOutdatedWorkspace, id: "3" },
{ ...MockOutdatedWorkspace, id: "4" },
{ ...MockWorkspace, id: "5" },
{
...MockRunningOutdatedWorkspace,
id: "6",
owner_id: MockUserMember.id,
owner_name: MockUserMember.username,
},
];
function getPopulatedUpdates(): Map<string, Update> {
type MutableUpdate = Omit<Update, "affected_workspaces"> & {
affected_workspaces: Workspace[];
};
const updates = new Map<string, MutableUpdate>();
for (const it of workspaces) {
const versionId = it.template_active_version_id;
const version = updates.get(versionId);
if (version) {
version.affected_workspaces.push(it);
continue;
}
updates.set(versionId, {
...MockTemplateVersion,
template_display_name: it.template_display_name,
affected_workspaces: [it],
});
}
return updates as Map<string, Update>;
}
const updates = getPopulatedUpdates();
const meta: Meta<typeof BatchUpdateConfirmation> = {
title: "pages/WorkspacesPage/BatchUpdateConfirmation",
parameters: { chromatic },
component: BatchUpdateConfirmation,
decorators: [
(Story) => {
const queryClient = useQueryClient();
for (const [id, it] of updates) {
queryClient.setQueryData(["batchUpdate", id], it);
}
return <Story />;
},
],
args: {
onClose: action("onClose"),
onConfirm: action("onConfirm"),
open: true,
checkedWorkspaces: workspaces,
},
};
export default meta;
type Story = StoryObj<typeof BatchUpdateConfirmation>;
const Example: Story = {};
export { Example as BatchUpdateConfirmation };
@@ -1,490 +0,0 @@
import type { Interpolation, Theme } from "@emotion/react";
import { API } from "api/api";
import type { TemplateVersion, Workspace } from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { Loader } from "components/Loader/Loader";
import { MemoizedInlineMarkdown } from "components/Markdown/Markdown";
import { Stack } from "components/Stack/Stack";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import {
ClockIcon,
MonitorDownIcon,
SettingsIcon,
UserIcon,
} from "lucide-react";
import { type FC, type ReactNode, useEffect, useMemo, useState } from "react";
import { useQueries } from "react-query";
dayjs.extend(relativeTime);
type BatchUpdateConfirmationProps = {
checkedWorkspaces: readonly Workspace[];
open: boolean;
isLoading: boolean;
onClose: () => void;
onConfirm: () => void;
};
export interface Update extends TemplateVersion {
template_display_name: string;
affected_workspaces: readonly Workspace[];
}
export const BatchUpdateConfirmation: FC<BatchUpdateConfirmationProps> = ({
checkedWorkspaces,
open,
onClose,
onConfirm,
isLoading,
}) => {
// Ignore workspaces with no pending update
const outdatedWorkspaces = useMemo(
() => checkedWorkspaces.filter((workspace) => workspace.outdated),
[checkedWorkspaces],
);
// Separate out dormant workspaces. You cannot update a dormant workspace without
// activate it, so notify the user that these selected workspaces will not be updated.
const [dormantWorkspaces, workspacesToUpdate] = useMemo(() => {
const dormantWorkspaces = [];
const workspacesToUpdate = [];
for (const it of outdatedWorkspaces) {
if (it.dormant_at) {
dormantWorkspaces.push(it);
} else {
workspacesToUpdate.push(it);
}
}
return [dormantWorkspaces, workspacesToUpdate];
}, [outdatedWorkspaces]);
// We need to know which workspaces are running, so we can provide more detailed
// warnings about them
const runningWorkspacesToUpdate = useMemo(
() =>
workspacesToUpdate.filter(
(workspace) => workspace.latest_build.status === "running",
),
[workspacesToUpdate],
);
// If there aren't any running _and_ outdated workspaces selected, we can skip
// the consequences page, since an update shouldn't have any consequences that
// the stop didn't already. If there are dormant workspaces but no running
// workspaces, start there instead.
const [stage, setStage] = useState<
"consequences" | "dormantWorkspaces" | "updates" | null
>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: consider refactoring
useEffect(() => {
if (runningWorkspacesToUpdate.length > 0) {
setStage("consequences");
} else if (dormantWorkspaces.length > 0) {
setStage("dormantWorkspaces");
} else {
setStage("updates");
}
}, [runningWorkspacesToUpdate, dormantWorkspaces, checkedWorkspaces, open]);
// Figure out which new versions everything will be updated to so that we can
// show update messages and such.
const newVersions = useMemo(() => {
type MutableUpdateInfo = {
id: string;
template_display_name: string;
affected_workspaces: Workspace[];
};
const newVersions = new Map<string, MutableUpdateInfo>();
for (const it of workspacesToUpdate) {
const versionId = it.template_active_version_id;
const version = newVersions.get(versionId);
if (version) {
version.affected_workspaces.push(it);
continue;
}
newVersions.set(versionId, {
id: versionId,
template_display_name: it.template_display_name,
affected_workspaces: [it],
});
}
type ReadonlyUpdateInfo = Readonly<MutableUpdateInfo> & {
affected_workspaces: readonly Workspace[];
};
return newVersions as Map<string, ReadonlyUpdateInfo>;
}, [workspacesToUpdate]);
// Not all of the information we want is included in the `Workspace` type, so we
// need to query all of the versions.
const results = useQueries({
queries: [...newVersions.values()].map((version) => ({
queryKey: ["batchUpdate", version.id],
queryFn: async () => ({
// ...but the query _also_ doesn't have everything we need, like the
// template display name!
...version,
...(await API.getTemplateVersion(version.id)),
}),
})),
});
const { data, error } = {
data: results.every((result) => result.isSuccess && result.data)
? results.map((result) => result.data!)
: undefined,
error: results.some((result) => result.error),
};
const onProceed = () => {
switch (stage) {
case "updates":
onConfirm();
break;
case "dormantWorkspaces":
setStage("updates");
break;
case "consequences":
setStage(
dormantWorkspaces.length > 0 ? "dormantWorkspaces" : "updates",
);
break;
}
};
const workspaceCount = `${workspacesToUpdate.length} ${
workspacesToUpdate.length === 1 ? "workspace" : "workspaces"
}`;
let confirmText: ReactNode = <>Review updates&hellip;</>;
if (stage === "updates") {
confirmText = <>Update {workspaceCount}</>;
}
return (
<ConfirmDialog
open={open}
onClose={onClose}
title={`Update ${workspaceCount}`}
hideCancel
confirmLoading={isLoading}
confirmText={confirmText}
onConfirm={onProceed}
description={
<>
{stage === "consequences" && (
<Consequences runningWorkspaces={runningWorkspacesToUpdate} />
)}
{stage === "dormantWorkspaces" && (
<DormantWorkspaces workspaces={dormantWorkspaces} />
)}
{stage === "updates" && (
<Updates
workspaces={workspacesToUpdate}
updates={data}
error={error}
/>
)}
</>
}
/>
);
};
interface ConsequencesProps {
runningWorkspaces: Workspace[];
}
const Consequences: FC<ConsequencesProps> = ({ runningWorkspaces }) => {
const workspaceCount = `${runningWorkspaces.length} ${
runningWorkspaces.length === 1 ? "running workspace" : "running workspaces"
}`;
const owners = new Set(runningWorkspaces.map((it) => it.owner_id)).size;
const ownerCount = `${owners} ${owners === 1 ? "owner" : "owners"}`;
return (
<>
<p>You are about to update {workspaceCount}.</p>
<ul css={styles.consequences}>
<li>
Updating will start workspaces on their latest template versions. This
can delete non-persistent data.
</li>
<li>
Anyone connected to a running workspace will be disconnected until the
update is complete.
</li>
<li>Any unsaved data will be lost.</li>
</ul>
<Stack
justifyContent="center"
direction="row"
wrap="wrap"
css={styles.summary}
>
<Stack direction="row" alignItems="center" spacing={1}>
<PersonIcon />
<span>{ownerCount}</span>
</Stack>
</Stack>
</>
);
};
interface DormantWorkspacesProps {
workspaces: Workspace[];
}
const DormantWorkspaces: FC<DormantWorkspacesProps> = ({ workspaces }) => {
const mostRecent = workspaces.reduce(
(latestSoFar, against) => {
if (!latestSoFar) {
return against;
}
return new Date(against.last_used_at).getTime() >
new Date(latestSoFar.last_used_at).getTime()
? against
: latestSoFar;
},
undefined as Workspace | undefined,
);
const owners = new Set(workspaces.map((it) => it.owner_id)).size;
const ownersCount = `${owners} ${owners === 1 ? "owner" : "owners"}`;
return (
<>
<p>
{workspaces.length === 1
? "This selected workspace is dormant, and must be activated before it can be updated."
: "These selected workspaces are dormant, and must be activated before they can be updated."}
</p>
<ul css={styles.workspacesList}>
{workspaces.map((workspace) => (
<li key={workspace.id} css={styles.workspace}>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
>
<span css={styles.name}>{workspace.name}</span>
<Stack css={{ gap: 0, fontSize: 14, width: 128 }}>
<Stack direction="row" alignItems="center" spacing={1}>
<PersonIcon />
<span
css={{ whiteSpace: "nowrap", textOverflow: "ellipsis" }}
>
{workspace.owner_name}
</span>
</Stack>
<Stack direction="row" alignItems="center" spacing={1}>
<ClockIcon className="size-icon-xs" />
<span
css={{ whiteSpace: "nowrap", textOverflow: "ellipsis" }}
>
{lastUsed(workspace.last_used_at)}
</span>
</Stack>
</Stack>
</Stack>
</li>
))}
</ul>
<Stack
justifyContent="center"
direction="row"
wrap="wrap"
css={styles.summary}
>
<Stack direction="row" alignItems="center" spacing={1}>
<PersonIcon />
<span>{ownersCount}</span>
</Stack>
{mostRecent && (
<Stack direction="row" alignItems="center" spacing={1}>
<ClockIcon className="size-icon-xs" />
<span>Last used {lastUsed(mostRecent.last_used_at)}</span>
</Stack>
)}
</Stack>
</>
);
};
interface UpdatesProps {
workspaces: Workspace[];
updates?: Update[];
error?: unknown;
}
const Updates: FC<UpdatesProps> = ({ workspaces, updates, error }) => {
const workspaceCount = `${workspaces.length} ${
workspaces.length === 1 ? "outdated workspace" : "outdated workspaces"
}`;
const updateCount =
updates &&
`${updates.length} ${
updates.length === 1 ? "new version" : "new versions"
}`;
return (
<>
<TemplateVersionMessages updates={updates} error={error} />
<Stack
justifyContent="center"
direction="row"
wrap="wrap"
css={styles.summary}
>
<Stack direction="row" alignItems="center" spacing={1}>
<MonitorDownIcon className="size-icon-sm" />
<span>{workspaceCount}</span>
</Stack>
{updateCount && (
<Stack direction="row" alignItems="center" spacing={1}>
<SettingsIcon className="size-icon-xs" />
<span>{updateCount}</span>
</Stack>
)}
</Stack>
</>
);
};
interface TemplateVersionMessagesProps {
error?: unknown;
updates?: Update[];
}
const TemplateVersionMessages: FC<TemplateVersionMessagesProps> = ({
error,
updates,
}) => {
if (error) {
return <ErrorAlert error={error} />;
}
if (!updates) {
return <Loader />;
}
return (
<ul css={styles.updatesList}>
{updates.map((update) => (
<li key={update.id} css={styles.workspace}>
<Stack spacing={0}>
<Stack spacing={0.5} direction="row" alignItems="center">
<span css={styles.name}>{update.template_display_name}</span>
<span css={styles.newVersion}>&rarr; {update.name}</span>
</Stack>
<MemoizedInlineMarkdown
allowedElements={["ol", "ul", "li"]}
css={styles.message}
>
{update.message ?? "No message"}
</MemoizedInlineMarkdown>
<UsedBy workspaces={update.affected_workspaces} />
</Stack>
</li>
))}
</ul>
);
};
interface UsedByProps {
workspaces: readonly Workspace[];
}
const UsedBy: FC<UsedByProps> = ({ workspaces }) => {
const workspaceNames = workspaces.map((it) => it.name);
return (
<p css={{ fontSize: 13, paddingTop: 6, lineHeight: 1.2 }}>
Used by {workspaceNames.slice(0, 2).join(", ")}{" "}
{workspaceNames.length > 2 && (
<span title={workspaceNames.slice(2).join(", ")}>
and {workspaceNames.length - 2} more
</span>
)}
</p>
);
};
const lastUsed = (time: string) => {
const now = dayjs();
const then = dayjs(time);
return then.isAfter(now.subtract(1, "hour")) ? "now" : then.fromNow();
};
const PersonIcon: FC = () => {
// Using the Lucide icon with appropriate size class
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,
},
workspacesList: (theme) => ({
listStyleType: "none",
padding: 0,
border: `1px solid ${theme.palette.divider}`,
borderRadius: 8,
overflow: "hidden auto",
maxHeight: 184,
}),
updatesList: (theme) => ({
listStyleType: "none",
padding: 0,
border: `1px solid ${theme.palette.divider}`,
borderRadius: 8,
overflow: "hidden auto",
maxHeight: 256,
}),
workspace: (theme) => ({
padding: "8px 16px",
borderBottom: `1px solid ${theme.palette.divider}`,
"&:last-child": {
border: "none",
},
}),
name: (theme) => ({
fontWeight: 500,
color: theme.experimental.l1.text,
}),
newVersion: (theme) => ({
fontSize: 13,
fontWeight: 500,
color: theme.roles.active.fill.solid,
}),
message: {
fontSize: 14,
},
summary: {
gap: "6px 20px",
fontSize: 14,
},
} satisfies Record<string, Interpolation<Theme>>;
@@ -0,0 +1,287 @@
import { MockTemplateVersion, MockWorkspace } from "testHelpers/entities";
import type { Meta, Parameters, StoryObj } from "@storybook/react-vite";
import { templateVersionRoot } from "api/queries/templates";
import type {
TemplateVersion,
Workspace,
WorkspaceBuild,
} from "api/typesGenerated";
import { useQueryClient } from "react-query";
import { action } from "storybook/internal/actions";
import { expect, screen, userEvent, within } from "storybook/test";
import { BatchUpdateModalForm } from "./BatchUpdateModalForm";
import { ACTIVE_BUILD_STATUSES } from "./WorkspacesPage";
type Writeable<T> = { -readonly [Key in keyof T]: T[Key] };
type MutableWorkspace = Writeable<Omit<Workspace, "latest_build">> & {
latest_build: Writeable<WorkspaceBuild>;
};
const meta: Meta<typeof BatchUpdateModalForm> = {
title: "pages/WorkspacesPage/BatchUpdateModalForm",
component: BatchUpdateModalForm,
args: {
open: true,
isProcessing: false,
onSubmit: action("All selected workspaces have been updated"),
onCancel: action("Update canceled"),
},
};
export default meta;
type Story = StoryObj<typeof meta>;
type QueryEntry = NonNullable<Parameters["queries"]>;
type PatchedDependencies = Readonly<{
workspaces: readonly Workspace[];
queries: QueryEntry;
}>;
function createPatchedDependencies(size: number): PatchedDependencies {
const workspaces: Workspace[] = [];
const queries: QueryEntry = [];
for (let i = 1; i <= size; i++) {
const patchedTemplateVersion: TemplateVersion = {
...MockTemplateVersion,
id: `${MockTemplateVersion.id}-${i}`,
name: `${MockTemplateVersion.name}-${i}`,
};
const patchedWorkspace: Workspace = {
...MockWorkspace,
outdated: true,
id: `${MockWorkspace.id}-${i}`,
template_active_version_id: patchedTemplateVersion.id,
name: `${MockWorkspace.name}-${i}`,
latest_build: {
...MockWorkspace.latest_build,
status: "stopped",
},
};
workspaces.push(patchedWorkspace);
queries.push({
key: [templateVersionRoot, patchedWorkspace.template_active_version_id],
data: patchedTemplateVersion,
});
}
return { workspaces, queries };
}
export const NoWorkspacesSelected: Story = {
args: {
workspacesToUpdate: [],
},
};
export const OnlyReadyToUpdate: Story = {
beforeEach: (ctx) => {
const { workspaces, queries } = createPatchedDependencies(3);
ctx.args = { ...ctx.args, workspacesToUpdate: workspaces };
ctx.parameters.queries = queries;
},
};
export const NoWorkspacesToUpdate: Story = {
beforeEach: (ctx) => {
const { workspaces, queries } = createPatchedDependencies(3);
const notOutdated = workspaces.map<Workspace>((ws) => {
return { ...ws, outdated: false };
});
ctx.args = { ...ctx.args, workspacesToUpdate: notOutdated };
ctx.parameters.queries = queries;
},
};
export const CurrentlyProcessing: Story = {
args: { isProcessing: true },
beforeEach: (ctx) => {
const { workspaces, queries } = createPatchedDependencies(3);
ctx.args = { ...ctx.args, workspacesToUpdate: workspaces };
ctx.parameters.queries = queries;
},
};
/**
* @todo 2025-07-15 - Need to figure out if there's a decent way to validate
* that the onCancel callback gets called when you press the "Close" button,
* without going into Jest+RTL.
*/
export const OnlyDormantWorkspaces: Story = {
beforeEach: (ctx) => {
const { workspaces, queries } = createPatchedDependencies(3);
for (const ws of workspaces) {
const writable = ws as MutableWorkspace;
writable.dormant_at = new Date().toISOString();
}
ctx.args = { ...ctx.args, workspacesToUpdate: workspaces };
ctx.parameters.queries = queries;
},
};
export const FetchError: Story = {
beforeEach: (ctx) => {
const { workspaces, queries } = createPatchedDependencies(3);
ctx.args = { ...ctx.args, workspacesToUpdate: workspaces };
ctx.parameters.queries = queries;
},
decorators: [
(Story, ctx) => {
const queryClient = useQueryClient();
queryClient.clear();
for (const ws of ctx.args.workspacesToUpdate) {
void queryClient.fetchQuery({
queryKey: [templateVersionRoot, ws.template_active_version_id],
queryFn: () => {
throw new Error("Workspaces? Sir, this is a Wendy's.");
},
});
}
return <Story />;
},
],
};
export const TransitioningWorkspaces: Story = {
args: { isProcessing: true },
beforeEach: (ctx) => {
const { workspaces, queries } = createPatchedDependencies(
// Adding one extra so that we still have a stopped workspace at the
// end of the list
1 + ACTIVE_BUILD_STATUSES.length,
);
for (const [i, status] of ACTIVE_BUILD_STATUSES.entries()) {
const mutable = workspaces[i] as MutableWorkspace;
mutable.latest_build.status = status;
}
ctx.args = { ...ctx.args, workspacesToUpdate: workspaces };
ctx.parameters.queries = queries;
},
};
export const RunningWorkspaces: Story = {
beforeEach: (ctx) => {
const { workspaces, queries } = createPatchedDependencies(3);
const allRunning = workspaces.map<Workspace>((ws) => {
return {
...ws,
latest_build: {
...ws.latest_build,
status: "running",
},
};
});
ctx.args = { ...ctx.args, workspacesToUpdate: allRunning };
ctx.parameters.queries = queries;
},
};
export const RunningWorkspacesFailedValidation: Story = {
beforeEach: (ctx) => {
const { workspaces, queries } = createPatchedDependencies(3);
const allRunning = workspaces.map<Workspace>((ws) => {
return {
...ws,
latest_build: {
...ws.latest_build,
status: "running",
},
};
});
ctx.args = { ...ctx.args, workspacesToUpdate: allRunning };
ctx.parameters.queries = queries;
},
play: async () => {
// Can't use canvasElement from the play function's context because the
// component node uses React Portals and won't be part of the main
// canvas body
const modal = within(
screen.getByRole("dialog", { name: "Review updates" }),
);
const updateButton = modal.getByRole("button", { name: "Update" });
await userEvent.click(updateButton, {
/**
* @todo 2025-07-15 - Something in the test setup is causing the
* Update button to get treated as though it should opt out of
* pointer events, which causes userEvent to break. All of our code
* seems to be fine - we do have logic to disable pointer events,
* but only when the button is obviously configured wrong (e.g.,
* it's configured as a link but has no URL).
*
* Disabling this check makes things work again, but shoots our
* confidence for how accessible the UI is, even if we know that at
* this point, the button exists, has the right text content, and is
* not disabled.
*
* We should aim to remove this property as soon as possible,
* opening up an issue upstream if necessary.
*/
pointerEventsCheck: 0,
});
await modal.findByText("Please acknowledge risks to continue.");
const checkbox = modal.getByRole("checkbox", {
name: /I acknowledge these risks\./,
});
expect(checkbox).toHaveFocus();
},
};
export const MixOfWorkspaces: Story = {
args: { isProcessing: true },
/**
* List of all workspace kinds we're trying to represent here:
* - Ready to update + stopped
* - Ready to update + running
* - Ready to update + transitioning
* - Dormant
* - Not outdated + stopped
* - Not outdated + transitioning
*
* Deliberately omitted:
* - Not outdated + running (the update logic should skip the workspace, so
* you shouldn't care whether it's running)
*/
beforeEach: (ctx) => {
const { workspaces, queries } = createPatchedDependencies(6);
const readyToUpdateStopped = workspaces[0] as MutableWorkspace;
readyToUpdateStopped.outdated = true;
readyToUpdateStopped.latest_build.status = "stopped";
const readyToUpdateRunning = workspaces[1] as MutableWorkspace;
readyToUpdateRunning.outdated = true;
readyToUpdateRunning.latest_build.status = "running";
const readyToUpdateTransitioning = workspaces[2] as MutableWorkspace;
readyToUpdateTransitioning.outdated = true;
readyToUpdateTransitioning.latest_build.status = "starting";
const dormant = workspaces[3] as MutableWorkspace;
dormant.outdated = true;
dormant.latest_build.status = "stopped";
dormant.dormant_at = new Date().toISOString();
const noUpdatesNeededStopped = workspaces[4] as MutableWorkspace;
noUpdatesNeededStopped.outdated = false;
dormant.latest_build.status = "stopped";
const noUpdatesNeededTransitioning = workspaces[5] as MutableWorkspace;
noUpdatesNeededTransitioning.outdated = false;
noUpdatesNeededTransitioning.latest_build.status = "starting";
ctx.args = { ...ctx.args, workspacesToUpdate: workspaces };
ctx.parameters.queries = queries;
},
};
@@ -0,0 +1,626 @@
import { Label } from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import { templateVersion } from "api/queries/templates";
import type { Workspace } from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Avatar } from "components/Avatar/Avatar";
import { Badge } from "components/Badge/Badge";
import { Button } from "components/Button/Button";
import { Checkbox } from "components/Checkbox/Checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "components/Dialog/Dialog";
import { Spinner } from "components/Spinner/Spinner";
import { TriangleAlert } from "lucide-react";
import {
type FC,
type ForwardedRef,
type ReactNode,
useId,
useRef,
useState,
} from "react";
import { useQueries } from "react-query";
import { cn } from "utils/cn";
import { ACTIVE_BUILD_STATUSES } from "./WorkspacesPage";
export const BatchUpdateModalForm: FC<BatchUpdateModalFormProps> = ({
open,
isProcessing,
workspacesToUpdate,
onCancel,
onSubmit,
}) => {
return (
<Dialog
open={open}
onOpenChange={(newIsOpen) => {
if (!newIsOpen) {
onCancel();
}
}}
>
<DialogContent className="max-w-screen-md">
<ReviewForm
workspacesToUpdate={workspacesToUpdate}
isProcessing={isProcessing}
onCancel={onCancel}
onSubmit={onSubmit}
/>
</DialogContent>
</Dialog>
);
};
type WorkspacePartitionByUpdateType = Readonly<{
dormant: readonly Workspace[];
noUpdateNeeded: readonly Workspace[];
readyToUpdate: readonly Workspace[];
}>;
function separateWorkspacesByUpdateType(
workspaces: readonly Workspace[],
): WorkspacePartitionByUpdateType {
const noUpdateNeeded: Workspace[] = [];
const dormant: Workspace[] = [];
const readyToUpdate: Workspace[] = [];
for (const ws of workspaces) {
if (!ws.outdated) {
noUpdateNeeded.push(ws);
continue;
}
if (ws.dormant_at !== null) {
dormant.push(ws);
continue;
}
readyToUpdate.push(ws);
}
return { dormant, noUpdateNeeded, readyToUpdate };
}
type ReviewPanelProps = Readonly<{
workspaceName: string;
workspaceIconUrl: string;
running: boolean;
transitioning: boolean;
label?: ReactNode;
adornment?: ReactNode;
className?: string;
}>;
const ReviewPanel: FC<ReviewPanelProps> = ({
workspaceName,
label,
running,
transitioning,
workspaceIconUrl,
className,
}) => {
// Preemptively adding border to this component to help decouple the styling
// from the rest of the components in this file, and make the core parts of
// this component easier to reason about
return (
<div
className={cn(
"rounded-md px-4 py-3 border border-solid border-border text-sm",
className,
)}
>
<div className="flex flex-row flex-wrap grow items-center gap-3">
<Avatar size="sm" variant="icon" src={workspaceIconUrl} />
<div className="flex flex-col gap-0.5">
<span className="flex flex-row items-center gap-2">
<span className="leading-tight">{workspaceName}</span>
{running && (
<Badge size="xs" variant="warning" border="none">
Running
</Badge>
)}
{transitioning && (
<Badge size="xs" variant="warning" border="none">
Getting latest status
</Badge>
)}
</span>
<span className="text-xs leading-tight text-content-secondary">
{label}
</span>
</div>
</div>
</div>
);
};
const PanelListItem: FC<{ children: ReactNode }> = ({ children }) => {
return (
<li className="[&:not(:last-child)]:border-b-border [&:not(:last-child)]:border-b [&:not(:last-child)]:border-solid border-0">
{children}
</li>
);
};
type TemplateNameChangeProps = Readonly<{
oldTemplateVersionName: string;
newTemplateVersionName: string;
}>;
const TemplateNameChange: FC<TemplateNameChangeProps> = ({
oldTemplateVersionName: oldTemplateName,
newTemplateVersionName: newTemplateName,
}) => {
return (
<>
<span aria-hidden className="line-clamp-1">
{oldTemplateName} &rarr; {newTemplateName}
</span>
<span className="sr-only">
Workspace will go from version {oldTemplateName} to version{" "}
{newTemplateName}
</span>
</>
);
};
type RunningWorkspacesWarningProps = Readonly<{
acceptedRisks: boolean;
onAcceptedRisksChange: (newValue: boolean) => void;
checkboxRef: ForwardedRef<HTMLButtonElement>;
containerRef: ForwardedRef<HTMLDivElement>;
}>;
const RunningWorkspacesWarning: FC<RunningWorkspacesWarningProps> = ({
acceptedRisks,
onAcceptedRisksChange,
checkboxRef,
containerRef,
}) => {
return (
<div
ref={containerRef}
className="rounded-md border-border-warning border border-solid p-4"
>
<h4 className="m-0 font-semibold flex flex-row items-center gap-2 text-content-primary">
<TriangleAlert className="text-content-warning" size={16} />
Running workspaces detected
</h4>
<ul className="flex flex-col gap-1 m-0 px-5 pt-1.5 [&>li]:leading-snug text-content-secondary">
<li>
Updating a workspace will start it on its latest template version.
This can delete non-persistent data.
</li>
<li>
Anyone connected to a running workspace will be disconnected until the
update is complete.
</li>
<li>Any unsaved data will be lost.</li>
</ul>
<Label className="flex flex-row gap-3 items-center leading-tight pt-6">
<Checkbox
ref={checkboxRef}
checked={acceptedRisks}
onCheckedChange={onAcceptedRisksChange}
/>
I acknowledge these risks.
</Label>
</div>
);
};
type ContainerProps = Readonly<{
asChild?: boolean;
children?: ReactNode;
}>;
const Container: FC<ContainerProps> = ({ children, asChild = false }) => {
const Wrapper = asChild ? Slot : "div";
return (
<Wrapper className="max-h-[80vh] flex flex-col flex-nowrap">
{children}
</Wrapper>
);
};
type ContainerBodyProps = Readonly<{
headerText: ReactNode;
description: ReactNode;
showDescription?: boolean;
children?: ReactNode;
}>;
const ContainerBody: FC<ContainerBodyProps> = ({
children,
headerText,
description,
showDescription = false,
}) => {
return (
// Have to subtract parent padding via margin values and then add it
// back as child padding so that there's no risk of the scrollbar
// covering up content when the container gets tall enough to overflow
<div className="overflow-y-auto flex flex-col gap-3 -mx-8 -mt-8 p-8">
<div className="flex flex-col gap-3">
<DialogTitle asChild>
<h3 className="text-3xl font-semibold m-0 leading-tight">
{headerText}
</h3>
</DialogTitle>
<DialogDescription
className={cn("m-0 text-base", !showDescription && "sr-only")}
>
{description}
</DialogDescription>
</div>
{children}
</div>
);
};
type ContainerFooterProps = Readonly<{
className?: string;
children?: ReactNode;
}>;
const ContainerFooter: FC<ContainerFooterProps> = ({ children, className }) => {
return (
<div
className={cn(
// Also have to subtract padding here to make sure footer is
// full-bleed, and there's no risk of the border getting
// confused for the outline of one of the panels
"border-0 border-t border-solid border-t-border pt-8 -mx-8 px-8",
className,
)}
>
{children}
</div>
);
};
type WorkspacesListSectionProps = Readonly<{
headerText: ReactNode;
description: ReactNode;
children?: ReactNode;
}>;
const WorkspacesListSection: FC<WorkspacesListSectionProps> = ({
children,
headerText,
description,
}) => {
return (
<section className="flex flex-col gap-3.5">
<div className="max-w-prose">
<h4 className="m-0">{headerText}</h4>
<p className="m-0 text-sm leading-snug text-content-secondary">
{description}
</p>
</div>
<ul className="m-0 list-none p-0 flex flex-col rounded-md border border-solid border-border">
{children}
</ul>
</section>
);
};
// Used to force the user to acknowledge that batch updating has risks in
// certain situations and could destroy their data
type RisksStage = "notAccepted" | "accepted" | "failedValidation";
type ReviewFormProps = Readonly<{
workspacesToUpdate: readonly Workspace[];
isProcessing: boolean;
onCancel: () => void;
onSubmit: () => void;
}>;
const ReviewForm: FC<ReviewFormProps> = ({
workspacesToUpdate,
isProcessing,
onCancel,
onSubmit,
}) => {
const hookId = useId();
const [stage, setStage] = useState<RisksStage>("notAccepted");
const risksContainerRef = useRef<HTMLDivElement>(null);
const risksCheckboxRef = useRef<HTMLButtonElement>(null);
// Dormant workspaces can't be activated without activating them first. For
// now, we'll only show the user that some workspaces can't be updated, and
// then skip over them for all other update logic
const { dormant, noUpdateNeeded, readyToUpdate } =
separateWorkspacesByUpdateType(workspacesToUpdate);
// The workspaces don't have all necessary data by themselves, so we need to
// fetch the unique template versions, and massage the results
const uniqueTemplateVersionIds = new Set<string>(
readyToUpdate.map((ws) => ws.template_active_version_id),
);
const templateVersionQueries = useQueries({
queries: [...uniqueTemplateVersionIds].map((id) => templateVersion(id)),
});
// React Query persists previous errors even if a query is no longer in the
// error state, so we need to explicitly check the isError property to see
// if any of the queries actively have an error
const error = templateVersionQueries.find((q) => q.isError)?.error;
const hasWorkspaces = workspacesToUpdate.length > 0;
const someWorkspacesCanBeUpdated = readyToUpdate.length > 0;
const formIsNeeded = someWorkspacesCanBeUpdated || dormant.length > 0;
if (!formIsNeeded) {
return (
<Container>
<ContainerBody
headerText={
hasWorkspaces
? "All workspaces up to date"
: "No workspaces selected"
}
showDescription
description={
hasWorkspaces ? (
<>
None of the{" "}
<span className="text-content-primary font-semibold">
{workspacesToUpdate.length}
</span>{" "}
selected workspaces need updates.
</>
) : (
"Nothing to update."
)
}
>
{error !== undefined && <ErrorAlert error={error} />}
</ContainerBody>
<ContainerFooter className="flex flex-row justify-end">
<Button variant="outline" onClick={onCancel}>
Close
</Button>
</ContainerFooter>
</Container>
);
}
const runningIds = new Set<string>(
readyToUpdate
.filter((ws) => ws.latest_build.status === "running")
.map((ws) => ws.id),
);
/**
* Two things:
* 1. We have to make sure that we don't let the user submit anything while
* workspaces are transitioning, or else we'll run into a race condition.
* If a user starts a workspace, and then immediately batch-updates it,
* the workspace won't be in the running state yet. We need to issue
* warnings about how updating running workspaces is a destructive
* action, but if the the user goes through the form quickly enough,
* they'll be able to update without seeing the warning.
* 2. Just to be on the safe side, we also need to derive the transitioning
* IDs from all checked workspaces, because the separation result could
* theoretically change on re-render after any workspace state
* transitions end.
*/
const transitioningIds = new Set<string>(
workspacesToUpdate
.filter((ws) => ACTIVE_BUILD_STATUSES.includes(ws.latest_build.status))
.map((ws) => ws.id),
);
const hasRunningWorkspaces = runningIds.size > 0;
const risksAcknowledged = !hasRunningWorkspaces || stage === "accepted";
const failedValidationId =
stage === "failedValidation" ? `${hookId}-failed-validation` : undefined;
// For UX/accessibility reasons, we're splitting a lot of hairs between
// various invalid/disabled states. We do not just want to throw a blanket
// `disabled` attribute on a button and call it a day. The most important
// thing is that we need to give the user feedback on how to get unstuck if
// they fail any input validations
const safeToSubmit = transitioningIds.size === 0 && error === undefined;
const buttonIsDisabled = !safeToSubmit || isProcessing;
const submitIsValid =
risksAcknowledged && error === undefined && readyToUpdate.length > 0;
return (
<Container asChild>
<form
onSubmit={(e) => {
e.preventDefault();
if (!someWorkspacesCanBeUpdated) {
onCancel();
return;
}
if (submitIsValid) {
onSubmit();
return;
}
if (stage === "accepted") {
return;
}
setStage("failedValidation");
// Makes sure that if the modal is long enough to scroll and
// if the warning section checkbox isn't on screen anymore,
// the warning section goes back to being on screen
risksContainerRef.current?.scrollIntoView({
behavior: "smooth",
});
risksCheckboxRef.current?.focus();
}}
>
<ContainerBody
headerText="Review updates"
description="The following workspaces will be updated:"
>
<div className="flex flex-col gap-4">
{error !== undefined && <ErrorAlert error={error} />}
{hasRunningWorkspaces && (
<RunningWorkspacesWarning
checkboxRef={risksCheckboxRef}
containerRef={risksContainerRef}
acceptedRisks={stage === "accepted"}
onAcceptedRisksChange={(newChecked) => {
if (newChecked) {
setStage("accepted");
} else {
setStage("notAccepted");
}
}}
/>
)}
{readyToUpdate.length > 0 && (
<WorkspacesListSection
headerText="Ready to update"
description="These workspaces will have their templates be updated to the latest version."
>
{readyToUpdate.map((ws) => {
const matchedQuery = templateVersionQueries.find(
(q) => q.data?.id === ws.template_active_version_id,
);
const newTemplateName = matchedQuery?.data?.name;
return (
<PanelListItem key={ws.id}>
<ReviewPanel
className="border-none"
running={runningIds.has(ws.id)}
transitioning={transitioningIds.has(ws.id)}
workspaceName={ws.name}
workspaceIconUrl={ws.template_icon}
label={
newTemplateName !== undefined && (
<TemplateNameChange
newTemplateVersionName={newTemplateName}
oldTemplateVersionName={
ws.latest_build.template_version_name
}
/>
)
}
/>
</PanelListItem>
);
})}
</WorkspacesListSection>
)}
{noUpdateNeeded.length > 0 && (
<WorkspacesListSection
headerText="Already updated"
description="These workspaces are already updated and will be skipped."
>
{noUpdateNeeded.map((ws) => (
<PanelListItem key={ws.id}>
<ReviewPanel
className="border-none"
running={false}
transitioning={transitioningIds.has(ws.id)}
workspaceName={ws.name}
workspaceIconUrl={ws.template_icon}
/>
</PanelListItem>
))}
</WorkspacesListSection>
)}
{dormant.length > 0 && (
<WorkspacesListSection
headerText="Dormant workspaces"
description={
<>
Dormant workspaces cannot be updated without first
activating the workspace. They will always be skipped during
batch updates.
</>
}
>
{dormant.map((ws) => (
<li
key={ws.id}
className="[&:not(:last-child)]:border-b-border [&:not(:last-child)]:border-b [&:not(:last-child)]:border-solid border-0"
>
<ReviewPanel
className="border-none"
running={false}
transitioning={transitioningIds.has(ws.id)}
workspaceName={ws.name}
workspaceIconUrl={ws.template_icon}
/>
</li>
))}
</WorkspacesListSection>
)}
</div>
</ContainerBody>
<ContainerFooter>
<div className="flex flex-row flex-wrap justify-end gap-4">
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button
variant="default"
type="submit"
disabled={buttonIsDisabled}
aria-describedby={failedValidationId}
>
{isProcessing && (
<>
<Spinner loading />
<span className="sr-only">
Waiting for workspaces to finish processing
</span>
</>
)}
{!safeToSubmit && !isProcessing && (
<span className="sr-only">
Unable to complete batch update because of workspace error
</span>
)}
{someWorkspacesCanBeUpdated ? (
<span aria-hidden={buttonIsDisabled}>Update</span>
) : (
"Close"
)}
</Button>
</div>
{stage === "failedValidation" && (
<p
id={failedValidationId}
className="m-0 text-highlight-red text-right text-sm pt-2"
>
Please acknowledge risks to continue.
</p>
)}
</ContainerFooter>
</form>
</Container>
);
};
type BatchUpdateModalFormProps = Readonly<{
open: boolean;
isProcessing: boolean;
workspacesToUpdate: readonly Workspace[];
onCancel: () => void;
onSubmit: () => void;
}>;
@@ -85,17 +85,29 @@ describe("WorkspacesPage", () => {
expect(deleteWorkspace).toHaveBeenCalledWith(workspaces[1].id);
});
describe("batch update", () => {
it("ignores up-to-date workspaces", async () => {
const workspaces = [
{ ...MockWorkspace, id: "1" }, // running, not outdated. no warning.
{ ...MockDormantWorkspace, id: "2" }, // dormant, not outdated. no warning.
describe("batch updates", () => {
it("skips up-to-date workspaces after confirming update", async () => {
const workspaces: readonly Workspace[] = [
// Not outdated but running; should have no warning
{ ...MockWorkspace, id: "1" },
// Dormant; no warning
{ ...MockDormantWorkspace, id: "2" },
// Out of date but not running; no warning
{ ...MockOutdatedWorkspace, id: "3" },
{ ...MockOutdatedWorkspace, id: "4" },
// Out of date but running; should issue warning
{
...MockOutdatedWorkspace,
id: "4",
latest_build: {
...MockOutdatedWorkspace.latest_build,
status: "running",
},
},
];
jest
.spyOn(API, "getWorkspaces")
.mockResolvedValue({ workspaces, count: workspaces.length });
const updateWorkspace = jest.spyOn(API, "updateWorkspace");
const user = userEvent.setup();
renderWithAuth(<WorkspacesPage />);
@@ -106,28 +118,32 @@ describe("WorkspacesPage", () => {
}
await user.click(screen.getByRole("button", { name: /bulk actions/i }));
const updateButton = await screen.findByTestId("bulk-action-update");
await user.click(updateButton);
const dropdownItem = await screen.findByRole("menuitem", {
name: /Update/,
});
await user.click(dropdownItem);
// One click: no running workspaces warning, no dormant workspaces warning.
// There is a running workspace and a dormant workspace selected, but they
// are not outdated.
const confirmButton = await screen.findByTestId("confirm-button");
const dialog = await screen.findByRole("dialog");
expect(dialog).toHaveTextContent(/used by/i);
await user.click(confirmButton);
const modal = await screen.findByRole("dialog", {
name: /Review Updates/i,
});
const confirmCheckbox = within(modal).getByRole("checkbox", {
name: /I acknowledge these risks\./,
});
await user.click(confirmCheckbox);
const updateModalButton = within(modal).getByRole("button", {
name: /Update/,
});
await user.click(updateModalButton);
// `workspaces[0]` was up-to-date, and running
// `workspaces[1]` was dormant
await waitFor(() => {
expect(updateWorkspace).toHaveBeenCalledTimes(2);
});
await waitFor(() => expect(updateWorkspace).toHaveBeenCalledTimes(2));
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2], [], false);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[3], [], false);
});
it("warns about and updates running workspaces", async () => {
const workspaces = [
it("lets user update a running workspace (after user goes through warning)", async () => {
const workspaces: readonly Workspace[] = [
{ ...MockRunningOutdatedWorkspace, id: "1" },
{ ...MockOutdatedWorkspace, id: "2" },
{ ...MockOutdatedWorkspace, id: "3" },
@@ -135,6 +151,7 @@ describe("WorkspacesPage", () => {
jest
.spyOn(API, "getWorkspaces")
.mockResolvedValue({ workspaces, count: workspaces.length });
const updateWorkspace = jest.spyOn(API, "updateWorkspace");
const user = userEvent.setup();
renderWithAuth(<WorkspacesPage />);
@@ -145,20 +162,24 @@ describe("WorkspacesPage", () => {
}
await user.click(screen.getByRole("button", { name: /bulk actions/i }));
const updateButton = await screen.findByTestId("bulk-action-update");
await user.click(updateButton);
// Two clicks: 1 running workspace, no dormant workspaces warning.
const confirmButton = await screen.findByTestId("confirm-button");
const dialog = await screen.findByRole("dialog");
expect(dialog).toHaveTextContent(/1 running workspace/i);
await user.click(confirmButton);
expect(dialog).toHaveTextContent(/used by/i);
await user.click(confirmButton);
await waitFor(() => {
expect(updateWorkspace).toHaveBeenCalledTimes(3);
const dropdownItem = await screen.findByRole("menuitem", {
name: /Update/,
});
await user.click(dropdownItem);
const modal = await screen.findByRole("dialog", {
name: /Review Updates/i,
});
const confirmCheckbox = within(modal).getByRole("checkbox", {
name: /I acknowledge these risks\./,
});
await user.click(confirmCheckbox);
const updateModalButton = within(modal).getByRole("button", {
name: /Update/,
});
await user.click(updateModalButton);
await waitFor(() => expect(updateWorkspace).toHaveBeenCalledTimes(3));
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[0], [], false);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[1], [], false);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2], [], false);
@@ -183,67 +204,24 @@ describe("WorkspacesPage", () => {
}
await user.click(screen.getByRole("button", { name: /bulk actions/i }));
const updateButton = await screen.findByTestId("bulk-action-update");
await user.click(updateButton);
const dropdownItem = await screen.findByRole("menuitem", {
name: /Update/,
});
await user.click(dropdownItem);
// Two clicks: no running workspaces warning, 1 dormant workspace.
const confirmButton = await screen.findByTestId("confirm-button");
const dialog = await screen.findByRole("dialog");
expect(dialog).toHaveTextContent(/dormant/i);
await user.click(confirmButton);
expect(dialog).toHaveTextContent(/used by/i);
await user.click(confirmButton);
const modal = await screen.findByRole("dialog", {
name: /Review Updates/i,
});
const updateModalButton = within(modal).getByRole("button", {
name: /Update/,
});
await user.click(updateModalButton);
// `workspaces[0]` was dormant
await waitFor(() => {
expect(updateWorkspace).toHaveBeenCalledTimes(2);
});
await waitFor(() => expect(updateWorkspace).toHaveBeenCalledTimes(2));
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[1], [], false);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2], [], false);
});
it("warns about running workspaces and then dormant workspaces", async () => {
const workspaces = [
{ ...MockRunningOutdatedWorkspace, id: "1" },
{ ...MockDormantOutdatedWorkspace, id: "2" },
{ ...MockOutdatedWorkspace, id: "3" },
{ ...MockOutdatedWorkspace, id: "4" },
{ ...MockWorkspace, id: "5" },
];
jest
.spyOn(API, "getWorkspaces")
.mockResolvedValue({ workspaces, count: workspaces.length });
const updateWorkspace = jest.spyOn(API, "updateWorkspace");
const user = userEvent.setup();
renderWithAuth(<WorkspacesPage />);
await waitForLoaderToBeRemoved();
for (const workspace of workspaces) {
await user.click(getWorkspaceCheckbox(workspace));
}
await user.click(screen.getByRole("button", { name: /bulk actions/i }));
const updateButton = await screen.findByTestId("bulk-action-update");
await user.click(updateButton);
// Three clicks: 1 running workspace, 1 dormant workspace.
const confirmButton = await screen.findByTestId("confirm-button");
const dialog = await screen.findByRole("dialog");
expect(dialog).toHaveTextContent(/1 running workspace/i);
await user.click(confirmButton);
expect(dialog).toHaveTextContent(/dormant/i);
await user.click(confirmButton);
expect(dialog).toHaveTextContent(/used by/i);
await user.click(confirmButton);
// `workspaces[1]` was dormant, and `workspaces[4]` was up-to-date
await waitFor(() => {
expect(updateWorkspace).toHaveBeenCalledTimes(3);
});
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[0], [], false);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2], [], false);
expect(updateWorkspace).toHaveBeenCalledWith(workspaces[3], [], false);
});
});
it("stops only the running and selected workspaces", async () => {
@@ -17,7 +17,7 @@ import { useQuery, useQueryClient } from "react-query";
import { useSearchParams } from "react-router";
import { pageTitle } from "utils/page";
import { BatchDeleteConfirmation } from "./BatchDeleteConfirmation";
import { BatchUpdateConfirmation } from "./BatchUpdateConfirmation";
import { BatchUpdateModalForm } from "./BatchUpdateModalForm";
import { useBatchActions } from "./batchActions";
import { useStatusFilterMenu, useTemplateFilterMenu } from "./filter/menus";
import { WorkspacesPageView } from "./WorkspacesPageView";
@@ -27,7 +27,7 @@ import { WorkspacesPageView } from "./WorkspacesPageView";
* workspace is in the middle of a transition and will eventually reach a more
* stable state/status.
*/
const ACTIVE_BUILD_STATUSES: readonly WorkspaceStatus[] = [
export const ACTIVE_BUILD_STATUSES: readonly WorkspaceStatus[] = [
"canceling",
"deleting",
"pending",
@@ -235,12 +235,12 @@ const WorkspacesPage: FC = () => {
}}
/>
<BatchUpdateConfirmation
<BatchUpdateModalForm
open={activeBatchAction === "update"}
checkedWorkspaces={checkedWorkspaces}
isLoading={batchActions.isProcessing}
onClose={() => setActiveBatchAction(undefined)}
onConfirm={async () => {
workspacesToUpdate={checkedWorkspaces}
isProcessing={batchActions.isProcessing}
onCancel={() => setActiveBatchAction(undefined)}
onSubmit={async () => {
await batchActions.updateTemplateVersions({
workspaces: checkedWorkspaces,
isDynamicParametersEnabled: false,
@@ -78,6 +78,11 @@ export function useBatchActions(
},
});
// Not a great idea to return the promises from the Promise.all calls below
// because that then gives you a void array, which doesn't make sense with
// TypeScript's type system. Best to await them, and then have the wrapper
// mutation function return its own void promise
const favoriteAllMutation = useMutation({
mutationFn: async (workspaces: readonly Workspace[]): Promise<void> => {
await Promise.all(