From 9f44f26a36b8fc9a532ec88f443f3758daffa45c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 18 Jul 2026 18:45:16 -0700 Subject: [PATCH] FN-8350: move configuration versions into Settings Move project configuration revision history and rollback controls from Command Center to Settings. - Add a Project Settings configuration versions section with rollback handling. - Remove the Command Center revision card and retain organization portability controls. - Update translations, dashboard documentation, tests, and release metadata. Files changed: .changeset/fn-8350-config-versions-settings.md | 7 ++ docs/dashboard-guide.md | 3 +- .../dashboard/app/components/SettingsModal.tsx | 11 ++++ .../command-center/OrgPortabilityControls.css | 47 ++------------ .../command-center/OrgPortabilityControls.tsx | 55 +--------------- .../__tests__/CommandCenter.mobile-scroll.test.tsx | 2 +- .../__tests__/OrgPortabilityControls.test.tsx | 37 ++--------- .../sections/ConfigurationVersionsSection.css | 56 ++++++++++++++++ .../sections/ConfigurationVersionsSection.tsx | 75 ++++++++++++++++++++++ .../ConfigurationVersionsSection.test.tsx | 58 +++++++++++++++++ packages/i18n/locales/en/app.json | 29 +++++---- 11 files changed, 238 insertions(+), 142 deletions(-) Fusion-Task-Id: FN-8350 Fusion-Task-Lineage: a7f68720-038a-42a6-b064-38533c584492 Co-authored-by: Fusion (runfusion.ai) --- .../fn-8350-config-versions-settings.md | 7 ++ docs/dashboard-guide.md | 3 +- .../app/components/SettingsModal.tsx | 11 +++ .../command-center/OrgPortabilityControls.css | 47 +----------- .../command-center/OrgPortabilityControls.tsx | 55 +------------- .../CommandCenter.mobile-scroll.test.tsx | 2 +- .../__tests__/OrgPortabilityControls.test.tsx | 37 ++------- .../sections/ConfigurationVersionsSection.css | 56 ++++++++++++++ .../sections/ConfigurationVersionsSection.tsx | 75 +++++++++++++++++++ .../ConfigurationVersionsSection.test.tsx | 58 ++++++++++++++ packages/i18n/locales/en/app.json | 29 +++---- 11 files changed, 238 insertions(+), 142 deletions(-) create mode 100644 .changeset/fn-8350-config-versions-settings.md create mode 100644 packages/dashboard/app/components/settings/sections/ConfigurationVersionsSection.css create mode 100644 packages/dashboard/app/components/settings/sections/ConfigurationVersionsSection.tsx create mode 100644 packages/dashboard/app/components/settings/sections/__tests__/ConfigurationVersionsSection.test.tsx diff --git a/.changeset/fn-8350-config-versions-settings.md b/.changeset/fn-8350-config-versions-settings.md new file mode 100644 index 0000000000..b628eb1687 --- /dev/null +++ b/.changeset/fn-8350-config-versions-settings.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Move configuration version history and rollback controls into Settings. +category: feature +dev: Configuration revisions now have a Project Settings navigation section. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 9712ea0e9a..0fc8c18513 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1219,7 +1219,8 @@ Features: - **Overview controls dashboard** sits at the top of the Overview landing surface on desktop and mobile. It includes AI engine stop/start backed by `globalPause`, live scheduler status from executor stats, the shared Global Max Concurrent slider backed by `/api/global-concurrency`, range sliders for `maxConcurrent`, `maxTriageConcurrent`, and `maxWorktrees` that persist through `/api/settings`, and a compact theme dropdown with the same color-chip swatches and Shadcn variant list as Settings → Appearance. The four concurrency sliders ask for confirmation after a changed value settles; confirming persists the new cap, while cancel, backdrop, or Escape dismissal reverts to the last persisted value without saving. The global and current-project max-concurrent sliders show running-agent counts plus a current-use dot on the track once utilization data loads; triage and worktree sliders remain cap-only. These controls reuse existing APIs and App-level theme setters; they do not add a new backend route or second theme owner. - **Org export / import** lets an operator download a portable organization JSON bundle or paste one for a dry-run preview before confirming the apply step. Exports are secret-scrubbed by default: credentials and tokens are never included, while safe secret references can remain for setup in the destination project. -- **Configuration versions** lists recorded project-setting revisions newest first. Select **Roll back** on any revision and confirm once to restore it; the restore is recorded as a new forward revision, so it can itself be undone without manually reconstructing settings. +- **Configuration versions** lives in **Settings → Project → Configuration Versions**. It lists recorded project-setting revisions newest first; select **Roll back** on any revision and confirm once to restore it. The restore is recorded as a new forward revision, so it can itself be undone without manually reconstructing settings. + - **Overview** summarizes token usage/cost, autonomy, active nodes, sessions, agent runs, tasks done, model breadth, and real open signals, and includes the SDLC throughput funnel for the selected range at the bottom of the Overview content in loading, error, empty, and populated states. **Codebase tokens** is a local-only `cl100k_base`-calibrated pre-tokenization estimate; it does not upload source content or add a runtime tokenizer. **Disk size** is local apparent size (regular files and symlink entries only, not allocated blocks or directory bookkeeping). Both project-intrinsic cards render even before agent usage settles and show `Approx. (partial)` when bounded traversal is incomplete. Git projects scan non-ignored tracked files; non-git fallback excludes only directories whose basename starts with `.` or is exactly `node_modules`, `dist`, or `build`. Source candidates are lstat-gated regular files (symlinks and out-of-root candidates are skipped); disk walking does not follow symlinks. Defaults cap source scanning at 50,000 entries, 4 seconds, 64 MiB total, and 2 MiB/file, disk walking at 500,000 entries and 4 seconds, and cache results for two minutes. System telemetry now formats small byte quantities granularly as B/KB rather than displaying sub-MB values as 0 MB. Its token total and Live activity snapshot token metric refresh on a bounded live cadence and animate number changes while preserving reduced-motion preferences. The sessions card uses the selected-range `ActivityAnalytics.sessions` value already loaded for the overview. The Live activity snapshot also shows the current board-state count for tasks in progress, independent of the selected analytics date range. Overview's active-agent and daily activity values count durable-agent usage events plus ephemeral task-worker execution runs, including graph-owned workflow step sessions that publish `agentRuns` lifecycle rows, with the same agent counted once per day/range if both sources record activity. Overview includes a graph-rich software-factory snapshot with the existing top-model-consumers bar, tool-category bar, top-model token-share pie, and the daily activity multi-series line chart placed before the daily activity sparkline/trend so the richer line graph sits higher in the chart grid. These reuse the already-loaded tokens, tools, activity, and signals analytics; the signals count comes from `/api/command-center/signals` and renders unavailable (`—`) while the incidents-backed response is loading or unavailable. The chart reveal/glow accents are decorative and disabled when reduced-motion preferences are active. The SDLC completion rate is shown as a radial gauge and is calculated as cohort conversion from in-range triage entrants, so the rate is capped at 100% even when older tasks finish during the range. diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 862c6ccc14..af7e25fcee 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -60,6 +60,7 @@ import { MemorySection } from "./settings/sections/MemorySection"; import { ResearchProjectSection } from "./settings/sections/ResearchProjectSection"; import { ProjectMcpSection } from "./settings/sections/ProjectMcpSection"; import { BackupsSection } from "./settings/sections/BackupsSection"; +import { ConfigurationVersionsSection } from "./settings/sections/ConfigurationVersionsSection"; import { DatabaseBackupsSection } from "./settings/sections/DatabaseBackupsSection"; import { LoadingSpinner } from "./LoadingSpinner"; import { PluginsSection } from "./settings/sections/PluginsSection"; @@ -494,6 +495,13 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [ { id: "commands", label: "Commands & Scripts", labelKey: "settings.nav.commands", scope: "project", searchableText: ["test command", "build command", "verification command", "workflow scripts", "commands"] }, { id: "worktrees", label: "Worktrees", labelKey: "settings.nav.worktrees", scope: "project", searchableText: ["worktree directory", "copy files", "recycle worktrees", "branch naming", "sibling branch rename"] }, { id: "merge", label: "Merge", labelKey: "settings.nav.merge", scope: "project", searchableText: ["auto merge", "AI merge", "merge strategy", "plan approval", "direct merge", "integration branch", "push after merge"] }, + /* + FNXC:SettingsNavigation 2026-07-18-12:30: + FN-8350 makes configuration history a project Settings destination instead of a + Command Center card. Register it in the shared section registry so desktop + navigation, the mobile picker, and Settings search expose one canonical view. + */ + { id: "config-versions", label: "Configuration Versions", labelKey: "settings.nav.configVersions", scope: "project", searchableText: ["configuration versions", "revision history", "roll back settings", "restore configuration", "config rollback"] }, { id: "__ai_header", label: "AI & Models", labelKey: "settings.nav.aiHeader", scope: undefined, isGroupHeader: true }, /* @@ -4040,6 +4048,9 @@ export function SettingsModal({ projectId={projectId} /> ); + // FNXC:SettingsNavigation 2026-07-18-12:30: FN-8350 refreshes the Settings form after a rollback so restored values immediately replace the pre-rollback view. + case "config-versions": + return refreshSettingsForm(false)} />; case "agent-permissions": return ( Promise; @@ -43,24 +35,6 @@ export function OrgPortabilityControls({ projectId, onSettingsRefresh }: OrgPort const [preview, setPreview] = useState(null); const [previewedBundle, setPreviewedBundle] = useState | null>(null); const bundleVersion = useRef(0); - const [revisions, setRevisions] = useState(null); - const [revisionsError, setRevisionsError] = useState(null); - const [rollbackId, setRollbackId] = useState(null); - - const revisionPath = withProjectId("/config/revisions", projectId); - const loadRevisions = async () => { - try { - setRevisionsError(null); - const response = await api<{ revisions?: ConfigurationRevision[] }>(revisionPath); - setRevisions(Array.isArray(response.revisions) ? response.revisions : []); - } catch (error) { - setRevisions(null); - setRevisionsError(error instanceof Error ? error.message : t("commandCenter.portability.versions.loadError", "Unable to load configuration versions")); - } - }; - - useEffect(() => { void loadRevisions(); }, [projectId]); - const exportOrg = async () => { setExportState("working"); try { @@ -116,7 +90,6 @@ export function OrgPortabilityControls({ projectId, onSettingsRefresh }: OrgPort try { await api(withProjectId("/org/import", projectId), { method: "POST", body: JSON.stringify({ bundle: previewedBundle, dryRun: false }) }); await onSettingsRefresh(); - await loadRevisions(); setImportState("success"); } catch (error) { setImportError(error instanceof Error ? error.message : t("commandCenter.portability.import.error", "Unable to import bundle")); @@ -124,26 +97,6 @@ export function OrgPortabilityControls({ projectId, onSettingsRefresh }: OrgPort } }; - const rollback = async (revision: ConfigurationRevision) => { - const approved = await confirm({ - title: t("commandCenter.portability.versions.confirmTitle", "Roll back configuration?"), - message: t("commandCenter.portability.versions.confirmMessage", "Restore this version? The rollback is recorded as a new version."), - confirmLabel: t("commandCenter.portability.versions.confirmRollback", "Roll back"), - cancelLabel: t("actions.cancel", "Cancel"), - }); - if (!approved) return; - setRollbackId(revision.id); - try { - await api(withProjectId(`/config/revisions/${encodeURIComponent(revision.id)}/rollback`, projectId), { method: "POST" }); - await onSettingsRefresh(); - await loadRevisions(); - } catch (error) { - setRevisionsError(error instanceof Error ? error.message : t("commandCenter.portability.versions.rollbackError", "Unable to roll back configuration")); - } finally { - setRollbackId(null); - } - }; - const exportLabel = exportState === "working" ? t("commandCenter.portability.export.working", "Exporting…") : t("commandCenter.portability.export.action", "Export org bundle"); return <> @@ -176,9 +129,5 @@ export function OrgPortabilityControls({ projectId, onSettingsRefresh }: OrgPort {importError ?

