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 ", which is a different (and here untrue)
+ // statement about the comparison period.
+ deltaLabel: delta === undefined ? undefined : "vs previous bucket",
+ state: seriesState(series),
+ };
+}
+
+/** One series on the shared label axis the metric charts consume. */
+function chartSeries(
+ widget: DashboardWidgetView,
+ series: WidgetSeries,
+): ChartSeries[] {
+ return [
+ {
+ name: widgetTitle(widget),
+ data: series.points.map((p) => ({ label: p.key, value: p.value })),
+ },
+ ];
+}
+
+/** The resolved series drawn the way the widget's visualization asks. */
+function WidgetBody({
+ widget,
+ series,
+}: {
+ widget: DashboardWidgetView;
+ series: WidgetSeries;
+}) {
+ const title = widgetTitle(widget);
+ switch (widget.visualization) {
case "line":
- return ;
+ return (
+
+ );
case "area":
- return ;
+ return (
+
+ );
case "bar":
- return ;
- case "number":
- return series.total === null ? (
- Total unavailable
- ) : (
-
+ return (
+
);
case "table":
- return (
+ return series.points.length === 0 ? (
+ No data yet.
+ ) : (
+ {title}
{series.points.map((p) => (
{p.key}
- {p.value.toLocaleString()}
+
+ {p.value.toLocaleString()}
+
))}
@@ -69,37 +187,235 @@ function WidgetBody({ widget }: { widget: DashboardWidgetView }) {
}
}
-function WidgetCard({ widget, columns }: { widget: DashboardWidgetView; columns: 1 | 2 | 3 | 4 }) {
- const span = widget.span ? (Math.min(widget.span, columns) as 1 | 2 | 3 | 4) : 1;
+export interface DashboardWidgetProps {
+ widget: DashboardWidgetView;
+ /**
+ * Controls for this widget's header, opposite its title — a drag handle, a
+ * remove button, a panel that says where the number comes from. The
+ * renderer never invents them; a dashboard with nothing to do to a tile
+ * passes none.
+ */
+ actions?: ReactNode;
+ className?: string;
+}
+
+/**
+ * One widget of a dashboard, drawn with the metric tier: a scalar as a
+ * `StatTile` (value, format, a sparkline and a bucket delta when the metric is
+ * bucketed over time, and a `MetricStateBadge` from the series' coverage), a
+ * series as a metric chart inside a `Card`.
+ *
+ * Exported, and not merely an internal of {@link Dashboard}, because a host
+ * that resolves each widget through its own query — so one widget's failure
+ * cannot blank its siblings — must render each widget in its own component.
+ * React fixes the number of hooks a component may call, so those queries
+ * cannot be hoisted into one renderer that takes the whole view. Compose this
+ * from such a component; never redraw it.
+ */
+export function DashboardWidget({
+ widget,
+ actions,
+ className,
+}: DashboardWidgetProps) {
+ const { series } = widget;
+ const title = widgetTitle(widget);
+
+ // `failed` is read BEFORE the series, not after: a failed resolution leaves
+ // no series behind, so testing for the series first would draw every
+ // failure as a wait that never ends.
+ if (widget.visualization === "number") {
+ const metric: Metric = widget.failed
+ ? {
+ id: widget.id,
+ label: title,
+ value: null,
+ state: "provider_unavailable",
+ }
+ : series === null
+ ? { id: widget.id, label: title, value: null, state: "loading" }
+ : widgetMetric(widget, series);
+ return ;
+ }
+
+ // A chart or table card carries its coverage badge in the card's own
+ // description slot — the line under the title that says what this card is —
+ // so a reader meets "Partial data" where they meet the title, as they do on
+ // a tile, instead of as a sentence pushed above the geometry.
+ const state =
+ widget.failed || series === null ? undefined : seriesState(series);
return (
-
- {widget.series.coverage === "partial" && (
-
- Partial telemetry
-
+ : undefined}
+ actions={actions}
+ className={className}
+ >
+ {widget.failed ? (
+ Unable to load.
+ ) : series === null ? (
+
+ ) : (
+
)}
-
);
}
+/** A widget and the node drawn for it, for a caller laying the tiles out itself. */
+export interface DashboardTile {
+ id: string;
+ /**
+ * The widget this tile draws. A layout that needs a second copy of a tile —
+ * a drag overlay, say — draws it from here rather than reusing the node,
+ * which is already mounted in the grid.
+ */
+ widget: DashboardWidgetView;
+ /** The widget as drawn: the kit's card, or `slots.renderWidget`'s. */
+ node: ReactNode;
+}
+
+/**
+ * Where a host composes its own features *around* this renderer, without
+ * taking over the drawing. Every slot is optional; a dashboard that needs none
+ * of them passes no `slots` at all and gets the full default rendering.
+ */
+export interface DashboardSlots {
+ /** Controls in the dashboard's own header, opposite its title. */
+ actions?: ReactNode;
+ /**
+ * Draws one widget in place of {@link DashboardWidget}. For a host that
+ * resolves each widget in its own query: return a component that resolves
+ * the series and then mounts `DashboardWidget` with it.
+ */
+ renderWidget?: (widget: DashboardWidgetView) => ReactNode;
+ /**
+ * Lays the tiles out in place of the default grid or stack — for a grid
+ * whose tiles the viewer can reorder. Tiles arrive in the view's order.
+ */
+ layout?: (tiles: DashboardTile[]) => ReactNode;
+ /** Shown in place of the layout when the view holds no widgets. */
+ empty?: ReactNode;
+}
+
+/**
+ * Runs of adjacent widgets that are laid out together: consecutive scalars
+ * become one `KPIRow`, everything else a grid of cards.
+ *
+ * Declared order is never rearranged — only adjacent scalars are gathered — so
+ * the page shows what the declaration says, in the order it says it. Hoisting
+ * every scalar to the top would read better on some dashboards and would also
+ * silently overrule a declaration the renderer does not own.
+ */
+function layoutRuns(tiles: DashboardTile[]): DashboardTile[][] {
+ const runs: DashboardTile[][] = [];
+ for (const tile of tiles) {
+ const scalar = tile.widget.visualization === "number";
+ const previous = runs[runs.length - 1];
+ const sameKind =
+ previous && (previous[0].widget.visualization === "number") === scalar;
+ if (sameKind) previous.push(tile);
+ else runs.push([tile]);
+ }
+ return runs;
+}
+
+function Tiles({ tiles }: { tiles: DashboardTile[] }) {
+ return (
+ <>
+ {tiles.map((tile) => (
+ {tile.node}
+ ))}
+ >
+ );
+}
+
+function DefaultLayout({
+ tiles,
+ columns,
+ grid,
+}: {
+ tiles: DashboardTile[];
+ columns: 1 | 2 | 3 | 4;
+ grid: boolean;
+}) {
+ if (!grid) {
+ return (
+
+
+
+ );
+ }
+ return (
+
+ {layoutRuns(tiles).map((run) =>
+ run[0].widget.visualization === "number" ? (
+
+
+
+ ) : (
+
+
+
+ ),
+ )}
+
+ );
+}
+
/**
* Render a resolved dashboard. Pass a `DashboardView` (from your own data or via
* `fromDashboardData(runDashboard(...))`). `accent` overrides the `--primary`
* token for this dashboard's subtree only, so every chart picks it up.
*/
-export function Dashboard({ data, className }: { data: DashboardView; className?: string }) {
+export function Dashboard({
+ data,
+ className,
+ slots,
+}: {
+ data: DashboardView;
+ className?: string;
+ slots?: DashboardSlots;
+}) {
const columns = data.columns ?? 2;
- const style = data.accent ? ({ "--primary": data.accent } as React.CSSProperties) : undefined;
+ const style = data.accent
+ ? ({ "--primary": data.accent } as React.CSSProperties)
+ : undefined;
const isGrid = (data.layout ?? "grid") === "grid";
+ const tiles: DashboardTile[] = data.widgets.map((widget) => ({
+ id: widget.id,
+ widget,
+ node: slots?.renderWidget?.(widget) ?? (
+ // A scalar tile sits in a KPIRow, whose own geometry sets its width,
+ // so a span only applies to a card in the grid.
+
+ ),
+ }));
+
return (
-
-
- {data.widgets.map((widget) => (
-
- ))}
-
+
+ {tiles.length === 0 ? (
+ (slots?.empty ?? (
+ No widgets.
+ ))
+ ) : slots?.layout ? (
+ slots.layout(tiles)
+ ) : (
+
+ )}
);
}
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 c20a6015a..b0612c06f 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
@@ -13,7 +13,13 @@ export {
LineChart,
StatChart,
} from "./charts.js";
-export { Dashboard } from "./dashboard.js";
+export {
+ Dashboard,
+ type DashboardSlots,
+ type DashboardTile,
+ DashboardWidget,
+ type DashboardWidgetProps,
+} from "./dashboard.js";
export { formatAxisKey, formatAxisValue, parseTimeKey } from "./format.js";
export {
linearScale,
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/metric-state.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/metric-state.tsx
index 0fb88d175..bd77e15af 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/metric-state.tsx
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/metric-state.tsx
@@ -8,6 +8,11 @@ export type MetricState =
| "provider_unavailable"
| "not_configured"
| "sample"
+ // The series resolved, but no single number summarises it: a distinct
+ // count, an average, a percentile or a ratio has no scalar total across
+ // several groups, and summing or averaging those summaries would
+ // misreport the result. Distinct from `no_data`, where nothing matched.
+ | "no_total"
| "ready";
const stateLabels: Record, string> = {
@@ -18,6 +23,7 @@ const stateLabels: Record, string> = {
provider_unavailable: "Provider unavailable",
not_configured: "Not configured",
sample: "Sample data",
+ no_total: "No total",
};
export function assertSampleModeAllowed(
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/metric-tiles.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/metric-tiles.tsx
index c4c73bd1a..6ed791030 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/metric-tiles.tsx
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/metric-tiles.tsx
@@ -1,5 +1,5 @@
import { ArrowDown, ArrowUp, Minus } from "lucide-react";
-import type { ComponentProps, KeyboardEvent } from "react";
+import type { ComponentProps, KeyboardEvent, ReactNode } from "react";
import {
CardRoot as Card,
CardContent,
@@ -28,8 +28,14 @@ export interface Metric {
/** Stable identity for list keying; falls back to position when omitted. */
id?: string;
label: string;
- /** The computed value as a plain number — `Number(bigint)` a bigint source. */
- value: number;
+ /**
+ * The computed value as a plain number — `Number(bigint)` a bigint source.
+ * `null` when no number exists: it renders as a dash, never as `0`. A
+ * withheld total and a zero are different answers, and a tile that drew
+ * `null` as `0` would report the second when the pipeline said the first.
+ * {@link state} says which of partial / empty / no-total withheld it.
+ */
+ value: number | null;
/**
* How {@link value} is rendered. `"percent"` treats the value as an
* already-scaled percentage (45 → "45%"), not a fraction — unlike
@@ -70,6 +76,7 @@ const valuelessStates: MetricState[] = [
"no_data",
"not_configured",
"provider_unavailable",
+ "no_total",
];
export function formatMetricValue(
@@ -110,13 +117,20 @@ function MetricValue({
if (metric.state === "loading") {
return ;
}
- const valueless = !!metric.state && valuelessStates.includes(metric.state);
+ // No number to show: either none exists, or the state is one for which none
+ // is meaningful. A unit with no value to qualify is noise, so it goes too.
+ if (
+ metric.value === null ||
+ (metric.state && valuelessStates.includes(metric.state))
+ ) {
+ return — ;
+ }
// Standalone figures use the font's proportional digits — tabular-nums is
// for columns that must align, and looks loose at display sizes.
return (
- {valueless ? "—" : formatMetricValue(metric.value, metric.format)}
- {!valueless && metric.unit && (
+ {formatMetricValue(metric.value, metric.format)}
+ {metric.unit && (
{metric.unit}
@@ -225,12 +239,18 @@ function selectableCard(metric: Metric): {
* A single headline number — label, value, optional delta and trend sparkline,
* with a freshness badge when the metric isn't `ready`. The compact building
* block of a {@link KPIRow}.
+ *
+ * `actions` puts controls in the header beside the badge, for a caller that
+ * owns the tile as well as the number — a dashboard whose viewer can drag,
+ * remove or interrogate each tile. Without it the tile is purely presentational.
*/
export function StatTile({
metric,
+ actions,
className,
}: {
metric: Metric;
+ actions?: ReactNode;
className?: string;
}) {
const selectable = selectableCard(metric);
@@ -240,10 +260,16 @@ export function StatTile({
{...selectable.props}
>
-
+
{metric.label}
{metric.state && }
+ {actions != null && (
+ {actions}
+ )}
@@ -281,7 +307,10 @@ export function MetricCard({
return (
-
+
{metric.label}
{metric.state && }
@@ -316,19 +345,27 @@ export function MetricCard({
/**
* A responsive row of {@link StatTile}s — the handful of headline numbers a
* dashboard leads with.
+ *
+ * Pass `metrics` and the row draws a tile per metric. Pass `children` instead
+ * when the tiles need something a {@link Metric} cannot carry (a per-tile
+ * control, a key the caller chooses): the row then only lays out the tiles
+ * given, so its geometry still lives here and is not copied to the call site.
*/
export function KPIRow({
metrics,
+ children,
className,
}: {
- metrics: Metric[];
+ metrics?: Metric[];
+ children?: ReactNode;
className?: string;
}) {
return (
- {metrics.map((metric, i) => (
-
- ))}
+ {children ??
+ metrics?.map((metric, i) => (
+
+ ))}
);
}
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 55be73394..25acfe944 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
@@ -8,6 +8,8 @@
// own types here is what lets `@codefly-dev/ui` stay a pure component library with no
// dependency on the SDK's transport stack.
+import type { MetricFormat } from "./metric-tiles.js";
+
/** How a widget draws its series. */
export type WidgetVisualization = "line" | "bar" | "area" | "number" | "table";
@@ -32,9 +34,27 @@ export interface DashboardWidgetView {
id: string;
visualization: WidgetVisualization;
title?: string;
- series: WidgetSeries;
+ /**
+ * The resolved series, or `null` while it is still being resolved. A
+ * renderer draws a placeholder for `null` rather than an empty series,
+ * because "not yet" and "nothing matched" are different answers and the
+ * second one is a fact about the data.
+ */
+ series: WidgetSeries | null;
+ /**
+ * The series could not be resolved — an error, not an empty result. A
+ * caller that resolves each widget separately sets it per widget, so one
+ * failure says so in its own card instead of blanking its siblings.
+ */
+ failed?: boolean;
/** Grid columns this widget spans (1–4); ignored in a stack. */
span?: 1 | 2 | 3 | 4;
+ /** How a scalar widget's value is rendered. Defaults to a plain number. */
+ format?: MetricFormat;
+ /** Suffix after a scalar widget's value, e.g. "req/s". */
+ unit?: string;
+ /** When false a rising value reads as bad (an error count). Defaults true. */
+ higherIsBetter?: boolean;
}
/** The fully-resolved dashboard the renderer paints. */
@@ -57,6 +77,9 @@ interface ResolvedWidgetLike {
visualization: WidgetVisualization;
title?: string;
series: WidgetSeries;
+ format?: MetricFormat;
+ unit?: string;
+ higherIsBetter?: boolean;
}
interface DashboardDataLike {
title?: string;
@@ -84,6 +107,9 @@ export function fromDashboardData(
visualization: widget.visualization,
title: widget.title,
series: widget.series,
+ format: widget.format,
+ unit: widget.unit,
+ higherIsBetter: widget.higherIsBetter,
})),
};
}
diff --git a/module/services/frontend/code/src/components/__tests__/kit.test.ts b/module/services/frontend/code/src/components/__tests__/kit.test.ts
new file mode 100644
index 000000000..0278abef2
--- /dev/null
+++ b/module/services/frontend/code/src/components/__tests__/kit.test.ts
@@ -0,0 +1,406 @@
+import { readdirSync, readFileSync } from "node:fs";
+import { dirname, join, relative } from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+import packageJson from "../../../package.json" with { type: "json" };
+
+/**
+ * The host app composes `@codefly-dev/ui`; it does not re-implement it.
+ *
+ * The audit that opened issue #1041 found a solution's Dashboard tab drawn with
+ * the kit's older basic chart tier while the host's own operations pages used
+ * its metric tier — because `src/solutions/SolutionDashboard.tsx` had its own
+ * copy of the widget-to-chart dispatch, the responsive grid, the card shell and
+ * the empty state beside the kit's ``. Two renderers of one thing do
+ * not stay equal: one gets the sparkline, the crosshair readout and the
+ * screen-reader data table a release later, and the other does not. The owner
+ * asked for a linter.
+ *
+ * The rule is not invented here: another module that composes this kit already
+ * carries it, in a gate of the same shape, and that one is copied rather than
+ * replaced. Three legs, because no single one of them is sufficient:
+ *
+ * - **A name the kit exports** may be declared here only by a wrapper that
+ * renders the kit's component of that name, or with a reviewed entry in
+ * {@link ALLOWED}. Both forms of the declaration count — exported and
+ * module-local — because a duplicate hidden as a file-local helper is the
+ * commonest way one arrives (two of them were sitting here when this gate
+ * was written, and the upstream version of this rule, which looked only at
+ * exported declarations, would have walked past both).
+ * - **`class-variance-authority`** — the library the kit builds its variants
+ * with — is not a dependency of this package, so a kit-shaped primitive
+ * cannot be built here at all. This leg is a fact, not a convention, which
+ * is why it is worth more than the first.
+ * - **The chart surfaces stay re-exports.** `src/components/charts/chart.tsx`
+ * and `src/features/dashboard/ui/charts/line-chart.tsx` are each a single
+ * `export … from "@codefly-dev/ui/dashboard"`. Issue #1041 asked what they
+ * were; they are already right, and this keeps them so.
+ *
+ * What the gate cannot do, said plainly: a duplicate under a NEW name, built by
+ * hand, passes all three legs. {@link ALLOWED} is the backstop for that — a
+ * label a reviewer reads and asks about, not a detector. Classifying files by
+ * their names instead would fail on the next file anyone adds, and a gate that
+ * cries wolf is a gate that gets deleted.
+ */
+
+const here = dirname(fileURLToPath(import.meta.url));
+const appRoot = join(here, "..", "..", "..");
+
+/**
+ * A name this package declares although the kit exports it, and why. Each entry
+ * says what the two things are, why neither can stand in for the other, and
+ * what would have to change for the entry to go.
+ */
+const ALLOWED: Record = {
+ "Dashboard in src/features/dashboard/ui/dashboard.tsx":
+ "the host's dashboard DSL surface: it reads the viewer's organization from host auth and resolves each declared metric through `useMetric` as it draws. The kit's `` is pure and takes an already-resolved `DashboardView`, so neither can stand in for the other. Goes when the DSL resolves its metrics outside the renderer and hands the kit a view, as `src/solutions/SolutionDashboard.tsx` now does.",
+ "Dashboard in src/components/dashboard.tsx":
+ "the widget-slot renderer: a discriminated union of host-supplied widgets (sparkline, bars, series, arbitrary node), each carrying its own loading/error/empty state, rather than a declared metric view. Goes when the audit page's widgets are expressible as a `DashboardView` and it mounts the kit's renderer instead.",
+ "Layout in src/features/dashboard/ui/layout.tsx":
+ "page rhythm for a dashboard page — an `` and a vertical stack. The kit's `Layout` is the application shell (sidebar, header, content region); this is what goes inside one. Goes when it is re-expressed on the kit's `Page`/`PageHeader`.",
+ "MetricCard in src/features/dashboard/ui/metric-card.tsx":
+ "resolves one declared metric through `useMetric` and then paints whichever chart the declaration asked for; the kit's `MetricCard` takes an already-resolved `Metric`. Goes with the DSL's resolution moving outside the card, the same change that retires the `Dashboard` entry above.",
+ "MetricCard in src/features/event-operations/ui/event-operations-page.tsx":
+ "a tile over an already-formatted string value plus a caption line. The kit's `StatTile`/`MetricCard` take a numeric `Metric` and have no caption slot, so this is not yet expressible as one. Goes when `StatTile` carries a description, or when these counts are expressed as `Metric`s with a `format`.",
+ "MetricCard in src/features/job-operations/ui/job-operations-page.tsx":
+ "the same tile as the event-operations page's, over the job queue's counts, and it goes with it — the two are each other's duplicate as much as the kit's, and both should become one call to the kit's tile.",
+};
+
+/**
+ * Chart surfaces that are, and stay, thin re-exports of the kit.
+ *
+ * Issue #1041 asked what these two are, since both carry a kit export's name.
+ * They are each a single `export … from "@codefly-dev/ui/dashboard"`: they ship
+ * the kit's component, not another one, so neither is a duplicate and neither
+ * needs an entry in {@link ALLOWED}. What they do carry is a hazard this gate
+ * cannot see, recorded here rather than left for the next reader to find: both
+ * publish the name `LineChart`, and they publish DIFFERENT kit components under
+ * it — `src/components/charts` re-exports the metric-tier `LineChart`, while
+ * `src/features/dashboard/ui/charts` re-exports `TrendLineChart`. An import of
+ * `LineChart` therefore means one of two charts depending on which path it came
+ * from. Retiring the second alias belongs with the dashboard-DSL work that
+ * retires `MetricCard in src/features/dashboard/ui/metric-card.tsx` below, its
+ * only consumer.
+ */
+const CHART_RE_EXPORTS = [
+ "src/components/charts/chart.tsx",
+ "src/features/dashboard/ui/charts/line-chart.tsx",
+];
+
+function walk(dir: string, out: string[] = []): string[] {
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const path = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (entry.name !== "__tests__" && entry.name !== "node_modules") {
+ walk(path, out);
+ }
+ continue;
+ }
+ if (/\.tsx?$/.test(entry.name) && !/\.(test|stories)\./.test(entry.name)) {
+ out.push(path);
+ }
+ }
+ return out;
+}
+
+/** Every capitalised name the kit's entry points export. */
+function kitExports(entryPoints: string[]): Set {
+ const names = new Set();
+ for (const source of entryPoints) {
+ for (const block of source.matchAll(/export\s*\{([^}]*)\}/g)) {
+ for (const raw of block[1].split(",")) {
+ const name =
+ raw
+ .trim()
+ .replace(/^type\s+/, "")
+ .split(/\s+as\s+/)
+ .pop()
+ ?.trim() ?? "";
+ if (/^[A-Z][A-Za-z0-9]*$/.test(name)) names.add(name);
+ }
+ }
+ }
+ return names;
+}
+
+/**
+ * The names a file imports FROM the kit, under the kit's own spelling: the `X`
+ * of `import { X as KitX } from "@codefly-dev/ui/layout"`. A wrapper is
+ * recognised by this, not by its alias, so renaming the alias cannot turn a
+ * re-implementation into a wrapper.
+ */
+function kitImports(source: string): Set {
+ const imported = new Set();
+ const from =
+ /import\s+(?:type\s+)?\{([^}]*)\}\s*from\s*["']@codefly-dev\/(?:ui|saas-ui)(?:\/[\w./-]+)?["']/g;
+ for (const block of source.matchAll(from)) {
+ for (const raw of block[1].split(",")) {
+ const name = raw
+ .trim()
+ .replace(/^type\s+/, "")
+ .split(/\s+as\s+/)[0]
+ .trim();
+ if (name) imported.add(name);
+ }
+ }
+ return imported;
+}
+
+/**
+ * A component declaration, exported or module-local, at any indentation — a
+ * duplicate nested inside another component is still a duplicate, and the
+ * commonest hiding place for one.
+ */
+const DECLARATION =
+ /^[ \t]*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|const|let|class)\s+([A-Z][A-Za-z0-9]*)/gm;
+
+/**
+ * Files whose default export's NAME is fixed by the framework, not chosen:
+ * Next's route conventions make every route component a `Page`, every nested
+ * shell a `Layout`, and so on, and the kit exports primitives called `Page` and
+ * `Layout`. A route component is not a second copy of either — it is the thing
+ * that mounts them — so its default export is not read as a declaration taking
+ * the kit's name. A NAMED declaration in such a file still is.
+ */
+const ROUTE_CONVENTION =
+ /^src\/app\/.*\/(page|layout|template|default|error|global-error|loading|not-found|forbidden|unauthorized)\.tsx?$/;
+const DEFAULT_EXPORT = /^[ \t]*export\s+default\s/;
+
+interface Offence {
+ name: string;
+ path: string;
+}
+
+/**
+ * Declarations in these files that take a kit export's name without composing
+ * the kit's component of that name. Pure, so the fixtures below can exercise it
+ * on sources that are not in the tree.
+ */
+function findOffences(
+ files: { path: string; source: string }[],
+ kit: Set,
+ allowed: Record = ALLOWED,
+): Offence[] {
+ const offences: Offence[] = [];
+ for (const { path, source } of files) {
+ const composed = kitImports(source);
+ const routeFile = ROUTE_CONVENTION.test(path);
+ const seen = new Set();
+ for (const declaration of source.matchAll(DECLARATION)) {
+ const name = declaration[1];
+ if (!kit.has(name) || seen.has(name)) continue;
+ if (routeFile && DEFAULT_EXPORT.test(declaration[0])) continue;
+ seen.add(name);
+ if (composed.has(name)) continue;
+ if (`${name} in ${path}` in allowed) continue;
+ offences.push({ name, path });
+ }
+ }
+ return offences;
+}
+
+function appSources(): { path: string; source: string }[] {
+ return walk(join(appRoot, "src"))
+ .map((file) => relative(appRoot, file).replaceAll("\\", "/"))
+ .filter(
+ (path) =>
+ !path.startsWith("src/gen/") && !path.startsWith("src/generated/"),
+ )
+ .map((path) => ({
+ path,
+ source: readFileSync(join(appRoot, path), "utf8"),
+ }));
+}
+
+function kitNames(): Set {
+ const entries = walk(join(appRoot, "packages/codefly-ui/src"))
+ .filter((file) => /[/\\]index\.ts$/.test(file))
+ .map((file) => readFileSync(file, "utf8"));
+ return kitExports(entries);
+}
+
+describe("the host app composes the kit rather than re-implementing it", () => {
+ it("declares no component the kit already exports", () => {
+ const kit = kitNames();
+ expect(
+ kit.size,
+ "the kit's entry points were not readable, so this gate proves nothing",
+ ).toBeGreaterThan(100);
+
+ const offences = findOffences(appSources(), kit).map(
+ ({ name, path }) =>
+ `${name} in ${path} — @codefly-dev/ui already exports it. Compose the kit's component, or add "${name} in ${path}" to ALLOWED with what the two things are and why neither can stand in for the other.`,
+ );
+ expect(offences).toEqual([]);
+ });
+
+ it("cannot build a kit-shaped primitive: the library for building them is not a dependency", () => {
+ // The strongest leg, because it is a fact rather than a convention: `cva`
+ // is how the kit composes its variants, and it left this package's
+ // dependencies with issue #1041. The kit declares it for itself.
+ expect(Object.keys(packageJson.dependencies)).not.toContain(
+ "class-variance-authority",
+ );
+ const importers = appSources()
+ .filter(({ source }) => /["']class-variance-authority["']/.test(source))
+ .map(({ path }) => path);
+ expect(importers).toEqual([]);
+ });
+
+ it.each(CHART_RE_EXPORTS)(
+ "%s stays a re-export of the kit's charts",
+ (path) => {
+ const source = readFileSync(join(appRoot, path), "utf8");
+ expect(source).toContain('from "@codefly-dev/ui/dashboard"');
+ // No component of its own: no JSX, no class strings, no variants.
+ expect(source).not.toMatch(/\bfunction\b|=>|cva\(|cn\(/);
+ },
+ );
+
+ it("keeps every entry in ALLOWED a real one", () => {
+ const kit = kitNames();
+ for (const [key, reason] of Object.entries(ALLOWED)) {
+ const [name, path] = key.split(" in ");
+ expect(
+ kit.has(name),
+ `${key}: ${name} is not a kit export any more, so this entry is stale — delete it`,
+ ).toBe(true);
+ expect(
+ new RegExp(
+ `^(?:export\\s+)?(?:function|const|class)\\s+${name}\\b`,
+ "m",
+ ).test(readFileSync(join(appRoot, path), "utf8")),
+ `${key} is not declared there any more, so this entry is stale — delete it`,
+ ).toBe(true);
+ expect(
+ reason.length,
+ `${key} needs what the two things are and why, not a listing`,
+ ).toBeGreaterThan(80);
+ }
+ });
+});
+
+// Three directions, because a gate is only as good as what it is known to
+// catch, to let through, and to catch when the same duplication arrives by
+// another route. Each fixture is a source that is not in the tree, so these
+// hold whatever the tree happens to contain.
+describe("the detector", () => {
+ const kit = new Set(["Card", "CardRoot", "StatTile", "Page"]);
+ const detect = (path: string, source: string) =>
+ findOffences([{ path, source }], kit, {}).map(
+ (o) => `${o.name} in ${o.path}`,
+ );
+
+ it("catches a component taking a kit export's name", () => {
+ expect(
+ detect(
+ "src/features/x/ui/card.tsx",
+ `export function Card({ children }: { children: ReactNode }) {
+ return {children}
;
+ }`,
+ ),
+ ).toEqual(["Card in src/features/x/ui/card.tsx"]);
+ });
+
+ it("lets a wrapper that renders the kit's own component through", () => {
+ expect(
+ detect(
+ "src/features/x/ui/card.tsx",
+ `import { Card as KitCard } from "@codefly-dev/ui/layout";
+ export function Card(props: ComponentProps) {
+ return ;
+ }`,
+ ),
+ ).toEqual([]);
+ });
+
+ it("catches the same duplication declared module-locally instead of exported", () => {
+ // How both duplicates in this tree arrived: not as a new file anyone
+ // would review as a component, but as a helper at the bottom of a page.
+ // The upstream version of this rule matched only `export function`, and
+ // let this shape past.
+ expect(
+ detect(
+ "src/features/x/ui/page.tsx",
+ `function Card({ title }: { title: string }) {
+ return {title}
;
+ }
+ export function Deliveries() { return ; }`,
+ ),
+ ).toEqual(["Card in src/features/x/ui/page.tsx"]);
+ });
+
+ it("catches an arrow-function component, which is the same declaration", () => {
+ expect(
+ detect(
+ "src/features/x/ui/tile.tsx",
+ `const StatTile = ({ value }: { value: number }) => {value} ;`,
+ ),
+ ).toEqual(["StatTile in src/features/x/ui/tile.tsx"]);
+ });
+
+ it("catches a declaration that imports a DIFFERENT kit component under the name it takes", () => {
+ // Importing `CardRoot` and calling the result `Card` is not a wrapper
+ // around `Card`: it is a second `Card` built on a kit primitive. This is
+ // how a duplicate escapes a plain name gate — the kit and the consumer
+ // call the same thing by different names, so neither name collides.
+ expect(
+ detect(
+ "src/features/x/ui/card.tsx",
+ `import { CardRoot } from "@codefly-dev/ui/layout";
+ export function Card({ title }: { title: string }) {
+ return {title} ;
+ }`,
+ ),
+ ).toEqual(["Card in src/features/x/ui/card.tsx"]);
+ });
+
+ it("is not fooled by an alias: a wrapper is recognised by the kit's own spelling", () => {
+ expect(
+ detect(
+ "src/features/x/ui/card.tsx",
+ `import { StatTile as Tile } from "@codefly-dev/ui/dashboard";
+ export const StatTile = (props: ComponentProps) => ;`,
+ ),
+ ).toEqual([]);
+ });
+
+ it("ignores a re-export, which ships the kit's component rather than another one", () => {
+ expect(
+ detect(
+ "src/components/charts/chart.tsx",
+ `export { StatTile, Card } from "@codefly-dev/ui/dashboard";`,
+ ),
+ ).toEqual([]);
+ });
+
+ it("lets a Next route component keep the name the framework gives it", () => {
+ expect(
+ detect(
+ "src/app/admin/users/page.tsx",
+ `export default function Page() { return ; }`,
+ ),
+ ).toEqual([]);
+ });
+
+ it("still catches a NAMED kit-shaped component inside a route file", () => {
+ // The exemption is for the one export whose name Next fixes, not for the
+ // file: a second `Card` beside the route component is still a second Card.
+ expect(
+ detect(
+ "src/app/admin/users/page.tsx",
+ `function Card({ title }: { title: string }) { return {title}
; }
+ export default function Page() { return ; }`,
+ ),
+ ).toEqual(["Card in src/app/admin/users/page.tsx"]);
+ });
+
+ it("ignores a name the kit does not export", () => {
+ expect(
+ detect(
+ "src/features/x/ui/panel.tsx",
+ `export function PayloadBlock({ title }: { title: string }) { return {title}
; }`,
+ ),
+ ).toEqual([]);
+ });
+});
diff --git a/module/services/frontend/code/src/features/webhooks/ui/webhook-deliveries-panel.tsx b/module/services/frontend/code/src/features/webhooks/ui/webhook-deliveries-panel.tsx
index 972c11c46..dc57752f5 100644
--- a/module/services/frontend/code/src/features/webhooks/ui/webhook-deliveries-panel.tsx
+++ b/module/services/frontend/code/src/features/webhooks/ui/webhook-deliveries-panel.tsx
@@ -295,14 +295,14 @@ function DeliveryDetail({
{/* Request payload */}
-
{/* Response body */}
- ` — the same renderer,
+// from the same package instance, that a solution's own remote mounts and that
+// the host's operations pages are built from. Nothing here re-implements it:
+// this file owns only what is genuinely the host's (the per-widget audit
+// queries, the viewer's saved arrangement of the tiles, and the ⓘ that says
+// where a number comes from) and composes it AROUND the renderer through the
+// renderer's own slots.
import {
- createSaasClient,
- type ResolvedWidget,
- runDashboard,
-} from "@codefly-dev/saas-sdk";
-// Charts come from the shared kit, not host-internal components: the same
-// primitives a solution's own remote would render with, so host-rendered and
-// solution-rendered dashboards look identical and there is one charting
-// implementation to maintain.
-import {
- AreaChart,
- BarList,
- LineChart,
+ Dashboard,
+ DashboardWidget,
+ type DashboardWidgetView,
SortableGrid,
- StatChart,
} from "@codefly-dev/ui/dashboard";
import { useQuery } from "@tanstack/react-query";
import { GripVertical, Info, Plus, X } from "lucide-react";
@@ -35,10 +33,6 @@ import { useAuth } from "@/lib/auth";
import { apiTransport } from "@/lib/connect/transport";
import {
Button,
- Card,
- CardContent,
- CardHeader,
- CardTitle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -47,7 +41,6 @@ import {
PopoverContent,
PopoverTitle,
PopoverTrigger,
- Skeleton,
} from "@/shared/ui";
import {
addableTiles,
@@ -99,103 +92,35 @@ function singleWidgetGraph(graph: DataGraph, widget: MetricWidget): DataGraph {
};
}
-// Render a resolved widget's series in the shape its visualization asks for. A
-// series with no points is "no data yet" for every kind — the audit RPC omits a
-// bucket rather than emitting a zero, so an empty series means nothing matched,
-// not a real zero worth plotting.
-function WidgetBody({ widget }: { widget: ResolvedWidget }) {
- return (
- <>
- {widget.series.coverage === "partial" && (
-
- Partial telemetry
-
- )}
-
- >
- );
-}
-
-function WidgetValues({ widget }: { widget: ResolvedWidget }) {
- const { series, visualization } = widget;
- if (series.points.length === 0) {
- return (
-
- {series.coverage === "partial"
- ? "Telemetry unavailable or incomplete."
- : "No data yet."}
-
- );
- }
- switch (visualization) {
- case "line":
- return (
-
- );
- case "area":
- return (
-
- );
- case "bar":
- return ;
- case "number":
- return series.total === null ? (
-
- {series.coverage === "partial"
- ? "Incomplete telemetry; total unavailable."
- : "Total unavailable across groups."}
-
- ) : (
-
- );
- case "table":
- return (
-
-
- {series.points.map((point) => (
-
- {point.key}
-
- {point.value.toLocaleString()}
-
-
- ))}
-
-
- );
- default: {
- // Compile-time exhaustiveness: a new WidgetVisualization must be
- // handled here or this assignment fails to type-check.
- const _exhaustive: never = visualization;
- return _exhaustive;
- }
- }
-}
-
-function WidgetCard({
+// One declared widget, resolved and drawn.
+//
+// The query lives here, in a component of its own, because each widget is
+// resolved in isolation: a widget whose metric's audit query errors fails
+// alone instead of blanking every sibling that would share one batched
+// resolution. React fixes the number of hooks a component may call, so these
+// queries cannot be hoisted into the renderer that takes the whole view —
+// which is why the kit exports `DashboardWidget` beside `` and this
+// mounts that, rather than drawing a card of its own.
+function SolutionWidget({
graph,
widget,
solutionId,
dashboardId,
orgId,
- grip,
- info,
- remove,
+ actions,
}: {
graph: DataGraph;
widget: MetricWidget;
solutionId: string;
dashboardId: string;
orgId: string;
- grip: ReactNode;
- info: ReactNode;
- remove: ReactNode;
+ actions: ReactNode;
}) {
// The graph is part of the key so a solution that redeploys with a changed
// declaration refetches instead of serving another graph's cached series that
// happens to share the same solution/dashboard/widget/org ids. Disabled until
// the org resolves so the pre-org window reads as loading, never as empty.
- const { data, isPending, isError } = useQuery({
+ const { data, isError } = useQuery({
queryKey: [
"solution-widget",
solutionId,
@@ -215,28 +140,30 @@ function WidgetCard({
});
return (
-
-
- {grip}
-
- {widget.title ?? widget.metric}
-
- {info}
- {remove}
-
-
- {isError ? (
- Unable to load.
- ) : isPending || !data ? (
-
- ) : (
-
- )}
-
-
+
);
}
+// A declared widget as the renderer's view model, with no series yet. The
+// series is bound by `SolutionWidget`, the one place that has resolved it; a
+// `null` series is the honest value here and the renderer draws it as a wait
+// rather than as an empty result.
+function declaredWidget(widget: MetricWidget): DashboardWidgetView {
+ return {
+ id: widget.id,
+ visualization: widget.visualization,
+ title: widget.title ?? widget.metric,
+ series: null,
+ };
+}
+
// Reading `window.localStorage` can itself throw: blocked site data or a
// sandboxed frame raise a SecurityError from the getter, before any getItem. No
// storage means every viewer sees the declared default.
@@ -273,7 +200,7 @@ function noSubscription() {
*/
function useDashboardLayout(
storageKey: string,
- dashboard: Dashboard,
+ dashboard: DashboardDeclaration,
): [string[], (next: readonly string[]) => void] {
const stored = useSyncExternalStore(
noSubscription,
@@ -525,7 +452,7 @@ function SolutionDashboard({
solutionId,
}: {
graph: DataGraph;
- dashboard: Dashboard;
+ dashboard: DashboardDeclaration;
solutionId: string;
}) {
const { user, organizationId } = useAuth();
@@ -544,52 +471,45 @@ function SolutionDashboard({
const widget = widgets.get(tileId);
return widget?.title ?? widget?.metric ?? tileId;
};
- const renderTile = (tileId: string) => {
- const widget = widgets.get(tileId);
- if (!widget) return null;
+ // The grip, the ⓘ and the × — the three host-only controls — go into the
+ // renderer's own per-widget actions slot, so they sit in the card header the
+ // kit draws instead of a header this file draws.
+ const controls = (tileId: string) => {
const title = titleOf(tileId);
+ const widget = widgets.get(tileId);
return (
- {
- const step = ARROW_STEP[event.key];
- if (step === undefined) return;
- event.preventDefault();
- const grip = event.currentTarget;
- // 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();
- }}
- >
-
-
- }
- info={ }
- remove={
- save(removeTile(layout, tileId))}
- >
-
-
- }
- />
+ <>
+ {
+ const step = ARROW_STEP[event.key];
+ if (step === undefined) return;
+ event.preventDefault();
+ const grip = event.currentTarget;
+ // 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();
+ }}
+ >
+
+
+ {widget && }
+ save(removeTile(layout, tileId))}
+ >
+
+
+ >
);
};
// The floating copy while a tile is dragged: the same card, but with no
@@ -612,56 +532,82 @@ function SolutionDashboard({
);
return (
- )}
- info={glyph( )}
- remove={glyph( )}
+ actions={
+ <>
+ {glyph( )}
+ {glyph( )}
+ {glyph( )}
+ >
+ }
/>
);
};
return (
-
-
- {dashboard.title && (
-
- {dashboard.title}
-
- )}
-
save(addTile(layout, tileId))}
- />
-
- {shown.length === 0 ? (
-
- No widgets are shown. Add one back with the + button.
-
- ) : (
- // The kit's SortableGrid rather than its Grid/Stack: the tiles are
- // dragged onto each other to swap. The classes are those Grid
- // cols={2} and Stack draw with.
- tileId)}
- onSwap={(dragged, target) => save(swapTiles(layout, dragged, target))}
- itemLabel={titleOf}
- renderItem={renderTile}
- renderOverlay={renderDragged}
- className={
- dashboard.layout === "stack"
- ? "flex flex-col gap-4"
- : "grid grid-cols-1 gap-4 sm:grid-cols-2"
- }
- />
- )}
-
+ declaredWidget(widget)),
+ }}
+ slots={{
+ actions: (
+ save(addTile(layout, tileId))}
+ />
+ ),
+ renderWidget: (view) => {
+ const widget = widgets.get(view.id);
+ if (!widget) return null;
+ return (
+
+ );
+ },
+ // 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) => {
+ const nodes = new Map(tiles.map((tile) => [tile.id, tile.node]));
+ return (
+ tile.id)}
+ onSwap={(dragged, target) =>
+ save(swapTiles(layout, dragged, target))
+ }
+ itemLabel={titleOf}
+ renderItem={(id) => 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"
+ }
+ />
+ );
+ },
+ empty: (
+
+ No widgets are shown. Add one back with the + button.
+
+ ),
+ }}
+ />
);
}
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 8a64814bb..941dd350d 100644
--- a/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx
+++ b/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx
@@ -116,13 +116,18 @@ function aggregateHandler(
const DEFAULT_ORDER = ["Logins over time", "Top event types", "Total logins"];
-// The tile titles, in the order the dashboard shows them.
+// The tile titles, in the order the dashboard shows them. The kit's renderer
+// titles a series widget through its Card (`card-heading`) and a scalar one
+// through its StatTile (`metric-label`), so a tile is named by whichever of
+// the two it carries.
function tileTitles(): string[] {
return screen
.getAllByRole("listitem")
.map(
(tile) =>
- tile.querySelector('[data-slot="card-title"]')?.textContent ?? "",
+ tile.querySelector(
+ '[data-slot="card-heading"], [data-slot="metric-label"]',
+ )?.textContent ?? "",
);
}
@@ -155,13 +160,22 @@ describe("SolutionDashboards", () => {
renderInApp( );
- // Bar widget: each event_type bucket becomes a labelled bar.
- expect(await screen.findByText("saas.auth.login")).toBeTruthy();
- expect(screen.getByText("42")).toBeTruthy();
+ // Bar widget: each event_type bucket becomes a bar, and every exact
+ // value is reachable in the chart's accessible data table — which is
+ // what a reader who cannot see the geometry gets.
+ const bars = await screen.findByRole("table", { name: "Top event types" });
+ expect(
+ within(bars).getByRole("row", { name: /saas\.auth\.login/ }),
+ ).toBeTruthy();
+ expect(within(bars).getByText("42")).toBeTruthy();
// Number widget: the total-logins stat sums the time buckets (3 + 5).
expect(screen.getByText("8")).toBeTruthy();
- // Each widget's title is rendered from the declaration.
- expect(screen.getByText("Logins over time")).toBeTruthy();
+ // Each widget's title is rendered from the declaration. A chart widget
+ // says it three times: once visibly as its card's heading, and twice in
+ // the accessible data table that is the chart's screen-reader
+ // representation — as the table's caption and as its one series' column
+ // header, there being no other name for the series than the widget's.
+ expect(screen.getAllByText("Logins over time")).toHaveLength(3);
expect(screen.getByText("Total logins")).toBeTruthy();
});
From 3639df5402eb6b0066e7cdd505973640b01527ba Mon Sep 17 00:00:00 2001
From: Antoine Toussaint
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Thu, 8 Oct 2026 17:42:11 -0400
Subject: [PATCH 2/2] fix(dashboard): add layout declarations and preserve
widget identity (#1044)
---
module/AUDIT_METRICS.md | 10 +-
module/docs/plugin-manifest-schema.md | 53 +++++-
module/services/frontend/code/next-env.d.ts | 4 +-
.../services/frontend/code/package-lock.json | 16 +-
module/services/frontend/code/package.json | 8 +-
.../code/packages/codefly-ui/README.md | 17 +-
.../code/packages/codefly-ui/package.json | 2 +-
.../dashboard/__tests__/dashboard.test.tsx | 19 +-
.../__tests__/sortable-grid.test.tsx | 14 ++
.../src/dashboard/__tests__/types.test.ts | 21 +++
.../codefly-ui/src/dashboard/dashboard.tsx | 157 ++++++++--------
.../codefly-ui/src/dashboard/index.ts | 50 +++--
.../src/dashboard/sortable-grid.tsx | 61 ++++--
.../codefly-ui/src/dashboard/types.ts | 20 +-
.../codefly-ui/src/skin/preview.generated.css | 12 ++
.../saas-plugin-manifest/package.json | 2 +-
.../plugin.codefly.schema.json | 47 ++++-
.../saas-plugin-manifest/src/data-graph.ts | 64 ++++++-
.../saas-plugin-manifest/src/index.ts | 2 +
.../test/dashboard-layout.test.ts | 82 ++++++++
.../code/packages/saas-sdk/package.json | 2 +-
.../saas-sdk/src/datagraph/dashboard.ts | 10 +
.../code/packages/saas-sdk/src/index.ts | 58 +++---
.../code/packages/saas-sdk/src/schema.ts | 14 ++
.../packages/saas-sdk/test/dashboard.test.ts | 26 +++
.../code/packages/saas-ui/package.json | 2 +-
.../code/src/solutions/SolutionDashboard.tsx | 34 +++-
.../__tests__/SolutionDashboard.test.tsx | 102 +++++++++-
.../__tests__/dashboard-layout.test.ts | 35 ++++
.../__tests__/fixtures/platform-signins.json | 177 ++++++++++++++++++
.../code/src/solutions/dashboard-layout.ts | 7 +
31 files changed, 930 insertions(+), 198 deletions(-)
create mode 100644 module/services/frontend/code/packages/saas-plugin-manifest/test/dashboard-layout.test.ts
create mode 100644 module/services/frontend/code/src/solutions/__tests__/fixtures/platform-signins.json
diff --git a/module/AUDIT_METRICS.md b/module/AUDIT_METRICS.md
index 44c0a2ec4..0b0adfbad 100644
--- a/module/AUDIT_METRICS.md
+++ b/module/AUDIT_METRICS.md
@@ -238,11 +238,19 @@ 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
+drag-to-reorder grid using the kit's shared layout), 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.
+The declaration can add dashboard `columns`, widget `span`, and named
+`sections` with their own heading and column count. The defaults need none of
+these: adjacent scalar widgets form compact four-column rows, series use two
+columns, and cards keep their natural height. A stack stays single-column.
+Section and widget order come from the declaration until a viewer customizes
+within a section. See [the layout contract](docs/plugin-manifest-schema.md#dashboard-layout)
+for responsive spans, persistence and the compatibility limit of legacy hosts.
+
## Independent validation
Run `go test -race ./pkg/infra/auditmetricstest` from accounts/code for pure
diff --git a/module/docs/plugin-manifest-schema.md b/module/docs/plugin-manifest-schema.md
index f6a75e0b0..63ad0d313 100644
--- a/module/docs/plugin-manifest-schema.md
+++ b/module/docs/plugin-manifest-schema.md
@@ -97,6 +97,54 @@ has three node kinds:
one metric. These metric-bound widgets are distinct from `ui.widgets`, which
are presentation slots contributed to host surfaces; the two never mix.
+### Dashboard layout
+
+Layout fields are **additive**. Existing declarations need no edits. A grid
+packs consecutive `number` widgets into four-column rows and series into
+two-column rows, collapsing to one column on narrow screens. Runs retain widget
+order; a scalar is never moved ahead of a preceding chart. Cards align at their
+natural height, so a short or empty card does not stretch to its neighbour.
+`stack` keeps one widget per row and ignores column/span hints.
+
+- `Dashboard.columns?: 1 | 2 | 3 | 4` overrides the default for both kinds of row.
+- `MetricWidget.span?: 1 | 2 | 3 | 4` applies to scalars and series, clamped to
+ the containing row's columns and the current responsive breakpoint.
+- `Dashboard.sections?: { id, title, description?, columns? }[]` declares named
+ bands in section order. Each widget may name one with `section`; the id must
+ exist. A section inherits the dashboard column count unless it overrides it.
+ Widgets without a section appear first; within each section, widget order is
+ preserved. Empty sections draw nothing.
+
+For example, an existing dashboard can add:
+
+```json
+{
+ "id": "overview",
+ "layout": "grid",
+ "columns": 2,
+ "sections": [
+ { "id": "headline", "title": "Headline numbers", "columns": 4 },
+ { "id": "trends", "title": "Over time" }
+ ],
+ "widgets": [
+ { "id": "total", "metric": "total_events", "visualization": "number", "section": "headline" },
+ { "id": "trend", "metric": "events_per_day", "visualization": "line", "section": "trends", "span": 2 }
+ ]
+}
+```
+
+`runDashboard` and `fromDashboardData` carry these fields to the kit unchanged.
+The host's sortable view uses the same geometry. Dragging swaps widget IDs;
+keyboard arrows move one place within the widget's declared section. A viewer's
+preference does not change declared section membership.
+
+**Independent rollout:** hosts with this validator ignore unknown presentation
+fields on dashboards, sections and widgets. Unknown metric/query fields still
+fail validation. Previously released hosts used exact-key validation and will
+reject the new fields: upgrade those hosts before adding layout fields, or keep
+sending the unchanged declaration to them. Additive types cannot retrofit an
+already-deployed validator.
+
On a registered solution's page the host renders every declared dashboard in a
**Dashboard** tab beside the solution's own **App** tab, never stacked above
the solution; the solution stays mounted while the dashboard is open. A
@@ -104,7 +152,10 @@ solution that declares no dashboard gets no tab bar. Each viewer can arrange a
dashboard for themselves: reorder its declared widgets, remove
them, and re-add the ones they removed. The declared widgets, in declared order,
are where every viewer starts, and a widget declared later reaches viewers who
-already rearranged. A viewer's arrangement is a preference (ADR 0007): it never
+already rearranged. Saved layouts use widget IDs, not positions, and record the
+IDs known at save time. An unchanged saved default follows a new declaration;
+a customized order keeps surviving IDs, appends newly declared IDs once, and
+keeps intentionally removed widgets hidden. A viewer's arrangement is a preference (ADR 0007): it never
adds a metric the dashboard does not draw, so a metric declared only as a
derived metric's input stays off the page.
diff --git a/module/services/frontend/code/next-env.d.ts b/module/services/frontend/code/next-env.d.ts
index a419cbe4e..ce4e94a6b 100644
--- a/module/services/frontend/code/next-env.d.ts
+++ b/module/services/frontend/code/next-env.d.ts
@@ -1,7 +1,7 @@
///
///
-import "./.next/dev/types/routes.d.ts";
-import "./.next/dev/types/root-params.d.ts";
+import "./.next/types/routes.d.ts";
+import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/module/services/frontend/code/package-lock.json b/module/services/frontend/code/package-lock.json
index 46b1d9462..1bc610f5c 100644
--- a/module/services/frontend/code/package-lock.json
+++ b/module/services/frontend/code/package-lock.json
@@ -15,10 +15,10 @@
"@bufbuild/protobuf": "^2.11.0",
"@codefly-dev/saas-plugin-contract": "2.4.2",
"@codefly-dev/saas-plugin-react": "0.4.3",
- "@codefly-dev/saas-sdk": "0.3.17",
- "@codefly-dev/saas-ui": "0.12.6",
- "@codefly-dev/ui": "0.12.6",
- "@codefly/saas-plugin-manifest": "0.1.1",
+ "@codefly-dev/saas-sdk": "0.3.18",
+ "@codefly-dev/saas-ui": "0.12.7",
+ "@codefly-dev/ui": "0.12.7",
+ "@codefly/saas-plugin-manifest": "0.1.2",
"@codefly/saas-settings": "0.1.0",
"@connectrpc/connect": "^2.1.1",
"@connectrpc/connect-web": "^2.1.1",
@@ -15233,7 +15233,7 @@
},
"packages/codefly-ui": {
"name": "@codefly-dev/ui",
- "version": "0.12.6",
+ "version": "0.12.7",
"license": "Apache-2.0",
"dependencies": {
"@base-ui/react": "^1.3.0",
@@ -15314,7 +15314,7 @@
},
"packages/saas-plugin-manifest": {
"name": "@codefly/saas-plugin-manifest",
- "version": "0.1.1",
+ "version": "0.1.2",
"license": "Apache-2.0",
"dependencies": {
"@codefly-dev/saas-plugin-contract": "2.4.2"
@@ -15347,7 +15347,7 @@
},
"packages/saas-sdk": {
"name": "@codefly-dev/saas-sdk",
- "version": "0.3.17",
+ "version": "0.3.18",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^2.11.0",
@@ -15381,7 +15381,7 @@
},
"packages/saas-ui": {
"name": "@codefly-dev/saas-ui",
- "version": "0.12.6",
+ "version": "0.12.7",
"license": "Apache-2.0",
"devDependencies": {
"@bufbuild/protobuf": "^2.11.0",
diff --git a/module/services/frontend/code/package.json b/module/services/frontend/code/package.json
index 47289638d..475f3589a 100644
--- a/module/services/frontend/code/package.json
+++ b/module/services/frontend/code/package.json
@@ -38,10 +38,10 @@
"@bufbuild/protobuf": "^2.11.0",
"@codefly-dev/saas-plugin-contract": "2.4.2",
"@codefly-dev/saas-plugin-react": "0.4.3",
- "@codefly-dev/saas-sdk": "0.3.17",
- "@codefly-dev/saas-ui": "0.12.6",
- "@codefly-dev/ui": "0.12.6",
- "@codefly/saas-plugin-manifest": "0.1.1",
+ "@codefly-dev/saas-sdk": "0.3.18",
+ "@codefly-dev/saas-ui": "0.12.7",
+ "@codefly-dev/ui": "0.12.7",
+ "@codefly/saas-plugin-manifest": "0.1.2",
"@codefly/saas-settings": "0.1.0",
"@connectrpc/connect": "^2.1.1",
"@connectrpc/connect-web": "^2.1.1",
diff --git a/module/services/frontend/code/packages/codefly-ui/README.md b/module/services/frontend/code/packages/codefly-ui/README.md
index 95543fdca..0ff7b2105 100644
--- a/module/services/frontend/code/packages/codefly-ui/README.md
+++ b/module/services/frontend/code/packages/codefly-ui/README.md
@@ -36,7 +36,7 @@ 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
+ `` paints with the **metric tier** — `StatTile` in compact rows 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
@@ -642,3 +642,18 @@ Version 0.12.3 refreshes the default preview palette from appearance contract 2.
Version 0.12.4 also makes destructive/danger Badge text mix 30% toward the active
foreground, retaining its status hue while meeting small-text contrast on its
tinted background in the tested default and supplied light/dark skins.
+
+### Declared dashboard geometry
+
+`DashboardView.sections` names bands with `id`, `title`, optional `description`
+and `columns`; a widget's `section` refers to one. Ungrouped widgets appear first,
+then sections in declared order. Consecutive scalars default to four columns,
+series to two, with natural-height cards. `columns` overrides both defaults and
+`span` applies to every widget, clamped responsively. `stack` ignores both hints.
+`fromDashboardData` preserves these fields from the SDK.
+
+The `layout` slot receives the tiles and a `renderLayout(nodesById)` callback.
+Pass that callback to `SortableGrid.renderLayout` to retain exactly the default
+geometry under one drag context, including drops across scalar/chart bands.
+`SortableGrid.canSwap` can constrain drops to declared sections. Keep widget IDs
+stable across declaration changes; never persist positional indexes.
diff --git a/module/services/frontend/code/packages/codefly-ui/package.json b/module/services/frontend/code/packages/codefly-ui/package.json
index 18f4fc37c..056f89478 100644
--- a/module/services/frontend/code/packages/codefly-ui/package.json
+++ b/module/services/frontend/code/packages/codefly-ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@codefly-dev/ui",
- "version": "0.12.6",
+ "version": "0.12.7",
"description": "The shared, versioned Codefly SaaS frontend kit: the plugin host and the tokens-as-data skin mechanism, standalone so the host and Module-Federation remotes import and dedupe one copy.",
"repository": {
"type": "git",
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 d305553f5..a148c8144 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
@@ -340,16 +340,13 @@ describe("the default layout", () => {
{ 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);
+ const grid = container.querySelector(".grid.items-start");
+ expect(grid?.className).toContain("lg:grid-cols-4");
+ const tiles = [...container.querySelectorAll("[data-dashboard-tile]")];
+ expect(tiles).toHaveLength(4);
+ expect(tiles[2].className).toContain("sm:col-span-2");
+ expect(tiles[2].className).toContain("col-start-1");
+ expect(tiles[3].className).toContain("col-start-1");
expect(
[...container.querySelectorAll('[data-slot="metric-label"], h3')].map(
(node) => node.textContent,
@@ -369,7 +366,7 @@ describe("the default layout", () => {
}}
/>,
);
- expect(container.querySelector("section > div")?.className).toContain(
+ expect(container.querySelector(".flex.flex-col")?.className).toContain(
"flex flex-col",
);
expect(container.querySelector('[class*="grid-cols-4"]')).toBeNull();
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/sortable-grid.test.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/sortable-grid.test.tsx
index 04b972c53..2dac84832 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/sortable-grid.test.tsx
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/sortable-grid.test.tsx
@@ -252,3 +252,17 @@ describe("SortableGrid", () => {
expect(animate).not.toHaveBeenCalled();
});
});
+
+it("does not turn a drop on another section into a swap with the nearest allowed tile", () => {
+ const onSwap = vi.fn();
+ render(
+ id}
+ canSwap={(a, b) => ["a", "b"].includes(a) === ["a", "b"].includes(b)}
+ />,
+ );
+ dragTo("a", center("d"))();
+ expect(onSwap).not.toHaveBeenCalled();
+});
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 397e29328..57cd03399 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
@@ -41,3 +41,24 @@ describe("fromDashboardData", () => {
expect(view.widgets).toEqual([]);
});
});
+
+it("preserves sections, columns and widget spans from the SDK", () => {
+ const data = {
+ columns: 3 as const,
+ sections: [{ id: "summary", title: "Summary", columns: 4 as const }],
+ widgets: [
+ {
+ id: "total",
+ visualization: "number" as const,
+ span: 2 as const,
+ section: "summary",
+ series: { points: [], total: 0 },
+ },
+ ],
+ };
+ const view = fromDashboardData(data);
+ expect(view.columns).toBe(3);
+ expect(view.sections).toEqual(data.sections);
+ expect(view.widgets[0]).toMatchObject({ span: 2, section: "summary" });
+ expect(fromDashboardData(data, { columns: 1 }).columns).toBe(1);
+});
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 63d8f5fc6..f284268c6 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
@@ -7,7 +7,7 @@
// 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,
+// It paints with the kit's METRIC tier — `StatTile` rows 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
@@ -15,7 +15,7 @@
// 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 type { ReactNode } from "react";
import { Card } from "../layout/card.js";
import { Section } from "../layout/page.js";
import { Skeleton } from "../layout/skeleton.js";
@@ -28,7 +28,7 @@ import {
} 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 Metric, StatTile } from "./metric-tiles.js";
import type {
DashboardView,
DashboardWidgetView,
@@ -292,72 +292,89 @@ export interface DashboardSlots {
* Lays the tiles out in place of the default grid or stack — for a grid
* whose tiles the viewer can reorder. Tiles arrive in the view's order.
*/
- layout?: (tiles: DashboardTile[]) => ReactNode;
+ layout?: (
+ tiles: DashboardTile[],
+ renderLayout: (nodes: ReadonlyMap) => ReactNode,
+ ) => ReactNode;
/** Shown in place of the layout when the view holds no widgets. */
empty?: ReactNode;
}
-/**
- * Runs of adjacent widgets that are laid out together: consecutive scalars
- * become one `KPIRow`, everything else a grid of cards.
- *
- * Declared order is never rearranged — only adjacent scalars are gathered — so
- * the page shows what the declaration says, in the order it says it. Hoisting
- * every scalar to the top would read better on some dashboards and would also
- * silently overrule a declaration the renderer does not own.
- */
-function layoutRuns(tiles: DashboardTile[]): DashboardTile[][] {
- const runs: DashboardTile[][] = [];
- for (const tile of tiles) {
- const scalar = tile.widget.visualization === "number";
- const previous = runs[runs.length - 1];
- const sameKind =
- previous && (previous[0].widget.visualization === "number") === scalar;
- if (sameKind) previous.push(tile);
- else runs.push([tile]);
- }
- return runs;
-}
-
-function Tiles({ tiles }: { tiles: DashboardTile[] }) {
- return (
- <>
- {tiles.map((tile) => (
- {tile.node}
- ))}
- >
- );
-}
-
+// One geometry for a static dashboard and the host's sortable tiles. Wrappers
+// own spans, including scalar spans; align-start prevents empty cards stretching.
function DefaultLayout({
tiles,
- columns,
- grid,
+ data,
}: {
tiles: DashboardTile[];
- columns: 1 | 2 | 3 | 4;
- grid: boolean;
+ data: DashboardView;
}) {
- if (!grid) {
- return (
-
-
-
- );
- }
+ const grid = (data.layout ?? "grid") === "grid";
+ const groups = [
+ {
+ id: undefined,
+ title: undefined,
+ description: undefined,
+ columns: data.columns,
+ },
+ ...(data.sections ?? []),
+ ];
return (
- {layoutRuns(tiles).map((run) =>
- run[0].widget.visualization === "number" ? (
-
-
-
- ) : (
-
-
-
- ),
- )}
+ {groups.map((group) => {
+ const members = tiles.filter(
+ (tile) => tile.widget.section === group.id,
+ );
+ if (members.length === 0) return null;
+ const columns = group.columns ?? data.columns ?? 4;
+ const automatic =
+ group.columns === undefined && data.columns === undefined;
+ return (
+
+
+ {members.map((tile, index) => {
+ const scalar = tile.widget.visualization === "number";
+ const previous = members[index - 1];
+ const newBand =
+ previous &&
+ (previous.widget.visualization === "number") !== scalar;
+ const span = Math.min(
+ tile.widget.span ?? (automatic && !scalar ? 2 : 1),
+ columns,
+ ) as 1 | 2 | 3 | 4;
+ return (
+
+ {tile.node}
+
+ );
+ })}
+
+
+ );
+ })}
);
}
@@ -376,27 +393,14 @@ export function Dashboard({
className?: string;
slots?: DashboardSlots;
}) {
- const columns = data.columns ?? 2;
const style = data.accent
? ({ "--primary": data.accent } as React.CSSProperties)
: undefined;
- const isGrid = (data.layout ?? "grid") === "grid";
const tiles: DashboardTile[] = data.widgets.map((widget) => ({
id: widget.id,
widget,
- node: slots?.renderWidget?.(widget) ?? (
- // A scalar tile sits in a KPIRow, whose own geometry sets its width,
- // so a span only applies to a card in the grid.
-
- ),
+ node: slots?.renderWidget?.(widget) ?? ,
}));
return (
@@ -412,9 +416,14 @@ export function Dashboard({
No widgets.
))
) : slots?.layout ? (
- slots.layout(tiles)
+ slots.layout(tiles, (nodes) => (
+ ({ ...tile, node: nodes.get(tile.id) }))}
+ />
+ ))
) : (
-
+
)}
);
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 (
-
);
}
@@ -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)) {