diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 4725d80..1bbf4c5 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -230,17 +230,31 @@ export function Layout() { {/* Footer */}
-

- Powered by{" "} - - OpenDecree - -

+
+

+ v{__APP_VERSION__} + {" · "} + + Docs + +

+

+ Powered by{" "} + + OpenDecree + +

+
diff --git a/src/components/__tests__/Layout.full.test.tsx b/src/components/__tests__/Layout.full.test.tsx new file mode 100644 index 0000000..dd6c8e2 --- /dev/null +++ b/src/components/__tests__/Layout.full.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { AuthContext } from "../../lib/auth"; +import { Layout } from "../Layout"; + +vi.mock("../../lib/config", () => ({ + config: { layoutMode: "full", appName: "Test App", logoUrl: "" }, +})); + +beforeAll(() => { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi + .fn() + .mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }), + }); +}); + +function renderLayout() { + return render( + + {} }}> + + + , + ); +} + +describe("Layout — full mode", () => { + it("renders sidebar navigation", () => { + renderLayout(); + expect(screen.getByRole("navigation")).toBeInTheDocument(); + }); + + it("shows Home nav link", () => { + renderLayout(); + expect(screen.getByText("Home")).toBeInTheDocument(); + }); + + it("renders footer with version number", () => { + renderLayout(); + expect(screen.getByText(/^v\d/)).toBeInTheDocument(); + }); + + it("renders footer Docs link", () => { + renderLayout(); + expect(screen.getByRole("link", { name: "Docs" })).toBeInTheDocument(); + }); + + it("renders footer Powered by OpenDecree link", () => { + renderLayout(); + expect(screen.getByRole("link", { name: "OpenDecree" })).toBeInTheDocument(); + }); +}); diff --git a/src/components/__tests__/Layout.single-tenant.test.tsx b/src/components/__tests__/Layout.single-tenant.test.tsx new file mode 100644 index 0000000..bb57268 --- /dev/null +++ b/src/components/__tests__/Layout.single-tenant.test.tsx @@ -0,0 +1,89 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { AuthContext } from "../../lib/auth"; +import { Layout } from "../Layout"; + +vi.mock("../../lib/config", () => ({ + config: { + layoutMode: "single-tenant", + tenantId: "00000000-0000-0000-0000-000000000001", + appName: "Test App", + logoUrl: "", + }, +})); + +beforeAll(() => { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi + .fn() + .mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }), + }); +}); + +function renderLayout() { + return render( + + {}, + }} + > + + + , + ); +} + +describe("Layout — single-tenant mode", () => { + it("renders sidebar navigation", () => { + renderLayout(); + expect(screen.getByRole("navigation")).toBeInTheDocument(); + }); + + it("does not show Home nav link", () => { + renderLayout(); + expect(screen.queryByText("Home")).not.toBeInTheDocument(); + }); + + it("shows Configuration nav item", () => { + renderLayout(); + expect(screen.getByText("Configuration")).toBeInTheDocument(); + }); + + it("shows History nav item", () => { + renderLayout(); + expect(screen.getByText("History")).toBeInTheDocument(); + }); + + it("shows Audit Log nav item", () => { + renderLayout(); + expect(screen.getByText("Audit Log")).toBeInTheDocument(); + }); + + it("shows Usage nav item", () => { + renderLayout(); + expect(screen.getByText("Usage")).toBeInTheDocument(); + }); + + it("renders footer with version number", () => { + renderLayout(); + expect(screen.getByText(/^v\d/)).toBeInTheDocument(); + }); + + it("renders footer Docs link", () => { + renderLayout(); + expect(screen.getByRole("link", { name: "Docs" })).toBeInTheDocument(); + }); + + it("renders footer Powered by OpenDecree link", () => { + renderLayout(); + expect(screen.getByRole("link", { name: "OpenDecree" })).toBeInTheDocument(); + }); +}); diff --git a/src/pages/tenants/TenantDetail.tsx b/src/pages/tenants/TenantDetail.tsx index 2b00d50..523e8aa 100644 --- a/src/pages/tenants/TenantDetail.tsx +++ b/src/pages/tenants/TenantDetail.tsx @@ -98,6 +98,9 @@ export function TenantDetail() { const [pendingChanges, setPendingChanges] = useState>(new Map()); const [description, setDescription] = useState(""); + const productMode = + appConfig.layoutMode === "single-tenant" || appConfig.layoutMode === "config-only"; + const fields = schema?.fields ?? []; const configValues = config?.values ?? []; const locks = locksData?.locks ?? []; @@ -416,6 +419,7 @@ export function TenantDetail() { lastChanged={lastChangedMap.get(field.path ?? "")} editing={editing} showLocks={canManageLocks(auth.role)} + productMode={productMode} onChange={(v) => handleChange(field.path ?? "", v)} onUndo={() => handleUndo(field.path ?? "")} onLock={() => lockMutation.mutate(field.path ?? "")} @@ -461,6 +465,7 @@ interface FieldRowProps { lastChanged?: { actor: string; time: string }; editing: boolean; showLocks: boolean; + productMode: boolean; onChange: (value: string) => void; onUndo: () => void; onLock: () => void; @@ -475,6 +480,7 @@ function FieldRow({ lastChanged, editing, showLocks, + productMode, onChange, onUndo, onLock, @@ -494,7 +500,7 @@ function FieldRow({ : "border-gray-200 dark:border-gray-800" }`} > - +
{field.title ? ( @@ -505,7 +511,15 @@ function FieldRow({ ) : ( - {field.path} + + {field.path} + )} {isDirty && } {field.nullable && ( @@ -524,7 +538,15 @@ function FieldRow({ {field.sensitive && }
{field.description && ( -

{field.description}

+

+ {field.description} +

)} {field.deprecated && field.redirectTo && (

@@ -657,11 +679,14 @@ function formatDuration(raw: string): string { return parts.join("") || "0s"; } -function TypeBadge({ type }: { type?: FieldType }) { +function TypeBadge({ type, subtle = false }: { type?: FieldType; subtle?: boolean }) { + const colorClass = subtle + ? "bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500" + : fieldTypeColor(type); return ( {fieldTypeIcon(type)} diff --git a/src/pages/tenants/__tests__/TenantDetail.full.test.tsx b/src/pages/tenants/__tests__/TenantDetail.full.test.tsx new file mode 100644 index 0000000..535f4c6 --- /dev/null +++ b/src/pages/tenants/__tests__/TenantDetail.full.test.tsx @@ -0,0 +1,129 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { AuthContext } from "../../../lib/auth"; +import { TenantDetail } from "../TenantDetail"; + +const TENANT_ID = "00000000-0000-0000-0000-000000000002"; + +vi.mock("../../../lib/config", () => ({ + config: { + layoutMode: "full", + tenantId: undefined, + appName: "", + logoUrl: "", + defaultRole: "superadmin", + defaultSubject: "admin", + }, +})); + +vi.mock("../../../lib/hooks", () => ({ + useApiClient: () => ({}), + useTenant: () => ({ + data: { + tenant: { + id: "00000000-0000-0000-0000-000000000002", + name: "Test Corp", + schemaId: "schema-2", + schemaVersion: 1, + }, + }, + isLoading: false, + error: null, + }), + useSchemaVersion: () => ({ + data: { + schema: { + id: "schema-2", + name: "Service Config", + version: 1, + fields: [ + { + path: "service.name", + title: "Service Name", + description: "The name of the service", + type: "FIELD_TYPE_STRING", + }, + { + path: "timeout", + description: "Request timeout in seconds", + type: "FIELD_TYPE_INT", + }, + ], + }, + }, + isLoading: false, + }), + useConfig: () => ({ + data: { config: { version: 1, values: [] } }, + isLoading: false, + }), + useFieldLocks: () => ({ data: { locks: [] } }), + useAuditLog: () => ({ data: { entries: [] } }), + useVersions: () => ({ data: { versions: [] } }), +})); + +vi.mock("../../../components/ConfigHistory", () => ({ + ConfigHistory: () => null, +})); + +beforeAll(() => { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi + .fn() + .mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }), + }); +}); + +function renderPage() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + {} }}> + + } /> + + + + , + ); +} + +describe("TenantDetail — full mode (no productMode)", () => { + it("renders the tenant name", () => { + renderPage(); + expect(screen.getByText("Test Corp")).toBeInTheDocument(); + }); + + it("renders field descriptions at small (text-xs) size", () => { + const { container } = renderPage(); + const descriptions = container.querySelectorAll("p.text-xs.text-gray-500"); + expect(descriptions.length).toBeGreaterThan(0); + }); + + it("does not use prominent (text-sm text-gray-600) size for descriptions", () => { + const { container } = renderPage(); + const prominentDesc = container.querySelectorAll("p.text-sm.text-gray-600"); + expect(prominentDesc.length).toBe(0); + }); + + it("renders type badges with per-type color (not neutral gray)", () => { + const { container } = renderPage(); + const subtleBadges = container.querySelectorAll(".bg-gray-100.text-gray-400"); + expect(subtleBadges.length).toBe(0); + }); + + it("renders untitled field path as bold primary", () => { + const { container } = renderPage(); + const boldPath = container.querySelector(".font-mono.font-medium"); + expect(boldPath).toBeInTheDocument(); + }); + + it("shows Back to tenants link in full mode", () => { + renderPage(); + expect(screen.getByText(/Back/)).toBeInTheDocument(); + }); +}); diff --git a/src/pages/tenants/__tests__/TenantDetail.product-mode.test.tsx b/src/pages/tenants/__tests__/TenantDetail.product-mode.test.tsx new file mode 100644 index 0000000..5f958d6 --- /dev/null +++ b/src/pages/tenants/__tests__/TenantDetail.product-mode.test.tsx @@ -0,0 +1,130 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { AuthContext } from "../../../lib/auth"; +import { TenantDetail } from "../TenantDetail"; + +// Inline literal — vi.mock factory is hoisted, can't reference const from same file. +const TENANT_ID = "00000000-0000-0000-0000-000000000001"; + +vi.mock("../../../lib/config", () => ({ + config: { + layoutMode: "single-tenant", + tenantId: "00000000-0000-0000-0000-000000000001", + appName: "", + logoUrl: "", + defaultRole: "admin", + defaultSubject: "admin", + }, +})); + +vi.mock("../../../lib/hooks", () => ({ + useApiClient: () => ({}), + useTenant: () => ({ + data: { + tenant: { + id: "00000000-0000-0000-0000-000000000001", + name: "Acme Corp", + schemaId: "schema-1", + schemaVersion: 1, + }, + }, + isLoading: false, + error: null, + }), + useSchemaVersion: () => ({ + data: { + schema: { + id: "schema-1", + name: "App Config", + version: 1, + fields: [ + { + path: "app.name", + title: "Application Name", + description: "The display name of your application", + type: "FIELD_TYPE_STRING", + }, + { + path: "max_retries", + description: "Maximum number of retries", + type: "FIELD_TYPE_INT", + }, + ], + }, + }, + isLoading: false, + }), + useConfig: () => ({ + data: { config: { version: 1, values: [] } }, + isLoading: false, + }), + useFieldLocks: () => ({ data: { locks: [] } }), + useAuditLog: () => ({ data: { entries: [] } }), + useVersions: () => ({ data: { versions: [] } }), +})); + +vi.mock("../../../components/ConfigHistory", () => ({ + ConfigHistory: () => null, +})); + +beforeAll(() => { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi + .fn() + .mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }), + }); +}); + +function renderPage() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + {} }}> + + } /> + + + + , + ); +} + +describe("TenantDetail — single-tenant productMode", () => { + it("renders the tenant name", () => { + renderPage(); + expect(screen.getByText("Acme Corp")).toBeInTheDocument(); + }); + + it("renders field descriptions at prominent (text-sm) size", () => { + const { container } = renderPage(); + const descriptions = container.querySelectorAll("p.text-sm.text-gray-600"); + expect(descriptions.length).toBeGreaterThan(0); + }); + + it("does not use small (text-xs) size for descriptions", () => { + const { container } = renderPage(); + const smallDesc = container.querySelectorAll("p.text-xs.text-gray-500"); + expect(smallDesc.length).toBe(0); + }); + + it("renders type badges with neutral gray (subtle) color", () => { + const { container } = renderPage(); + const subtleBadges = container.querySelectorAll(".bg-gray-100.text-gray-400"); + expect(subtleBadges.length).toBeGreaterThan(0); + }); + + it("renders untitled field path as secondary (not bold)", () => { + const { container } = renderPage(); + const pathSpan = container.querySelector(".font-mono.text-gray-500"); + expect(pathSpan).toBeInTheDocument(); + }); + + it("does not show Back to tenants link", () => { + renderPage(); + expect(screen.queryByText(/Back/)).not.toBeInTheDocument(); + }); +}); diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index fe2a0be..fe08b4e 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,5 +1,7 @@ /// +declare const __APP_VERSION__: string; + interface ImportMetaEnv { readonly VITE_API_URL: string; readonly VITE_LAYOUT_MODE: string; diff --git a/vite.config.ts b/vite.config.ts index 8de4ae5..bbbb73f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,14 @@ import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; +import { readFileSync } from "node:fs"; import { defineConfig } from "vite"; +const pkg = JSON.parse(readFileSync("./package.json", "utf-8")) as { version: string }; + export default defineConfig({ + define: { + __APP_VERSION__: JSON.stringify(pkg.version), + }, plugins: [react(), tailwindcss()], server: { proxy: { diff --git a/vitest.config.ts b/vitest.config.ts index c52da8c..aa83c54 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,12 @@ +import { readFileSync } from "node:fs"; import { defineConfig } from "vitest/config"; +const pkg = JSON.parse(readFileSync("./package.json", "utf-8")) as { version: string }; + export default defineConfig({ + define: { + __APP_VERSION__: JSON.stringify(pkg.version), + }, test: { environment: "jsdom", globals: true,