diff --git a/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.stories.tsx b/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.stories.tsx deleted file mode 100644 index 140d433d3e860..0000000000000 --- a/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.stories.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { action } from "@storybook/addon-actions"; -import type { Meta, StoryObj } from "@storybook/react"; -import type { Workspace } from "api/typesGenerated"; -import { useQueryClient } from "react-query"; -import { chromatic } from "testHelpers/chromatic"; -import { - MockDormantOutdatedWorkspace, - MockOutdatedWorkspace, - MockRunningOutdatedWorkspace, - MockTemplateVersion, - MockUserMember, - MockWorkspace, -} from "testHelpers/entities"; -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 { - type MutableUpdate = Omit & { - affected_workspaces: Workspace[]; - }; - - const updates = new Map(); - 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; -} - -const updates = getPopulatedUpdates(); - -const meta: Meta = { - 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 ; - }, - ], - args: { - onClose: action("onClose"), - onConfirm: action("onConfirm"), - open: true, - checkedWorkspaces: workspaces, - }, -}; - -export default meta; -type Story = StoryObj; - -const Example: Story = {}; - -export { Example as BatchUpdateConfirmation }; diff --git a/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.tsx b/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.tsx deleted file mode 100644 index a6b0a27b374f4..0000000000000 --- a/site/src/pages/WorkspacesPage/BatchUpdateConfirmation.tsx +++ /dev/null @@ -1,494 +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 { MonitorDownIcon } from "lucide-react"; -import { ClockIcon, 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 = ({ - 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(); - 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 & { - affected_workspaces: readonly Workspace[]; - }; - - return newVersions as Map; - }, [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 ( - - {stage === "consequences" && ( - - )} - {stage === "dormantWorkspaces" && ( - - )} - {stage === "updates" && ( - - )} - - } - /> - ); -}; - -interface ConsequencesProps { - runningWorkspaces: Workspace[]; -} - -const Consequences: FC = ({ 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 ( - <> -

You are about to update {workspaceCount}.

-
    -
  • - Updating will start workspaces on their latest template versions. This - can delete non-persistent data. -
  • -
  • - Anyone connected to a running workspace will be disconnected until the - update is complete. -
  • -
  • Any unsaved data will be lost.
  • -
- - - - {ownerCount} - - - - ); -}; - -interface DormantWorkspacesProps { - workspaces: Workspace[]; -} - -const DormantWorkspaces: FC = ({ 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 ( - <> -

- {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. - - )} -

-
    - {workspaces.map((workspace) => ( -
  • - - {workspace.name} - - - - - {workspace.owner_name} - - - - - - {lastUsed(workspace.last_used_at)} - - - - -
  • - ))} -
- - - - {ownersCount} - - {mostRecent && ( - - - Last used {lastUsed(mostRecent.last_used_at)} - - )} - - - ); -}; - -interface UpdatesProps { - workspaces: Workspace[]; - updates?: Update[]; - error?: unknown; -} - -const Updates: FC = ({ 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 ( - <> - - - - - {workspaceCount} - - {updateCount && ( - - - {updateCount} - - )} - - - ); -}; - -interface TemplateVersionMessagesProps { - error?: unknown; - updates?: Update[]; -} - -const TemplateVersionMessages: FC = ({ - error, - updates, -}) => { - if (error) { - return ; - } - - if (!updates) { - return ; - } - - return ( -
    - {updates.map((update) => ( -
  • - - - {update.template_display_name} - → {update.name} - - - {update.message ?? "No message"} - - - -
  • - ))} -
- ); -}; - -interface UsedByProps { - workspaces: readonly Workspace[]; -} - -const UsedBy: FC = ({ workspaces }) => { - const workspaceNames = workspaces.map((it) => it.name); - - return ( -

- Used by {workspaceNames.slice(0, 2).join(", ")}{" "} - {workspaceNames.length > 2 && ( - - and {workspaceNames.length - 2} more - - )} -

- ); -}; - -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 ; -}; - -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>; diff --git a/site/src/pages/WorkspacesPage/BatchUpdateModalForm.stories.tsx b/site/src/pages/WorkspacesPage/BatchUpdateModalForm.stories.tsx new file mode 100644 index 0000000000000..8b44f02d07fe1 --- /dev/null +++ b/site/src/pages/WorkspacesPage/BatchUpdateModalForm.stories.tsx @@ -0,0 +1,277 @@ +import type { Meta, Parameters, StoryObj } from "@storybook/react"; +import { expect, screen, userEvent, within } from "@storybook/test"; +import { templateVersionRoot } from "api/queries/templates"; +import type { + TemplateVersion, + Workspace, + WorkspaceBuild, +} from "api/typesGenerated"; +import { useQueryClient } from "react-query"; +import { MockTemplateVersion, MockWorkspace } from "testHelpers/entities"; +import { BatchUpdateModalForm } from "./BatchUpdateModalForm"; +import { ACTIVE_BUILD_STATUSES } from "./WorkspacesPage"; + +type Writeable = { -readonly [Key in keyof T]: T[Key] }; +type MutableWorkspace = Writeable> & { + latest_build: Writeable; +}; + +const meta: Meta = { + title: "pages/WorkspacesPage/BatchUpdateModalForm", + component: BatchUpdateModalForm, + args: { + open: true, + isProcessing: false, + onSubmit: () => window.alert("Hooray! Everything has been submitted"), + // Since we're using Radix, any cancel functionality is also going to + // trigger when you click outside the component bounds, which would make + // doing an alert really annoying in the Storybook web UI + onCancel: () => console.log("Canceled"), + }, +}; + +export default meta; +type Story = StoryObj; + +type QueryEntry = NonNullable; + +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 = { ...ctx.parameters, queries }; + }, +}; + +export const NoWorkspacesToUpdate: Story = { + beforeEach: (ctx) => { + const { workspaces, queries } = createPatchedDependencies(3); + for (const ws of workspaces) { + const writable = ws as MutableWorkspace; + writable.outdated = false; + } + + ctx.args = { ...ctx.args, workspacesToUpdate: workspaces }; + ctx.parameters = { ...ctx.parameters, queries }; + }, +}; + +export const CurrentlyProcessing: Story = { + args: { isProcessing: true }, + beforeEach: (ctx) => { + const { workspaces, queries } = createPatchedDependencies(3); + ctx.args = { ...ctx.args, workspacesToUpdate: workspaces }; + ctx.parameters = { ...ctx.parameters, 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 = { ...ctx.parameters, queries }; + }, +}; + +export const FetchError: Story = { + beforeEach: (ctx) => { + const { workspaces, queries } = createPatchedDependencies(3); + ctx.args = { ...ctx.args, workspacesToUpdate: workspaces }; + ctx.parameters = { ...ctx.parameters, 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 ; + }, + ], +}; + +export const TransitioningWorkspaces: Story = { + args: { isProcessing: true }, + beforeEach: (ctx) => { + const { workspaces, queries } = createPatchedDependencies( + 2 * ACTIVE_BUILD_STATUSES.length, + ); + for (const [i, ws] of workspaces.entries()) { + if (i % 2 === 0) { + continue; + } + const writable = ws.latest_build as Writeable; + writable.status = ACTIVE_BUILD_STATUSES[i % ACTIVE_BUILD_STATUSES.length]; + } + ctx.args = { ...ctx.args, workspacesToUpdate: workspaces }; + ctx.parameters = { ...ctx.parameters, queries }; + }, +}; + +export const RunningWorkspaces: Story = { + beforeEach: (ctx) => { + const { workspaces, queries } = createPatchedDependencies(3); + for (const ws of workspaces) { + const writable = ws.latest_build as Writeable; + writable.status = "running"; + } + ctx.args = { ...ctx.args, workspacesToUpdate: workspaces }; + ctx.parameters = { ...ctx.parameters, queries }; + }, +}; + +export const RunningWorkspacesFailedValidation: Story = { + beforeEach: (ctx) => { + const { workspaces, queries } = createPatchedDependencies(3); + for (const ws of workspaces) { + const writable = ws.latest_build as Writeable; + writable.status = "running"; + } + ctx.args = { ...ctx.args, workspacesToUpdate: workspaces }; + ctx.parameters = { ...ctx.parameters, 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 consequences to continue."); + + const checkbox = modal.getByRole("checkbox", { + name: /I acknowledge these consequences\./, + }); + 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 = { ...ctx.parameters, queries }; + }, +}; diff --git a/site/src/pages/WorkspacesPage/BatchUpdateModalForm.tsx b/site/src/pages/WorkspacesPage/BatchUpdateModalForm.tsx new file mode 100644 index 0000000000000..7e1a0f1358296 --- /dev/null +++ b/site/src/pages/WorkspacesPage/BatchUpdateModalForm.tsx @@ -0,0 +1,634 @@ +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 PropsWithChildren, + type ReactNode, + useId, + useRef, + useState, +} from "react"; +import { useQueries } from "react-query"; +import { cn } from "utils/cn"; +import { ACTIVE_BUILD_STATUSES } from "./WorkspacesPage"; + +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 = ({ + 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 ( +
+
+ +
+ + {workspaceName} + {running && ( + + Running + + )} + {transitioning && ( + + Getting latest status + + )} + + + {label} + +
+
+
+ ); +}; + +type TemplateNameChangeProps = Readonly<{ + oldTemplateVersionName: string; + newTemplateVersionName: string; +}>; + +const TemplateNameChange: FC = ({ + oldTemplateVersionName: oldTemplateName, + newTemplateVersionName: newTemplateName, +}) => { + return ( + <> + + {oldTemplateName} → {newTemplateName} + + + Workspace will go from version {oldTemplateName} to version{" "} + {newTemplateName} + + + ); +}; + +type RunningWorkspacesWarningProps = Readonly<{ + acceptedConsequences: boolean; + onAcceptedConsequencesChange: (newValue: boolean) => void; + checkboxRef: ForwardedRef; + containerRef: ForwardedRef; +}>; +const RunningWorkspacesWarning: FC = ({ + acceptedConsequences, + onAcceptedConsequencesChange, + checkboxRef, + containerRef, +}) => { + return ( +
+

+ + Running workspaces detected +

+ +
    +
  • + Updating a workspace will start it on its latest template version. + This can delete non-persistent data. +
  • +
  • + Anyone connected to a running workspace will be disconnected until the + update is complete. +
  • +
  • Any unsaved data will be lost.
  • +
+ + +
+ ); +}; + +type ContainerProps = Readonly< + PropsWithChildren<{ + asChild?: boolean; + }> +>; +const Container: FC = ({ children, asChild = false }) => { + const Wrapper = asChild ? Slot : "div"; + return ( + + {children} + + ); +}; + +type ContainerBodyProps = Readonly< + PropsWithChildren<{ + headerText: ReactNode; + description: ReactNode; + showDescription?: boolean; + }> +>; +const ContainerBody: FC = ({ + 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 +
+
+ +

+ {headerText} +

+
+ + + {description} + +
+ + {children} +
+ ); +}; + +type ContainerFooterProps = Readonly< + PropsWithChildren<{ + className?: string; + }> +>; +const ContainerFooter: FC = ({ children, className }) => { + return ( +
+ {children} +
+ ); +}; + +type WorkspacesListSectionProps = Readonly< + PropsWithChildren<{ + headerText: ReactNode; + description: ReactNode; + }> +>; +const WorkspacesListSection: FC = ({ + children, + headerText, + description, +}) => { + return ( +
+
+

{headerText}

+

+ {description} +

+
+ +
    + {children} +
+
+ ); +}; + +// Used to force the user to acknowledge that batch updating has risks in +// certain situations and could destroy their data +type ConsequencesStage = "notAccepted" | "accepted" | "failedValidation"; + +type ReviewFormProps = Readonly<{ + workspacesToUpdate: readonly Workspace[]; + isProcessing: boolean; + onCancel: () => void; + onSubmit: () => void; +}>; + +const ReviewForm: FC = ({ + workspacesToUpdate, + isProcessing, + onCancel, + onSubmit, +}) => { + const hookId = useId(); + const [stage, setStage] = useState("notAccepted"); + const consequencesContainerRef = useRef(null); + const consequencesCheckboxRef = useRef(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( + 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 ( + + + None of the{" "} + + {workspacesToUpdate.length} + {" "} + selected workspaces need updates. + + ) : ( + "Nothing to update." + ) + } + > + {error !== undefined && } + + + + + + + ); + } + + const runningIds = new Set( + 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( + workspacesToUpdate + .filter((ws) => ACTIVE_BUILD_STATUSES.includes(ws.latest_build.status)) + .map((ws) => ws.id), + ); + + const hasRunningWorkspaces = runningIds.size > 0; + const consequencesResolved = !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 = + consequencesResolved && error === undefined && readyToUpdate.length > 0; + + return ( + +
{ + 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 + consequencesContainerRef.current?.scrollIntoView({ + behavior: "smooth", + }); + consequencesCheckboxRef.current?.focus(); + }} + > + +
+ {error !== undefined && } + + {hasRunningWorkspaces && ( + { + if (newChecked) { + setStage("accepted"); + } else { + setStage("notAccepted"); + } + }} + /> + )} + + {readyToUpdate.length > 0 && ( + + {readyToUpdate.map((ws) => { + const matchedQuery = templateVersionQueries.find( + (q) => q.data?.id === ws.template_active_version_id, + ); + const newTemplateName = matchedQuery?.data?.name; + + return ( +
  • + + ) + } + /> +
  • + ); + })} +
    + )} + + {noUpdateNeeded.length > 0 && ( + + {noUpdateNeeded.map((ws) => ( +
  • + +
  • + ))} +
    + )} + + {dormant.length > 0 && ( + + Dormant workspaces cannot be updated without first + activating the workspace. They will always be skipped during + batch updates. + + } + > + {dormant.map((ws) => ( +
  • + +
  • + ))} +
    + )} +
    +
    + + +
    + + +
    + + {stage === "failedValidation" && ( +

    + Please acknowledge consequences to continue. +

    + )} +
    +
    +
    + ); +}; + +type BatchUpdateModalFormProps = Readonly<{ + open: boolean; + isProcessing: boolean; + workspacesToUpdate: readonly Workspace[]; + onCancel: () => void; + onSubmit: () => void; +}>; + +export const BatchUpdateModalForm: FC = ({ + open, + isProcessing, + workspacesToUpdate, + onCancel, + onSubmit, +}) => { + return ( + { + if (open) { + onCancel(); + } + }} + > + + {/* + * Because of how the Dialog component works, we need to make + * sure that at least the parent stays mounted at all times. But + * if we move all the state into ReviewForm, that means that its + * state only mounts when the user actually opens up the batch + * update form. That saves us from mounting a bunch of extra + * state and firing extra queries, when realistically, the form + * will stay closed 99% of the time while the user is on the + * workspaces page. + */} + + + + ); +}; diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx index 678f0331331a6..3d03f98c26773 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx @@ -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(); @@ -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 consequences\./, + }); + 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(); @@ -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 consequences\./, + }); + 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(); - 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 () => { diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index 18caa6aba3285..809f6a83b40a8 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -17,7 +17,7 @@ import { useQuery, useQueryClient } from "react-query"; import { useSearchParams } from "react-router-dom"; import { pageTitle } from "utils/page"; import { BatchDeleteConfirmation } from "./BatchDeleteConfirmation"; -import { BatchUpdateConfirmation } from "./BatchUpdateConfirmation"; +import { BatchUpdateModalForm } from "./BatchUpdateModalForm"; import { WorkspacesPageView } from "./WorkspacesPageView"; import { useBatchActions } from "./batchActions"; import { useStatusFilterMenu, useTemplateFilterMenu } from "./filter/menus"; @@ -27,7 +27,7 @@ import { useStatusFilterMenu, useTemplateFilterMenu } from "./filter/menus"; * 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", @@ -234,12 +234,12 @@ const WorkspacesPage: FC = () => { }} /> - setActiveBatchAction(undefined)} - onConfirm={async () => { + workspacesToUpdate={checkedWorkspaces} + isProcessing={batchActions.isProcessing} + onCancel={() => setActiveBatchAction(undefined)} + onSubmit={async () => { await batchActions.updateTemplateVersions({ workspaces: checkedWorkspaces, isDynamicParametersEnabled: false, pFad - Phonifier reborn

    Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

    Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


    Alternative Proxies:

    Alternative Proxy

    pFad Proxy

    pFad v3 Proxy

    pFad v4 Proxy