{importError}

: null} -
-

{t("commandCenter.portability.versions.title", "Configuration versions")}

{t("commandCenter.portability.versions.description", "Restore any recorded project configuration version.")}

- {revisionsError ?

{revisionsError}

: revisions === null ?

{t("commandCenter.portability.versions.loading", "Loading versions…")}

: revisions.length === 0 ?

{t("commandCenter.portability.versions.empty", "No configuration versions yet.")}

:
    {revisions.map((revision) =>
  • {revision.configKind}{new Date(revision.createdAt).toLocaleString()}
  • )}
} -
; } diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx index 1203f91307..85259dbcce 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx @@ -382,7 +382,7 @@ describe("CommandCenter mobile scroll regression (FN-6595)", () => { expect(screen.getByTestId("command-center-controls")).toBeTruthy(); expect(screen.getByTestId("cc-controls-concurrency")).toBeTruthy(); expect(screen.getByTestId("cc-controls-org-portability")).toBeTruthy(); - expect(screen.getByTestId("cc-controls-config-versions")).toBeTruthy(); + expect(screen.queryByTestId("cc-controls-config-versions")).toBeNull(); expect(screen.queryByTestId("cc-controls-org-chart")).toBeNull(); expect(screen.queryByTestId("cc-controls-heartbeat")).toBeNull(); assertScrollOwnerContract(overviewPanel); diff --git a/packages/dashboard/app/components/command-center/__tests__/OrgPortabilityControls.test.tsx b/packages/dashboard/app/components/command-center/__tests__/OrgPortabilityControls.test.tsx index 6207955911..788c848b95 100644 --- a/packages/dashboard/app/components/command-center/__tests__/OrgPortabilityControls.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/OrgPortabilityControls.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { ConfirmDialogProvider } from "../../../hooks/useConfirm"; import { OrgPortabilityControls } from "../OrgPortabilityControls"; @@ -22,39 +22,27 @@ describe("OrgPortabilityControls", () => { }); afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); - it("renders the export/import and empty version surfaces", async () => { - api.mockResolvedValueOnce({ revisions: [] }); + it("renders the export/import controls without a configuration versions card", async () => { renderControls(); expect(await screen.findByTestId("cc-controls-org-portability")).toBeTruthy(); - expect(screen.getByTestId("cc-controls-config-versions")).toBeTruthy(); - expect(screen.getByTestId("cc-config-versions-empty")).toBeTruthy(); + expect(screen.queryByTestId("cc-controls-config-versions")).toBeNull(); expect(screen.getByRole("button", { name: "Export org bundle" })).toBeTruthy(); }); it("exports through the project-scoped route and reports success", async () => { - api.mockResolvedValueOnce({ revisions: [] }).mockResolvedValueOnce({ bundle: { version: 1 } }); + api.mockResolvedValueOnce({ bundle: { version: 1 } }); renderControls(); - await screen.findByTestId("cc-config-versions-empty"); fireEvent.click(screen.getByRole("button", { name: "Export org bundle" })); await waitFor(() => expect(api).toHaveBeenCalledWith("/org/export?projectId=project-1", { method: "POST" })); expect(await screen.findByText("Export ready")).toBeTruthy(); }); - it("shows version load errors instead of an empty leftover list shell", async () => { - api.mockRejectedValueOnce(new Error("history unavailable")); - renderControls(); - - expect(await screen.findByRole("alert")).toHaveTextContent("history unavailable"); - expect(screen.queryByTestId("cc-config-versions-empty")).toBeNull(); - expect(screen.queryByTestId("cc-config-versions-list")).toBeNull(); - }); it("previews then confirms and applies an import", async () => { - api.mockResolvedValueOnce({ revisions: [] }).mockResolvedValueOnce({ result: { created: { agents: ["agent"] } } }).mockResolvedValueOnce({ result: {} }).mockResolvedValueOnce({ revisions: [] }); + api.mockResolvedValueOnce({ result: { created: { agents: ["agent"] } } }).mockResolvedValueOnce({ result: {} }); renderControls(); - await screen.findByTestId("cc-config-versions-empty"); fireEvent.change(screen.getByLabelText("Org bundle JSON"), { target: { value: '{"version":1}' } }); fireEvent.click(screen.getByRole("button", { name: "Preview import" })); expect(await screen.findByTestId("cc-org-import-preview")).toBeTruthy(); @@ -66,9 +54,8 @@ describe("OrgPortabilityControls", () => { it("invalidates a preview when the bundle changes before its dry-run response", async () => { let resolvePreview: ((value: { result: unknown }) => void) | undefined; - api.mockResolvedValueOnce({ revisions: [] }).mockImplementationOnce(() => new Promise<{ result: unknown }>((resolve) => { resolvePreview = resolve; })); + api.mockImplementationOnce(() => new Promise<{ result: unknown }>((resolve) => { resolvePreview = resolve; })); renderControls(); - await screen.findByTestId("cc-config-versions-empty"); fireEvent.change(screen.getByLabelText("Org bundle JSON"), { target: { value: '{"version":1}' } }); fireEvent.click(screen.getByRole("button", { name: "Preview import" })); @@ -78,17 +65,7 @@ describe("OrgPortabilityControls", () => { resolvePreview?.({ result: { created: { agents: ["agent"] } } }); await waitFor(() => expect(screen.queryByTestId("cc-org-import-preview")).toBeNull()); expect(screen.getByRole("button", { name: "Apply import" })).toBeDisabled(); - expect(api).toHaveBeenCalledTimes(2); + expect(api).toHaveBeenCalledTimes(1); }); - it("renders populated versions and rolls one back after confirmation", async () => { - api.mockResolvedValueOnce({ revisions: [{ id: "revision-1", configKind: "project-settings", createdAt: "2026-07-18T12:00:00.000Z", source: "mutation" }] }).mockResolvedValueOnce({ revision: { id: "forward" } }).mockResolvedValueOnce({ revisions: [] }); - const { onSettingsRefresh } = renderControls(); - expect(await screen.findByTestId("cc-config-versions-list")).toBeTruthy(); - fireEvent.click(screen.getByRole("button", { name: "Roll back" })); - fireEvent.click(within(await screen.findByRole("dialog", { name: "Roll back configuration?" })).getByRole("button", { name: "Roll back" })); - - await waitFor(() => expect(api).toHaveBeenCalledWith("/config/revisions/revision-1/rollback?projectId=project-1", { method: "POST" })); - expect(onSettingsRefresh).toHaveBeenCalled(); - }); }); diff --git a/packages/dashboard/app/components/settings/sections/ConfigurationVersionsSection.css b/packages/dashboard/app/components/settings/sections/ConfigurationVersionsSection.css new file mode 100644 index 0000000000..b39d95269e --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/ConfigurationVersionsSection.css @@ -0,0 +1,56 @@ +/* +FNXC:SettingsNavigation 2026-07-18-12:30: +FN-8350 gives configuration history Settings-owned layout rather than retaining +Command Center card selectors after the dashboard affordance is removed. +*/ +.configuration-versions-section { + display: grid; + gap: var(--space-sm); +} + +.configuration-versions-section__list { + display: grid; + gap: var(--space-sm); + margin: 0; + padding: 0; + list-style: none; +} + +.configuration-versions-section__item { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + padding-block: var(--space-sm); + border-block-end: 1px solid var(--border-subtle); +} + +.configuration-versions-section__item span { + display: grid; + gap: var(--space-xs); + min-inline-size: 0; +} + +.configuration-versions-section__item strong { + color: var(--text); +} + +.configuration-versions-section__item small { + color: var(--text-muted); +} + +.configuration-versions-section__error { + color: var(--color-error); +} + +@media (max-width: 768px) { + .configuration-versions-section__item { + align-items: stretch; + flex-direction: column; + } + + .configuration-versions-section__item .btn { + inline-size: 100%; + justify-content: center; + } +} diff --git a/packages/dashboard/app/components/settings/sections/ConfigurationVersionsSection.tsx b/packages/dashboard/app/components/settings/sections/ConfigurationVersionsSection.tsx new file mode 100644 index 0000000000..bcccfff203 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/ConfigurationVersionsSection.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from "react"; +import { RotateCcw } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { api, withProjectId } from "../../../api/legacy"; +import { useConfirm } from "../../../hooks/useConfirm"; +import "./ConfigurationVersionsSection.css"; + +interface ConfigurationRevision { + id: string; + configKind: string; + createdAt: string; + source: "mutation" | "rollback"; + changedBy?: { kind?: string; id?: string }; +} + +interface ConfigurationVersionsSectionProps { + projectId?: string; + onSettingsRefresh: () => Promise; +} + +/* +FNXC:SettingsNavigation 2026-07-18-12:30: +FN-8350 moves recorded configuration versions and their confirmed rollback action +out of Command Center into Settings. This preserves the project-scoped revision +API and refreshes the Settings form after a restore so displayed controls match +the newly recorded forward revision. +*/ +export function ConfigurationVersionsSection({ projectId, onSettingsRefresh }: ConfigurationVersionsSectionProps) { + const { t } = useTranslation("app"); + const { confirm } = useConfirm(); + const [revisions, setRevisions] = useState(null); + const [revisionsError, setRevisionsError] = useState(null); + const [rollbackId, setRollbackId] = useState(null); + + const loadRevisions = async () => { + try { + setRevisionsError(null); + const response = await api<{ revisions?: ConfigurationRevision[] }>(withProjectId("/config/revisions", projectId)); + setRevisions(Array.isArray(response.revisions) ? response.revisions : []); + } catch (error) { + setRevisions(null); + setRevisionsError(error instanceof Error ? error.message : t("settings.configVersions.loadError", "Unable to load configuration versions")); + } + }; + + useEffect(() => { void loadRevisions(); }, [projectId]); + + const rollback = async (revision: ConfigurationRevision) => { + const approved = await confirm({ + title: t("settings.configVersions.confirmTitle", "Roll back configuration?"), + message: t("settings.configVersions.confirmMessage", "Restore this version? The rollback is recorded as a new version."), + confirmLabel: t("settings.configVersions.confirmRollback", "Roll back"), + cancelLabel: t("actions.cancel", "Cancel"), + }); + if (!approved) return; + setRollbackId(revision.id); + try { + await api(withProjectId(`/config/revisions/${encodeURIComponent(revision.id)}/rollback`, projectId), { method: "POST" }); + await onSettingsRefresh(); + await loadRevisions(); + } catch (error) { + setRevisionsError(error instanceof Error ? error.message : t("settings.configVersions.rollbackError", "Unable to roll back configuration")); + } finally { + setRollbackId(null); + } + }; + + return
+

{t("settings.configVersions.title", "Configuration versions")}

+

{t("settings.configVersions.description", "Restore any recorded project configuration version.")}

+ {revisionsError ?

{revisionsError}

: revisions === null ?

{t("settings.configVersions.loading", "Loading versions…")}

: revisions.length === 0 ?

{t("settings.configVersions.empty", "No configuration versions yet.")}

:
    {revisions.map((revision) =>
  • {revision.configKind}{new Date(revision.createdAt).toLocaleString()}
  • )}
} +
; +} + +export default ConfigurationVersionsSection; diff --git a/packages/dashboard/app/components/settings/sections/__tests__/ConfigurationVersionsSection.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/ConfigurationVersionsSection.test.tsx new file mode 100644 index 0000000000..bad0f86377 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/__tests__/ConfigurationVersionsSection.test.tsx @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { ConfirmDialogProvider } from "../../../../hooks/useConfirm"; +import { ConfigurationVersionsSection } from "../ConfigurationVersionsSection"; + +const api = vi.fn(); +vi.mock("../../../../api/legacy", () => ({ + api: (...args: unknown[]) => api(...args), + withProjectId: (path: string, projectId?: string) => projectId ? `${path}?projectId=${projectId}` : path, +})); + +function renderSection() { + const onSettingsRefresh = vi.fn().mockResolvedValue(undefined); + render(); + return { onSettingsRefresh }; +} + +describe("ConfigurationVersionsSection", () => { + beforeEach(() => vi.clearAllMocks()); + afterEach(cleanup); + + it("renders the empty project configuration history", async () => { + api.mockResolvedValueOnce({ revisions: [] }); + renderSection(); + + expect(await screen.findByTestId("settings-config-versions-empty")).toHaveTextContent("No configuration versions yet."); + expect(api).toHaveBeenCalledWith("/config/revisions?projectId=project-1"); + }); + + it("renders revision rows returned by the project-scoped API", async () => { + api.mockResolvedValueOnce({ revisions: [{ id: "revision-1", configKind: "project-settings", createdAt: "2026-07-18T12:00:00.000Z", source: "mutation" }] }); + renderSection(); + + expect(await screen.findByTestId("settings-config-versions-list")).toHaveTextContent("project-settings"); + expect(screen.getByRole("button", { name: "Roll back" })).toBeTruthy(); + }); + + it("surfaces revision load failures without an empty list shell", async () => { + api.mockRejectedValueOnce(new Error("history unavailable")); + renderSection(); + + expect(await screen.findByRole("alert")).toHaveTextContent("history unavailable"); + expect(screen.queryByTestId("settings-config-versions-empty")).toBeNull(); + expect(screen.queryByTestId("settings-config-versions-list")).toBeNull(); + }); + + it("rolls back a confirmed revision and refreshes settings", async () => { + api.mockResolvedValueOnce({ revisions: [{ id: "revision-1", configKind: "project-settings", createdAt: "2026-07-18T12:00:00.000Z", source: "mutation" }] }).mockResolvedValueOnce({ revision: { id: "forward" } }).mockResolvedValueOnce({ revisions: [] }); + const { onSettingsRefresh } = renderSection(); + await screen.findByTestId("settings-config-versions-list"); + + fireEvent.click(screen.getByRole("button", { name: "Roll back" })); + fireEvent.click(within(await screen.findByRole("dialog", { name: "Roll back configuration?" })).getByRole("button", { name: "Roll back" })); + + await waitFor(() => expect(api).toHaveBeenCalledWith("/config/revisions/revision-1/rollback?projectId=project-1", { method: "POST" })); + await waitFor(() => expect(onSettingsRefresh).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 0fa35e8281..a1f4299569 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -1612,19 +1612,6 @@ "confirmTitle": "Import organization bundle?", "confirmMessage": "This applies the previewed configuration to this project.", "confirmApply": "Import bundle" - }, - "versions": { - "title": "Configuration versions", - "description": "Restore any recorded project configuration version.", - "loading": "Loading versions…", - "loadError": "Unable to load configuration versions", - "rollbackError": "Unable to roll back configuration", - "empty": "No configuration versions yet.", - "rollback": "Roll back", - "rollingBack": "Rolling back…", - "confirmTitle": "Roll back configuration?", - "confirmMessage": "Restore this version? The rollback is recorded as a new version.", - "confirmRollback": "Roll back" } }, "ecosystem": { @@ -6339,7 +6326,8 @@ "project": "Specific to this project" }, "globalMcp": "MCP Servers · Global", - "mcp": "MCP Servers · Project" + "mcp": "MCP Servers · Project", + "configVersions": "Configuration Versions" }, "nodeRouting": { "blockExecution": "Block execution", @@ -6831,6 +6819,19 @@ "embeddedConnectionCap": "Embedded PostgreSQL connection cap", "embeddedConnectionCapError": "Enter a value between 32 and 2,000.", "advanced": "Advanced database settings" + }, + "configVersions": { + "title": "Configuration versions", + "description": "Restore any recorded project configuration version.", + "loading": "Loading versions…", + "loadError": "Unable to load configuration versions", + "rollbackError": "Unable to roll back configuration", + "empty": "No configuration versions yet.", + "rollback": "Roll back", + "rollingBack": "Rolling back…", + "confirmTitle": "Roll back configuration?", + "confirmMessage": "Restore this version? The rollback is recorded as a new version.", + "confirmRollback": "Roll back" } }, "setup": {