From 5b4304ed7e979c5842d5a2a1e3d6989c3b617f06 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:55:58 -0400 Subject: [PATCH 1/2] feat(frontend): draw every dashboard with the kit's metric tier, and refuse re-implementing it (#1041) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@codefly-dev/ui/dashboard` shipped two tiers. The host's own operations pages used the metric one; a solution's dashboard got the older basic one — twice over, because `src/solutions/SolutionDashboard.tsx` carried its own copy of the widget-to-chart dispatch, the responsive grid, the card shell and the empty state beside the kit's ``, and both copies drew with `charts.tsx`. So the same declaration rendered plainer inside the host than beside it, and there were two renderers of one thing to keep in step. **A fix at the owner.** `` now paints with the metric tier, contract unchanged (a resolved `DashboardView` in; no fetching, no host context, no app imports). A scalar widget is a `StatTile` — the series total in the widget's format and unit, a sparkline and a delta against the previous bucket when the metric is bucketed over time, and a `MetricStateBadge` from `series.coverage`. A series widget is the kit's metric line/area/bar chart over a `ChartSeries`, which brings the axes, the crosshair readout, the legend and the visually-hidden data table that is the chart's screen-reader representation. A withheld total renders as a dash and never as `0`: `Metric.value` now admits `null` so that is a property of the primitive rather than of a state mapping, and a new `no_total` state says which of partial / empty / no-total withheld it. A series with no points reads as no data, not as a zero, because the aggregate omits a bucket instead of emitting an empty one. A sparkline and a bucket delta are drawn only for a time-bucketed series — a line through counts grouped by event type would assert an order the data does not have. `SolutionDashboard.tsx` now mounts that renderer and draws nothing itself. It keeps exactly what is the host's, composed through four new presentational slots: the + menu as header `actions`, the drag-to-reorder `SortableGrid` as `layout`, the per-widget audit query as `renderWidget`, and its own `empty` line. The queries stay one per widget — a widget whose metric errors still fails alone — which is why the kit also exports `DashboardWidget`, the same renderer for one widget: React fixes the number of hooks a component may call, so a varying set of per-widget queries cannot live in a renderer that takes the whole view. Its 16 existing tests still pass; two assertions and one helper moved onto the markup the metric tier produces, each with the reason in place. **A gate**, in the shape the kit's other consumer already uses: `src/components/__tests__/kit.test.ts`. A declaration taking a name `@codefly-dev/ui` exports must render the kit's component of that name or carry a reviewed entry saying what the two things are; `class-variance-authority` is no longer a dependency of this app (nothing imported it), so a kit-shaped primitive cannot be built here at all; and the two chart surfaces stay re-exports. Module-local declarations count, not only exported ones — that is how both duplicates in this tree arrived, and a gate matching only exports walked past both. Nine fixtures cover must-catch, must-not-catch, and the same duplication reached another way. Docs: `module/AUDIT_METRICS.md` gains "How a resolved series is drawn" — the tier, and a table of what each widget and each coverage state renders, which is what downstream consumers were guessing at; `module/services/frontend/AGENTS.md` says what the solution dashboard owns and what the kit draws; the kit's README names the tier and the slots. Refs #1041 Co-Authored-By: Claude Opus 5 (1M context) --- module/AUDIT_METRICS.md | 33 +- module/services/frontend/AGENTS.md | 34 ++ .../services/frontend/code/package-lock.json | 1 - module/services/frontend/code/package.json | 1 - .../code/packages/codefly-ui/README.md | 11 +- .../dashboard/__tests__/dashboard.test.tsx | 474 ++++++++++++++++-- .../src/dashboard/__tests__/types.test.ts | 10 +- .../codefly-ui/src/dashboard/dashboard.tsx | 382 ++++++++++++-- .../codefly-ui/src/dashboard/index.ts | 8 +- .../codefly-ui/src/dashboard/metric-state.tsx | 6 + .../codefly-ui/src/dashboard/metric-tiles.tsx | 61 ++- .../codefly-ui/src/dashboard/types.ts | 28 +- .../code/src/components/__tests__/kit.test.ts | 406 +++++++++++++++ .../webhooks/ui/webhook-deliveries-panel.tsx | 9 +- .../code/src/solutions/SolutionDashboard.tsx | 354 ++++++------- .../__tests__/SolutionDashboard.test.tsx | 28 +- 16 files changed, 1528 insertions(+), 318 deletions(-) create mode 100644 module/services/frontend/code/src/components/__tests__/kit.test.ts diff --git a/module/AUDIT_METRICS.md b/module/AUDIT_METRICS.md index 473dec252..44c0a2ec4 100644 --- a/module/AUDIT_METRICS.md +++ b/module/AUDIT_METRICS.md @@ -194,7 +194,8 @@ never the SDK binding input. Regenerate the export before generating the SDK. ## Unknown and partial results -Empty queries have no points and render **No data yet**. Numeric sums with no +Empty queries have no points and render as no data, in the form the widget's +visualization has for it (see *How a resolved series is drawn*). Numeric sums with no numeric observations omit their value, just like averages/percentiles; an emitted zero stays zero. `samples[alias]` reports observations before reduction (so repeated logical IDs are not mistaken for missing telemetry). Fewer samples than @@ -212,6 +213,36 @@ The host DSL also withholds totals after top-N truncation. Authorization/RPC errors remain errors, never empty successful results. Existing consumers of the SDK must handle `total: null` when adopting this contract. +## How a resolved series is drawn + +Every dashboard built from these series — the host's own pages, a solution's +declared dashboard, and a solution remote that mounts the kit itself — is drawn +by one renderer, `` from `@codefly-dev/ui/dashboard`, with the kit's +**metric tier**. There is no second, plainer rendering: a consuming solution +does not have to ask which half of the kit its dashboard will get. + +| Widget | Drawn as | Empty series | `coverage: "partial"` | `total: null` | +| --- | --- | --- | --- | --- | +| `number` | `StatTile`: the series total, in the widget's `format` and `unit`; a sparkline and a delta against the previous bucket when the metric is bucketed over **time** | `—` with a **No data** badge, never `0` | `—` with a **Partial data** badge | `—` with a **No total** badge when coverage is complete | +| `line` / `area` / `bar` | the kit's metric chart over a `ChartSeries` — axes from the appearance palette, a crosshair readout, a legend for two or more series, and every exact value in a visually-hidden data table | the chart's own **No data** plot, plus a **No data** badge on the card | the observed points still plot, with a **Partial data** badge on the card | nothing: a series widget shows no total | +| `table` | key/value rows | **No data yet.** | a **Partial data** badge on the card | — | + +Two of those cells are the contract, not presentation. A `null` total renders as +a dash and never as `0`, because "cannot say" and "nothing happened" are +different answers and only one of them is in the data; and a series with no +points is no data rather than a zero, because the aggregate omits a bucket +instead of emitting an empty one. A sparkline and a bucket delta are drawn +**only** for a time-bucketed series: a line through counts grouped by event type +or region would assert an order, and a delta between two of them a change over +time, that a categorical grouping does not have. + +A host composes its own features around that renderer — ``'s slots +take header actions, a layout (the host's solution dashboard passes a +drag-to-reorder grid), a per-widget renderer for a host that resolves each +widget in its own query, and an empty state. A consumer re-implementing any part +of the drawing is refused by a gate: `src/components/__tests__/kit.test.ts` in +the frontend. + ## Independent validation Run `go test -race ./pkg/infra/auditmetricstest` from accounts/code for pure diff --git a/module/services/frontend/AGENTS.md b/module/services/frontend/AGENTS.md index 00759fb49..bf0ba54e6 100644 --- a/module/services/frontend/AGENTS.md +++ b/module/services/frontend/AGENTS.md @@ -115,6 +115,40 @@ whether `/s/{id}` renders, and it stays installation-blind. observation speaks for the present is the host's to decide, and a copy of that window here would drift from it. +## Drawing a solution's declared dashboard + +`src/solutions/SolutionDashboard.tsx` **mounts** the shared kit's +`` (`@codefly-dev/ui/dashboard`); it draws nothing itself. Which +tier each widget and each coverage state is drawn with is the module's +[AUDIT_METRICS.md](../../AUDIT_METRICS.md) ("How a resolved series is drawn") — +one renderer for the host's pages, a declared solution dashboard and a solution +remote alike. + +What this file owns is only what is the host's, composed through that +renderer's slots: + +- **the audit queries**, one per widget, so a widget whose metric errors fails + alone instead of blanking its siblings. Each lives in its own component + (`SolutionWidget`) that resolves the series and then mounts the kit's + `DashboardWidget`, because React fixes the number of hooks a component may + call and a renderer taking the whole view cannot hold a varying number of + queries; +- **the viewer's arrangement** — reorder by drag or arrow keys, remove, add + back, saved per viewer and organization (`dashboard-layout.ts`), handed to + the renderer's `layout` slot as the kit's `SortableGrid`; +- **the ⓘ** that says what a tile counts, from which audit events, over what + range and who caused the newest ones (`metric-info.ts`, the principal + directory, the recent-events drill-down), handed to each widget as its + header actions. + +Re-implementing any part of the drawing is refused by +`src/components/__tests__/kit.test.ts`: a declaration taking a name +`@codefly-dev/ui` exports must render the kit's component of that name or carry +a reviewed entry, `class-variance-authority` is not a dependency of this app so +a kit-shaped primitive cannot be built here, and the two chart surfaces +(`src/components/charts/chart.tsx`, +`src/features/dashboard/ui/charts/line-chart.tsx`) stay re-exports. + ## Loading a remote, and its CSP `/s/[solutionId]` (`src/app/(dashboard)/s/[solutionId]/page.tsx`) loads the diff --git a/module/services/frontend/code/package-lock.json b/module/services/frontend/code/package-lock.json index b10edc9c7..46b1d9462 100644 --- a/module/services/frontend/code/package-lock.json +++ b/module/services/frontend/code/package-lock.json @@ -34,7 +34,6 @@ "@simplewebauthn/browser": "^13.3.0", "@tanstack/react-query": "^5.60.0", "@tanstack/react-table": "^8.21.3", - "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "codefly": "https://github.com/codefly-dev/sdk-js/archive/9f3bb78682a0c2b9a70631d35c2ae0094c281ac5.tar.gz", "js-yaml": "^4.3.2", diff --git a/module/services/frontend/code/package.json b/module/services/frontend/code/package.json index 7f3f13676..47289638d 100644 --- a/module/services/frontend/code/package.json +++ b/module/services/frontend/code/package.json @@ -57,7 +57,6 @@ "@simplewebauthn/browser": "^13.3.0", "@tanstack/react-query": "^5.60.0", "@tanstack/react-table": "^8.21.3", - "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "codefly": "https://github.com/codefly-dev/sdk-js/archive/9f3bb78682a0c2b9a70631d35c2ae0094c281ac5.tar.gz", "js-yaml": "^4.3.2", diff --git a/module/services/frontend/code/packages/codefly-ui/README.md b/module/services/frontend/code/packages/codefly-ui/README.md index 070eee474..95543fdca 100644 --- a/module/services/frontend/code/packages/codefly-ui/README.md +++ b/module/services/frontend/code/packages/codefly-ui/README.md @@ -36,6 +36,15 @@ and the v1 subset — is [CATALOG.md](./CATALOG.md). (`Banner`, `EmptyState`, `ErrorState`) and overlays (`Dialog`, `AlertDialog`, `Notice`, `Tooltip`, `DropdownMenu`); `dashboard` is ``, charts, `fromDashboardData`; `chat` is ``. React only: no plugin runtime, no host context. + `` paints with the **metric tier** — `StatTile`/`KPIRow` for scalar + widgets, `metric-chart`'s line/area/bar over `ChartSeries` for series ones — + which is the tier the host's own operations pages use, so a solution's + dashboard and the host's own are the same picture. `DashboardWidget` is that + renderer for one widget, exported for a consumer that resolves each widget in + its own query; `slots` take header actions, a layout, a per-widget renderer + and an empty state, so a consumer composes its own features around the + renderer rather than beside it. What each widget and each coverage state + draws is tabulated in the module's `AUDIT_METRICS.md`. This is the surface a solution fe-remote consumes. `` is fed by `@codefly-dev/saas-sdk`'s `useChatStream` — the hook owns the SSE/WS transport, the component stays pure, the same split as `runDashboard` → ``. @@ -106,7 +115,7 @@ solution measured where it had to reach past the kit. | `@codefly-dev/ui/plugin-host/ui` | Client UI adapters (`PluginErrorBoundary`) | | `@codefly-dev/ui/skin` | `resolveSkin`, skin types | | `@codefly-dev/ui/layout` | `Card`/`Section`/`Tabs` + shadcn primitives (React-only) | -| `@codefly-dev/ui/dashboard` | `Dashboard`, charts, `fromDashboardData` (React-only) | +| `@codefly-dev/ui/dashboard` | `Dashboard`, `DashboardWidget`, metric tiles + charts, `fromDashboardData` (React-only) | | `@codefly-dev/ui/chat` | `Chat` (React-only) | | `@codefly-dev/ui/content` | `Content`, `Markdown`, `JsonView`, `CodeBlock`, `TextBlock` (React-only) | | `@codefly-dev/ui/board` | `Board` (React-only) | diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/dashboard.test.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/dashboard.test.tsx index cd9ffb4a2..d305553f5 100644 --- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/dashboard.test.tsx +++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/dashboard.test.tsx @@ -1,77 +1,449 @@ // @vitest-environment happy-dom -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, render, screen, within } from "@testing-library/react"; import { afterEach, describe, expect, it } from "vitest"; import { Dashboard } from "../dashboard.js"; -import type { DashboardView } from "../types.js"; +import type { + DashboardView, + DashboardWidgetView, + WidgetSeries, +} from "../types.js"; afterEach(cleanup); -const emptySeries = { points: [], total: 0 }; +// Two daily buckets: a time-bucketed series, so it earns a trend. +const daily: WidgetSeries = { + points: [ + { key: "2026-09-01T00:00:00Z", value: 3 }, + { key: "2026-09-02T00:00:00Z", value: 5 }, + ], + total: 8, +}; + +// The same counts grouped by a category: an ordered line through these would +// assert a direction the data does not have. +const byCategory: WidgetSeries = { + points: [ + { key: "identity", value: 3 }, + { key: "security", value: 5 }, + ], + total: 8, +}; + +const empty: WidgetSeries = { points: [], total: 0, coverage: "empty" }; + +function view(...widgets: DashboardWidgetView[]): DashboardView { + return { widgets }; +} + +function show(...widgets: DashboardWidgetView[]) { + return render(); +} describe("Dashboard", () => { - it("renders its header through Section and each widget through Card", () => { - const data: DashboardView = { - title: "Traffic", - description: "last 7 days", - widgets: [ - { - id: "w1", - title: "Visits", - visualization: "number", - series: emptySeries, - }, - ], - }; - const { container } = render(); - - // Section owns the header markup — its
root and heading classes. - const section = container.querySelector("section"); - expect(section).not.toBeNull(); + it("renders its header through Section and each series widget through Card", () => { + const { container } = render( + , + ); + + // Section owns the header markup — its
root and heading. + expect(container.querySelector("section")).not.toBeNull(); expect( screen.getByRole("heading", { name: "Traffic", level: 2 }), ).toBeTruthy(); expect(screen.getByText("last 7 days")).toBeTruthy(); - // Card owns the surface — one card per widget, painted by the primitive. - const card = container.querySelector( - ".rounded-lg.border.bg-card.p-4.text-card-foreground.shadow-sm", - ); - expect(card).not.toBeNull(); + // Card owns the surface — one card per series widget, painted by the + // primitive, with the widget's title as its heading. + expect( + container.querySelector( + ".rounded-lg.border.bg-card.p-4.text-card-foreground.shadow-sm", + ), + ).not.toBeNull(); expect( screen.getByRole("heading", { name: "Visits", level: 3 }), ).toBeTruthy(); }); it("scopes the accent override onto the Section root", () => { - const data: DashboardView = { - accent: "hotpink", - widgets: [{ id: "w1", visualization: "number", series: emptySeries }], - }; - const { container } = render(); + const { container } = render( + , + ); const section = container.querySelector("section") as HTMLElement; expect(section.style.getPropertyValue("--primary")).toBe("hotpink"); }); + + it("says so, rather than drawing an empty grid, when the view has no widgets", () => { + show(); + expect(screen.getByText("No widgets.")).toBeTruthy(); + }); }); -it("preserves partial telemetry and unavailable totals from the SDK view", () => { - render( - { + it("draws a metric chart with an accessible data table", () => { + const { container } = render( + , + ); + const table = screen.getByRole("table", { name: "Events" }); + expect(within(table).getByRole("row", { name: /identity/ })).toBeTruthy(); + expect(within(table).getByText("3")).toBeTruthy(); + expect(within(table).getByText("5")).toBeTruthy(); + expect(container.querySelector("svg")?.getAttribute("aria-hidden")).toBe( + "true", + ); + }); + + it("draws the chart's own empty state instead of an axis with nothing on it", () => { + const { container } = render( + , + ); + expect( + container.querySelector("figure")?.getAttribute("aria-label"), + ).toBe("Events: no data"); + expect(container.querySelector("svg")).toBeNull(); + // Said twice on purpose, and in two registers: the card badges the + // coverage beside its title, and the plot says it where the geometry + // would have been. + expect(screen.getAllByText("No data")).toHaveLength(2); + }); + + it("badges a partial series on the card, from `series.coverage`", () => { + render( + , + ); + expect(screen.getByText("Partial data")).toBeTruthy(); + // A partial series keeps its observed points: the chart still draws. + expect(screen.getByRole("table", { name: "Events" })).toBeTruthy(); + }); + }, +); + +describe("a number widget", () => { + it("is a stat tile showing the series total in the widget's format", () => { + show({ + id: "w", + title: "Revenue", + visualization: "number", + series: { points: [{ key: "all", value: 4_200_000 }], total: 4_200_000 }, + format: "currency", + }); + expect(screen.getByText("Revenue")).toBeTruthy(); + expect(screen.getByText("$4.2M")).toBeTruthy(); + }); + + it("appends the widget's unit", () => { + show({ + id: "w", + title: "Throughput", + visualization: "number", + series: { points: [{ key: "all", value: 42 }], total: 42 }, + unit: "req/s", + }); + expect(screen.getByText("req/s")).toBeTruthy(); + }); + + it("sparklines a time-bucketed metric and deltas its last bucket", () => { + const { container } = show({ + id: "w", + title: "Logins", + visualization: "number", + series: daily, + }); + expect(screen.getByText("8")).toBeTruthy(); + expect(container.querySelector("svg")).not.toBeNull(); + // 3 → 5 between the last two buckets. + expect(screen.getByText(/66\.7%/).textContent).toContain( + "vs previous bucket", + ); + }); + + it("draws neither a trend nor a delta for a metric grouped by category", () => { + // A sparkline over "identity, security" would read as a direction, and a + // delta between them as a change over time. Neither is true of a + // categorical grouping, whose order is the aggregate's, not time's. + const { container } = show({ + id: "w", + title: "Logins", + visualization: "number", + series: byCategory, + }); + expect(screen.getByText("8")).toBeTruthy(); + expect(container.querySelector("svg")).toBeNull(); + expect(screen.queryByText(/%/)).toBeNull(); + }); + + it("gives no delta for a rise from zero, which is not a percentage", () => { + show({ + id: "w", + visualization: "number", + series: { + points: [ + { key: "2026-09-01T00:00:00Z", value: 0 }, + { key: "2026-09-02T00:00:00Z", value: 5 }, ], - }} - />, - ); - expect(screen.getByRole("status").textContent).toBe("Partial telemetry"); - expect(screen.getByText("Total unavailable")).toBeTruthy(); - expect(screen.queryByText("0")).toBeNull(); + total: 5, + }, + }); + expect(screen.queryByText(/%/)).toBeNull(); + }); + + it("shows a dash and says no total, never a 0, when the total is withheld", () => { + // `total: null` means the pipeline cannot name one number — a distinct + // count or an average across groups. Rendering it as 0 would report + // "nothing happened" where the answer is "cannot say". + show({ + id: "w", + title: "Distinct actors", + visualization: "number", + series: { ...byCategory, total: null }, + }); + expect(screen.getByText("—")).toBeTruthy(); + expect(screen.getByText("No total")).toBeTruthy(); + expect(screen.queryByText("0")).toBeNull(); + }); + + it("badges a partial series, which is why its total is withheld", () => { + show({ + id: "w", + title: "Logins", + visualization: "number", + series: { ...byCategory, total: null, coverage: "partial" }, + }); + expect(screen.getByText("Partial data")).toBeTruthy(); + expect(screen.getByText("—")).toBeTruthy(); + expect(screen.queryByText("No total")).toBeNull(); + }); + + it("reads an empty series as no data, not as a zero", () => { + // The audit aggregate omits a bucket rather than emitting a zero one, so + // a series with no points means nothing matched. + show({ id: "w", title: "Logins", visualization: "number", series: empty }); + expect(screen.getByText("No data")).toBeTruthy(); + expect(screen.getByText("—")).toBeTruthy(); + expect(screen.queryByText("0")).toBeNull(); + }); + + it("keeps a real zero a zero", () => { + show({ + id: "w", + title: "Failures", + visualization: "number", + series: { points: [{ key: "all", value: 0 }], total: 0 }, + }); + expect(screen.getByText("0")).toBeTruthy(); + expect(screen.queryByText("—")).toBeNull(); + }); +}); + +describe("a table widget", () => { + it("lists each point's key and value", () => { + show({ + id: "w", + title: "By type", + visualization: "table", + series: byCategory, + }); + const table = screen.getByRole("table", { name: "By type" }); + expect(within(table).getByText("identity")).toBeTruthy(); + expect(within(table).getByText("5")).toBeTruthy(); + }); + + it("says there is nothing yet for an empty series", () => { + show({ id: "w", title: "By type", visualization: "table", series: empty }); + expect(screen.getByText("No data yet.")).toBeTruthy(); + }); +}); + +describe("a widget still being resolved, or that failed", () => { + it("waits on a null series rather than drawing it as empty", () => { + const { container } = show( + { id: "chart", title: "Logins", visualization: "line", series: null }, + { id: "tile", title: "Total", visualization: "number", series: null }, + ); + expect(container.querySelectorAll('[data-slot="skeleton"]')).toHaveLength( + 2, + ); + expect(screen.queryByText("No data")).toBeNull(); + }); + + it("says a failed widget failed, and does not wait forever on its missing series", () => { + // A failed resolution leaves no series behind, so a renderer that tested + // for the series first would draw every failure as an endless wait. + const { container } = show( + { + id: "chart", + title: "Logins", + visualization: "line", + series: null, + failed: true, + }, + { + id: "tile", + title: "Total", + visualization: "number", + series: null, + failed: true, + }, + ); + expect(screen.getByText("Unable to load.")).toBeTruthy(); + expect(screen.getByText("Provider unavailable")).toBeTruthy(); + expect(container.querySelector('[data-slot="skeleton"]')).toBeNull(); + }); +}); + +describe("the default layout", () => { + it("gathers adjacent scalars into one KPI row and keeps declared order", () => { + const { container } = show( + { id: "a", title: "A", visualization: "number", series: daily }, + { id: "b", title: "B", visualization: "number", series: daily }, + { id: "c", title: "C", visualization: "line", series: daily }, + { id: "d", title: "D", visualization: "number", series: daily }, + ); + // Two tiles, then a chart, then a tile: three runs, the KPI row's own + // four-up geometry only around the adjacent pair. + const rows = [ + ...container.querySelectorAll('section > div > div[class*="grid"]'), + ]; + expect(rows).toHaveLength(3); + expect(rows[0].className).toContain("lg:grid-cols-4"); + expect(rows[0].children).toHaveLength(2); + expect(rows[1].className).toContain("sm:grid-cols-2"); + expect(rows[2].children).toHaveLength(1); + expect( + [...container.querySelectorAll('[data-slot="metric-label"], h3')].map( + (node) => node.textContent, + ), + ).toEqual(["A", "B", "C", "D"]); + }); + + it("stacks every widget in a row of its own for a stack layout", () => { + const { container } = render( + , + ); + expect(container.querySelector("section > div")?.className).toContain( + "flex flex-col", + ); + expect(container.querySelector('[class*="grid-cols-4"]')).toBeNull(); + }); +}); + +// The seams a host composes its own features through. They are what let the +// host's solution dashboard keep its reorderable grid, its + menu and its +// per-widget queries while this renderer still does the drawing. +describe("slots", () => { + const widgets: DashboardWidgetView[] = [ + { id: "a", title: "A", visualization: "line", series: daily }, + { id: "b", title: "B", visualization: "number", series: daily }, + ]; + + it("puts actions in the dashboard's own header", () => { + render( + Add }} + />, + ); + expect(screen.getByRole("button", { name: "Add" })).toBeTruthy(); + }); + + it("hands every widget to renderWidget, in the view's order", () => { + render( +
  • {widget.id}
  • }} + />, + ); + expect( + screen.getAllByRole("listitem").map((item) => item.textContent), + ).toEqual(["a", "b"]); + }); + + it("hands the tiles, each with its widget, to a layout of the host's own", () => { + render( + ( +
      + {tiles.map((tile) => ( +
    • + {tile.node} +
    • + ))} +
    + ), + }} + />, + ); + const items = screen.getAllByRole("listitem"); + expect(items.map((item) => item.dataset.visualization)).toEqual([ + "line", + "number", + ]); + // The nodes are the kit's own cards: the chart's data table is in them. + expect(within(items[0]).getByRole("table", { name: "A" })).toBeTruthy(); + }); + + it("shows the host's own empty state in place of the layout", () => { + render( + Add one back with +.

    }} + />, + ); + expect(screen.getByText("Add one back with +.")).toBeTruthy(); + expect(screen.queryByText("No widgets.")).toBeNull(); + }); }); diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/types.test.ts b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/types.test.ts index 02f7d839d..397e29328 100644 --- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/types.test.ts +++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/types.test.ts @@ -25,8 +25,14 @@ describe("fromDashboardData", () => { expect(view.columns).toBe(3); expect(view.accent).toBe("#7c3aed"); expect(view.widgets).toHaveLength(1); - expect(view.widgets[0]).toMatchObject({ id: "trend", visualization: "line" }); - expect(view.widgets[0].series.total).toBe(3); + expect(view.widgets[0]).toMatchObject({ + id: "trend", + visualization: "line", + }); + // `fromDashboardData` bridges an ALREADY-resolved result, so the series + // it produces is never the `null` the view model allows for a widget + // still being resolved. + expect(view.widgets[0].series?.total).toBe(3); }); it("defaults layout to grid when the source omits it", () => { diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/dashboard.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/dashboard.tsx index 978723a30..63d8f5fc6 100644 --- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/dashboard.tsx +++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/dashboard.tsx @@ -6,13 +6,35 @@ // solution's Module-Federation remote render identical dashboards from the same // package instance. Data resolution (metric → audit query) is the job of // `@codefly-dev/saas-sdk`'s `runDashboard`; use `fromDashboardData` to bridge. +// +// It paints with the kit's METRIC tier — `StatTile`/`KPIRow` for scalars, +// `metric-chart`'s line/area/bar over `ChartSeries` for series — which is the +// tier the host's own operations pages are drawn with. Before, this renderer +// and the host's solution dashboard both used the older `charts.tsx` tier, so a +// solution's Dashboard tab read plainer than the host's own pages from the same +// kit, and there were two renderers to keep in step rather than one. import type * as React from "react"; +import { Fragment, type ReactNode } from "react"; import { Card } from "../layout/card.js"; import { Section } from "../layout/page.js"; -import { AreaChart, BarList, LineChart, StatChart } from "./charts.js"; +import { Skeleton } from "../layout/skeleton.js"; import { cn } from "./cn.js"; -import type { DashboardView, DashboardWidgetView } from "./types.js"; +import { parseTimeKey } from "./format.js"; +import { + AreaChart as MetricAreaChart, + BarChart as MetricBarChart, + LineChart as MetricLineChart, +} from "./metric-chart.js"; +import type { ChartSeries } from "./metric-geometry.js"; +import { type MetricState, MetricStateBadge } from "./metric-state.js"; +import { KPIRow, type Metric, StatTile } from "./metric-tiles.js"; +import type { + DashboardView, + DashboardWidgetView, + SeriesPoint, + WidgetSeries, +} from "./types.js"; // Column-span utility classes, matching the responsive grid below so a spanning // card widens in step with it. A span is clamped to the grid's column count by @@ -31,33 +53,129 @@ const GRID_COLS: Record<1 | 2 | 3 | 4, string> = { 4: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4", }; -function WidgetBody({ widget }: { widget: DashboardWidgetView }) { - const { series, visualization } = widget; - if (series.points.length === 0) { - return
    No data yet.
    ; +/** The widget's own title, or its id when the declaration gave none. */ +function widgetTitle(widget: DashboardWidgetView): string { + return widget.title ?? widget.id; +} + +/** + * Whether the series is bucketed over time, so its points are a trend rather + * than a set of categories. Only a time-bucketed series earns a sparkline and + * a bucket-over-bucket delta: drawing a line through counts grouped by event + * type or region would assert an order and a direction the data does not have. + */ +function isTimeBucketed(points: SeriesPoint[]): boolean { + return points.length > 1 && points.every((p) => parseTimeKey(p.key) !== null); +} + +/** + * The badge a resolved series earns, or `undefined` when it is simply ready. + * + * `partial` outranks a withheld total because it is the *reason* the total is + * withheld (a partial series keeps its observed points and gives up its total), + * and a reader who sees "No total" alone learns less than one who sees + * "Partial data". An empty series is `no_data`, never a zero: the audit + * aggregate omits a bucket rather than emitting one, so no points means nothing + * matched. + */ +function seriesState(series: WidgetSeries): MetricState | undefined { + if (series.coverage === "partial") return "partial"; + if (series.coverage === "empty" || series.points.length === 0) { + return "no_data"; } - switch (visualization) { + if (series.total === null) return "no_total"; + return undefined; +} + +/** + * Change from the second-to-last bucket to the last, as the signed fraction + * `StatTile` renders. A change from zero is not a percentage, so it is no + * delta at all rather than an infinite one. + */ +function lastBucketDelta(points: SeriesPoint[]): number | undefined { + if (points.length < 2) return undefined; + const previous = points[points.length - 2].value; + const latest = points[points.length - 1].value; + if (previous === 0) return undefined; + return (latest - previous) / Math.abs(previous); +} + +/** A resolved scalar widget as the `Metric` a tile binds to. */ +function widgetMetric( + widget: DashboardWidgetView, + series: WidgetSeries, +): Metric { + const trend = isTimeBucketed(series.points); + const delta = trend ? lastBucketDelta(series.points) : undefined; + return { + id: widget.id, + label: widgetTitle(widget), + // A withheld total is passed through as null, so the tile shows a dash + // and its badge says what withheld it. Coercing it to 0 here would + // report "nothing happened" where the pipeline said "cannot say". + value: series.total, + format: widget.format, + unit: widget.unit, + higherIsBetter: widget.higherIsBetter, + series: trend ? series.points.map((p) => p.value) : undefined, + delta, + // Only named when there is a delta: `MetricDelta` reads a label with no + // delta as "no data
    ); diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/index.ts b/module/services/frontend/code/packages/codefly-ui/src/dashboard/index.ts index b0612c06f..d54d5718c 100644 --- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/index.ts +++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/index.ts @@ -28,54 +28,50 @@ export { scaleX, scaleY, } from "./geometry.js"; -export { - type DashboardLayoutKind, - type DashboardView, - type DashboardWidgetView, - fromDashboardData, - type SeriesPoint, - type WidgetSeries, - type WidgetVisualization, -} from "./types.js"; - export { AreaChart as MetricAreaChart, - LineChart as MetricLineChart, BarChart as MetricBarChart, chartSeriesColor, + LineChart as MetricLineChart, type MetricChartProps, } from "./metric-chart.js"; export { + axisPositions, type ChartDatum, type ChartSeries, - type ResolvedSeries, type Point, - unionLabels, + type ResolvedSeries, resolveSeries, - stackSeries, - stackedExtent, type StackedSeries, + stackedExtent, + stackSeries, + unionLabels, valuesExtent, - axisPositions, } from "./metric-geometry.js"; - -export { Sparkline } from "./sparkline.js"; -export { SortableGrid, type SortableGridProps } from "./sortable-grid.js"; - export { - MetricProvenance, - MetricStateBadge, assertSampleModeAllowed, + MetricProvenance, type MetricState, + MetricStateBadge, } from "./metric-state.js"; - export { - MetricCard, - StatTile, - KPIRow, formatMetricValue, + KPIRow, type Metric, + MetricCard, type MetricFormat, + StatTile, } from "./metric-tiles.js"; - +export { SortableGrid, type SortableGridProps } from "./sortable-grid.js"; +export { Sparkline } from "./sparkline.js"; export { TrendLineChart } from "./trend-line-chart.js"; +export { + type DashboardLayoutKind, + type DashboardSectionView, + type DashboardView, + type DashboardWidgetView, + fromDashboardData, + type SeriesPoint, + type WidgetSeries, + type WidgetVisualization, +} from "./types.js"; diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/sortable-grid.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/sortable-grid.tsx index de422ad3d..69327a75a 100644 --- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/sortable-grid.tsx +++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/sortable-grid.tsx @@ -74,6 +74,10 @@ export type SortableGridProps = { itemLabel?: (id: string) => string; /** Classes for the list, which lay the tiles out (for example `grid grid-cols-2 gap-4`). */ className?: string; + /** Place sortable nodes in the kit's dashboard geometry under one drag context. */ + renderLayout?: (nodes: ReadonlyMap) => ReactNode; + /** Constrain a drop when the declaration keeps tiles in separate sections. */ + canSwap?: (draggedId: string, targetId: string) => boolean; }; // The tile under the pointer. In the gap between tiles it is the nearest one, @@ -112,9 +116,7 @@ const GLIDE: KeyframeAnimationOptions = { // Where each tile is drawn now, transforms included. function drawnRects(list: HTMLElement): Map { const rects = new Map(); - for (const tile of list.querySelectorAll( - ":scope > [data-sortable-id]", - )) { + for (const tile of list.querySelectorAll("[data-sortable-id]")) { rects.set(tile.dataset.sortableId ?? "", tile.getBoundingClientRect()); } return rects; @@ -137,7 +139,7 @@ class GlidingList extends Component<{ className?: string; children: ReactNode; }> { - private list = createRef(); + private list = createRef(); getSnapshotBeforeUpdate(previous: { order: string }) { const list = this.list.current; @@ -154,7 +156,7 @@ class GlidingList extends Component<{ const list = this.list.current; if (!before || !list || prefersReducedMotion()) return; for (const tile of list.querySelectorAll( - ":scope > [data-sortable-id]", + "[data-sortable-id]", )) { const from = before.get(tile.dataset.sortableId ?? ""); if (!from) continue; @@ -171,9 +173,10 @@ class GlidingList extends Component<{ render() { return ( -
      + // biome-ignore lint/a11y/useSemanticElements: A composed layout contains section headings and nested grids, not direct li children. +
      {this.props.children} -
    + ); } } @@ -211,7 +214,9 @@ function SortableTile({ [listeners], ); return ( -
  • {children} -
  • + ); } @@ -239,6 +244,8 @@ export function SortableGrid({ renderOverlay = renderItem, itemLabel = (id) => id, className, + renderLayout, + canSwap = () => true, }: SortableGridProps) { // A stable id keeps the ids dnd-kit writes into the page the same on the // server and the client. @@ -274,12 +281,28 @@ export function SortableGrid({ + tileUnderPointer({ + ...args, + droppableRects: new Map( + [...args.droppableRects].filter(([id]) => + canSwap(String(args.active.id), String(id)), + ), + ), + droppableContainers: args.droppableContainers.filter((container) => + canSwap(String(args.active.id), String(container.id)), + ), + }) + } accessibility={{ announcements }} onDragStart={({ active }) => setDragged(String(active.id))} onDragEnd={({ active, over }) => { setDragged(null); - if (over && over.id !== active.id) { + if ( + over && + over.id !== active.id && + canSwap(String(active.id), String(over.id)) + ) { onSwap(String(active.id), String(over.id)); } }} @@ -287,11 +310,17 @@ export function SortableGrid({ > - {ids.map((id) => ( - - {renderItem(id)} - - ))} + {(() => { + const nodes = new Map( + ids.map((id) => [ + id, + + {renderItem(id)} + , + ]), + ); + return renderLayout ? renderLayout(nodes) : [...nodes.values()]; + })()} {/* On a drop the copy glides into the tile's new slot, or back to its diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/types.ts b/module/services/frontend/code/packages/codefly-ui/src/dashboard/types.ts index 25acfe944..3183c3504 100644 --- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/types.ts +++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/types.ts @@ -30,7 +30,15 @@ export interface WidgetSeries { } /** A widget bound to its resolved series. */ +export interface DashboardSectionView { + id: string; + title: string; + description?: string; + columns?: 1 | 2 | 3 | 4; +} + export interface DashboardWidgetView { + section?: string; id: string; visualization: WidgetVisualization; title?: string; @@ -59,10 +67,11 @@ export interface DashboardWidgetView { /** The fully-resolved dashboard the renderer paints. */ export interface DashboardView { + sections?: readonly DashboardSectionView[]; title?: string; description?: string; layout?: DashboardLayoutKind; - /** Grid column count (default 2); ignored in a stack. */ + /** Grid columns (default 4 for scalars, 2 for series); ignored in a stack. */ columns?: 1 | 2 | 3 | 4; /** Optional accent (any CSS color) applied to charts via the primary token. */ accent?: string; @@ -73,6 +82,8 @@ export interface DashboardView { // structurally (not imported) so this package keeps zero runtime deps; any value // with these fields — including the SDK's `DashboardData` — satisfies it. interface ResolvedWidgetLike { + span?: 1 | 2 | 3 | 4; + section?: string; id: string; visualization: WidgetVisualization; title?: string; @@ -82,6 +93,8 @@ interface ResolvedWidgetLike { higherIsBetter?: boolean; } interface DashboardDataLike { + columns?: 1 | 2 | 3 | 4; + sections?: readonly DashboardSectionView[]; title?: string; layout?: DashboardLayoutKind; widgets: ResolvedWidgetLike[]; @@ -100,10 +113,13 @@ export function fromDashboardData( title: data.title, description: extra?.description, layout: data.layout ?? "grid", - columns: extra?.columns, + columns: extra?.columns ?? data.columns, + sections: data.sections, accent: extra?.accent, widgets: data.widgets.map((widget) => ({ id: widget.id, + span: widget.span, + section: widget.section, visualization: widget.visualization, title: widget.title, series: widget.series, diff --git a/module/services/frontend/code/packages/codefly-ui/src/skin/preview.generated.css b/module/services/frontend/code/packages/codefly-ui/src/skin/preview.generated.css index 79c8e1603..9aa2f31c1 100644 --- a/module/services/frontend/code/packages/codefly-ui/src/skin/preview.generated.css +++ b/module/services/frontend/code/packages/codefly-ui/src/skin/preview.generated.css @@ -570,6 +570,10 @@ order: 9999; } + .col-start-1 { + grid-column-start: 1; + } + .col-start-2 { grid-column-start: 2; } @@ -4430,6 +4434,10 @@ grid-column: span 2 / span 2; } + .sm\:col-start-1 { + grid-column-start: 1; + } + .sm\:flex { display: flex; } @@ -4553,6 +4561,10 @@ grid-column: span 4 / span 4; } + .lg\:col-start-1 { + grid-column-start: 1; + } + .lg\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } diff --git a/module/services/frontend/code/packages/saas-plugin-manifest/package.json b/module/services/frontend/code/packages/saas-plugin-manifest/package.json index cacb99046..fcacd0d8b 100644 --- a/module/services/frontend/code/packages/saas-plugin-manifest/package.json +++ b/module/services/frontend/code/packages/saas-plugin-manifest/package.json @@ -1,6 +1,6 @@ { "name": "@codefly/saas-plugin-manifest", - "version": "0.1.1", + "version": "0.1.2", "description": "Unified plugin.codefly.yaml manifest types, validation, and SolutionSpec projection for the Codefly SaaS platform.", "type": "module", "sideEffects": false, diff --git a/module/services/frontend/code/packages/saas-plugin-manifest/plugin.codefly.schema.json b/module/services/frontend/code/packages/saas-plugin-manifest/plugin.codefly.schema.json index 22c7be49e..b944f357e 100644 --- a/module/services/frontend/code/packages/saas-plugin-manifest/plugin.codefly.schema.json +++ b/module/services/frontend/code/packages/saas-plugin-manifest/plugin.codefly.schema.json @@ -76,14 +76,27 @@ "not": { "const": "solution" } }, "kind": { - "enum": ["string", "uuid", "int", "number", "bool", "enum", "string_array"] + "enum": [ + "string", + "uuid", + "int", + "number", + "bool", + "enum", + "string_array" + ] }, "values": { "type": "array", "minItems": 1, "maxItems": 64, "uniqueItems": true, - "items": { "type": "string", "minLength": 1, "maxLength": 1024, "pattern": "\\S" } + "items": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "\\S" + } }, "pii": { "description": "Marks a personally identifying field: the host strips it from every path that sends an event outside its audit store. Once admitted as pii a field stays so.", @@ -265,7 +278,10 @@ "resource": { "type": "string", "minLength": 1 }, "resourceId": { "type": "string", "minLength": 1 }, "collectionId": { "type": "string", "minLength": 1 }, - "payloadContains": { "type": "object", "additionalProperties": { "type": "string" } } + "payloadContains": { + "type": "object", + "additionalProperties": { "type": "string" } + } } }, "groupBy": { @@ -364,22 +380,43 @@ "type": "array", "items": { "type": "object", - "additionalProperties": false, + "additionalProperties": true, "required": ["id", "layout", "widgets"], "properties": { "id": { "$ref": "#/$defs/logicalId" }, "title": { "$ref": "#/$defs/description" }, "layout": { "enum": ["grid", "stack"] }, + "columns": { "enum": [1, 2, 3, 4] }, + "sections": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true, + "required": ["id", "title"], + "properties": { + "id": { "$ref": "#/$defs/logicalId" }, + "title": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + }, + "description": { "$ref": "#/$defs/description" }, + "columns": { "enum": [1, 2, 3, 4] } + } + } + }, "widgets": { "type": "array", "minItems": 1, "items": { "type": "object", - "additionalProperties": false, + "additionalProperties": true, "required": ["id", "metric", "visualization"], "properties": { "id": { "$ref": "#/$defs/logicalId" }, "metric": { "$ref": "#/$defs/logicalId" }, + "span": { "enum": [1, 2, 3, 4] }, + "section": { "$ref": "#/$defs/logicalId" }, "visualization": { "enum": ["line", "bar", "area", "number", "table"] }, diff --git a/module/services/frontend/code/packages/saas-plugin-manifest/src/data-graph.ts b/module/services/frontend/code/packages/saas-plugin-manifest/src/data-graph.ts index 8662c12ec..eb31c2921 100644 --- a/module/services/frontend/code/packages/saas-plugin-manifest/src/data-graph.ts +++ b/module/services/frontend/code/packages/saas-plugin-manifest/src/data-graph.ts @@ -171,9 +171,21 @@ export type Metric = SourceMetric | DerivedMetric; * presentation slots contributed to host surfaces; a `MetricWidget` renders a * data-graph metric inside a ``. */ +export type DashboardColumns = 1 | 2 | 3 | 4; + +/** A named band; widgets refer to its id through `section`. */ +export interface DashboardSection { + id: string; + title: string; + description?: string; + columns?: DashboardColumns; +} + export interface MetricWidget { id: string; metric: string; + span?: DashboardColumns; + section?: string; visualization: WidgetVisualization; title?: string; } @@ -183,6 +195,8 @@ export interface Dashboard { id: string; title?: string; layout: DashboardLayout; + columns?: DashboardColumns; + sections?: readonly DashboardSection[]; widgets: readonly MetricWidget[]; } @@ -575,11 +589,11 @@ function validateWidget( isObject(value), `dashboard '${dashboardId}' widget must be an object`, ); - assertExactKeys( - value, - ["id", "metric", "visualization", "title"], - `dashboard '${dashboardId}' widget`, - ); + // Presentation fields are forward-compatible. Validate the vocabulary this + // host understands; future layout hints neither grant authority nor query data. + assertColumns(value.span, "widget span"); + if (value.section !== undefined) + assertLogicalId(value.section, "widget section"); assertLogicalId(value.id, `dashboard '${dashboardId}' widget id`); assertLogicalId( value.metric, @@ -595,10 +609,39 @@ function validateWidget( ); } +function assertColumns(value: unknown, context: string): void { + assertGraph( + value === undefined || [1, 2, 3, 4].includes(value as number), + `${context} must be an integer from 1 to 4`, + ); +} + function validateDashboard(value: unknown): asserts value is Dashboard { assertGraph(isObject(value), "dashboard must be an object"); - assertExactKeys(value, ["id", "title", "layout", "widgets"], "dashboard"); + assertLogicalId(value.id, "dashboard id"); + assertColumns(value.columns, "dashboard columns"); + if (value.sections !== undefined) { + assertGraph( + Array.isArray(value.sections), + "dashboard sections must be an array", + ); + for (const section of value.sections) { + assertGraph(isObject(section), "dashboard section must be an object"); + assertLogicalId(section.id, "section id"); + assertGraph( + typeof section.title === "string" && section.title.trim().length > 0, + "section title must be non-empty", + ); + assertOptionalText(section.description, "section description"); + assertColumns(section.columns, "section columns"); + } + assertUnique( + (value.sections as DashboardSection[]).map((section) => section.id), + "section id", + ); + } + assertGraph( LAYOUT.includes(value.layout as DashboardLayout), `dashboard '${String(value.id)}' layout '${String(value.layout)}' is unsupported`, @@ -613,6 +656,15 @@ function validateDashboard(value: unknown): asserts value is Dashboard { ); for (const widget of value.widgets) validateWidget(widget, value.id as string); + for (const widget of value.widgets as MetricWidget[]) { + assertGraph( + widget.section === undefined || + (value.sections as DashboardSection[] | undefined)?.some( + (section) => section.id === widget.section, + ), + `widget '${widget.id}' names an unknown section`, + ); + } assertUnique( (value.widgets as MetricWidget[]).map((widget) => widget.id), `widget id in dashboard '${String(value.id)}'`, diff --git a/module/services/frontend/code/packages/saas-plugin-manifest/src/index.ts b/module/services/frontend/code/packages/saas-plugin-manifest/src/index.ts index 08916d74c..96841dd31 100644 --- a/module/services/frontend/code/packages/saas-plugin-manifest/src/index.ts +++ b/module/services/frontend/code/packages/saas-plugin-manifest/src/index.ts @@ -31,7 +31,9 @@ export { } from "./contracts.js"; export type { Dashboard, + DashboardColumns, DashboardLayout, + DashboardSection, DataGraph, DerivedMetric, EventDeclaration, diff --git a/module/services/frontend/code/packages/saas-plugin-manifest/test/dashboard-layout.test.ts b/module/services/frontend/code/packages/saas-plugin-manifest/test/dashboard-layout.test.ts new file mode 100644 index 000000000..b56629717 --- /dev/null +++ b/module/services/frontend/code/packages/saas-plugin-manifest/test/dashboard-layout.test.ts @@ -0,0 +1,82 @@ +// @vitest-environment node +import { readFileSync } from "node:fs"; +import Ajv2020 from "ajv/dist/2020.js"; +import { describe, expect, it } from "vitest"; +import fixture from "../../../src/solutions/__tests__/fixtures/platform-signins.json"; +import { assertDataGraph } from "../src/data-graph.js"; + +const schema = JSON.parse( + readFileSync( + new URL("../plugin.codefly.schema.json", import.meta.url), + "utf8", + ), +); +const validate = new Ajv2020({ strict: false }).compile({ + $defs: schema.$defs, + $ref: "#/$defs/dashboard", +}); +function graph() { + const result = structuredClone(fixture); + return { + ...result, + dashboards: result.dashboards.map((d) => ({ + ...d, + columns: 3, + sections: [{ id: "summary", title: "Summary", columns: 4 }], + widgets: d.widgets.map((w) => ({ ...w, span: 2, section: "summary" })), + })), + }; +} + +describe("additive layout declarations", () => { + it("accepts the unchanged nine-widget graph and a sectioned graph in both validators", () => { + for (const g of [fixture, graph()]) { + expect(() => assertDataGraph(g)).not.toThrow(); + expect(validate(g), JSON.stringify(validate.errors)).toBe(true); + } + }); + it.each([0, 5, 1.5, "2", null])( + "rejects invalid column counts and spans: %s", + (value) => { + for (const target of ["dashboard", "section", "widget"]) { + const g = graph(); + const d = g.dashboards[0]; + Object.assign( + target === "dashboard" + ? d + : target === "section" + ? d.sections[0] + : d.widgets[0], + { [target === "widget" ? "span" : "columns"]: value }, + ); + expect(() => assertDataGraph(g)).toThrow(); + expect(validate(g)).toBe(false); + } + }, + ); + it("rejects duplicate and missing section references", () => { + const duplicate = graph(); + duplicate.dashboards[0].sections.push(duplicate.dashboards[0].sections[0]); + expect(() => assertDataGraph(duplicate)).toThrow(/section id/); + const missing = graph(); + missing.dashboards[0].widgets[0].section = "absent"; + expect(() => assertDataGraph(missing)).toThrow(/unknown section/); + }); + it("permits ungrouped widgets alongside named sections", () => { + const g = graph(); + Reflect.deleteProperty(g.dashboards[0].widgets[0], "section"); + expect(() => assertDataGraph(g)).not.toThrow(); + }); +}); + +it("ignores future presentation hints while retaining strict metric validation", () => { + const g = graph(); + Object.assign(g.dashboards[0], { futureLayout: "compact" }); + Object.assign(g.dashboards[0].sections[0], { futureHeading: true }); + Object.assign(g.dashboards[0].widgets[0], { futureSize: 2 }); + expect(() => assertDataGraph(g)).not.toThrow(); + expect(validate(g)).toBe(true); + Object.assign(g.metrics[0], { futureQuery: "unsafe" }); + expect(() => assertDataGraph(g)).toThrow(/unknown field/); + expect(validate(g)).toBe(false); +}); diff --git a/module/services/frontend/code/packages/saas-sdk/package.json b/module/services/frontend/code/packages/saas-sdk/package.json index 0d611fe4a..02fe3faf7 100644 --- a/module/services/frontend/code/packages/saas-sdk/package.json +++ b/module/services/frontend/code/packages/saas-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@codefly-dev/saas-sdk", - "version": "0.3.17", + "version": "0.3.18", "description": "Typed Connect client, gateway-bound facades, and audit data-graph tooling for the saas-starter public API.", "repository": { "type": "git", diff --git a/module/services/frontend/code/packages/saas-sdk/src/datagraph/dashboard.ts b/module/services/frontend/code/packages/saas-sdk/src/datagraph/dashboard.ts index 8a5aed1af..749a4f017 100644 --- a/module/services/frontend/code/packages/saas-sdk/src/datagraph/dashboard.ts +++ b/module/services/frontend/code/packages/saas-sdk/src/datagraph/dashboard.ts @@ -1,5 +1,7 @@ import type { + DashboardColumns, DashboardLayout, + DashboardSection, DataGraph, WidgetVisualization, } from "../schema.js"; @@ -25,6 +27,8 @@ export type MetricId = T["metrics"][number]["id"]; /** A dashboard widget bound to its resolved metric series. */ export interface ResolvedWidget { + span?: DashboardColumns; + section?: string; id: string; visualization: WidgetVisualization; title?: string; @@ -42,6 +46,8 @@ export interface DashboardData { id: string; title?: string; layout: DashboardLayout; + columns?: DashboardColumns; + sections?: readonly DashboardSection[]; widgets: ResolvedWidget[]; byMetric: Record, MetricSeries>; } @@ -80,6 +86,8 @@ export async function runDashboard( } return { id: widget.id, + span: widget.span, + section: widget.section, visualization: widget.visualization, title: widget.title, metricId: widget.metric, @@ -91,6 +99,8 @@ export async function runDashboard( id: dashboard.id, title: dashboard.title, layout: dashboard.layout, + columns: dashboard.columns, + sections: dashboard.sections, widgets, byMetric, }; diff --git a/module/services/frontend/code/packages/saas-sdk/src/index.ts b/module/services/frontend/code/packages/saas-sdk/src/index.ts index 7c2a9268c..a6cc195a8 100644 --- a/module/services/frontend/code/packages/saas-sdk/src/index.ts +++ b/module/services/frontend/code/packages/saas-sdk/src/index.ts @@ -1,24 +1,3 @@ -export { createSaasClient, type SaasClient } from "./client/client.js"; -export { compileMetric, type EventTypeResolver } from "./datagraph/compile.js"; -export { - type DashboardData, - defineDataGraph, - type MetricId, - type ResolvedWidget, - runDashboard, -} from "./datagraph/dashboard.js"; -export { - assertAuditScopeContract, - runDataGraph, - runMetric, -} from "./datagraph/run.js"; -export type { - AuditAggregateClient, - AuditAggregateQuery, - MetricContext, - MetricPoint, - MetricSeries, -} from "./datagraph/types.js"; // The generated gateway-bound facade for the accounts/connect endpoint. // `accounts.New(gw)` binds the generated Connect clients to a transport (the // gateway seam); each accessor returns the typed client for one public service, @@ -34,6 +13,13 @@ export { type ListMyAccessibleScopesRequest, } from "../generated/typescript/src/gen/saas/accounts/v1/accessible_scopes_pb.js"; export { AuditService } from "../generated/typescript/src/gen/saas/accounts/v1/audit_pb.js"; +export { + type OrgMembership, + OrgRole, + type Team, + type TeamMembership, + TeamRole, +} from "../generated/typescript/src/gen/saas/accounts/v1/common_pb.js"; export { type Datasource, type DatasourceAccountLink, @@ -61,17 +47,33 @@ export { type ListTeamsRequest, type ListTeamsResponse, } from "../generated/typescript/src/gen/saas/accounts/v1/directory_pb.js"; -export { - type OrgMembership, - OrgRole, - type Team, - type TeamMembership, - TeamRole, -} from "../generated/typescript/src/gen/saas/accounts/v1/common_pb.js"; export { WebhookService } from "../generated/typescript/src/gen/saas/accounts/v1/webhooks_pb.js"; +export { createSaasClient, type SaasClient } from "./client/client.js"; +export { compileMetric, type EventTypeResolver } from "./datagraph/compile.js"; +export { + type DashboardData, + defineDataGraph, + type MetricId, + type ResolvedWidget, + runDashboard, +} from "./datagraph/dashboard.js"; +export { + assertAuditScopeContract, + runDataGraph, + runMetric, +} from "./datagraph/run.js"; +export type { + AuditAggregateClient, + AuditAggregateQuery, + MetricContext, + MetricPoint, + MetricSeries, +} from "./datagraph/types.js"; export type { Dashboard, + DashboardColumns, DashboardLayout, + DashboardSection, DataGraph, DerivedMetric, EventDeclaration, diff --git a/module/services/frontend/code/packages/saas-sdk/src/schema.ts b/module/services/frontend/code/packages/saas-sdk/src/schema.ts index 0a3928aa7..7fc88a007 100644 --- a/module/services/frontend/code/packages/saas-sdk/src/schema.ts +++ b/module/services/frontend/code/packages/saas-sdk/src/schema.ts @@ -134,9 +134,21 @@ export interface DerivedMetric { export type Metric = SourceMetric | DerivedMetric; /** One dashboard widget bound to a declared metric. */ +export type DashboardColumns = 1 | 2 | 3 | 4; + +/** A named band; widgets refer to its id through `section`. */ +export interface DashboardSection { + id: string; + title: string; + description?: string; + columns?: DashboardColumns; +} + export interface MetricWidget { id: string; metric: string; + span?: DashboardColumns; + section?: string; visualization: WidgetVisualization; title?: string; } @@ -146,6 +158,8 @@ export interface Dashboard { id: string; title?: string; layout: DashboardLayout; + columns?: DashboardColumns; + sections?: readonly DashboardSection[]; widgets: readonly MetricWidget[]; } diff --git a/module/services/frontend/code/packages/saas-sdk/test/dashboard.test.ts b/module/services/frontend/code/packages/saas-sdk/test/dashboard.test.ts index f7b08723b..e2e505e1d 100644 --- a/module/services/frontend/code/packages/saas-sdk/test/dashboard.test.ts +++ b/module/services/frontend/code/packages/saas-sdk/test/dashboard.test.ts @@ -184,3 +184,29 @@ describe("runDashboard", () => { expect(calls).toHaveLength(1); }); }); + +it("carries layout declarations through resolution without interpreting them", async () => { + const { client } = fakeAuditClient(() => [{ key: "login", count: 1 }]); + const sections = [{ id: "headline", title: "Headline", columns: 4 as const }]; + const declaration = { + ...graph, + dashboards: [ + { + ...graph.dashboards[0], + columns: 3 as const, + sections, + widgets: graph.dashboards[0].widgets.map((w) => ({ + ...w, + section: "headline", + span: 2 as const, + })), + }, + ], + }; + const result = await runDashboard(client, declaration, "activity", context); + expect(result.columns).toBe(3); + expect(result.sections).toEqual(sections); + expect( + result.widgets.every((w) => w.span === 2 && w.section === "headline"), + ).toBe(true); +}); diff --git a/module/services/frontend/code/packages/saas-ui/package.json b/module/services/frontend/code/packages/saas-ui/package.json index 505377c0c..d62870fe0 100644 --- a/module/services/frontend/code/packages/saas-ui/package.json +++ b/module/services/frontend/code/packages/saas-ui/package.json @@ -1,6 +1,6 @@ { "name": "@codefly-dev/saas-ui", - "version": "0.12.6", + "version": "0.12.7", "description": "Reusable SaaS-domain frontend components (datasources, …) the portal and solutions both import, built on @codefly-dev/ui primitives and driven by an injected typed client.", "repository": { "type": "git", diff --git a/module/services/frontend/code/src/solutions/SolutionDashboard.tsx b/module/services/frontend/code/src/solutions/SolutionDashboard.tsx index 28d86015e..aa752a5a3 100644 --- a/module/services/frontend/code/src/solutions/SolutionDashboard.tsx +++ b/module/services/frontend/code/src/solutions/SolutionDashboard.tsx @@ -161,6 +161,8 @@ function declaredWidget(widget: MetricWidget): DashboardWidgetView { visualization: widget.visualization, title: widget.title ?? widget.metric, series: null, + span: widget.span, + section: widget.section, }; } @@ -485,15 +487,32 @@ function SolutionDashboard({ size="icon-xs" className="cursor-grab text-muted-foreground" aria-label={`Move ${title}: drag the tile, or use the arrow keys`} + data-move-widget={tileId} onKeyDown={(event) => { const step = ARROW_STEP[event.key]; if (step === undefined) return; event.preventDefault(); const grip = event.currentTarget; + const dashboardElement = grip.closest("section"); // Moving a tile can move its DOM node, which drops focus; // commit first so the grip can take focus back. - flushSync(() => save(moveBy(layout, tileId, step))); - grip.focus(); + const peers = layout.filter( + (id) => widgets.get(id)?.section === widget?.section, + ); + const moved = moveBy(peers, tileId, step); + let index = 0; + flushSync(() => + save( + layout.map((id) => (peers.includes(id) ? moved[index++] : id)), + ), + ); + // Restore focus by widget identity after the layout update. + const replacement = [ + ...(dashboardElement?.querySelectorAll( + "button[data-move-widget]", + ) ?? []), + ].find((button) => button.dataset.moveWidget === tileId); + (replacement ?? grip).focus(); }} > @@ -555,6 +574,8 @@ function SolutionDashboard({ data={{ title: dashboard.title, layout: dashboard.layout, + columns: dashboard.columns, + sections: dashboard.sections, widgets: shown.map(({ widget }) => declaredWidget(widget)), }} slots={{ @@ -582,7 +603,7 @@ function SolutionDashboard({ // The kit's SortableGrid rather than its default grid: the tiles // are dragged onto each other to swap. The classes are those the // kit's grid and stack draw with. - layout: (tiles) => { + layout: (tiles, renderLayout) => { const nodes = new Map(tiles.map((tile) => [tile.id, tile.node])); return ( nodes.get(id) ?? null} renderOverlay={renderDragged} - className={ - dashboard.layout === "stack" - ? "flex flex-col gap-4" - : "grid grid-cols-1 gap-4 sm:grid-cols-2" + renderLayout={renderLayout} + canSwap={(a, b) => + widgets.get(a)?.section === widgets.get(b)?.section } /> ); diff --git a/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx b/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx index 941dd350d..0ad8f40fd 100644 --- a/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx +++ b/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { renderInApp, rpc } from "@/test/container"; import { server } from "@/test/setup"; import { SolutionDashboards } from "../SolutionDashboard"; +import platformSignins from "./fixtures/platform-signins.json"; // Auth state is mutable so a test can drop the org context and assert the // pre-org window renders as loading, not as an empty dashboard, or switch the @@ -420,7 +421,9 @@ describe("a viewer's layout", () => { const index = this.dataset.sortableId === undefined || !this.parentElement ? 0 - : [...this.parentElement.children].indexOf(this); + : [...document.querySelectorAll("[data-sortable-id]")].indexOf( + this, + ); const left = (index % 2) * 110; const top = Math.floor(index / 2) * 110; return { @@ -557,3 +560,100 @@ describe("a viewer's layout", () => { expect(tileTitles()).toEqual(["Top event types", "Total logins"]); }); }); + +describe("nine-widget layout", () => { + it("keeps the supplied declaration order and packs four scalars ahead of five charts", () => { + authState.organizationId = undefined; + const { container } = renderInApp( + , + ); + expect( + [...container.querySelectorAll("[data-sortable-id]")].map((el) => + el.getAttribute("data-sortable-id"), + ), + ).toEqual(platformSignins.dashboards[0].widgets.map((w) => w.id)); + const grid = container.querySelector(".grid.items-start"); + expect(grid?.children).toHaveLength(9); + expect(grid?.className).toContain("lg:grid-cols-4"); + const tiles = [...container.querySelectorAll("[data-dashboard-tile]")]; + expect( + tiles.slice(0, 4).every((tile) => !tile.className.includes("col-span")), + ).toBe(true); + expect( + tiles.slice(4).every((tile) => tile.className.includes("sm:col-span-2")), + ).toBe(true); + expect(tiles[4].className).toContain("col-start-1"); + }); + it("draws section headings, inherited columns and clamped scalar and series spans", () => { + authState.organizationId = undefined; + const original = platformSignins as DataGraph; + const d = original.dashboards[0]; + const custom: DataGraph = { + ...original, + dashboards: [ + { + ...d, + columns: 3, + sections: [ + { id: "summary", title: "Headline numbers", columns: 4 }, + { id: "trends", title: "Over time" }, + ], + widgets: d.widgets.map((w) => ({ + ...w, + section: w.visualization === "number" ? "summary" : "trends", + span: 4, + })), + }, + ], + }; + const { container } = renderInApp( + , + ); + expect( + screen.getByRole("heading", { name: "Headline numbers" }), + ).toBeTruthy(); + expect(screen.getByRole("heading", { name: "Over time" })).toBeTruthy(); + expect( + container.querySelector('[data-dashboard-section="summary"] .grid') + ?.className, + ).toContain("lg:grid-cols-4"); + expect( + container.querySelector('[data-dashboard-section="trends"] .grid') + ?.className, + ).toContain("lg:grid-cols-3"); + expect( + container.querySelector('[data-dashboard-tile="w_stat_logins"]') + ?.className, + ).toContain("lg:col-span-4"); + expect( + container.querySelector('[data-dashboard-tile="w_line_day"]')?.className, + ).toContain("lg:col-span-3"); + }); + it("restores keyboard focus by widget id when moving across scalar and chart bands", () => { + authState.organizationId = undefined; + renderInApp( + , + ); + const grip = screen.getByRole("button", { + name: "Move MFA completed: drag the tile, or use the arrow keys", + }); + grip.focus(); + fireEvent.keyDown(grip, { key: "ArrowDown" }); + const moved = screen.getByRole("button", { + name: "Move MFA completed: drag the tile, or use the arrow keys", + }); + expect(document.activeElement).toBe(moved); + fireEvent.keyDown(moved, { key: "ArrowUp" }); + expect( + [...document.querySelectorAll("[data-sortable-id]")].map((el) => + el.getAttribute("data-sortable-id"), + ), + ).toEqual(platformSignins.dashboards[0].widgets.map((w) => w.id)); + }); +}); diff --git a/module/services/frontend/code/src/solutions/__tests__/dashboard-layout.test.ts b/module/services/frontend/code/src/solutions/__tests__/dashboard-layout.test.ts index 57a8d8637..9d55b1f89 100644 --- a/module/services/frontend/code/src/solutions/__tests__/dashboard-layout.test.ts +++ b/module/services/frontend/code/src/solutions/__tests__/dashboard-layout.test.ts @@ -15,6 +15,7 @@ import { tileWidget, writeSavedLayout, } from "../dashboard-layout"; +import platformSignins from "./fixtures/platform-signins.json"; // A graph with one metric on no dashboard (`per_day`), one drawn only on the // second dashboard (`returns`), and one derived from two others (`net`). Only @@ -246,3 +247,37 @@ describe("dashboard layout", () => { expect(writeSavedLayout(null, "k", "{}")).toBe(false); }); }); + +// The platform-event fixture is the actual nine-widget declaration supplied +// with #1044, serialized without changing its event, metric or widget shape. +describe("a declaration growing from three widgets to nine", () => { + const current = platformSignins.dashboards[0] as Dashboard; + const before = { + ...current, + widgets: [current.widgets[0], current.widgets[4], current.widgets[6]], + }; + it("follows the declaration before any reorder, including a saved old default", () => { + expect(defaultLayout(current)).toEqual(current.widgets.map((w) => w.id)); + expect( + parseLayout(serializeLayout(defaultLayout(before), before), current), + ).toEqual(defaultLayout(current)); + }); + it("retains a custom order by identity and appends new ids once", () => { + const order = [before.widgets[2].id, before.widgets[0].id]; + const saved = serializeLayout(order, before); + expect(parseLayout(saved, current)).toEqual([ + ...order, + ...current.widgets + .filter((w) => !before.widgets.some((old) => old.id === w.id)) + .map((w) => w.id), + ]); + const renamed = { + ...current, + widgets: current.widgets + .map((w) => ({ ...w, title: "Updated title" })) + .reverse(), + }; + expect(parseLayout(saved, renamed).slice(0, 2)).toEqual(order); + expect(parseLayout(saved, current)).not.toContain(before.widgets[1].id); + }); +}); diff --git a/module/services/frontend/code/src/solutions/__tests__/fixtures/platform-signins.json b/module/services/frontend/code/src/solutions/__tests__/fixtures/platform-signins.json new file mode 100644 index 000000000..4c3a9b36d --- /dev/null +++ b/module/services/frontend/code/src/solutions/__tests__/fixtures/platform-signins.json @@ -0,0 +1,177 @@ +{ + "dashboards": [ + { + "id": "logins", + "layout": "grid", + "title": "Sign-ins", + "widgets": [ + { + "id": "w_stat_logins", + "metric": "total_logins", + "title": "Sign-ins", + "visualization": "number" + }, + { + "id": "w_stat_people", + "metric": "total_people", + "title": "People", + "visualization": "number" + }, + { + "id": "w_stat_per_person", + "metric": "logins_per_person", + "title": "Sign-ins per person", + "visualization": "number" + }, + { + "id": "w_stat_mfa", + "metric": "mfa_completions", + "title": "MFA completed", + "visualization": "number" + }, + { + "id": "w_line_day", + "metric": "logins_over_time", + "title": "Sign-ins per day", + "visualization": "line" + }, + { + "id": "w_area_people", + "metric": "people_per_day", + "title": "People signing in per day", + "visualization": "area" + }, + { + "id": "w_bar_method", + "metric": "logins_by_method", + "title": "Sign-in method", + "visualization": "bar" + }, + { + "id": "w_line_week", + "metric": "logins_by_week", + "title": "Sign-ins per week", + "visualization": "line" + }, + { + "id": "w_line_switches", + "metric": "org_switches_over_time", + "title": "Organisation switches per day", + "visualization": "line" + } + ] + } + ], + "events": [ + { + "name": "login", + "type": "saas.auth.login" + }, + { + "name": "mfa_completed", + "type": "saas.auth.mfa_challenge_completed" + }, + { + "name": "org_switched", + "type": "saas.auth.organization_switched" + } + ], + "metrics": [ + { + "aggregation": "count", + "bucket": "day", + "filter": { + "event": "login" + }, + "groupBy": "time", + "id": "logins_over_time", + "kind": "source", + "title": "Sign-ins per day" + }, + { + "aggregation": "count", + "bucket": "week", + "filter": { + "event": "login" + }, + "groupBy": "time", + "id": "logins_by_week", + "kind": "source", + "title": "Sign-ins per week" + }, + { + "aggregation": "count_distinct", + "bucket": "day", + "field": "actor_id", + "filter": { + "event": "login" + }, + "groupBy": "time", + "id": "people_per_day", + "kind": "source", + "title": "People signing in per day" + }, + { + "aggregation": "count", + "filter": { + "event": "login" + }, + "groupBy": "event_type", + "id": "total_logins", + "kind": "source", + "title": "Sign-ins" + }, + { + "aggregation": "count_distinct", + "field": "actor_id", + "filter": { + "event": "login" + }, + "groupBy": "event_type", + "id": "total_people", + "kind": "source", + "title": "People" + }, + { + "id": "logins_per_person", + "inputs": [ + "total_logins", + "total_people" + ], + "kind": "derived", + "operation": "ratio", + "title": "Sign-ins per person" + }, + { + "aggregation": "count", + "filter": { + "event": "login" + }, + "groupBy": "payload:method", + "id": "logins_by_method", + "kind": "source", + "title": "Sign-in method" + }, + { + "aggregation": "count", + "filter": { + "event": "mfa_completed" + }, + "groupBy": "event_type", + "id": "mfa_completions", + "kind": "source", + "title": "MFA challenges completed" + }, + { + "aggregation": "count", + "bucket": "day", + "filter": { + "event": "org_switched" + }, + "groupBy": "time", + "id": "org_switches_over_time", + "kind": "source", + "title": "Organisation switches per day" + } + ] +} diff --git a/module/services/frontend/code/src/solutions/dashboard-layout.ts b/module/services/frontend/code/src/solutions/dashboard-layout.ts index f94bffd49..f4ead8215 100644 --- a/module/services/frontend/code/src/solutions/dashboard-layout.ts +++ b/module/services/frontend/code/src/solutions/dashboard-layout.ts @@ -197,6 +197,13 @@ export function parseLayout( ) { return fallback; } + // An unchanged saved default is not a customization. Rebase it on the + // new declaration instead of appending new scalars behind old charts. + if ( + tiles.length === seen.length && + tiles.every((id, index) => id === seen[index]) + ) + return fallback; const layout: string[] = []; for (const tileId of tiles) { if (tileWidget(dashboard, tileId) && !layout.includes(tileId)) {