From 682adadce56b44c59fa07fe6f3cac0aee2397846 Mon Sep 17 00:00:00 2001 From: zeevdr Date: Sun, 31 May 2026 09:55:50 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(layout):=20single-tenant=20UX=20polish?= =?UTF-8?q?=20=E2=80=94=20footer,=20field=20hierarchy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the visual polish items from issue #13: - Sidebar footer: add version number and Docs link alongside the existing "Powered by OpenDecree" line. Version injected at build time via __APP_VERSION__ (Vite define from package.json). - Config editor: in single-tenant and config-only modes, show field descriptions at full text-sm weight ("prominent"), dim untitled field paths to secondary gray, and render type badges in neutral gray instead of per-type colors ("subtle"). - Tests: add Layout.full and Layout.single-tenant test suites covering footer content and nav item presence/absence. Closes #13 Co-Authored-By: Claude --- src/components/Layout.tsx | 36 +++++--- src/components/__tests__/Layout.full.test.tsx | 55 ++++++++++++ .../__tests__/Layout.single-tenant.test.tsx | 89 +++++++++++++++++++ src/pages/tenants/TenantDetail.tsx | 35 ++++++-- src/vite-env.d.ts | 2 + vite.config.ts | 6 ++ vitest.config.ts | 6 ++ 7 files changed, 213 insertions(+), 16 deletions(-) create mode 100644 src/components/__tests__/Layout.full.test.tsx create mode 100644 src/components/__tests__/Layout.single-tenant.test.tsx 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/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, From b63c94a0a8553f648e5fa4dd715e8605f4a8023b Mon Sep 17 00:00:00 2001 From: zeevdr Date: Sun, 31 May 2026 12:23:14 +0300 Subject: [PATCH 2/2] test(tenant-detail): cover productMode field display branches Add TenantDetail unit tests for the single-tenant and full layout modes introduced in the previous commit. Tests verify that field descriptions use prominent sizing in product mode and small sizing in full mode, that type badges use neutral gray (subtle) vs per-type colors, and that untitled field paths are secondary vs bold. Fixes codecov/patch failure on PR #76. Co-Authored-By: Claude --- .../__tests__/TenantDetail.full.test.tsx | 129 +++++++++++++++++ .../TenantDetail.product-mode.test.tsx | 130 ++++++++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 src/pages/tenants/__tests__/TenantDetail.full.test.tsx create mode 100644 src/pages/tenants/__tests__/TenantDetail.product-mode.test.tsx 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(); + }); +});