mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
@@ -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…</>;
|
||||
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}>→ {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} → {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(
|
||||
|
||||
Reference in New Issue
Block a user