From 2c270cc0b3db9cb3b19afb3af041989ad0f4642b Mon Sep 17 00:00:00 2001
From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com>
Date: Sun, 27 Sep 2026 23:08:29 -0400
Subject: [PATCH 01/29] feat(manifest): let a dashboard group its widgets into
titled sections
A solution with many metrics wants an overview band above the detail, but
the validator refused any grouping: a dashboard with `sections` failed
`dashboard has unknown field 'sections'`, so the host answered the
registration with 422 invalid_manifest.
A dashboard may now declare `sections` (each an `id`, a `title` and an
optional `description`, in the order they are drawn), and each widget then
names its `section`. A section may hold no widgets. A dashboard without
sections is unchanged, and none of its widgets may name one.
- data-graph.ts: `DashboardSection`, `Dashboard.sections`,
`MetricWidget.section`; the validator checks each section's shape, unique
section ids per dashboard, and that a widget names a declared section
exactly when its dashboard declares sections.
- plugin.codefly.schema.json carries the same field rules, including the
cross-field one (if/then/else on `sections`); the conformance suite pins
that the schema and the validator agree.
- saas-sdk's mirror of the shapes (src/schema.ts) and both packages' type
exports gain the section types. saas-sdk 0.3.16.
- The host's registry parse carries a sectioned graph through and refuses a
widget that names an undeclared section.
- docs/plugin-manifest-schema.md names the new keys and rules.
No other validator reads these keys: accounts parses only a dashboard's
`events` (for declared audit types), and the dashboard-authoring spec is a
separate shape.
Co-Authored-By: Claude Opus 5.5
---
module/docs/plugin-manifest-schema.md | 17 ++-
.../services/frontend/code/package-lock.json | 4 +-
module/services/frontend/code/package.json | 2 +-
.../plugin.codefly.schema.json | 28 ++++-
.../saas-plugin-manifest/src/data-graph.ts | 108 +++++++++++++++++-
.../saas-plugin-manifest/src/index.ts | 1 +
.../test/data-graph.test.ts | 91 +++++++++++++++
.../test/schema-conformance.test.ts | 37 ++++++
.../code/packages/saas-sdk/package.json | 2 +-
.../code/packages/saas-sdk/src/index.ts | 1 +
.../code/packages/saas-sdk/src/schema.ts | 11 ++
.../src/solutions/__tests__/registry.test.ts | 51 +++++++++
12 files changed, 340 insertions(+), 13 deletions(-)
diff --git a/module/docs/plugin-manifest-schema.md b/module/docs/plugin-manifest-schema.md
index 767b4f60f..03170be3a 100644
--- a/module/docs/plugin-manifest-schema.md
+++ b/module/docs/plugin-manifest-schema.md
@@ -96,6 +96,11 @@ has three node kinds:
- **`dashboards`** — a `layout` of `widgets`, each binding a `visualization` to
one metric. These metric-bound widgets are distinct from `ui.widgets`, which
are presentation slots contributed to host surfaces; the two never mix.
+ A dashboard may group its widgets into `sections` — each an `id`, a `title`
+ and an optional `description`, drawn in declared order — in which case every
+ widget names its `section`. A section may hold no widgets. A dashboard that
+ declares no sections draws its widgets as one untitled group, and no widget
+ may name a section.
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
@@ -108,13 +113,15 @@ already rearranged. 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.
-The schema owns the per-node field formats — including the two shape rules that
-cross fields: a metric carries a `bucket` exactly when it groups by `time`, and
-a `ratio`/`difference` takes exactly two inputs (`sum` takes two or more). The
+The schema owns the per-node field formats — including the shape rules that
+cross fields: a metric carries a `bucket` exactly when it groups by `time`, a
+`ratio`/`difference` takes exactly two inputs (`sum` takes two or more), and a
+widget names a `section` exactly when its dashboard declares `sections`. The
host validator adds only what a JSON Schema cannot express: referential
integrity — every metric filter names a declared event, every derived-metric
-input and every widget names a declared metric — and that the derived-metric
-reference graph is acyclic.
+input and every widget names a declared metric, every widget's section is one
+its dashboard declares, and section ids are unique within their dashboard — and
+that the derived-metric reference graph is acyclic.
What neither layer checks is **dimensional coherence** — whether a metric's
`groupBy` is meaningful for its data, or whether a derived metric's inputs have
diff --git a/module/services/frontend/code/package-lock.json b/module/services/frontend/code/package-lock.json
index 606968f29..5e5dcd2af 100644
--- a/module/services/frontend/code/package-lock.json
+++ b/module/services/frontend/code/package-lock.json
@@ -13,7 +13,7 @@
"dependencies": {
"@base-ui/react": "^1.3.0",
"@bufbuild/protobuf": "^2.11.0",
- "@codefly-dev/saas-sdk": "0.3.15",
+ "@codefly-dev/saas-sdk": "0.3.16",
"@codefly-dev/saas-ui": "0.11.4",
"@codefly-dev/ui": "0.11.4",
"@codefly/saas-plugin-contract": "2.3.0",
@@ -15384,7 +15384,7 @@
},
"packages/saas-sdk": {
"name": "@codefly-dev/saas-sdk",
- "version": "0.3.15",
+ "version": "0.3.16",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^2.11.0",
diff --git a/module/services/frontend/code/package.json b/module/services/frontend/code/package.json
index e64b5826e..0afd71679 100644
--- a/module/services/frontend/code/package.json
+++ b/module/services/frontend/code/package.json
@@ -36,7 +36,7 @@
"dependencies": {
"@base-ui/react": "^1.3.0",
"@bufbuild/protobuf": "^2.11.0",
- "@codefly-dev/saas-sdk": "0.3.15",
+ "@codefly-dev/saas-sdk": "0.3.16",
"@codefly-dev/saas-ui": "0.11.4",
"@codefly-dev/ui": "0.11.4",
"@codefly/saas-plugin-contract": "2.3.0",
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 80bc56de2..fd4299f28 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
@@ -370,6 +370,20 @@
"id": { "$ref": "#/$defs/logicalId" },
"title": { "$ref": "#/$defs/description" },
"layout": { "enum": ["grid", "stack"] },
+ "sections": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "title"],
+ "properties": {
+ "id": { "$ref": "#/$defs/logicalId" },
+ "title": { "$ref": "#/$defs/description" },
+ "description": { "$ref": "#/$defs/description" }
+ }
+ }
+ },
"widgets": {
"type": "array",
"minItems": 1,
@@ -383,10 +397,22 @@
"visualization": {
"enum": ["line", "bar", "area", "number", "table"]
},
- "title": { "$ref": "#/$defs/description" }
+ "title": { "$ref": "#/$defs/description" },
+ "section": { "$ref": "#/$defs/logicalId" }
}
}
}
+ },
+ "if": { "required": ["sections"] },
+ "then": {
+ "properties": {
+ "widgets": { "items": { "required": ["section"] } }
+ }
+ },
+ "else": {
+ "properties": {
+ "widgets": { "items": { "not": { "required": ["section"] } } }
+ }
}
}
}
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 f311aa96c..2cdc16f28 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
@@ -176,6 +176,21 @@ export interface MetricWidget {
metric: string;
visualization: WidgetVisualization;
title?: string;
+ /**
+ * The id of the dashboard section the widget is drawn in. Required when the
+ * dashboard declares sections, forbidden when it declares none.
+ */
+ section?: string;
+}
+
+/**
+ * A titled group of a dashboard's widgets, e.g. an overview band above the
+ * detail. A section may hold no widgets.
+ */
+export interface DashboardSection {
+ id: string;
+ title: string;
+ description?: string;
}
/** A layout of widgets, each rendering one metric. */
@@ -183,6 +198,11 @@ export interface Dashboard {
id: string;
title?: string;
layout: DashboardLayout;
+ /**
+ * The sections the widgets are grouped into, drawn in this order. Optional:
+ * a dashboard without sections draws its widgets as one untitled group.
+ */
+ sections?: readonly DashboardSection[];
widgets: readonly MetricWidget[];
}
@@ -577,7 +597,7 @@ function validateWidget(
);
assertExactKeys(
value,
- ["id", "metric", "visualization", "title"],
+ ["id", "metric", "visualization", "title", "section"],
`dashboard '${dashboardId}' widget`,
);
assertLogicalId(value.id, `dashboard '${dashboardId}' widget id`);
@@ -593,11 +613,71 @@ function validateWidget(
value.title,
`dashboard '${dashboardId}' widget '${String(value.id)}' title`,
);
+ if (value.section !== undefined) {
+ assertLogicalId(
+ value.section,
+ `dashboard '${dashboardId}' widget '${String(value.id)}' section`,
+ );
+ }
+}
+
+function validateSection(
+ value: unknown,
+ dashboardId: string,
+): asserts value is DashboardSection {
+ assertGraph(
+ isObject(value),
+ `dashboard '${dashboardId}' section must be an object`,
+ );
+ assertExactKeys(
+ value,
+ ["id", "title", "description"],
+ `dashboard '${dashboardId}' section`,
+ );
+ assertLogicalId(value.id, `dashboard '${dashboardId}' section id`);
+ const context = `dashboard '${dashboardId}' section '${String(value.id)}'`;
+ assertGraph(
+ typeof value.title === "string" && value.title.trim().length > 0,
+ `${context} title must be a non-empty string`,
+ );
+ assertOptionalText(value.description, `${context} description`);
+}
+
+// A dashboard that declares sections places every widget in one of them; one
+// that declares none places no widget in any.
+function assertWidgetSections(
+ dashboardId: string,
+ declared: readonly DashboardSection[] | undefined,
+ widgets: readonly MetricWidget[],
+): void {
+ const sections = declared?.map((section) => section.id);
+ for (const widget of widgets) {
+ const context = `dashboard '${dashboardId}' widget '${widget.id}'`;
+ if (sections === undefined) {
+ assertGraph(
+ widget.section === undefined,
+ `${context} names section '${widget.section}', but the dashboard declares no sections`,
+ );
+ } else {
+ assertGraph(
+ widget.section !== undefined,
+ `${context} must name one of the dashboard's sections`,
+ );
+ assertGraph(
+ sections.includes(widget.section),
+ `${context} names unknown section '${widget.section}'`,
+ );
+ }
+ }
}
function validateDashboard(value: unknown): asserts value is Dashboard {
assertGraph(isObject(value), "dashboard must be an object");
- assertExactKeys(value, ["id", "title", "layout", "widgets"], "dashboard");
+ assertExactKeys(
+ value,
+ ["id", "title", "layout", "sections", "widgets"],
+ "dashboard",
+ );
assertLogicalId(value.id, "dashboard id");
assertGraph(
LAYOUT.includes(value.layout as DashboardLayout),
@@ -617,6 +697,27 @@ function validateDashboard(value: unknown): asserts value is Dashboard {
(value.widgets as MetricWidget[]).map((widget) => widget.id),
`widget id in dashboard '${String(value.id)}'`,
);
+ if (value.sections !== undefined) {
+ assertGraph(
+ Array.isArray(value.sections),
+ `dashboard '${String(value.id)}' sections must be an array`,
+ );
+ assertGraph(
+ value.sections.length > 0,
+ `dashboard '${String(value.id)}' sections must declare at least one section`,
+ );
+ for (const section of value.sections)
+ validateSection(section, value.id as string);
+ assertUnique(
+ (value.sections as DashboardSection[]).map((section) => section.id),
+ `section id in dashboard '${String(value.id)}'`,
+ );
+ }
+ assertWidgetSections(
+ value.id as string,
+ value.sections as DashboardSection[] | undefined,
+ value.widgets as MetricWidget[],
+ );
assertOptionalText(value.title, `dashboard '${String(value.id)}' title`);
}
@@ -650,7 +751,8 @@ function assertAcyclic(metrics: readonly Metric[]): void {
* Validates a parsed data graph and narrows it to `DataGraph`. Beyond the
* per-node field rules, it enforces the cross-references that make the
* declaration a graph: every metric filter names a declared event, every
- * derived-metric input and every widget names a declared metric, and the
+ * derived-metric input and every widget names a declared metric, every widget
+ * of a dashboard that declares sections names one of them, and the
* derived-metric reference graph is acyclic.
*/
export function assertDataGraph(value: unknown): asserts value is DataGraph {
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..f6c6a8f15 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
@@ -32,6 +32,7 @@ export {
export type {
Dashboard,
DashboardLayout,
+ DashboardSection,
DataGraph,
DerivedMetric,
EventDeclaration,
diff --git a/module/services/frontend/code/packages/saas-plugin-manifest/test/data-graph.test.ts b/module/services/frontend/code/packages/saas-plugin-manifest/test/data-graph.test.ts
index 8b1becc9c..df9fb87c6 100644
--- a/module/services/frontend/code/packages/saas-plugin-manifest/test/data-graph.test.ts
+++ b/module/services/frontend/code/packages/saas-plugin-manifest/test/data-graph.test.ts
@@ -276,6 +276,97 @@ describe("assertDataGraph", () => {
});
});
+describe("dashboard sections", () => {
+ // The reference graph with its dashboard split into an overview band and an
+ // activity band, and a third section no widget is in yet.
+ function sectioned(mutate: (g: Record) => void = () => {}) {
+ return mutated((g) => {
+ rec(arr(g.dashboards)[0]).sections = [
+ {
+ id: "overview",
+ title: "Overview",
+ description: "The headline numbers.",
+ },
+ { id: "activity", title: "Activity" },
+ { id: "later", title: "Later" },
+ ];
+ widget(g, 0).section = "activity";
+ widget(g, 1).section = "overview";
+ mutate(g);
+ });
+ }
+ const section = (g: Record, index: number) =>
+ rec(arr(rec(arr(g.dashboards)[0]).sections)[index]);
+
+ it("accepts widgets grouped into declared sections, one of them empty", () => {
+ expect(() => assertDataGraph(sectioned())).not.toThrow();
+ });
+
+ it("accepts a dashboard with no sections and no widget naming one", () => {
+ expect(() => assertDataGraph(graph())).not.toThrow();
+ });
+
+ it("rejects a widget naming a section on a dashboard that declares none", () => {
+ expect(() =>
+ assertDataGraph(mutated((g) => (widget(g, 0).section = "overview"))),
+ ).toThrow(
+ /widget 'trend' names section 'overview', but the dashboard declares no sections/,
+ );
+ });
+
+ it("rejects a widget naming no section on a dashboard that declares some", () => {
+ expect(() =>
+ assertDataGraph(sectioned((g) => delete widget(g, 1).section)),
+ ).toThrow(/widget 'rate' must name one of the dashboard's sections/);
+ });
+
+ it("rejects a widget naming an undeclared section", () => {
+ expect(() =>
+ assertDataGraph(sectioned((g) => (widget(g, 1).section = "elsewhere"))),
+ ).toThrow(/widget 'rate' names unknown section 'elsewhere'/);
+ });
+
+ it("rejects a duplicate section id", () => {
+ expect(() =>
+ assertDataGraph(sectioned((g) => (section(g, 2).id = "activity"))),
+ ).toThrow(
+ /section id in dashboard 'overview' 'activity' is declared more than once/,
+ );
+ });
+
+ it("rejects a section id that is not a logical id", () => {
+ expect(() =>
+ assertDataGraph(sectioned((g) => (section(g, 2).id = "Later On"))),
+ ).toThrow(/section id 'Later On' is not a valid logical id/);
+ });
+
+ it("rejects a section with an empty or missing title", () => {
+ expect(() =>
+ assertDataGraph(sectioned((g) => (section(g, 1).title = " "))),
+ ).toThrow(/section 'activity' title must be a non-empty string/);
+ expect(() =>
+ assertDataGraph(sectioned((g) => delete section(g, 1).title)),
+ ).toThrow(/section 'activity' title must be a non-empty string/);
+ });
+
+ it("rejects an empty section description and an unknown section field", () => {
+ expect(() =>
+ assertDataGraph(sectioned((g) => (section(g, 0).description = ""))),
+ ).toThrow(/section 'overview' description must be a non-empty string/);
+ expect(() =>
+ assertDataGraph(sectioned((g) => (section(g, 0).order = 1))),
+ ).toThrow(/section has unknown field 'order'/);
+ });
+
+ it("rejects an empty sections list", () => {
+ expect(() =>
+ assertDataGraph(
+ mutated((g) => (rec(arr(g.dashboards)[0]).sections = [])),
+ ),
+ ).toThrow(/sections must declare at least one section/);
+ });
+});
+
it("accepts real catalog names with separate major versions and scoped filters", async () => {
const { readFileSync } = await import("node:fs");
const catalog = JSON.parse(
diff --git a/module/services/frontend/code/packages/saas-plugin-manifest/test/schema-conformance.test.ts b/module/services/frontend/code/packages/saas-plugin-manifest/test/schema-conformance.test.ts
index ad6a9b590..0083d6776 100644
--- a/module/services/frontend/code/packages/saas-plugin-manifest/test/schema-conformance.test.ts
+++ b/module/services/frontend/code/packages/saas-plugin-manifest/test/schema-conformance.test.ts
@@ -117,6 +117,29 @@ const OWNED_FIELD_VIOLATIONS: Record<
"unsupported widget visualization": (m) => {
arr(arr(rec(m.dashboard).dashboards)[0].widgets)[0].visualization = "pie";
},
+ "widget section on a dashboard with no sections": (m) => {
+ arr(arr(rec(m.dashboard).dashboards)[0].widgets)[0].section = "overview";
+ },
+ "widget with no section on a dashboard with sections": (m) => {
+ const dashboard = arr(rec(m.dashboard).dashboards)[0];
+ dashboard.sections = [{ id: "overview", title: "Overview" }];
+ for (const widget of arr(dashboard.widgets).slice(1))
+ widget.section = "overview";
+ },
+ "section id that is not a logical id": (m) => {
+ const dashboard = arr(rec(m.dashboard).dashboards)[0];
+ dashboard.sections = [{ id: "Overview Band", title: "Overview" }];
+ for (const widget of arr(dashboard.widgets))
+ widget.section = "Overview Band";
+ },
+ "section with an empty title": (m) => {
+ const dashboard = arr(rec(m.dashboard).dashboards)[0];
+ dashboard.sections = [{ id: "overview", title: "" }];
+ for (const widget of arr(dashboard.widgets)) widget.section = "overview";
+ },
+ "empty sections list": (m) => {
+ arr(rec(m.dashboard).dashboards)[0].sections = [];
+ },
"bad subscribe handler": (m) => {
arr(rec(m.events).subscribes)[0].handler = "Bad Handler";
},
@@ -167,6 +190,20 @@ describe("JSON Schema conformance", () => {
expect(() => assertPluginManifest(example)).not.toThrow();
});
+ it("accepts a dashboard grouped into sections in both the schema and the validator", () => {
+ const manifest = clone();
+ const dashboard = arr(rec(manifest.dashboard).dashboards)[0];
+ dashboard.sections = [
+ { id: "overview", title: "Overview", description: "The headline read." },
+ { id: "activity", title: "Activity" },
+ ];
+ const widgets = arr(dashboard.widgets);
+ widgets[0].section = "activity";
+ for (const widget of widgets.slice(1)) widget.section = "overview";
+ expect(validateWithSchema(manifest)).toBe(true);
+ expect(() => assertPluginManifest(manifest)).not.toThrow();
+ });
+
it.each(Object.keys(OWNED_FIELD_VIOLATIONS))(
"schema and validator agree on rejecting: %s",
(name: string) => {
diff --git a/module/services/frontend/code/packages/saas-sdk/package.json b/module/services/frontend/code/packages/saas-sdk/package.json
index 5f9ade62c..55dd02b7f 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.15",
+ "version": "0.3.16",
"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/index.ts b/module/services/frontend/code/packages/saas-sdk/src/index.ts
index 7c2a9268c..ca04c9bda 100644
--- a/module/services/frontend/code/packages/saas-sdk/src/index.ts
+++ b/module/services/frontend/code/packages/saas-sdk/src/index.ts
@@ -72,6 +72,7 @@ export { WebhookService } from "../generated/typescript/src/gen/saas/accounts/v1
export type {
Dashboard,
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..8117661d0 100644
--- a/module/services/frontend/code/packages/saas-sdk/src/schema.ts
+++ b/module/services/frontend/code/packages/saas-sdk/src/schema.ts
@@ -139,6 +139,15 @@ export interface MetricWidget {
metric: string;
visualization: WidgetVisualization;
title?: string;
+ /** The dashboard section the widget is drawn in, when the dashboard declares sections. */
+ section?: string;
+}
+
+/** A titled group of a dashboard's widgets. A section may hold no widgets. */
+export interface DashboardSection {
+ id: string;
+ title: string;
+ description?: string;
}
/** A layout of widgets, each rendering one metric. */
@@ -146,6 +155,8 @@ export interface Dashboard {
id: string;
title?: string;
layout: DashboardLayout;
+ /** The sections the widgets are grouped into, drawn in this order. */
+ sections?: readonly DashboardSection[];
widgets: readonly MetricWidget[];
}
diff --git a/module/services/frontend/code/src/solutions/__tests__/registry.test.ts b/module/services/frontend/code/src/solutions/__tests__/registry.test.ts
index 6c6bd290d..cbc26a348 100644
--- a/module/services/frontend/code/src/solutions/__tests__/registry.test.ts
+++ b/module/services/frontend/code/src/solutions/__tests__/registry.test.ts
@@ -151,6 +151,57 @@ describe("parseManifest dashboard slot", () => {
expect(parsed?.dashboard?.dashboards[0]?.id).toBe("activity");
});
+ it("carries a dashboard grouped into sections through", () => {
+ const sectioned = {
+ ...validGraph,
+ dashboards: [
+ {
+ ...validGraph.dashboards[0],
+ sections: [
+ { id: "overview", title: "Overview" },
+ { id: "detail", title: "Detail", description: "Day by day." },
+ ],
+ widgets: [
+ {
+ id: "logins",
+ metric: "logins",
+ visualization: "line",
+ section: "detail",
+ },
+ ],
+ },
+ ],
+ };
+ const parsed = parseManifest(baseManifest({ dashboard: sectioned }));
+ expect(parsed?.dashboard?.dashboards[0]?.sections).toEqual(
+ sectioned.dashboards[0].sections,
+ );
+ expect(parsed?.dashboard?.dashboards[0]?.widgets[0]?.section).toBe(
+ "detail",
+ );
+ });
+
+ it("rejects the registration when a widget names a section its dashboard does not declare", () => {
+ const misplaced = {
+ ...validGraph,
+ dashboards: [
+ {
+ ...validGraph.dashboards[0],
+ sections: [{ id: "overview", title: "Overview" }],
+ widgets: [
+ {
+ id: "logins",
+ metric: "logins",
+ visualization: "line",
+ section: "elsewhere",
+ },
+ ],
+ },
+ ],
+ };
+ expect(parseManifest(baseManifest({ dashboard: misplaced }))).toBeNull();
+ });
+
it("rejects the whole registration when the dashboard graph is malformed", () => {
// A widget bound to a metric the graph never declares fails referential
// integrity; the registration is refused rather than stored without it.
From 58b188146c596cca9271efb4a99c752f4f82086a Mon Sep 17 00:00:00 2001
From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com>
Date: Sun, 27 Sep 2026 23:10:37 -0400
Subject: [PATCH 02/29] feat(ui): SortableBoard, tiles in groups a viewer can
rearrange
SortableGrid rearranges one list of tiles. A dashboard with sections needs
the same drag across several groups, and groups that move themselves.
SortableBoard is that, and SortableGrid is now the board with one group, so
its behaviour and props are unchanged.
- One drag context over every group: a tile dropped on a tile in another
group swaps with it, and previews and glides like a swap within a group.
- `onMove`: while a tile is dragged, every group ends with a tile-sized
slot, and a drop there moves the tile to the end of that group. Without
it the board offers no slot.
- `onMoveGroup`: a group is dragged by a handle its header spreads (for
instance a grip by its title). The groups are one column, so a drop
inserts: the nearer half of a group decides before or after it, above the
first group is before it and below the last after it, and a line marks the
place. No tile is a drop target meanwhile. A `fixed` group stays put and
nothing lands before it.
- Every group and tile glides from where it was drawn when the order
changes, a tile that moved to another group too; a tile glides with its
group rather than on its own.
- Screen-reader announcements name the group a tile moves to, and where a
group lands.
- `renderOverlay` and the content-keyed item list work as they do on
SortableGrid.
A SortableGroups story shows it, and the UI inventory lists the story.
@codefly-dev/ui and @codefly-dev/saas-ui 0.11.5 (co-versioned).
Co-Authored-By: Claude Opus 5.5
---
.../services/frontend/code/package-lock.json | 8 +-
module/services/frontend/code/package.json | 4 +-
.../code/packages/codefly-ui/package.json | 2 +-
.../__tests__/sortable-grid.test.tsx | 505 ++++++++++++-
.../codefly-ui/src/dashboard/index.ts | 9 +-
.../src/dashboard/sortable-grid.tsx | 668 +++++++++++++++---
.../codefly-ui/stories/composites.stories.tsx | 73 ++
.../code/packages/saas-ui/package.json | 2 +-
.../services/frontend/code/ui-inventory.json | 6 +-
9 files changed, 1164 insertions(+), 113 deletions(-)
diff --git a/module/services/frontend/code/package-lock.json b/module/services/frontend/code/package-lock.json
index 5e5dcd2af..6d0cae1db 100644
--- a/module/services/frontend/code/package-lock.json
+++ b/module/services/frontend/code/package-lock.json
@@ -14,8 +14,8 @@
"@base-ui/react": "^1.3.0",
"@bufbuild/protobuf": "^2.11.0",
"@codefly-dev/saas-sdk": "0.3.16",
- "@codefly-dev/saas-ui": "0.11.4",
- "@codefly-dev/ui": "0.11.4",
+ "@codefly-dev/saas-ui": "0.11.5",
+ "@codefly-dev/ui": "0.11.5",
"@codefly/saas-plugin-contract": "2.3.0",
"@codefly/saas-plugin-manifest": "0.1.0",
"@codefly/saas-plugin-react": "0.4.1",
@@ -15266,7 +15266,7 @@
},
"packages/codefly-ui": {
"name": "@codefly-dev/ui",
- "version": "0.11.4",
+ "version": "0.11.5",
"license": "Apache-2.0",
"dependencies": {
"@base-ui/react": "^1.3.0",
@@ -15418,7 +15418,7 @@
},
"packages/saas-ui": {
"name": "@codefly-dev/saas-ui",
- "version": "0.11.4",
+ "version": "0.11.5",
"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 0afd71679..78c042baf 100644
--- a/module/services/frontend/code/package.json
+++ b/module/services/frontend/code/package.json
@@ -37,8 +37,8 @@
"@base-ui/react": "^1.3.0",
"@bufbuild/protobuf": "^2.11.0",
"@codefly-dev/saas-sdk": "0.3.16",
- "@codefly-dev/saas-ui": "0.11.4",
- "@codefly-dev/ui": "0.11.4",
+ "@codefly-dev/saas-ui": "0.11.5",
+ "@codefly-dev/ui": "0.11.5",
"@codefly/saas-plugin-contract": "2.3.0",
"@codefly/saas-plugin-manifest": "0.1.0",
"@codefly/saas-plugin-react": "0.4.1",
diff --git a/module/services/frontend/code/packages/codefly-ui/package.json b/module/services/frontend/code/packages/codefly-ui/package.json
index d86b3edfc..759371743 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.11.4",
+ "version": "0.11.5",
"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__/sortable-grid.test.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/sortable-grid.test.tsx
index 04b972c53..d44466e6a 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
@@ -8,7 +8,7 @@ import {
} from "@testing-library/react";
import { createPortal } from "react-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { SortableGrid } from "../sortable-grid.js";
+import { SortableBoard, SortableGrid } from "../sortable-grid.js";
// happy-dom lays nothing out, so each tile gets a slot by its place in the
// list: two columns of 100×100 cells, 10px apart. a b / c d.
@@ -134,6 +134,13 @@ describe("SortableGrid", () => {
expect(onSwap).toHaveBeenCalledExactlyOnceWith("b", "a");
});
+ it("offers no slot to move a tile to, since it takes no onMove", () => {
+ renderGrid();
+ const drop = dragTo("a", center("d"));
+ expect(screen.queryByText("Move here")).toBeNull();
+ drop();
+ });
+
it("changes nothing when the drop is off the grid", () => {
const { onSwap } = renderGrid();
dragTo("a", { clientX: 900, clientY: 900 })();
@@ -252,3 +259,499 @@ describe("SortableGrid", () => {
expect(animate).not.toHaveBeenCalled();
});
});
+
+describe("SortableBoard", () => {
+ // Each group is a band of the same two-column cells, GROUP_GAP below the
+ // one before it, so tiles in different groups never overlap. A group's
+ // slot is laid out like the tile after its last.
+ const GROUP_GAP = 400;
+ beforeEach(() => {
+ HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) {
+ const list = this.parentElement;
+ const group = list?.parentElement;
+ const inBoard =
+ this.tagName === "LI" && group?.dataset.sortableGroup !== undefined;
+ const { left, top, right, bottom } = inBoard
+ ? slot([...(list?.children ?? [])].indexOf(this))
+ : { left: 0, top: 0, right: CELL, bottom: CELL };
+ const offset =
+ inBoard && group?.parentElement
+ ? [...group.parentElement.children].indexOf(group) * GROUP_GAP
+ : 0;
+ return {
+ x: left,
+ y: top + offset,
+ left,
+ top: top + offset,
+ right,
+ bottom: bottom + offset,
+ width: right - left,
+ height: bottom - top,
+ toJSON: () => ({}),
+ } as DOMRect;
+ };
+ });
+
+ // Group one holds a b c, group two x y.
+ const GROUPS = [
+ { id: "one", ids: ["a", "b", "c"] },
+ { id: "two", ids: ["x", "y"] },
+ ];
+
+ function board(groups: { id: string; ids: string[] }[], props = {}) {
+ return (
+ ({
+ ...group,
+ label: `Group ${group.id}`,
+ header:
Group {group.id}
,
+ }))}
+ onSwap={vi.fn()}
+ itemLabel={(id) => `Tile ${id}`}
+ renderItem={(id) => `Tile ${id}`}
+ {...props}
+ />
+ );
+ }
+
+ function renderBoard(groups = GROUPS) {
+ const onSwap = vi.fn();
+ const onMove = vi.fn();
+ const view = render(board(groups, { onSwap, onMove }));
+ return { onSwap, onMove, ...view };
+ }
+
+ const middle = (element: HTMLElement) => {
+ const { left, top } = element.getBoundingClientRect();
+ return { clientX: left + CELL / 2, clientY: top + CELL / 2 };
+ };
+ const slots = () => screen.queryAllByText("Move here");
+ const slotOf = (group: string) => {
+ const found = slots().find(
+ (element) => element.dataset.sortableSlot === group,
+ );
+ if (!found) throw new Error(`no slot in group ${group}`);
+ return found;
+ };
+
+ // Picks a tile up, past the 5px a mouse drag needs to start. Returns the
+ // moves and the drop, so a test can aim at a slot the drag brought up.
+ function pickUp(id: string) {
+ const start = middle(tile(id));
+ fireEvent.mouseDown(tile(id), { ...start, button: 0 });
+ fireEvent.mouseMove(document, {
+ clientX: start.clientX + 10,
+ clientY: start.clientY + 10,
+ });
+ let at = start;
+ return {
+ moveTo(to: { clientX: number; clientY: number }) {
+ fireEvent.mouseMove(document, {
+ clientX: (at.clientX + to.clientX) / 2,
+ clientY: (at.clientY + to.clientY) / 2,
+ });
+ fireEvent.mouseMove(document, to);
+ at = to;
+ },
+ drop() {
+ fireEvent.mouseUp(document, at);
+ },
+ };
+ }
+
+ const announced = () =>
+ document.querySelector('[id^="DndLiveRegion"]')?.textContent ?? "";
+
+ it("draws each group's header above its tiles, in order", () => {
+ renderBoard();
+ expect(screen.getByText("Group one")).toBeTruthy();
+ expect(order()).toEqual(["a", "b", "c", "x", "y"]);
+ const two = tile("x").closest("[data-sortable-group]");
+ expect(two?.textContent).toBe("Group twoTile xTile y");
+ });
+
+ it("swaps a tile dropped on a tile in another group, and names that group", () => {
+ const { onSwap, onMove } = renderBoard();
+ const drag = pickUp("a");
+ drag.moveTo(middle(tile("y")));
+ // The two trade places across the groups while the drag is in flight.
+ expect(tile("a").style.transform).toBe("translate3d(110px, 400px, 0)");
+ expect(tile("y").style.transform).toBe("translate3d(-110px, -400px, 0)");
+ expect(tile("b").style.transform).toBe("");
+ expect(announced()).toContain(
+ "Tile a will swap with Tile y and move to Group two.",
+ );
+
+ drag.drop();
+ expect(onSwap).toHaveBeenCalledExactlyOnceWith("a", "y");
+ expect(onMove).not.toHaveBeenCalled();
+ });
+
+ it("shows a slot at the end of every group only while a drag is in flight", () => {
+ renderBoard();
+ expect(slots()).toEqual([]);
+ const drag = pickUp("b");
+ expect(slots().map((element) => element.dataset.sortableSlot)).toEqual([
+ "one",
+ "two",
+ ]);
+ // Each slot comes after its group's last tile.
+ expect(slotOf("two").previousElementSibling).toBe(tile("y"));
+ drag.drop();
+ expect(slots()).toEqual([]);
+ });
+
+ it("moves a tile dropped on a group's slot to the end of that group", () => {
+ const { onSwap, onMove } = renderBoard();
+ const drag = pickUp("a");
+ drag.moveTo(middle(slotOf("two")));
+ expect(announced()).toContain("Tile a will move to the end of Group two.");
+ drag.drop();
+ expect(onMove).toHaveBeenCalledExactlyOnceWith("a", "two");
+ expect(onSwap).not.toHaveBeenCalled();
+ });
+
+ it("changes nothing when the last tile of a group is dropped on that group's slot", () => {
+ const { onSwap, onMove } = renderBoard();
+ const drag = pickUp("y");
+ drag.moveTo(middle(slotOf("two")));
+ drag.drop();
+ expect(onMove).not.toHaveBeenCalled();
+ expect(onSwap).not.toHaveBeenCalled();
+ });
+
+ it("lands a drop in the gap between a group's tiles on its nearest one", () => {
+ const { onSwap } = renderBoard();
+ const drag = pickUp("a");
+ // 3px right of x, in the gap before y: x's center is the nearer.
+ drag.moveTo({ clientX: CELL + 3, clientY: GROUP_GAP + 40 });
+ drag.drop();
+ expect(onSwap).toHaveBeenCalledExactlyOnceWith("a", "x");
+ });
+
+ it("changes nothing when the drop is between two groups, or off the board", () => {
+ const { onSwap, onMove } = renderBoard();
+ const between = pickUp("x");
+ // Below group one's slot and above group two: on neither group.
+ between.moveTo({ clientX: 50, clientY: GROUP_GAP - 50 });
+ between.drop();
+ const off = pickUp("x");
+ off.moveTo({ clientX: 900, clientY: 2000 });
+ off.drop();
+ expect(onSwap).not.toHaveBeenCalled();
+ expect(onMove).not.toHaveBeenCalled();
+ });
+
+ it("glides a tile that moved to another group from where it was drawn", () => {
+ const animate = vi.fn();
+ HTMLElement.prototype.animate = animate;
+ const { rerender } = renderBoard();
+ rerender(
+ board([
+ { id: "one", ids: ["b", "c"] },
+ { id: "two", ids: ["x", "y", "a"] },
+ ]),
+ );
+ const glides = new Map(
+ animate.mock.contexts.map((element, call) => [
+ (element as HTMLElement).dataset.sortableId,
+ animate.mock.calls[call][0][0].transform,
+ ]),
+ );
+ // a left the first cell of group one for the third of group two; b and
+ // c each moved up a place behind it; x and y did not move.
+ expect(glides).toEqual(
+ new Map([
+ ["b", "translate(110px, 0px)"],
+ ["c", "translate(-110px, 110px)"],
+ ["a", "translate(0px, -510px)"],
+ ]),
+ );
+ });
+});
+
+describe("SortableBoard, moving groups", () => {
+ // The board starts TOP down the page, under whatever the page draws above
+ // it. Each group is a band BAND tall, GROUP_GAP below the one before it,
+ // with a header HEADER tall at its top; its tiles are the same two-column
+ // cells, under the header. Heights below are from the board's top: group
+ // one's middle is at 150, two's at 550.
+ const TOP = 200;
+ const GROUP_GAP = 400;
+ const BAND = 300;
+ const HEADER = 30;
+ const WIDTH = 2 * CELL + GAP;
+ const bandOf = (group: Element | null | undefined) =>
+ group?.parentElement ? [...group.parentElement.children].indexOf(group) : 0;
+ beforeEach(() => {
+ HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) {
+ let rect = { left: 0, top: 0, right: CELL, bottom: CELL };
+ if (this.dataset.sortableGroup !== undefined) {
+ const top = TOP + bandOf(this) * GROUP_GAP;
+ rect = { left: 0, top, right: WIDTH, bottom: top + BAND };
+ } else if (this.dataset.sortableGroupHeader !== undefined) {
+ const top = TOP + bandOf(this.parentElement) * GROUP_GAP;
+ rect = { left: 0, top, right: WIDTH, bottom: top + HEADER };
+ } else if (this.dataset.sortableId !== undefined) {
+ const list = this.parentElement;
+ const cell = slot([...(list?.children ?? [])].indexOf(this));
+ const top = TOP + bandOf(list?.parentElement) * GROUP_GAP + HEADER;
+ rect = { ...cell, top: cell.top + top, bottom: cell.bottom + top };
+ }
+ return {
+ ...rect,
+ x: rect.left,
+ y: rect.top,
+ width: rect.right - rect.left,
+ height: rect.bottom - rect.top,
+ toJSON: () => ({}),
+ } as DOMRect;
+ };
+ });
+
+ const GROUPS = [
+ { id: "one", ids: ["a", "b"] },
+ { id: "two", ids: ["x"] },
+ { id: "three", ids: ["y"] },
+ ];
+
+ // Each group's header holds a grip it is dragged by.
+ function board(
+ groups: { id: string; ids: string[]; fixed?: boolean }[],
+ props: Partial[0]> = {},
+ ) {
+ return (
+ ({
+ ...group,
+ label: `Group ${group.id}`,
+ header: (handle) => (
+
+ ),
+ preview:
Preview {group.id}
,
+ }))}
+ onSwap={vi.fn()}
+ itemLabel={(id) => `Tile ${id}`}
+ renderItem={(id) => `Tile ${id}`}
+ {...props}
+ />
+ );
+ }
+
+ function renderBoard(
+ groups: { id: string; ids: string[]; fixed?: boolean }[] = GROUPS,
+ ) {
+ const onSwap = vi.fn();
+ const onMove = vi.fn();
+ const onMoveGroup = vi.fn();
+ const view = render(board(groups, { onSwap, onMove, onMoveGroup }));
+ return { onSwap, onMove, onMoveGroup, ...view };
+ }
+
+ const group = (id: string) => {
+ const found = document.querySelector(
+ `[data-sortable-group="${id}"]`,
+ );
+ if (!found) throw new Error(`no group ${id}`);
+ return found;
+ };
+ const line = () =>
+ document.querySelector("[data-sortable-line]");
+ const announced = () =>
+ document.querySelector('[id^="DndLiveRegion"]')?.textContent ?? "";
+
+ // Picks a group up by its grip, past the 5px a mouse drag needs, and aims it
+ // at a height from the board's top (negative is above the board), and
+ // optionally off to one side.
+ function pickUpGroup(id: string) {
+ const band = GROUPS.findIndex((g) => g.id === id);
+ let at = { clientX: 20, clientY: TOP + band * GROUP_GAP + 10 };
+ fireEvent.mouseDown(screen.getByRole("button", { name: `Grip ${id}` }), {
+ ...at,
+ button: 0,
+ });
+ fireEvent.mouseMove(document, {
+ clientX: at.clientX,
+ clientY: at.clientY + 10,
+ });
+ return {
+ moveTo(height: number, clientX = at.clientX) {
+ const clientY = TOP + height;
+ fireEvent.mouseMove(document, {
+ clientX: (at.clientX + clientX) / 2,
+ clientY: (at.clientY + clientY) / 2,
+ });
+ at = { clientX, clientY };
+ fireEvent.mouseMove(document, at);
+ },
+ drop() {
+ fireEvent.mouseUp(document, at);
+ },
+ };
+ }
+
+ it("puts a group dropped on the top half of another before it", () => {
+ const { onMoveGroup, onSwap } = renderBoard();
+ const drag = pickUpGroup("three");
+ // Only the dragged group's header follows the pointer.
+ expect(group("three").dataset.dragging).toBe("true");
+ expect(screen.getByText("Preview three")).toBeTruthy();
+ drag.moveTo(50);
+ expect(line()?.dataset.sortableLine).toBe("before");
+ expect(line()?.parentElement).toBe(group("one"));
+ expect(announced()).toContain("Group three will move before Group one.");
+ drag.drop();
+ expect(onMoveGroup).toHaveBeenCalledExactlyOnceWith("three", 0);
+ expect(onSwap).not.toHaveBeenCalled();
+ expect(line()).toBeNull();
+ });
+
+ it("puts a group dropped on the bottom half of another after it", () => {
+ const { onMoveGroup } = renderBoard();
+ const drag = pickUpGroup("one");
+ drag.moveTo(GROUP_GAP + 250);
+ expect(line()?.dataset.sortableLine).toBe("after");
+ expect(line()?.parentElement).toBe(group("two"));
+ expect(announced()).toContain("Group one will move after Group two.");
+ drag.drop();
+ expect(onMoveGroup).toHaveBeenCalledExactlyOnceWith("one", 1);
+ });
+
+ it("lands a group dropped between two others on that place", () => {
+ const { onMoveGroup } = renderBoard();
+ const drag = pickUpGroup("one");
+ // In the gap under group two, nearer its bottom half than three's top.
+ drag.moveTo(GROUP_GAP + BAND + 20);
+ drag.drop();
+ expect(onMoveGroup).toHaveBeenCalledExactlyOnceWith("one", 1);
+ });
+
+ it("changes nothing when a group is dropped where it already is", () => {
+ const { onMoveGroup } = renderBoard();
+ // Two after one is where two is.
+ const drag = pickUpGroup("two");
+ drag.moveTo(250);
+ expect(line()).toBeNull();
+ expect(announced()).toContain("Group two will stay where it is.");
+ drag.drop();
+ // And so is two over its own place.
+ const own = pickUpGroup("two");
+ own.moveTo(GROUP_GAP + 50);
+ own.drop();
+ expect(onMoveGroup).not.toHaveBeenCalled();
+ });
+
+ it("puts a group dropped above the board first, below it last, and goes by height alone", () => {
+ const { onMoveGroup } = renderBoard();
+ // Over what the page draws above the board: before the first group.
+ const above = pickUpGroup("three");
+ above.moveTo(-100);
+ expect(line()?.dataset.sortableLine).toBe("before");
+ expect(line()?.parentElement).toBe(group("one"));
+ expect(announced()).toContain("Group three will move before Group one.");
+ above.drop();
+ expect(onMoveGroup).toHaveBeenLastCalledWith("three", 0);
+ // Past the last group: after it.
+ const below = pickUpGroup("one");
+ below.moveTo(3000);
+ below.drop();
+ expect(onMoveGroup).toHaveBeenLastCalledWith("one", 2);
+ // Off to one side, over two's top half: before two.
+ const aside = pickUpGroup("three");
+ aside.moveTo(GROUP_GAP + 50, 900);
+ aside.drop();
+ expect(onMoveGroup).toHaveBeenLastCalledWith("three", 1);
+ expect(onMoveGroup).toHaveBeenCalledTimes(3);
+ });
+
+ it("makes no tile a drop target and shows no slot while a group is dragged", () => {
+ const { onMoveGroup, onSwap, onMove } = renderBoard();
+ const drag = pickUpGroup("two");
+ expect(screen.queryByText("Move here")).toBeNull();
+ // Over tile a, which is on group one's top half.
+ drag.moveTo(HEADER + 50);
+ expect(tile("a").style.transform).toBe("");
+ expect(tile("x").style.transform).toBe("");
+ drag.drop();
+ expect(onSwap).not.toHaveBeenCalled();
+ expect(onMove).not.toHaveBeenCalled();
+ expect(onMoveGroup).toHaveBeenCalledExactlyOnceWith("two", 0);
+ });
+
+ it("still swaps tiles on a board that moves groups", () => {
+ const { onSwap, onMoveGroup } = renderBoard();
+ const { left, top } = tile("a").getBoundingClientRect();
+ fireEvent.mouseDown(tile("a"), {
+ clientX: left + 50,
+ clientY: top + 50,
+ button: 0,
+ });
+ const target = tile("y").getBoundingClientRect();
+ for (const step of [0.1, 0.5, 1]) {
+ fireEvent.mouseMove(document, {
+ clientX: left + 50 + (target.left - left) * step,
+ clientY: top + 50 + (target.top - top) * step,
+ });
+ }
+ expect(line()).toBeNull();
+ fireEvent.mouseUp(document, {
+ clientX: target.left + 50,
+ clientY: target.top + 50,
+ });
+ expect(onSwap).toHaveBeenCalledExactlyOnceWith("a", "y");
+ expect(onMoveGroup).not.toHaveBeenCalled();
+ });
+
+ it("never moves a fixed group, nor puts one before it", () => {
+ const { onMoveGroup } = renderBoard([
+ { ...GROUPS[0], fixed: true },
+ GROUPS[1],
+ GROUPS[2],
+ ]);
+ const fixed = pickUpGroup("one");
+ expect(group("one").dataset.dragging).toBeUndefined();
+ fixed.moveTo(GROUP_GAP * 2 + 250);
+ fixed.drop();
+ expect(onMoveGroup).not.toHaveBeenCalled();
+ // Over the fixed first group, or above it, a group goes right after it.
+ const onto = pickUpGroup("three");
+ onto.moveTo(-50);
+ expect(announced()).toContain("Group three will move before Group two.");
+ onto.drop();
+ expect(onMoveGroup).toHaveBeenCalledExactlyOnceWith("three", 1);
+ });
+
+ it("drags no group on a board that does not move groups", () => {
+ const onSwap = vi.fn();
+ render(board(GROUPS, { onSwap }));
+ const drag = pickUpGroup("one");
+ expect(group("one").dataset.dragging).toBeUndefined();
+ drag.moveTo(GROUP_GAP + 250);
+ drag.drop();
+ expect(onSwap).not.toHaveBeenCalled();
+ });
+
+ it("glides a moved group, and its tiles with it rather than on their own", () => {
+ const animate = vi.fn();
+ HTMLElement.prototype.animate = animate;
+ const { rerender } = renderBoard();
+ rerender(board([GROUPS[1], GROUPS[0], GROUPS[2]]));
+ const glides = new Map(
+ animate.mock.contexts.map((element, call) => [
+ (element as HTMLElement).dataset.sortableGroup ??
+ (element as HTMLElement).dataset.sortableId,
+ animate.mock.calls[call][0][0].transform,
+ ]),
+ );
+ // One and two traded bands; three, and every tile, stayed put within
+ // its group.
+ expect(glides).toEqual(
+ new Map([
+ ["two", "translate(0px, 400px)"],
+ ["one", "translate(0px, -400px)"],
+ ]),
+ );
+ });
+});
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..a4173e416 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
@@ -54,7 +54,14 @@ export {
} from "./metric-geometry.js";
export { Sparkline } from "./sparkline.js";
-export { SortableGrid, type SortableGridProps } from "./sortable-grid.js";
+export {
+ SortableBoard,
+ type SortableBoardProps,
+ SortableGrid,
+ type SortableGridProps,
+ type SortableGroup,
+ type SortableGroupHandle,
+} from "./sortable-grid.js";
export {
MetricProvenance,
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..c4ee2a8cf 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
@@ -1,26 +1,42 @@
"use client";
/**
- * SortableGrid — tiles a viewer rearranges by dragging one onto another. The
- * two swap places; no other tile moves.
+ * SortableBoard — tiles a viewer rearranges by dragging, in one or more
+ * groups (for instance the sections of a dashboard). A tile dropped on another
+ * swaps places with it, in the same group or another; no other tile moves.
+ * SortableGrid is the board with a single group.
*
* Why a swap: moving a tile into another's place shifts every tile between
* them, and on a grid that shift wraps across rows, so a sideways drag would
* move two tiles and a downward one three.
*
- * While dragging, a floating copy of the tile follows the pointer. Its place in
- * the grid stays dimmed and moves to where the drop will put it, and the tile
- * it will swap with slides into the slot it left. The grid never reflows
+ * While dragging, a floating copy of the tile follows the pointer. Its place on
+ * the board stays dimmed and moves to where the drop will put it, and the tile
+ * it will swap with slides into the place it left. The board never reflows
* mid-drag: tiles move by transform over the layout measured when the drag
* began, so tiles of different heights cannot pull the target out from under
- * the pointer. A drop off the grid, or back on the tile's own slot, changes
+ * the pointer. A drop off the board, or back on the tile's own place, changes
* nothing, and the floating copy glides back.
*
+ * A board that takes `onMove` also lets a tile go to the end of any group:
+ * while a drag is in flight, every group ends with a dashed, tile-sized slot,
+ * and a drop on it moves the tile there. The slots exist only during a drag.
+ *
+ * A board that takes `onMoveGroup` also lets the viewer reorder the groups. A
+ * group is dragged by a handle its header spreads (for instance a grip beside
+ * its title), with its preview following the pointer. The groups are one
+ * column, so a drop inserts rather than swaps: on the top half of a group the
+ * dragged one goes before it, on the bottom half after it, and a line marks
+ * the place meanwhile. Only the height counts: above the first group is before
+ * it, below the last after it. During a group's drag no tile is a drop target
+ * and there is no slot. A `fixed` group cannot be dragged, and no group lands
+ * on it: one dropped there goes to the nearest place a group can go.
+ *
* Whenever the order changes (a drop, or the caller moving, adding or removing
- * a tile), every tile glides from where it was drawn to where it lands. That
- * includes tiles the change did not move in the order: swapping a tall tile
- * with a short one changes the height of both rows, which moves the tiles
- * beside them.
+ * a tile or a group), every tile and group glides from where it was drawn to
+ * where it lands, from one group to another too. That includes tiles the
+ * change did not move in the order: swapping a tall tile with a short one
+ * changes the height of both rows, which moves the tiles beside them.
*
* A mouse drag starts after 5px of movement, so a click on a control inside a
* tile still lands. A touch drag starts after a 250ms press, so a swipe still
@@ -29,15 +45,21 @@
* one place).
*/
import {
+ type Active,
type Announcements,
type CollisionDetection,
closestCenter,
DndContext,
DragOverlay,
+ type DroppableContainer,
MouseSensor,
+ type Over,
pointerWithin,
TouchSensor,
type UniqueIdentifier,
+ useDndContext,
+ useDraggable,
+ useDroppable,
useSensor,
useSensors,
} from "@dnd-kit/core";
@@ -50,19 +72,64 @@ import { CSS } from "@dnd-kit/utilities";
import {
Component,
createRef,
+ type DOMAttributes,
type ReactNode,
type SyntheticEvent,
+ useCallback,
useId,
useMemo,
useState,
} from "react";
import { cn } from "./cn.js";
-export type SortableGridProps = {
- /** The tiles' ids, in the order they are shown. */
+/**
+ * What a group is dragged by: spread it on the element that starts the drag,
+ * for instance a grip in the group's header. It carries the pointer and touch
+ * handlers only; the element's keyboard alternative is the caller's.
+ */
+export type SortableGroupHandle = Pick<
+ DOMAttributes,
+ "onMouseDown" | "onTouchStart"
+>;
+
+/** One group of tiles on a board. */
+export type SortableGroup = {
+ /** The group's id, which `onMove` and `onMoveGroup` hand back. */
+ id: string;
+ /** The group's tiles' ids, in the order they are shown. */
ids: readonly string[];
+ /**
+ * Drawn above the group's tiles, for instance its heading. On a board that
+ * takes `onMoveGroup`, a function of the handle to spread on what drags
+ * the group.
+ */
+ header?: ReactNode | ((handle: SortableGroupHandle) => ReactNode);
+ /** What follows the pointer while the group is dragged, for instance its title. Defaults to its label. */
+ preview?: ReactNode;
+ /** A group that stays where it is: it cannot be dragged, and a group dropped on it goes to the nearest place a group can go. */
+ fixed?: boolean;
+ /** Classes for the group's list, which lay its tiles out (for example `grid grid-cols-2 gap-4`). */
+ className?: string;
+ /** The group's name in what a screen reader announces. Defaults to its id. */
+ label?: string;
+};
+
+export type SortableBoardProps = {
+ /** The groups, in the order they are shown. A tile id appears in one group only. */
+ groups: readonly SortableGroup[];
/** A tile was dropped on another: the dragged tile's id, then the target's. */
onSwap: (draggedId: string, targetId: string) => void;
+ /**
+ * A tile was dropped on a group's slot: move it to the end of that group.
+ * Without it, the board offers no slot.
+ */
+ onMove?: (draggedId: string, groupId: string) => void;
+ /**
+ * A group was dragged by its handle and dropped before or after another:
+ * move it to `index`, its place in the new order. Without it, groups stay
+ * where they are.
+ */
+ onMoveGroup?: (groupId: string, index: number) => void;
renderItem: (id: string) => ReactNode;
/**
* What the floating copy shows while a tile is dragged. Defaults to
@@ -72,36 +139,138 @@ export type SortableGridProps = {
renderOverlay?: (id: string) => ReactNode;
/** A tile's name in what a screen reader announces during a drag. Defaults to its id. */
itemLabel?: (id: string) => string;
+ /** Classes for the board, which lay the groups out (for example `space-y-6`). */
+ className?: string;
+};
+
+export type SortableGridProps = Omit<
+ SortableBoardProps,
+ "groups" | "onMove" | "onMoveGroup" | "className"
+> & {
+ /** The tiles' ids, in the order they are shown. */
+ ids: readonly string[];
/** Classes for the list, which lay the tiles out (for example `grid grid-cols-2 gap-4`). */
className?: string;
};
-// The tile under the pointer. In the gap between tiles it is the nearest one,
-// so a drop just beside a tile still lands on it; off the grid there is none.
+// What the board keeps on each draggable and droppable: the group it belongs
+// to, and what it is. A tile; a group's slot; a group being dragged; or one
+// half of a group, where a dragged group lands before or after it.
+type Place = {
+ group: string;
+ kind: "tile" | "slot" | "group" | "before" | "after";
+};
+
+function placeOf(target: { data: { current?: unknown } }): Place | undefined {
+ return target.data.current as Place | undefined;
+}
+
+// The group a drag is moving, when it moves a group rather than a tile.
+function movingGroup(active: Active): string | undefined {
+ const place = placeOf(active);
+ return place?.kind === "group" ? place.group : undefined;
+}
+
+type Bounds = { left: number; right: number; top: number; bottom: number };
+
+function bounds(rects: readonly Bounds[]): Bounds {
+ return {
+ left: Math.min(...rects.map((rect) => rect.left)),
+ right: Math.max(...rects.map((rect) => rect.right)),
+ top: Math.min(...rects.map((rect) => rect.top)),
+ bottom: Math.max(...rects.map((rect) => rect.bottom)),
+ };
+}
+
+// The tile under the pointer. In the gap between a group's tiles it is the
+// group's nearest one (or its slot), so a drop just beside a tile still lands
+// on it; off every group there is none, and a group's header is off it.
const tileUnderPointer: CollisionDetection = (args) => {
const pointer = args.pointerCoordinates;
if (!pointer) return [];
const under = pointerWithin(args);
if (under.length > 0) return under;
- const rects = [...args.droppableRects.values()];
- const onGrid =
- rects.length > 0 &&
- pointer.x >= Math.min(...rects.map((rect) => rect.left)) &&
- pointer.x <= Math.max(...rects.map((rect) => rect.right)) &&
- pointer.y >= Math.min(...rects.map((rect) => rect.top)) &&
- pointer.y <= Math.max(...rects.map((rect) => rect.bottom));
- if (!onGrid) return [];
- return closestCenter({
- ...args,
- collisionRect: {
- left: pointer.x,
- right: pointer.x,
- top: pointer.y,
- bottom: pointer.y,
- width: 0,
- height: 0,
+ const byGroup = new Map();
+ for (const container of args.droppableContainers) {
+ const group = placeOf(container)?.group;
+ byGroup.set(group, [...(byGroup.get(group) ?? []), container]);
+ }
+ for (const containers of byGroup.values()) {
+ const rects = containers.flatMap((container) => {
+ const rect = args.droppableRects.get(container.id);
+ return rect ? [rect] : [];
+ });
+ if (rects.length === 0) continue;
+ const group = bounds(rects);
+ if (
+ pointer.x < group.left ||
+ pointer.x > group.right ||
+ pointer.y < group.top ||
+ pointer.y > group.bottom
+ ) {
+ continue;
+ }
+ return closestCenter({
+ ...args,
+ droppableContainers: containers,
+ collisionRect: {
+ left: pointer.x,
+ right: pointer.x,
+ top: pointer.y,
+ bottom: pointer.y,
+ width: 0,
+ height: 0,
+ },
+ });
+ }
+ return [];
+};
+
+// Where a dragged group lands: the half of a group nearest the pointer's
+// height, the top half for before it and the bottom half for after it. The
+// groups are one column, so only the height counts: between two groups either
+// nearest half is the same place, above the first group is before it (the
+// page's own header, where a viewer drags a group to put it first), and below
+// the last is after it. A drag the viewer does not want lands back on its own
+// place, or is cancelled with Escape.
+const halfUnderPointer: CollisionDetection = ({
+ pointerCoordinates: pointer,
+ droppableContainers,
+ droppableRects,
+}) => {
+ if (!pointer) return [];
+ const halves = droppableContainers.flatMap((container) => {
+ const rect = droppableRects.get(container.id);
+ if (!rect) return [];
+ const middle = rect.top + rect.height / 2;
+ const before = placeOf(container)?.kind === "before";
+ const top = before ? rect.top : middle;
+ const bottom = before ? middle : rect.bottom;
+ const distance = Math.max(0, top - pointer.y, pointer.y - bottom);
+ return [{ container, distance }];
+ });
+ if (halves.length === 0) return [];
+ const [nearest] = halves.sort((a, b) => a.distance - b.distance);
+ return [
+ {
+ id: nearest.container.id,
+ data: { droppableContainer: nearest.container, value: nearest.distance },
},
+ ];
+};
+
+// A tile's drag lands on a tile or a slot, a group's drag on a group's half.
+const landing: CollisionDetection = (args) => {
+ const group = movingGroup(args.active) !== undefined;
+ const droppableContainers = args.droppableContainers.filter((container) => {
+ const kind = placeOf(container)?.kind;
+ return group
+ ? kind === "before" || kind === "after"
+ : kind === "tile" || kind === "slot";
});
+ return group
+ ? halfUnderPointer({ ...args, droppableContainers })
+ : tileUnderPointer({ ...args, droppableContainers });
};
const GLIDE: KeyframeAnimationOptions = {
@@ -109,15 +278,36 @@ const GLIDE: KeyframeAnimationOptions = {
easing: "cubic-bezier(0.2, 0, 0, 1)",
};
-// 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]",
- )) {
- rects.set(tile.dataset.sortableId ?? "", tile.getBoundingClientRect());
+// The board's groups, and their tiles, but not a nested board's.
+const GROUPS = ":scope > [data-sortable-group]";
+const TILES = `${GROUPS} > ul > [data-sortable-id]`;
+
+type Drawn = { groups: Map; tiles: Map };
+
+// Where each group and tile is drawn now, transforms included.
+function drawnRects(board: HTMLElement): Drawn {
+ const drawn: Drawn = { groups: new Map(), tiles: new Map() };
+ for (const group of board.querySelectorAll(GROUPS)) {
+ drawn.groups.set(
+ group.dataset.sortableGroup ?? "",
+ group.getBoundingClientRect(),
+ );
+ }
+ for (const tile of board.querySelectorAll(TILES)) {
+ drawn.tiles.set(
+ tile.dataset.sortableId ?? "",
+ tile.getBoundingClientRect(),
+ );
}
- return rects;
+ return drawn;
+}
+
+function glide(element: HTMLElement, dx: number, dy: number) {
+ if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return;
+ element.animate?.(
+ [{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "none" }],
+ GLIDE,
+ );
}
function prefersReducedMotion(): boolean {
@@ -127,71 +317,94 @@ function prefersReducedMotion(): boolean {
);
}
-// The tile list. When the order changes it reads where every tile is drawn
-// just before React commits the new order, the one moment the page still shows
-// the old one, and then glides each tile from there to where it lands. A class
-// because getSnapshotBeforeUpdate is React's only way into that moment. (A
-// dropped tile is hidden while its floating copy glides into its new slot.)
-class GlidingList extends Component<{
+// The board. When the order changes it reads where every group and tile is
+// drawn just before React commits the new order, the one moment the page still
+// shows the old one, and then glides each from there to where it lands. It
+// reads the whole board, keyed by id, so a tile that moved to another group
+// (and so is a new element there) glides too. A tile glides with its group, so
+// on its own it glides only by what it moved within the group. A class because
+// getSnapshotBeforeUpdate is React's only way into that moment. (A dropped
+// tile, or a dropped group's header, is hidden while its floating copy glides
+// into its new place.)
+class GlidingBoard extends Component<{
order: string;
className?: string;
children: ReactNode;
}> {
- private list = createRef();
+ private board = createRef();
getSnapshotBeforeUpdate(previous: { order: string }) {
- const list = this.list.current;
- return list && previous.order !== this.props.order
- ? drawnRects(list)
+ const board = this.board.current;
+ return board && previous.order !== this.props.order
+ ? drawnRects(board)
: null;
}
componentDidUpdate(
_previous: unknown,
_state: unknown,
- before: Map | null,
+ before: Drawn | null,
) {
- const list = this.list.current;
- if (!before || !list || prefersReducedMotion()) return;
- for (const tile of list.querySelectorAll(
- ":scope > [data-sortable-id]",
- )) {
- const from = before.get(tile.dataset.sortableId ?? "");
- if (!from) continue;
- const to = tile.getBoundingClientRect();
- const dx = from.left - to.left;
- const dy = from.top - to.top;
- if (Math.abs(dx) < 1 && Math.abs(dy) < 1) continue;
- tile.animate?.(
- [{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "none" }],
- GLIDE,
+ const board = this.board.current;
+ if (!before || !board || prefersReducedMotion()) return;
+ // Everything is read before anything glides: a group's glide moves
+ // its tiles with it.
+ const after = drawnRects(board);
+ const moved = new Map();
+ for (const group of board.querySelectorAll(GROUPS)) {
+ const id = group.dataset.sortableGroup ?? "";
+ const from = before.groups.get(id);
+ const to = after.groups.get(id);
+ if (!from || !to) continue;
+ const delta = { dx: from.left - to.left, dy: from.top - to.top };
+ moved.set(id, delta);
+ glide(group, delta.dx, delta.dy);
+ }
+ for (const tile of board.querySelectorAll(TILES)) {
+ const id = tile.dataset.sortableId ?? "";
+ const from = before.tiles.get(id);
+ const to = after.tiles.get(id);
+ if (!from || !to) continue;
+ const group = moved.get(
+ tile.parentElement?.parentElement?.dataset.sortableGroup ?? "",
+ );
+ glide(
+ tile,
+ from.left - to.left - (group?.dx ?? 0),
+ from.top - to.top - (group?.dy ?? 0),
);
}
}
render() {
return (
-
+
{this.props.children}
-
+
);
}
}
function SortableTile({
id,
+ group,
sorting,
children,
}: {
id: string;
+ group: string;
sorting: boolean;
children: ReactNode;
}) {
- // The list animates order changes itself (see GlidingList), so dnd-kit's
+ // The board animates order changes itself (see GlidingBoard), so dnd-kit's
// own layout animation is off; left on, it would slide the swapped tile back
// to where the drag began before sliding it on to where it lands.
const { setNodeRef, listeners, transform, transition, isDragging } =
- useSortable({ id, animateLayoutChanges: () => false });
+ useSortable({
+ id,
+ data: { group, kind: "tile" } satisfies Place,
+ animateLayoutChanges: () => false,
+ });
// React passes a press inside a portal (a popover or menu opened from the
// tile) up through the tile, though the portal is drawn elsewhere on the
// page. Only a press on the tile itself starts a drag, so text in such a
@@ -232,22 +445,137 @@ function SortableTile({
);
}
-export function SortableGrid({
- ids,
+// The end of a group, while a drag is in flight: a drop here moves the tile to
+// the end of the group. As big as the tile being dragged, so the drop target
+// reads as a place the tile will fit.
+function MoveSlot({ id, group }: { id: string; group: string }) {
+ const { setNodeRef, isOver } = useDroppable({
+ id,
+ data: { group, kind: "slot" } satisfies Place,
+ });
+ const { activeNodeRect } = useDndContext();
+ return (
+
+ Move here
+
+ );
+}
+
+// One group on the board. On a board that moves groups, the group is dragged
+// by the handle its header spreads (the header is what the drag lifts, so the
+// floating copy is its size), and each half of the group is where a dragged
+// group lands: the top half before it, the bottom half after it.
+function SortableGroupView({
+ group,
+ boardId,
+ movable,
+ line,
+ children,
+}: {
+ group: SortableGroup;
+ boardId: string;
+ movable: boolean;
+ line?: "before" | "after";
+ children: ReactNode;
+}) {
+ const {
+ setNodeRef: setLifted,
+ listeners,
+ isDragging,
+ } = useDraggable({
+ id: `${boardId}:group:${group.id}`,
+ data: { group: group.id, kind: "group" } satisfies Place,
+ disabled: !movable,
+ });
+ const { setNodeRef: setBefore } = useDroppable({
+ id: `${boardId}:before:${group.id}`,
+ data: { group: group.id, kind: "before" } satisfies Place,
+ disabled: !movable,
+ });
+ const { setNodeRef: setAfter } = useDroppable({
+ id: `${boardId}:after:${group.id}`,
+ data: { group: group.id, kind: "after" } satisfies Place,
+ disabled: !movable,
+ });
+ const setHalves = useCallback(
+ (element: HTMLElement | null) => {
+ setBefore(element);
+ setAfter(element);
+ },
+ [setBefore, setAfter],
+ );
+ // The board's sensors are a mouse's and a touch's, so these two are all
+ // the listeners there are.
+ const { onMouseDown, onTouchStart } = (listeners ??
+ {}) as SortableGroupHandle;
+ const header =
+ typeof group.header === "function"
+ ? group.header({ onMouseDown, onTouchStart })
+ : group.header;
+ return (
+
+ );
+}
+
+type Dragged = { kind: "tile" | "group"; id: string };
+
+export function SortableBoard({
+ groups,
onSwap,
+ onMove,
+ onMoveGroup,
renderItem,
renderOverlay = renderItem,
itemLabel = (id) => id,
className,
-}: SortableGridProps) {
+}: SortableBoardProps) {
// A stable id keeps the ids dnd-kit writes into the page the same on the
// server and the client.
const contextId = useId();
- const [dragged, setDragged] = useState(null);
- // Callers usually pass a new array on every render, so the order is keyed
- // by its content: dnd-kit sees a new items list only when the order changes.
- const order = JSON.stringify(ids);
- const items = useMemo(() => JSON.parse(order) as string[], [order]);
+ const [dragged, setDragged] = useState(null);
+ // Where a dragged group would land, to mark the place with a line.
+ const [overPlace, setOverPlace] = useState(null);
+ // Callers usually pass new arrays on every render, so the order is keyed by
+ // its content: dnd-kit sees a new items list only when the order changes.
+ // One sortable context over every group, so a tile previews its swap with
+ // a tile in another group the way it does with one in its own.
+ const order = JSON.stringify(groups.map((group) => [group.id, group.ids]));
+ const items = useMemo(
+ () => (JSON.parse(order) as [string, string[]][]).flatMap(([, ids]) => ids),
+ [order],
+ );
+ // A slot's id is scoped to this board, so it cannot collide with a tile's.
+ const slotId = (group: string) => `${contextId}:slot:${group}`;
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 5 } }),
@@ -256,53 +584,189 @@ export function SortableGrid({
}),
);
const name = (id: UniqueIdentifier) => itemLabel(String(id));
+ const groupOf = (id: UniqueIdentifier) =>
+ groups.find((group) => group.ids.includes(String(id)));
+ const groupName = (id: string) =>
+ groups.find((group) => group.id === id)?.label ?? id;
+ // Where a tile dropped on `over` lands: a group's end, the other tile's
+ // place (naming that group when it is another one), or nowhere.
+ const outcome = (
+ active: UniqueIdentifier,
+ over: Over | null,
+ ):
+ | { kind: "move"; group: string }
+ | { kind: "swap"; target: string; group?: string }
+ | null => {
+ if (!over) return null;
+ const place = placeOf(over);
+ if (place?.kind === "slot") {
+ const ids = groups.find((group) => group.id === place.group)?.ids ?? [];
+ return ids[ids.length - 1] === String(active)
+ ? null
+ : { kind: "move", group: place.group };
+ }
+ if (place?.kind !== "tile" || over.id === active) return null;
+ const target = groupOf(over.id);
+ return {
+ kind: "swap",
+ target: String(over.id),
+ group: target && target !== groupOf(active) ? target.id : undefined,
+ };
+ };
+ // Where a group dropped on a group's half lands: its place in the new
+ // order, before or after that group, or nowhere when that is where it is.
+ const groupOutcome = (
+ moving: string,
+ place: Place | null | undefined,
+ ): { target: string; side: "before" | "after"; index: number } | null => {
+ if (place?.kind !== "before" && place?.kind !== "after") return null;
+ const order = groups.map((group) => group.id);
+ const from = order.indexOf(moving);
+ const at = order.indexOf(place.group);
+ if (from === -1 || at === -1) return null;
+ let index = place.kind === "after" ? at + 1 : at;
+ if (from < index) index -= 1;
+ return index === from
+ ? null
+ : { target: place.group, side: place.kind, index };
+ };
+ const stays = (id: UniqueIdentifier) => `${name(id)} will stay where it is.`;
const announcements: Announcements = {
- onDragStart: ({ active }) => `Picked up ${name(active.id)}.`,
- onDragOver: ({ active, over }) =>
- over && over.id !== active.id
- ? `${name(active.id)} will swap with ${name(over.id)}.`
- : `${name(active.id)} will stay where it is.`,
- onDragEnd: ({ active, over }) =>
- over && over.id !== active.id
- ? `${name(active.id)} swapped with ${name(over.id)}.`
- : `${name(active.id)} stayed where it is.`,
- onDragCancel: ({ active }) =>
- `Moving ${name(active.id)} was cancelled; it stayed where it is.`,
+ onDragStart: ({ active }) => {
+ const moving = movingGroup(active);
+ return `Picked up ${moving === undefined ? name(active.id) : groupName(moving)}.`;
+ },
+ onDragOver: ({ active, over }) => {
+ const moving = movingGroup(active);
+ if (moving !== undefined) {
+ const next = groupOutcome(moving, over && placeOf(over));
+ return next
+ ? `${groupName(moving)} will move ${next.side} ${groupName(next.target)}.`
+ : `${groupName(moving)} will stay where it is.`;
+ }
+ const next = outcome(active.id, over);
+ if (!next) return stays(active.id);
+ if (next.kind === "move") {
+ return `${name(active.id)} will move to the end of ${groupName(next.group)}.`;
+ }
+ return next.group
+ ? `${name(active.id)} will swap with ${name(next.target)} and move to ${groupName(next.group)}.`
+ : `${name(active.id)} will swap with ${name(next.target)}.`;
+ },
+ onDragEnd: ({ active, over }) => {
+ const moving = movingGroup(active);
+ if (moving !== undefined) {
+ const next = groupOutcome(moving, over && placeOf(over));
+ return next
+ ? `${groupName(moving)} moved ${next.side} ${groupName(next.target)}.`
+ : `${groupName(moving)} stayed where it is.`;
+ }
+ const next = outcome(active.id, over);
+ if (!next) return `${name(active.id)} stayed where it is.`;
+ if (next.kind === "move") {
+ return `${name(active.id)} moved to the end of ${groupName(next.group)}.`;
+ }
+ return next.group
+ ? `${name(active.id)} swapped with ${name(next.target)} and moved to ${groupName(next.group)}.`
+ : `${name(active.id)} swapped with ${name(next.target)}.`;
+ },
+ onDragCancel: ({ active }) => {
+ const moving = movingGroup(active);
+ return `Moving ${moving === undefined ? name(active.id) : groupName(moving)} was cancelled; it stayed where it is.`;
+ },
};
+ const landingGroup =
+ dragged?.kind === "group" ? groupOutcome(dragged.id, overPlace) : null;
+ const liftedGroup =
+ dragged?.kind === "group"
+ ? groups.find((group) => group.id === dragged.id)
+ : undefined;
return (
setDragged(String(active.id))}
+ onDragStart={({ active }) => {
+ const moving = movingGroup(active);
+ setDragged(
+ moving === undefined
+ ? { kind: "tile", id: String(active.id) }
+ : { kind: "group", id: moving },
+ );
+ }}
+ onDragOver={({ over }) => setOverPlace((over && placeOf(over)) ?? null)}
onDragEnd={({ active, over }) => {
setDragged(null);
- if (over && over.id !== active.id) {
- onSwap(String(active.id), String(over.id));
+ setOverPlace(null);
+ const moving = movingGroup(active);
+ if (moving !== undefined) {
+ const next = groupOutcome(moving, over && placeOf(over));
+ if (next) onMoveGroup?.(moving, next.index);
+ return;
}
+ const next = outcome(active.id, over);
+ if (next?.kind === "move") onMove?.(String(active.id), next.group);
+ else if (next?.kind === "swap") onSwap(String(active.id), next.target);
+ }}
+ onDragCancel={() => {
+ setDragged(null);
+ setOverPlace(null);
}}
- onDragCancel={() => setDragged(null)}
>
-
- {ids.map((id) => (
-
- {renderItem(id)}
-
+
+ {groups.map((group) => (
+
+ {group.ids.map((id) => (
+
+ {renderItem(id)}
+
+ ))}
+ {onMove && dragged?.kind === "tile" && (
+
+ )}
+
))}
-
+
- {/* On a drop the copy glides into the tile's new slot, or back to its
- old one when there was nothing to swap with. */}
+ {/* On a drop the copy glides into the tile's new place, or back to its
+ old one when there was nothing to swap with; a group's copy is
+ its preview, and glides onto its header. */}
- {dragged ? (
+ {dragged?.kind === "tile" ? (
) : null}
);
}
+
+// The single group of a SortableGrid.
+const GRID = "tiles";
+
+export function SortableGrid({ ids, className, ...board }: SortableGridProps) {
+ return ;
+}
diff --git a/module/services/frontend/code/packages/codefly-ui/stories/composites.stories.tsx b/module/services/frontend/code/packages/codefly-ui/stories/composites.stories.tsx
index 1d37dfd74..c868a67d0 100644
--- a/module/services/frontend/code/packages/codefly-ui/stories/composites.stories.tsx
+++ b/module/services/frontend/code/packages/codefly-ui/stories/composites.stories.tsx
@@ -2,6 +2,7 @@ import { useState } from "react";
import { Chat, type ChatMessage } from "../src/chat/index.js";
import {
Dashboard,
+ SortableBoard,
SortableGrid,
type WidgetVisualization,
} from "../src/dashboard/index.js";
@@ -108,3 +109,75 @@ function SortableTilesExample() {
export const SortableTiles = {
render: () => ,
};
+
+// Drag a tile onto another to swap the two, across groups too, or onto the
+// slot at the end of a group to move it there. Drag a group by the grip in its
+// header to put it before or after another.
+function SortableGroupsExample() {
+ const [groups, setGroups] = useState([
+ { id: "overview", title: "Overview", ids: ["Requests", "Users"] },
+ { id: "detail", title: "Detail", ids: ["Errors", "Latency"] },
+ ]);
+ return (
+ ({
+ id: group.id,
+ ids: group.ids,
+ label: group.title,
+ header: (handle) => (
+
+
+ )}
+ />
+ );
+}
+export const SortableGroups = {
+ render: () => ,
+};
diff --git a/module/services/frontend/code/packages/saas-ui/package.json b/module/services/frontend/code/packages/saas-ui/package.json
index 491745d99..0dcf98419 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.11.4",
+ "version": "0.11.5",
"description": "Reusable SaaS-domain frontend components (datasources, \u2026) 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/ui-inventory.json b/module/services/frontend/code/ui-inventory.json
index 5023e20e8..d94330102 100644
--- a/module/services/frontend/code/ui-inventory.json
+++ b/module/services/frontend/code/ui-inventory.json
@@ -340,10 +340,14 @@
{
"file": "packages/codefly-ui/stories/composites.stories.tsx",
"export": "SortableTiles"
+ },
+ {
+ "file": "packages/codefly-ui/stories/composites.stories.tsx",
+ "export": "SortableGroups"
}
],
"coverage": "owner stories; browser/skin verification pending",
- "remaining": "Verify dragging in the explorer with a mouse and on touch, and visual baselines under external skins."
+ "remaining": "Verify dragging in the explorer with a mouse and on touch, across groups too, reordering groups by their handle, and visual baselines under external skins."
},
{
"source": "packages/codefly-ui/src/dashboard/sparkline.tsx",
From f6af1022e30889301d94b8bf5a6e71b80a720eff Mon Sep 17 00:00:00 2001
From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com>
Date: Sun, 27 Sep 2026 23:19:59 -0400
Subject: [PATCH 03/29] feat(frontend): draw a solution dashboard in its
declared sections, and let a viewer arrange them
A solution dashboard that declares sections is drawn as one titled group
per section, in declared order, each with its description and its own +
menu. Sections come from the declaration; a viewer only groups and arranges
the tiles in their own view:
- drag a tile onto a tile in another section to swap the two, or onto the
slot that ends every section during a drag to move it there; the grip's
arrow keys cross a section boundary;
- rename a section (from its title or its options menu), remove one (its
tiles move to the section above, or below for the first), reorder
sections by dragging a section's grip or with its arrow keys, and add
sections of their own after the last. A viewer's section starts empty
and is filled by dragging tiles in.
Each + menu, the dashboard's and a section's, offers only the declared
widgets the viewer removed, and a section's + puts the widget back in that
section. No menu offers a metric the dashboard has no widget for, and no
section adds one (ADR 0007). A dashboard without sections draws and saves
as before, with its untitled group first.
The layout moves to version 2 in the same per-viewer key: sections, each
with its tiles and the viewer's title if they gave one, plus the declared
widgets and sections it was made against. So a section or widget the
solution declares later still appears, while one the viewer removed stays
removed. A version-1 layout (a flat list of tiles) is read by putting each
tile in its declared section; an id the dashboard does not declare, such as
a `metric:` tile saved before ADR 0007 applied, is dropped. A viewer's own
section id is `custom:` and 16 hex characters, and its title is trimmed and
capped at 60 characters.
The layout still lives in browser storage, under the existing STOPGAP
label. docs/plugin-manifest-schema.md says what a viewer can do with
sections.
Co-Authored-By: Claude Opus 5.5
---
module/docs/plugin-manifest-schema.md | 20 +-
.../code/src/solutions/SolutionDashboard.tsx | 489 +++++++++++-
.../__tests__/SolutionDashboard.test.tsx | 718 +++++++++++++++++-
.../__tests__/dashboard-layout.test.ts | 574 +++++++++++++-
.../code/src/solutions/dashboard-layout.ts | 574 ++++++++++++--
5 files changed, 2224 insertions(+), 151 deletions(-)
diff --git a/module/docs/plugin-manifest-schema.md b/module/docs/plugin-manifest-schema.md
index 03170be3a..3e94f6219 100644
--- a/module/docs/plugin-manifest-schema.md
+++ b/module/docs/plugin-manifest-schema.md
@@ -107,11 +107,21 @@ On a registered solution's page the host renders every declared dashboard in a
the solution; the solution stays mounted while the dashboard is open. A
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
-adds a metric the dashboard does not draw, so a metric declared only as a
-derived metric's input stays off the page.
+them, and re-add the ones they removed. The declared widgets, in declared order
+and in their declared sections, are where every viewer starts, and a widget
+declared later reaches viewers who already rearranged, in its declared section.
+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.
+
+Sections are declared by the solution; a viewer only groups and arranges the
+tiles. A viewer can move a tile into another section, rename, reorder and
+remove sections, and add sections of their own, which start empty and are
+filled by dragging tiles in. A removed section's tiles move to the section
+above it (below, for the first), and a section's + puts a removed widget back
+into that section. A section the solution declares later still appears for a
+viewer who rearranged; one the viewer removed stays removed. None of this
+reaches other viewers or the declaration.
The schema owns the per-node field formats — including the shape rules that
cross fields: a metric carries a `bucket` exactly when it groups by `time`, a
diff --git a/module/services/frontend/code/src/solutions/SolutionDashboard.tsx b/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
index 526feeade..b2977f9fb 100644
--- a/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
+++ b/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
@@ -18,12 +18,21 @@ import {
AreaChart,
BarList,
LineChart,
- SortableGrid,
+ SortableBoard,
+ type SortableGroupHandle,
StatChart,
} from "@codefly-dev/ui/dashboard";
import { useQuery } from "@tanstack/react-query";
-import { GripVertical, Info, Plus, X } from "lucide-react";
-import { type ReactNode, useState, useSyncExternalStore } from "react";
+import { Ellipsis, GripVertical, Info, Pencil, Plus, X } from "lucide-react";
+import {
+ type KeyboardEvent,
+ type ReactNode,
+ useEffect,
+ useId,
+ useRef,
+ useState,
+ useSyncExternalStore,
+} from "react";
import { flushSync } from "react-dom";
import { resolveActor } from "@/features/audit/model/transforms";
import {
@@ -43,6 +52,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
+ Input,
Popover,
PopoverContent,
PopoverTitle,
@@ -51,15 +61,29 @@ import {
} from "@/shared/ui";
import {
addableTiles,
+ addSection,
addTile,
+ canRemoveSection,
+ type Layout,
+ type LayoutSection,
layoutKey,
+ layoutTiles,
moveBy,
+ moveSection,
+ moveSectionBy,
+ moveTileToSection,
+ newSectionId,
parseLayout,
readSavedLayout,
+ removeSection,
removeTile,
+ renameSection,
+ SECTION_TITLE_MAX,
+ sectionTitle,
serializeLayout,
swapTiles,
tileWidget,
+ UNSECTIONED,
writeSavedLayout,
} from "./dashboard-layout";
import {
@@ -88,13 +112,18 @@ const SINGLE_WIDGET_DASHBOARD = "widget";
// resolves only that widget's metric (and its derived inputs). Resolving each
// widget through its own call is what isolates failures: a widget whose metric's
// audit query errors fails alone, instead of blanking every sibling that shares
-// the dashboard's single batched resolution.
+// the dashboard's single batched resolution. The throwaway dashboard declares
+// no sections, so its widget names none.
function singleWidgetGraph(graph: DataGraph, widget: MetricWidget): DataGraph {
return {
events: graph.events,
metrics: graph.metrics,
dashboards: [
- { id: SINGLE_WIDGET_DASHBOARD, layout: "grid", widgets: [widget] },
+ {
+ id: SINGLE_WIDGET_DASHBOARD,
+ layout: "grid",
+ widgets: [{ ...widget, section: undefined }],
+ },
],
};
}
@@ -274,7 +303,7 @@ function noSubscription() {
function useDashboardLayout(
storageKey: string,
dashboard: Dashboard,
-): [string[], (next: readonly string[]) => void] {
+): [Layout, (next: Layout) => void] {
const stored = useSyncExternalStore(
noSubscription,
() => readSavedLayout(browserStorage(), storageKey),
@@ -289,7 +318,7 @@ function useDashboardLayout(
edited?.key === storageKey ? edited.raw : stored,
dashboard,
);
- const save = (next: readonly string[]) => {
+ const save = (next: Layout) => {
const raw = serializeLayout(next, dashboard);
setEdited({ key: storageKey, raw });
writeSavedLayout(browserStorage(), storageKey, raw);
@@ -304,6 +333,12 @@ const ARROW_STEP: Partial> = {
ArrowRight: 1,
};
+// Sections are one column, so only up and down move one.
+const SECTION_STEP: Partial> = {
+ ArrowUp: -1,
+ ArrowDown: 1,
+};
+
// How many of the newest events the ⓘ lists.
const RECENT_EVENTS = 5;
@@ -475,7 +510,7 @@ function MetricInfo({
// Lists what the viewer can put back on the dashboard: the declared widgets
// they removed. A preference never adds a metric the dashboard does not draw
-// (ADR 0007).
+// (ADR 0007). Each section has its own, which puts the widget back there.
function AddTileMenu({
label,
addable,
@@ -517,8 +552,192 @@ function AddTileMenu({
);
}
-// A dashboard the viewer can arrange: drag a tile to reorder (or focus its grip
-// and use the arrow keys), × to remove one, + to add one back.
+// A section's title while the viewer renames it, focused with the title
+// selected. Enter or leaving the field keeps what was typed; Escape keeps the
+// title as it was. `refocus` says the viewer is still here (they pressed a
+// key), so focus goes back to where the rename began.
+function SectionTitleInput({
+ title,
+ onDone,
+}: {
+ title: string;
+ onDone: (typed: string | null, refocus: boolean) => void;
+}) {
+ const input = useRef(null);
+ // Leaving the field after Enter or Escape is not a second answer.
+ const done = useRef(false);
+ const finish = (typed: string | null, refocus: boolean) => {
+ if (done.current) return;
+ done.current = true;
+ onDone(typed, refocus);
+ };
+ useEffect(() => {
+ input.current?.focus();
+ input.current?.select();
+ }, []);
+ return (
+ {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ finish(event.currentTarget.value, true);
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ event.stopPropagation();
+ finish(null, true);
+ }
+ }}
+ onBlur={(event) => finish(event.currentTarget.value, false)}
+ />
+ );
+}
+
+// A section's options: rename it, or remove it. The last section left cannot
+// be removed.
+function SectionMenu({
+ sectionId,
+ title,
+ removable,
+ onRename,
+ onRemove,
+}: {
+ sectionId: string;
+ title: string;
+ removable: boolean;
+ onRename: () => void;
+ onRemove: () => void;
+}) {
+ // Renaming puts a focused field in the title's place. The menu hands the
+ // focus back to its trigger only while nothing else has taken it, so the
+ // field keeps it; an explicit `finalFocus` would take it back regardless.
+ return (
+
+
+ }
+ >
+
+
+
+ Rename section
+
+ Remove section
+
+
+
+ );
+}
+
+// A section's heading: the grip it is dragged by, its title (or the field
+// that renames it), what it is for, its own + menu and its options. The title
+// is itself the way to rename the section, with a pencil on hover or focus to
+// say so; the heading is named by the title alone. Only the grip drags.
+function SectionHeader({
+ sectionId,
+ grip,
+ title,
+ description,
+ empty,
+ rename,
+ onRename,
+ add,
+ options,
+}: {
+ sectionId: string;
+ grip: ReactNode;
+ title: string;
+ description?: string;
+ empty: boolean;
+ rename: ReactNode;
+ onRename: () => void;
+ add: ReactNode;
+ options: ReactNode;
+}) {
+ const titleId = useId();
+ return (
+
+ {grip}
+
+ {rename ?? (
+
+
+
+ )}
+ {description && (
+
{description}
+ )}
+ {empty && (
+
+ No widgets in this section. Drag a tile here, or add one back with
+ +.
+
+ )}
+
+
+ {add}
+ {options}
+
+
+ );
+}
+
+// What follows the pointer while a section is dragged: its title and what it
+// is for, not its tiles. Not a heading: the section's own heading stays where
+// it is meanwhile.
+function SectionPreview({
+ title,
+ description,
+}: {
+ title: string;
+ description?: string;
+}) {
+ return (
+
+
{title}
+ {description && (
+
{description}
+ )}
+
+ );
+}
+
+// The name a screen reader gives the untitled section of a dashboard that
+// declares none, once the viewer has added sections beside it.
+const UNTITLED_SECTION = "the untitled section";
+
+// A dashboard the viewer can arrange: drag a tile onto another to swap the two
+// (or focus its grip and use the arrow keys), × to remove one, + to add one
+// back. On a dashboard with sections a tile can be dragged to another section,
+// or onto the slot at the end of one. The viewer can rename, reorder or remove
+// a section, and add their own after the last; a section of their own starts
+// empty and holds only declared widgets.
function SolutionDashboard({
graph,
dashboard,
@@ -530,20 +749,124 @@ function SolutionDashboard({
}) {
const { user, organizationId } = useAuth();
const orgId = organizationId ?? "";
+ const root = useRef(null);
const storageKey = scopedDashboardDraftKey(
layoutKey(solutionId, dashboard.id),
{ organizationId, userId: user?.id },
);
const [layout, save] = useDashboardLayout(storageKey, dashboard);
- const shown = layout.flatMap((tileId) => {
+ const sections = dashboard.sections ?? [];
+ const shown = layoutTiles(layout).flatMap((tileId) => {
const widget = tileWidget(dashboard, tileId);
return widget ? [{ tileId, widget }] : [];
});
const widgets = new Map(shown.map(({ tileId, widget }) => [tileId, widget]));
+ const addable = addableTiles(graph, dashboard, layout);
+ // The section whose title is a field, while the viewer renames it, and
+ // where the focus goes back to once they are done: the options menu the
+ // rename was chosen from, or the title.
+ const [renaming, setRenaming] = useState<{
+ id: string;
+ back: "data-section-options" | "data-section-title";
+ } | null>(null);
+ // What a screen reader last heard about the sections: an add, a rename or
+ // a removal. (The board announces a tile's moves itself.)
+ const [announcement, setAnnouncement] = useState("");
const titleOf = (tileId: string) => {
const widget = widgets.get(tileId);
return widget?.title ?? widget?.metric ?? tileId;
};
+ const nameOf = (section: LayoutSection) =>
+ sectionTitle(dashboard, section) ?? UNTITLED_SECTION;
+ // A tile moved into another section is drawn anew there, so its grip is
+ // found again rather than kept; so is a section's options button.
+ const marked = (attribute: string, id: string) =>
+ [
+ ...(root.current?.querySelectorAll(`[${attribute}]`) ?? []),
+ ].find((element) => element.getAttribute(attribute) === id);
+ const gripOf = (tileId: string) => marked("data-tile-grip", tileId);
+ const addNewSection = () => {
+ const id = newSectionId(layout);
+ save(addSection(layout, id));
+ setRenaming({ id, back: "data-section-title" });
+ setAnnouncement("Added a section at the end.");
+ };
+ const finishRename = (
+ section: LayoutSection,
+ typed: string | null,
+ refocus: boolean,
+ ) => {
+ const next =
+ typed === null
+ ? layout
+ : renameSection(layout, dashboard, section.id, typed);
+ const before = nameOf(section);
+ const after = next.find(({ id }) => id === section.id);
+ const back = renaming?.back ?? "data-section-title";
+ // The field goes away, which drops its focus; commit first so the
+ // title or the options can take it back.
+ flushSync(() => {
+ setRenaming(null);
+ if (after && nameOf(after) !== before) {
+ save(next);
+ setAnnouncement(`Renamed ${before} to ${nameOf(after)}.`);
+ }
+ });
+ if (refocus) marked(back, section.id)?.focus();
+ };
+ // The section grip's arrow keys move the section one place up or down.
+ const moveSectionWithKey = (
+ section: LayoutSection,
+ event: KeyboardEvent,
+ ) => {
+ const step = SECTION_STEP[event.key];
+ if (step === undefined) return;
+ event.preventDefault();
+ const next = moveSectionBy(layout, section.id, step);
+ const at = next.findIndex(({ id }) => id === section.id);
+ if (at === layout.indexOf(section)) return;
+ const neighbour = step < 0 ? next[at + 1] : next[at - 1];
+ // Moving a section moves its DOM node, which drops focus; commit first
+ // so the grip can take focus back.
+ flushSync(() => {
+ save(next);
+ setAnnouncement(
+ `${nameOf(section)} moved ${step < 0 ? "above" : "below"} ${nameOf(neighbour)}.`,
+ );
+ });
+ marked("data-section-grip", section.id)?.focus();
+ };
+ const remove = (section: LayoutSection) => {
+ const at = layout.indexOf(section);
+ const into = layout[at - 1] ?? layout[at + 1];
+ if (!into || !canRemoveSection(layout, section.id)) return;
+ const moved = section.tiles.some((tileId) => widgets.has(tileId));
+ // The section's options go with it; the focus goes to the section that
+ // took its tiles, or to "Add section" when that one has no options. The
+ // menu, unmounted with the section, queues handing the focus back
+ // somewhere as it goes, so this waits until after it.
+ flushSync(() => {
+ save(removeSection(layout, section.id));
+ setAnnouncement(
+ moved
+ ? `Removed ${nameOf(section)}; its widgets moved to ${nameOf(into)}.`
+ : `Removed ${nameOf(section)}.`,
+ );
+ });
+ queueMicrotask(() =>
+ (
+ marked("data-section-options", into.id) ??
+ root.current?.querySelector("[data-add-section]")
+ )?.focus(),
+ );
+ };
+ const addMenu = (label: string, sectionId: string) => (
+ save(addTile(layout, dashboard, tileId, sectionId))}
+ />
+ );
const renderTile = (tileId: string) => {
const widget = widgets.get(tileId);
if (!widget) return null;
@@ -562,15 +885,15 @@ function SolutionDashboard({
size="icon-xs"
className="cursor-grab text-muted-foreground"
aria-label={`Move ${title}: drag the tile, or use the arrow keys`}
+ data-tile-grip={tileId}
onKeyDown={(event) => {
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();
+ gripOf(tileId)?.focus();
}}
>
@@ -627,40 +950,144 @@ function SolutionDashboard({
};
return (
-
+
{dashboard.title && (
{dashboard.title}
)}
- save(addTile(layout, tileId))}
- />
+ {/* A dashboard with sections has a + in every section instead. */}
+ {sections.length === 0 &&
+ addMenu(
+ `Add a widget back to ${dashboard.title ?? "this dashboard"}`,
+ UNSECTIONED,
+ )}
))}
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/format.ts b/module/services/frontend/code/packages/codefly-ui/src/dashboard/format.ts
index 3b350a6f3..3030ca692 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/format.ts
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/format.ts
@@ -13,6 +13,15 @@ export function formatAxisValue(value: number): string {
return valueFormat.format(value);
}
+// A share from 0 to 1 written as a percentage, to one decimal: 0.4 → "40%",
+// 0.7727 → "77.3%". What a widget whose metric declares `format: "percent"`
+// writes for each value, on the tile, the bars, the table and the y axis.
+const shareFormat = new Intl.NumberFormat(undefined, { style: "percent", maximumFractionDigits: 1 });
+
+export function formatShare(value: number): string {
+ return shareFormat.format(value);
+}
+
// Series keys that are time buckets arrive as ISO timestamps from the audit RPC
// (`YYYY-MM-DDThh:mm:ss±hh`); anything else is a plain category key. Gate on the
// ISO shape so a non-date key ("US East", "42") is never coerced through Date.
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 a4173e416..48f11ac8f 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
@@ -14,7 +14,12 @@ export {
StatChart,
} from "./charts.js";
export { Dashboard } from "./dashboard.js";
-export { formatAxisKey, formatAxisValue, parseTimeKey } from "./format.js";
+export {
+ formatAxisKey,
+ formatAxisValue,
+ formatShare,
+ parseTimeKey,
+} from "./format.js";
export {
linearScale,
niceTicks,
@@ -29,6 +34,7 @@ export {
fromDashboardData,
type SeriesPoint,
type WidgetSeries,
+ type WidgetValueFormat,
type WidgetVisualization,
} from "./types.js";
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..afcc3a349 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
@@ -11,6 +11,12 @@
/** How a widget draws its series. */
export type WidgetVisualization = "line" | "bar" | "area" | "number" | "table";
+/**
+ * How a widget writes its values, from its metric's declared format: `number`
+ * as they are, `percent` a share from 0 to 1 as a percentage (0.4 → "40%").
+ */
+export type WidgetValueFormat = "number" | "percent";
+
/** How a dashboard arranges its widgets. */
export type DashboardLayoutKind = "grid" | "stack";
@@ -33,6 +39,8 @@ export interface DashboardWidgetView {
visualization: WidgetVisualization;
title?: string;
series: WidgetSeries;
+ /** How the widget writes its values; `number` when omitted. */
+ format?: WidgetValueFormat;
/** Grid columns this widget spans (1–4); ignored in a stack. */
span?: 1 | 2 | 3 | 4;
}
@@ -57,6 +65,7 @@ interface ResolvedWidgetLike {
visualization: WidgetVisualization;
title?: string;
series: WidgetSeries;
+ format?: WidgetValueFormat;
}
interface DashboardDataLike {
title?: string;
@@ -84,6 +93,7 @@ export function fromDashboardData(
visualization: widget.visualization,
title: widget.title,
series: widget.series,
+ format: widget.format,
})),
};
}
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 fd4299f28..9cefaf5da 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
@@ -58,6 +58,10 @@
"type": "string",
"pattern": "^[a-z][a-z0-9_]*(?:\\.[a-z0-9_]+)+$"
},
+ "metricValueFormat": {
+ "description": "How a metric's value is written: `number` (the default) as it is, or `percent`, a share from 0 to 1 written times 100 with a percent sign (0.4 reads 40%).",
+ "enum": ["number", "percent"]
+ },
"declaredEventType": {
"description": "A type a solution declares: .., each segment starting with a letter, outside the reserved `saas` namespace.",
"type": "string",
@@ -291,7 +295,8 @@
"type": "number",
"exclusiveMinimum": 0,
"maximum": 1
- }
+ },
+ "format": { "$ref": "#/$defs/metricValueFormat" }
},
"allOf": [
{
@@ -347,7 +352,8 @@
"type": "array",
"minItems": 2,
"items": { "$ref": "#/$defs/logicalId" }
- }
+ },
+ "format": { "$ref": "#/$defs/metricValueFormat" }
},
"if": {
"required": ["operation"],
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 2cdc16f28..d229467c7 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
@@ -55,6 +55,13 @@ export type MetricAggregation =
/** How a derived metric combines the metrics it references. */
export type MetricOperation = "sum" | "ratio" | "difference";
+/**
+ * How a metric's value is written wherever the dashboard shows it. `number`,
+ * the default, writes it as it is. `percent` reads it as a share from 0 to 1
+ * and writes it times 100 with a percent sign, so 0.4 reads "40%".
+ */
+export type MetricValueFormat = "number" | "percent";
+
/** How a widget renders the metric it is bound to. */
export type WidgetVisualization = "line" | "bar" | "area" | "number" | "table";
@@ -145,6 +152,8 @@ export interface SourceMetric {
field?: string;
/** Quantile in (0,1] for `aggregation: "percentile"`; forbidden otherwise. */
percentile?: number;
+ /** How the value is written; `number` when omitted. */
+ format?: MetricValueFormat;
}
/** A metric derived by combining metrics already declared in the same graph. */
@@ -161,6 +170,8 @@ export interface DerivedMetric {
* least two.
*/
inputs: readonly string[];
+ /** How the value is written; `number` when omitted. */
+ format?: MetricValueFormat;
}
export type Metric = SourceMetric | DerivedMetric;
@@ -269,6 +280,7 @@ function isGroupDimension(value: unknown): value is MetricGroupBy {
);
}
const OPERATION: readonly MetricOperation[] = ["sum", "ratio", "difference"];
+const VALUE_FORMAT: readonly MetricValueFormat[] = ["number", "percent"];
const VISUALIZATION: readonly WidgetVisualization[] = [
"line",
"bar",
@@ -433,6 +445,7 @@ function validateSourceMetric(value: Record): void {
"aggregation",
"field",
"percentile",
+ "format",
],
`metric '${String(value.id)}'`,
);
@@ -542,7 +555,7 @@ function validateSourceMetric(value: Record): void {
function validateDerivedMetric(value: Record): void {
assertExactKeys(
value,
- ["id", "kind", "title", "description", "operation", "inputs"],
+ ["id", "kind", "title", "description", "operation", "inputs", "format"],
`metric '${String(value.id)}'`,
);
const context = `metric '${String(value.id)}'`;
@@ -585,6 +598,11 @@ function validateMetric(value: unknown): asserts value is Metric {
);
if (value.kind === "source") validateSourceMetric(value);
else validateDerivedMetric(value);
+ assertGraph(
+ value.format === undefined ||
+ VALUE_FORMAT.includes(value.format as MetricValueFormat),
+ `metric '${String(value.id)}' format '${String(value.format)}' is unsupported`,
+ );
}
function validateWidget(
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 f6c6a8f15..1a367cd7e 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
@@ -44,6 +44,7 @@ export type {
MetricFilter,
MetricGroupBy,
MetricOperation,
+ MetricValueFormat,
MetricWidget,
SourceMetric,
WidgetVisualization,
diff --git a/module/services/frontend/code/packages/saas-plugin-manifest/test/data-graph.test.ts b/module/services/frontend/code/packages/saas-plugin-manifest/test/data-graph.test.ts
index df9fb87c6..c9c3a42d8 100644
--- a/module/services/frontend/code/packages/saas-plugin-manifest/test/data-graph.test.ts
+++ b/module/services/frontend/code/packages/saas-plugin-manifest/test/data-graph.test.ts
@@ -276,6 +276,32 @@ describe("assertDataGraph", () => {
});
});
+describe("metric value format", () => {
+ it("accepts a percent format on a derived and a source metric", () => {
+ expect(() =>
+ assertDataGraph(
+ mutated((g) => {
+ metric(g, 2).format = "percent";
+ metric(g, 0).format = "number";
+ }),
+ ),
+ ).not.toThrow();
+ });
+
+ it("accepts a metric with no format", () => {
+ expect(() => assertDataGraph(graph())).not.toThrow();
+ });
+
+ it.each([
+ ["source", 0],
+ ["derived", 2],
+ ])("rejects an unsupported format on a %s metric", (_kind, index) => {
+ expect(() =>
+ assertDataGraph(mutated((g) => (metric(g, index).format = "currency"))),
+ ).toThrow(/format 'currency' is unsupported/);
+ });
+});
+
describe("dashboard sections", () => {
// The reference graph with its dashboard split into an overview band and an
// activity band, and a third section no widget is in yet.
diff --git a/module/services/frontend/code/packages/saas-plugin-manifest/test/schema-conformance.test.ts b/module/services/frontend/code/packages/saas-plugin-manifest/test/schema-conformance.test.ts
index 0083d6776..a1ab8c17a 100644
--- a/module/services/frontend/code/packages/saas-plugin-manifest/test/schema-conformance.test.ts
+++ b/module/services/frontend/code/packages/saas-plugin-manifest/test/schema-conformance.test.ts
@@ -101,6 +101,12 @@ const OWNED_FIELD_VIOLATIONS: Record<
"metric bucket without a time group_by": (m) => {
arr(rec(m.dashboard).metrics)[0].groupBy = "actor";
},
+ "unsupported source metric format": (m) => {
+ arr(rec(m.dashboard).metrics)[0].format = "currency";
+ },
+ "unsupported derived metric format": (m) => {
+ arr(rec(m.dashboard).metrics)[3].format = "fraction";
+ },
"unsupported derived metric operation": (m) => {
arr(rec(m.dashboard).metrics)[3].operation = "product";
},
@@ -204,6 +210,15 @@ describe("JSON Schema conformance", () => {
expect(() => assertPluginManifest(manifest)).not.toThrow();
});
+ it("accepts metrics that name a value format in both the schema and the validator", () => {
+ const manifest = clone();
+ const metrics = arr(rec(manifest.dashboard).metrics);
+ metrics[0].format = "number";
+ metrics[3].format = "percent";
+ expect(validateWithSchema(manifest)).toBe(true);
+ expect(() => assertPluginManifest(manifest)).not.toThrow();
+ });
+
it.each(Object.keys(OWNED_FIELD_VIOLATIONS))(
"schema and validator agree on rejecting: %s",
(name: string) => {
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..abe4fe981 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,6 +1,7 @@
import type {
DashboardLayout,
DataGraph,
+ MetricValueFormat,
WidgetVisualization,
} from "../schema.js";
import { resolveMetrics } from "./run.js";
@@ -30,6 +31,8 @@ export interface ResolvedWidget {
title?: string;
metricId: string;
series: MetricSeries;
+ /** How the widget's values are written: its metric's declared format. */
+ format?: MetricValueFormat;
}
/**
@@ -84,6 +87,8 @@ export async function runDashboard(
title: widget.title,
metricId: widget.metric,
series,
+ format: graph.metrics.find((metric) => metric.id === widget.metric)
+ ?.format,
};
});
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 ca04c9bda..21fda6450 100644
--- a/module/services/frontend/code/packages/saas-sdk/src/index.ts
+++ b/module/services/frontend/code/packages/saas-sdk/src/index.ts
@@ -84,6 +84,7 @@ export type {
MetricFilter,
MetricGroupBy,
MetricOperation,
+ MetricValueFormat,
MetricWidget,
SourceMetric,
WidgetVisualization,
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 8117661d0..998c00ea0 100644
--- a/module/services/frontend/code/packages/saas-sdk/src/schema.ts
+++ b/module/services/frontend/code/packages/saas-sdk/src/schema.ts
@@ -37,6 +37,13 @@ export type MetricAggregation =
/** How a derived metric combines the metrics it references. */
export type MetricOperation = "sum" | "ratio" | "difference";
+/**
+ * How a metric's value is written wherever the dashboard shows it. `number`,
+ * the default, writes it as it is. `percent` reads it as a share from 0 to 1
+ * and writes it times 100 with a percent sign, so 0.4 reads "40%".
+ */
+export type MetricValueFormat = "number" | "percent";
+
/** How a widget renders the metric it is bound to. */
export type WidgetVisualization = "line" | "bar" | "area" | "number" | "table";
@@ -118,6 +125,8 @@ export interface SourceMetric {
field?: string;
/** Quantile in (0,1] for `aggregation: "percentile"` (0.95 → p95). */
percentile?: number;
+ /** How the value is written; `number` when omitted. */
+ format?: MetricValueFormat;
}
/** A metric derived by combining metrics already declared in the same graph. */
@@ -129,6 +138,8 @@ export interface DerivedMetric {
operation: MetricOperation;
/** Ids of the metrics this one combines (`ratio`/`difference` take two, `sum` at least two). */
inputs: readonly string[];
+ /** How the value is written; `number` when omitted. */
+ format?: MetricValueFormat;
}
export type Metric = SourceMetric | DerivedMetric;
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..6dc91d64e 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
@@ -104,6 +104,23 @@ describe("runDashboard", () => {
expect(data.byMetric.events_over_time.metricId).toBe("events_over_time");
});
+ it("carries each widget's metric format, and none for a metric without one", async () => {
+ const formatted = defineDataGraph({
+ ...graph,
+ metrics: [{ ...graph.metrics[0], format: "percent" }, graph.metrics[1]],
+ });
+ const { client } = fakeAuditClient(() => [
+ { key: "user.signed_in.v1", count: 7 },
+ ]);
+
+ const data = await runDashboard(client, formatted, "activity", context);
+
+ expect(data.widgets.map((widget) => widget.format)).toEqual([
+ "percent",
+ undefined,
+ ]);
+ });
+
it("rejects an unknown dashboard id", async () => {
const { client } = fakeAuditClient(() => []);
await expect(
diff --git a/module/services/frontend/code/src/solutions/SolutionDashboard.tsx b/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
index 6d74e9d66..c96972039 100644
--- a/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
+++ b/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
@@ -17,6 +17,7 @@ import {
import {
AreaChart,
BarList,
+ formatShare,
LineChart,
SortableBoard,
type SortableGroupHandle,
@@ -178,6 +179,10 @@ function withoutValues({
function WidgetValues({ widget }: { widget: ResolvedWidget }) {
const { series, visualization } = widget;
+ // A metric declared as a percent is a share from 0 to 1, written as a
+ // percentage everywhere the tile shows a value; a plain number keeps each
+ // chart's own default.
+ const formatValue = widget.format === "percent" ? formatShare : undefined;
const missing = withoutValues(widget);
if (missing) {
return
{missing.tile}
;
@@ -185,18 +190,32 @@ function WidgetValues({ widget }: { widget: ResolvedWidget }) {
switch (visualization) {
case "line":
return (
-
+
);
case "area":
return (
-
+
);
case "bar":
- return ;
+ return ;
case "number":
// A number with no total has said so above; this only narrows it.
return series.total === null ? null : (
-
+
);
case "table":
return (
@@ -206,7 +225,9 @@ function WidgetValues({ widget }: { widget: ResolvedWidget }) {
))}
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 a5894ce39..8001649ed 100644
--- a/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx
+++ b/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx
@@ -233,6 +233,88 @@ describe("SolutionDashboards", () => {
expect(await screen.findByText("Unable to load.")).toBeTruthy();
});
+ it("writes a metric declared as a percent as a percentage, and a plain ratio as a number", async () => {
+ const rates: DataGraph = {
+ events: [
+ { name: "passed", type: "review.passed.v1" },
+ { name: "completed", type: "review.completed.v1" },
+ ],
+ metrics: [
+ {
+ id: "passed",
+ kind: "source",
+ filter: { event: "passed" },
+ groupBy: "category",
+ aggregation: "count",
+ },
+ {
+ id: "completed",
+ kind: "source",
+ filter: { event: "completed" },
+ groupBy: "category",
+ aggregation: "count",
+ },
+ {
+ id: "pass_rate",
+ kind: "derived",
+ operation: "ratio",
+ inputs: ["passed", "completed"],
+ format: "percent",
+ },
+ {
+ id: "completed_per_pass",
+ kind: "derived",
+ operation: "ratio",
+ inputs: ["completed", "passed"],
+ },
+ ],
+ dashboards: [
+ {
+ id: "activity",
+ title: "Activity",
+ layout: "grid",
+ widgets: [
+ {
+ id: "w_rate",
+ metric: "pass_rate",
+ visualization: "number",
+ title: "Pass rate",
+ },
+ {
+ id: "w_per_pass",
+ metric: "completed_per_pass",
+ visualization: "number",
+ title: "Completed per pass",
+ },
+ ],
+ },
+ ],
+ };
+ server.use(
+ http.post(
+ rpc("AuditService", "AggregateAuditLog"),
+ async ({ request }) => {
+ const body = (await request.json()) as { eventType?: string };
+ const count = body.eventType === "review.passed.v1" ? "4" : "10";
+ return HttpResponse.json({
+ buckets: [{ key: "lifecycle", count }],
+ });
+ },
+ ),
+ );
+
+ renderDashboards(rates);
+
+ // 4 passed of 10 completed.
+ expect(await within(tile("Pass rate")).findByText("40%")).toBeTruthy();
+ expect(
+ await within(tile("Completed per pass")).findByText(
+ (2.5).toLocaleString(),
+ ),
+ ).toBeTruthy();
+ expect(screen.queryByText("0.4")).toBeNull();
+ });
+
it("reads as loading until the org context resolves, never as empty", () => {
authState.organizationId = undefined;
diff --git a/module/services/frontend/code/src/solutions/__tests__/registry.test.ts b/module/services/frontend/code/src/solutions/__tests__/registry.test.ts
index cbc26a348..a04311423 100644
--- a/module/services/frontend/code/src/solutions/__tests__/registry.test.ts
+++ b/module/services/frontend/code/src/solutions/__tests__/registry.test.ts
@@ -202,6 +202,23 @@ describe("parseManifest dashboard slot", () => {
expect(parseManifest(baseManifest({ dashboard: misplaced }))).toBeNull();
});
+ it("carries a metric's value format through", () => {
+ const formatted = {
+ ...validGraph,
+ metrics: [{ ...validGraph.metrics[0], format: "percent" }],
+ };
+ const parsed = parseManifest(baseManifest({ dashboard: formatted }));
+ expect(parsed?.dashboard?.metrics[0]?.format).toBe("percent");
+ });
+
+ it("rejects the registration when a metric names an unsupported format", () => {
+ const misformatted = {
+ ...validGraph,
+ metrics: [{ ...validGraph.metrics[0], format: "currency" }],
+ };
+ expect(parseManifest(baseManifest({ dashboard: misformatted }))).toBeNull();
+ });
+
it("rejects the whole registration when the dashboard graph is malformed", () => {
// A widget bound to a metric the graph never declares fails referential
// integrity; the registration is refused rather than stored without it.
From d92434e4dfa60f2f379b1c8e9343e4bdaace53bb Mon Sep 17 00:00:00 2001
From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com>
Date: Tue, 29 Sep 2026 13:47:54 -0400
Subject: [PATCH 06/29] fix(sdk): put a 0 on each day of a per-day count that
had no events
A per-day line chart dropped every day with no events and drew a straight
line over it. The audit RPC returns no bucket for a day with no rows, and the
kit's line chart spaces its points evenly by position, not by date. So ten
closes on ten days across a 24-day span drew as a flat line at 1, which reads
as one close a day, and the x axis compressed the empty days away.
runMetric now puts a 0 at every day between the first and the last day the
RPC returned, for an aggregation that is 0 over no events: count,
count_distinct and sum. Week and month buckets step by a week and a month.
With every bucket present, the even spacing is spacing by date.
- avg, min, max and percentile have no value over no events, so a bucket
with none stays out, as before.
- A bucket the RPC did return, whose value was dropped as unknown (events
that day, none carrying the field), stays out and keeps the series
partial. It is not a day with no events.
- A key that does not read as a date, or one off the bucket's step from the
first, leaves the points as they came.
- Totals do not change: the zeros add nothing.
- Only the span between the first and the last returned bucket is filled,
never the days before or after it.
Co-Authored-By: Claude Opus 5.5
---
.../packages/saas-sdk/src/datagraph/run.ts | 100 ++++++++++-
.../code/packages/saas-sdk/test/run.test.ts | 155 ++++++++++++++++++
2 files changed, 254 insertions(+), 1 deletion(-)
diff --git a/module/services/frontend/code/packages/saas-sdk/src/datagraph/run.ts b/module/services/frontend/code/packages/saas-sdk/src/datagraph/run.ts
index 57ac7f178..3f339f0a0 100644
--- a/module/services/frontend/code/packages/saas-sdk/src/datagraph/run.ts
+++ b/module/services/frontend/code/packages/saas-sdk/src/datagraph/run.ts
@@ -2,6 +2,7 @@ import type {
DataGraph,
DerivedMetric,
Metric,
+ MetricAggregation,
MetricBucket,
MetricGroupBy,
SourceMetric,
@@ -72,6 +73,96 @@ function toSeries(
};
}
+// The aggregations whose value over no events is 0. An avg, min, max or
+// percentile has no value without events, so a bucket with none stays out.
+const ZERO_OVER_NO_EVENTS: readonly MetricAggregation[] = [
+ "count",
+ "count_distinct",
+ "sum",
+];
+
+// A time bucket's key: the date the server truncated to, then whatever follows
+// it ("T00:00:00+00"), which a filled bucket copies so its key reads the same.
+const TIME_KEY = /^(\d{4})-(\d{2})-(\d{2})(.*)$/;
+
+// A filled series stops growing here: years of days is a few thousand
+// buckets, and two keys far apart must not balloon one series.
+const MAX_FILLED_BUCKETS = 100_000;
+
+function bucketDate(key: string): { date: Date; rest: string } | null {
+ const match = TIME_KEY.exec(key);
+ if (!match) return null;
+ const [, year, month, day, rest] = match;
+ const date = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
+ // Date.UTC rolls an impossible date over (Feb 30 is Mar 2), so a date that
+ // does not read back the same is not one.
+ return Number.isNaN(date.getTime()) ||
+ date.toISOString().slice(0, 10) !== `${year}-${month}-${day}`
+ ? null
+ : { date, rest };
+}
+
+function nextBucket(date: Date, bucket: MetricBucket): Date {
+ const year = date.getUTCFullYear();
+ const month = date.getUTCMonth();
+ const day = date.getUTCDate();
+ switch (bucket) {
+ case "week":
+ return new Date(Date.UTC(year, month, day + 7));
+ case "month":
+ return new Date(Date.UTC(year, month + 1, day));
+ default:
+ return new Date(Date.UTC(year, month, day + 1));
+ }
+}
+
+/**
+ * The audit RPC returns no bucket for a time bucket with no events, and a
+ * chart spaces its points evenly, so a day with none vanished and the line ran
+ * straight over it. This puts a 0 at every bucket between the first and the
+ * last one the RPC returned that it did not return. A bucket it did return,
+ * whose value was dropped as unknown, stays out: that is not a bucket with no
+ * events. A key that does not read as a date, or one off the bucket's step
+ * from the first, leaves the points as they are.
+ */
+export function fillEmptyBuckets(
+ points: readonly MetricPoint[],
+ returnedKeys: readonly string[],
+ bucket: MetricBucket,
+): MetricPoint[] {
+ const unchanged = [...points];
+ const returned = returnedKeys.map(bucketDate);
+ if (returned.length === 0 || returned.includes(null)) return unchanged;
+ const parsed = returned as { date: Date; rest: string }[];
+ const day = (date: Date) => date.toISOString().slice(0, 10);
+ const seen = new Set(parsed.map(({ date }) => day(date)));
+ const times = parsed.map(({ date }) => date.getTime());
+ const first = parsed[times.indexOf(Math.min(...times))];
+ const last = Math.max(...times);
+
+ const grid: string[] = [];
+ for (let at = first.date; at.getTime() <= last; at = nextBucket(at, bucket)) {
+ if (grid.length === MAX_FILLED_BUCKETS) return unchanged;
+ grid.push(day(at));
+ }
+ const onGrid = new Set(grid);
+ if ([...seen].some((date) => !onGrid.has(date))) return unchanged;
+
+ const valueAt = new Map();
+ for (const point of points) {
+ const at = bucketDate(point.key);
+ if (!at) return unchanged;
+ valueAt.set(day(at.date), point);
+ }
+ if (valueAt.size !== points.length) return unchanged;
+
+ return grid.flatMap((date): MetricPoint[] => {
+ const point = valueAt.get(date);
+ if (point) return [point];
+ return seen.has(date) ? [] : [{ key: `${date}${first.rest}`, value: 0 }];
+ });
+}
+
/** Resolve a single source metric against the audit RPC. */
export async function runMetric(
client: AuditAggregateClient,
@@ -110,7 +201,14 @@ export async function runMetric(
}
return toSeries(
metric.id,
- points,
+ metric.groupBy === "time" &&
+ ZERO_OVER_NO_EVENTS.includes(metric.aggregation)
+ ? fillEmptyBuckets(
+ points,
+ response.buckets.map((bucket) => bucket.key),
+ metric.bucket ?? "day",
+ )
+ : points,
metric.groupBy,
metric.bucket,
metric.aggregation === "count" || metric.aggregation === "sum",
diff --git a/module/services/frontend/code/packages/saas-sdk/test/run.test.ts b/module/services/frontend/code/packages/saas-sdk/test/run.test.ts
index cc7381b36..76c9fff5b 100644
--- a/module/services/frontend/code/packages/saas-sdk/test/run.test.ts
+++ b/module/services/frontend/code/packages/saas-sdk/test/run.test.ts
@@ -704,3 +704,158 @@ describe("derived additive totals", () => {
});
}
});
+
+// The audit RPC returns no bucket for a day with no events. Plotted as it came
+// back, a day with none vanished and the line ran straight over it.
+describe("a time series with days that have no events", () => {
+ const day = (date: string) => `${date}T00:00:00+00`;
+ const perDay = (
+ aggregation: SourceMetric["aggregation"],
+ bucket: SourceMetric["bucket"] = "day",
+ ): SourceMetric => ({
+ id: "per_day",
+ kind: "source",
+ filter: { event: "signed_in" },
+ groupBy: "time",
+ bucket,
+ aggregation,
+ ...(aggregation === "count" ? {} : { field: "payload:amount" }),
+ ...(aggregation === "percentile" ? { percentile: 0.5 } : {}),
+ });
+
+ it("puts a 0 at each day between the first and the last with none", async () => {
+ const { client } = fakeAuditClient(() => [
+ { key: day("2026-09-02"), count: 1 },
+ { key: day("2026-09-05"), count: 1 },
+ { key: day("2026-09-06"), count: 2 },
+ ]);
+
+ const series = await runMetric(client, perDay("count"), (n) => n, context);
+
+ expect(series.points).toEqual([
+ { key: day("2026-09-02"), value: 1 },
+ { key: day("2026-09-03"), value: 0 },
+ { key: day("2026-09-04"), value: 0 },
+ { key: day("2026-09-05"), value: 1 },
+ { key: day("2026-09-06"), value: 2 },
+ ]);
+ expect(series.total).toBe(4);
+ expect(series.coverage).toBe("complete");
+ });
+
+ it.each(["count_distinct", "sum"] as const)(
+ "fills a %s, which is 0 over no events",
+ async (aggregation) => {
+ const { client } = fakeAuditClient(() => [
+ { key: day("2026-09-02"), count: 3, metrics: { value: 2 } },
+ { key: day("2026-09-04"), count: 1, metrics: { value: 1 } },
+ ]);
+
+ const series = await runMetric(
+ client,
+ perDay(aggregation),
+ (n) => n,
+ context,
+ );
+
+ expect(series.points.map((point) => point.value)).toEqual([2, 0, 1]);
+ },
+ );
+
+ it.each(["avg", "min", "max", "percentile"] as const)(
+ "leaves out a day with no events for a %s, which has no value then",
+ async (aggregation) => {
+ const { client } = fakeAuditClient(() => [
+ { key: day("2026-09-02"), count: 3, metrics: { value: 2 } },
+ { key: day("2026-09-04"), count: 1, metrics: { value: 1 } },
+ ]);
+
+ const series = await runMetric(
+ client,
+ perDay(aggregation),
+ (n) => n,
+ context,
+ );
+
+ expect(series.points.map((point) => point.key)).toEqual([
+ day("2026-09-02"),
+ day("2026-09-04"),
+ ]);
+ },
+ );
+
+ it("does not put a 0 where the RPC returned a day whose value is unknown", async () => {
+ const { client } = fakeAuditClient(() => [
+ { key: day("2026-09-02"), count: 3, metrics: { value: 2 } },
+ // Events that day, none with the field: the RPC omits the value.
+ { key: day("2026-09-03"), count: 2, samples: { value: BigInt(0) } },
+ { key: day("2026-09-05"), count: 1, metrics: { value: 1 } },
+ ]);
+
+ const series = await runMetric(client, perDay("sum"), (n) => n, context);
+
+ expect(series.points).toEqual([
+ { key: day("2026-09-02"), value: 2 },
+ { key: day("2026-09-04"), value: 0 },
+ { key: day("2026-09-05"), value: 1 },
+ ]);
+ expect(series.coverage).toBe("partial");
+ });
+
+ it("steps a week and a month bucket by a week and a month", async () => {
+ const weeks = await runMetric(
+ fakeAuditClient(() => [
+ { key: day("2026-08-31"), count: 1 },
+ { key: day("2026-09-14"), count: 1 },
+ ]).client,
+ perDay("count", "week"),
+ (n) => n,
+ context,
+ );
+ const months = await runMetric(
+ fakeAuditClient(() => [
+ { key: day("2026-01-01"), count: 1 },
+ { key: day("2026-04-01"), count: 1 },
+ ]).client,
+ perDay("count", "month"),
+ (n) => n,
+ context,
+ );
+
+ expect(weeks.points.map((point) => point.key)).toEqual([
+ day("2026-08-31"),
+ day("2026-09-07"),
+ day("2026-09-14"),
+ ]);
+ expect(months.points.map((point) => [point.key, point.value])).toEqual([
+ [day("2026-01-01"), 1],
+ [day("2026-02-01"), 0],
+ [day("2026-03-01"), 0],
+ [day("2026-04-01"), 1],
+ ]);
+ });
+
+ it("leaves the points as they are when a key is off the bucket's step or not a date", async () => {
+ const offStep = await runMetric(
+ fakeAuditClient(() => [
+ { key: day("2026-08-31"), count: 1 },
+ { key: day("2026-09-10"), count: 1 },
+ ]).client,
+ perDay("count", "week"),
+ (n) => n,
+ context,
+ );
+ const notADate = await runMetric(
+ fakeAuditClient(() => [
+ { key: day("2026-09-02"), count: 1 },
+ { key: "2026-02-30", count: 1 },
+ ]).client,
+ perDay("count"),
+ (n) => n,
+ context,
+ );
+
+ expect(offStep.points).toHaveLength(2);
+ expect(notADate.points).toHaveLength(2);
+ });
+});
From 3c7a90cfacf4bc75062962643533f03064c907dd Mon Sep 17 00:00:00 2001
From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com>
Date: Tue, 29 Sep 2026 17:26:03 -0400
Subject: [PATCH 07/29] feat(frontend): make a chart tile two rows tall, so two
numbers stack beside it
A section's grid lays tiles out in rows, and a row is as tall as its tallest
tile. A number tile is about 120px and a line chart about 282px, so a number
beside a chart left some 160px empty under it.
A line, area, bar or table tile now spans two rows of its section's grid, and
every card fills its cell. Two number tiles stack beside a chart, and the grid
works out the row heights from what the tiles hold, so no height is fixed. The
tiles keep the order the viewer gave them: the grid does not pack later tiles
into earlier holes. So a section with an odd number of cells still ends on a
half-empty row.
The kit's SortableBoard gains an optional `itemClassName(id)`: classes for one
tile's list item, which is its group's grid item. It knows nothing about
charts; the host says which tiles are tall.
Measured on a local host at 1440px wide: before, the page had a gap under
every number tile beside a chart or bar list; after, no tile is shorter than
the space it sits in, and the only empty cells are the last cell of a section
with an odd cell count.
Co-Authored-By: Claude Opus 5.5
---
.../__tests__/sortable-grid.test.tsx | 14 +++++++++++++
.../src/dashboard/sortable-grid.tsx | 10 ++++++++++
.../code/src/solutions/SolutionDashboard.tsx | 20 ++++++++++++++++++-
.../__tests__/SolutionDashboard.test.tsx | 9 +++++++++
4 files changed, 52 insertions(+), 1 deletion(-)
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 d44466e6a..0e108dd49 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
@@ -110,6 +110,20 @@ describe("SortableGrid", () => {
expect(order()).toEqual(["a", "b", "c", "d"]);
});
+ it("puts a tile's own classes on its list item, the grid item", () => {
+ render(
+ (id === "a" ? "row-span-2" : undefined)}
+ renderItem={(id) => Tile {id}}
+ />,
+ );
+ expect(tile("a").classList).toContain("row-span-2");
+ expect(tile("a").classList).toContain("cursor-grab");
+ expect(tile("b").classList).not.toContain("row-span-2");
+ });
+
it("swaps a tile dropped on another, and moves no other tile meanwhile", () => {
const { onSwap } = renderGrid();
// a to d is diagonal on the grid: b and c sit between them in the order.
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 c4ee2a8cf..8379da383 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
@@ -139,6 +139,11 @@ export type SortableBoardProps = {
renderOverlay?: (id: string) => ReactNode;
/** A tile's name in what a screen reader announces during a drag. Defaults to its id. */
itemLabel?: (id: string) => string;
+ /**
+ * Classes for one tile's list item, which is its group's grid item: for
+ * instance `row-span-2` for a tile about twice as tall as the others.
+ */
+ itemClassName?: (id: string) => string | undefined;
/** Classes for the board, which lay the groups out (for example `space-y-6`). */
className?: string;
};
@@ -389,11 +394,13 @@ function SortableTile({
id,
group,
sorting,
+ className,
children,
}: {
id: string;
group: string;
sorting: boolean;
+ className?: string;
children: ReactNode;
}) {
// The board animates order changes itself (see GlidingBoard), so dnd-kit's
@@ -432,6 +439,7 @@ function SortableTile({
className={cn(
"cursor-grab touch-manipulation",
isDragging && "opacity-40",
+ className,
)}
// A CSS transition outranks the glide animation, so one is set only
// while a drag is sliding tiles around.
@@ -557,6 +565,7 @@ export function SortableBoard({
renderItem,
renderOverlay = renderItem,
itemLabel = (id) => id,
+ itemClassName,
className,
}: SortableBoardProps) {
// A stable id keeps the ids dnd-kit writes into the page the same on the
@@ -735,6 +744,7 @@ export function SortableBoard({
id={id}
group={group.id}
sorting={dragged?.kind === "tile"}
+ className={itemClassName?.(id)}
>
{renderItem(id)}
diff --git a/module/services/frontend/code/src/solutions/SolutionDashboard.tsx b/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
index c96972039..53c7c84e3 100644
--- a/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
+++ b/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
@@ -4,6 +4,7 @@ import type {
Dashboard,
DataGraph,
MetricWidget,
+ WidgetVisualization,
} from "@codefly/saas-plugin-manifest";
import {
createSaasClient,
@@ -105,6 +106,17 @@ import {
// the DataGraph — it can never widen a query past the viewer's own org.
const sdk = createSaasClient(apiTransport);
+// The tiles about twice as tall as a number: a chart with axes, a bar list or a
+// table. In a grid, each spans two rows, so two numbers stack beside it rather
+// than one number leaving the rest of the row empty; the grid sizes the rows,
+// and every card fills its cell.
+const TALL: ReadonlySet = new Set([
+ "line",
+ "area",
+ "bar",
+ "table",
+]);
+
// Dashboard id of the throwaway single-widget graph each card resolves. The card
// resolves its own widget in isolation, so this id is never surfaced.
const SINGLE_WIDGET_DASHBOARD = "widget";
@@ -309,7 +321,7 @@ function WidgetCard({
);
return (
-
+
{grip}
@@ -1176,6 +1188,12 @@ function SolutionDashboard({
save(moveSection(layout, sectionId, index))
}
itemLabel={titleOf}
+ itemClassName={(tileId) => {
+ const widget = widgets.get(tileId);
+ return widget && TALL.has(widget.visualization)
+ ? "row-span-2"
+ : undefined;
+ }}
renderItem={renderTile}
renderOverlay={renderDragged}
/>
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 8001649ed..ad4610dba 100644
--- a/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx
+++ b/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx
@@ -315,6 +315,15 @@ describe("SolutionDashboards", () => {
expect(screen.queryByText("0.4")).toBeNull();
});
+ it("makes a chart or a bar list two rows tall, so two numbers stack beside it", () => {
+ renderDashboards();
+
+ // A tile is its list item, the grid item the span applies to.
+ expect(tile("Logins over time").classList).toContain("row-span-2");
+ expect(tile("Top event types").classList).toContain("row-span-2");
+ expect(tile("Total logins").classList).not.toContain("row-span-2");
+ });
+
it("reads as loading until the org context resolves, never as empty", () => {
authState.organizationId = undefined;
From ccf0978921ec35722d1b89abfb1f700b4ca0e0fe Mon Sep 17 00:00:00 2001
From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com>
Date: Fri, 2 Oct 2026 14:50:03 -0400
Subject: [PATCH 08/29] fix(sdk): keep a derived metric complete over a day no
input had events on
The per-day zeros reached derived metrics. A ratio of two per-day counts
with a day on which neither had events read 0/0 there and marked the whole
series partial, so its tile said "Partial telemetry" where it said nothing
before the zeros. A derived metric over a count and an avg read the avg as
missing on the count's filled day and did the same.
Before the zeros no input returned such a day, so it was no missing
telemetry. runMetric now marks the zeros it puts in, and combineDerived
treats a key where every operand is one of them, or missing, as a key with
no events: a ratio there has no value and does not mark the series
partial, and a sum or difference of the zeros is 0, marked the same way.
A key where any input returned a bucket is combined as before, so a day
with events in the denominator and none in the numerator reads 0.
Co-Authored-By: Claude Opus 5.5
---
.../packages/saas-sdk/src/datagraph/run.ts | 37 ++++++++---
.../code/packages/saas-sdk/test/run.test.ts | 66 +++++++++++++++++++
2 files changed, 93 insertions(+), 10 deletions(-)
diff --git a/module/services/frontend/code/packages/saas-sdk/src/datagraph/run.ts b/module/services/frontend/code/packages/saas-sdk/src/datagraph/run.ts
index 3f339f0a0..133c2aebc 100644
--- a/module/services/frontend/code/packages/saas-sdk/src/datagraph/run.ts
+++ b/module/services/frontend/code/packages/saas-sdk/src/datagraph/run.ts
@@ -89,6 +89,10 @@ const TIME_KEY = /^(\d{4})-(\d{2})-(\d{2})(.*)$/;
// buckets, and two keys far apart must not balloon one series.
const MAX_FILLED_BUCKETS = 100_000;
+// The zeros put on buckets the RPC returned none for: no event matched there.
+// A derived metric tells them apart from a returned bucket by this mark.
+const noEvents = new WeakSet();
+
function bucketDate(key: string): { date: Date; rest: string } | null {
const match = TIME_KEY.exec(key);
if (!match) return null;
@@ -159,7 +163,10 @@ export function fillEmptyBuckets(
return grid.flatMap((date): MetricPoint[] => {
const point = valueAt.get(date);
if (point) return [point];
- return seen.has(date) ? [] : [{ key: `${date}${first.rest}`, value: 0 }];
+ if (seen.has(date)) return [];
+ const zero = { key: `${date}${first.rest}`, value: 0 };
+ noEvents.add(zero);
+ return [zero];
});
}
@@ -218,9 +225,13 @@ export async function runMetric(
// Combine the resolved series of a derived metric's inputs. Inputs are aligned
// on the union of their point keys (first-seen order). Missing operands
-// remain unknown and are never substituted with zero. Combining series grouped by different dimensions is
-// meaningless — the keyspaces don't line up — so it is rejected rather than
-// silently producing a series of stray values.
+// remain unknown and are never substituted with zero. A key no input has an
+// event on (each operand there is a filled zero or missing) is not missing
+// telemetry: before the zeros, no input returned it. Its zeros still sum, to a
+// filled zero in turn, and a ratio of them has no value. Combining series
+// grouped by different dimensions is meaningless — the keyspaces don't line
+// up — so it is rejected rather than silently producing a series of stray
+// values.
function combineDerived(
metric: DerivedMetric,
inputs: MetricSeries[],
@@ -260,14 +271,18 @@ function combineDerived(
}
}
}
- const valueAt = (input: MetricSeries, key: string): number | undefined =>
- input.points.find((point) => point.key === key)?.value;
+ const pointAt = (input: MetricSeries, key: string): MetricPoint | undefined =>
+ input.points.find((point) => point.key === key);
let partial = inputs.some((input) => input.coverage === "partial");
const points: MetricPoint[] = [];
for (const key of keys) {
- const values = inputs.map((input) => valueAt(input, key));
+ const operands = inputs.map((input) => pointAt(input, key));
+ const empty = operands.every(
+ (point) => point === undefined || noEvents.has(point),
+ );
+ const values = operands.map((point) => point?.value);
if (values.some((value) => value === undefined)) {
- partial = true;
+ if (!empty) partial = true;
continue;
}
const present = values as number[];
@@ -281,7 +296,7 @@ function combineDerived(
break;
case "ratio":
if (present[1] === 0) {
- partial = true;
+ if (!empty) partial = true;
continue;
}
value = present[0] / present[1];
@@ -295,7 +310,9 @@ function combineDerived(
partial = true;
continue;
}
- points.push({ key, value });
+ const point = { key, value };
+ if (empty) noEvents.add(point);
+ points.push(point);
}
return toSeries(
diff --git a/module/services/frontend/code/packages/saas-sdk/test/run.test.ts b/module/services/frontend/code/packages/saas-sdk/test/run.test.ts
index 76c9fff5b..53ac17a00 100644
--- a/module/services/frontend/code/packages/saas-sdk/test/run.test.ts
+++ b/module/services/frontend/code/packages/saas-sdk/test/run.test.ts
@@ -858,4 +858,70 @@ describe("a time series with days that have no events", () => {
expect(offStep.points).toHaveLength(2);
expect(notADate.points).toHaveLength(2);
});
+
+ // Before the zeros, a day no input had events on was a key no input
+ // returned, so a derived metric had nothing there to combine or miss.
+ it("does not call a derived metric partial over a day no input had events on", async () => {
+ const byActor = (
+ id: string,
+ aggregation: SourceMetric["aggregation"],
+ ): SourceMetric => ({
+ ...perDay(aggregation),
+ id,
+ filter: { event: "signed_in", actor: id },
+ });
+ const graph: DataGraph = {
+ events: [{ name: "signed_in", type: "user.signed_in.v1" }],
+ metrics: [
+ byActor("won", "count"),
+ byActor("all", "count"),
+ byActor("typical", "avg"),
+ {
+ id: "rate",
+ kind: "derived",
+ operation: "ratio",
+ inputs: ["won", "all"],
+ },
+ {
+ id: "spread",
+ kind: "derived",
+ operation: "difference",
+ inputs: ["all", "typical"],
+ },
+ ],
+ dashboards: [],
+ };
+ // No events at all on 09-04; on 09-03 none won.
+ const { client } = fakeAuditClient((request) => {
+ if (request.actorId === "won")
+ return [
+ { key: day("2026-09-02"), count: 1 },
+ { key: day("2026-09-05"), count: 1 },
+ ];
+ if (request.actorId === "all")
+ return [
+ { key: day("2026-09-02"), count: 2 },
+ { key: day("2026-09-03"), count: 4 },
+ { key: day("2026-09-05"), count: 4 },
+ ];
+ return [
+ { key: day("2026-09-02"), count: 2, metrics: { value: 2 } },
+ { key: day("2026-09-03"), count: 4, metrics: { value: 3 } },
+ { key: day("2026-09-05"), count: 4, metrics: { value: 1 } },
+ ];
+ });
+
+ const resolved = await runDataGraph(client, graph, context);
+
+ expect(resolved.rate.points).toEqual([
+ { key: day("2026-09-02"), value: 0.5 },
+ { key: day("2026-09-03"), value: 0 },
+ { key: day("2026-09-05"), value: 0.25 },
+ ]);
+ expect(resolved.rate.coverage).toBe("complete");
+ expect(resolved.spread.points.map((point) => point.value)).toEqual([
+ 0, 1, 3,
+ ]);
+ expect(resolved.spread.coverage).toBe("complete");
+ });
});
From 0e84d235dbfb86569f1c270c5b02979e5de41576 Mon Sep 17 00:00:00 2001
From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com>
Date: Fri, 2 Oct 2026 14:59:22 -0400
Subject: [PATCH 09/29] fix(frontend): save nothing for an arrow key that moves
no tile, and say where one crossed into another section
The arrow keys on a tile's grip saved the layout on every press, even one
that could not move the tile (up on the first tile, down on the last). A
save pins the viewer to their own layout, so a press that changed nothing
stopped the dashboard following its declared default. The grip now saves
only when the tile's section or place changes, as the section grip and
rename already do.
A press that took a tile into the next or previous section said nothing to
a screen reader, though a section move and a drag both do. It now says
" moved to the start of ." going down, or "to the end of"
going up.
Co-Authored-By: Claude Opus 5.5
---
.../code/src/solutions/SolutionDashboard.tsx | 30 ++++++++++++++++++-
.../__tests__/SolutionDashboard.test.tsx | 29 ++++++++++++++++++
2 files changed, 58 insertions(+), 1 deletion(-)
diff --git a/module/services/frontend/code/src/solutions/SolutionDashboard.tsx b/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
index 53c7c84e3..dcc52d390 100644
--- a/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
+++ b/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
@@ -416,6 +416,15 @@ const SECTION_STEP: Partial> = {
ArrowDown: 1,
};
+// The section a tile is in, and its place there.
+function placeOf(
+ layout: Layout,
+ tileId: string,
+): { section: LayoutSection; index: number } | undefined {
+ const section = layout.find(({ tiles }) => tiles.includes(tileId));
+ return section && { section, index: section.tiles.indexOf(tileId) };
+}
+
// How many of the newest events the ⓘ lists.
const RECENT_EVENTS = 5;
@@ -1007,9 +1016,28 @@ function SolutionDashboard({
const step = ARROW_STEP[event.key];
if (step === undefined) return;
event.preventDefault();
+ const next = moveBy(layout, tileId, step);
+ const from = placeOf(layout, tileId);
+ const to = placeOf(next, tileId);
+ // Past either end of the dashboard the tile stays, and a
+ // key that moves nothing saves nothing.
+ if (
+ !from ||
+ !to ||
+ (to.section.id === from.section.id && to.index === from.index)
+ ) {
+ return;
+ }
// 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)));
+ flushSync(() => {
+ save(next);
+ if (to.section.id !== from.section.id) {
+ setAnnouncement(
+ `${title} moved to the ${step < 0 ? "end" : "start"} of ${nameOf(to.section)}.`,
+ );
+ }
+ });
gripOf(tileId)?.focus();
}}
>
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 ad4610dba..a63d019e3 100644
--- a/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx
+++ b/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx
@@ -680,6 +680,18 @@ describe("a viewer's layout", () => {
expect(tileTitles()).toEqual(DEFAULT_ORDER);
});
+ // A save pins the viewer to their own layout, so a key that moves nothing
+ // must not write one.
+ it("saves nothing when the arrow keys cannot move the tile", () => {
+ renderDashboards();
+ const grip = screen.getByRole("button", {
+ name: "Move Logins over time: drag the tile, or use the arrow keys",
+ });
+ fireEvent.keyDown(grip, { key: "ArrowUp" });
+ expect(tileTitles()).toEqual(DEFAULT_ORDER);
+ expect(window.localStorage.getItem(LAYOUT_KEY)).toBeNull();
+ });
+
it("keeps each viewer's layout to themselves", () => {
const { unmount } = renderDashboards();
fireEvent.click(
@@ -1110,6 +1122,23 @@ describe("a dashboard in sections", () => {
expect(document.activeElement).toBe(grip());
});
+ it("says where the arrow keys moved a tile into another section", () => {
+ renderDashboards(sectioned);
+ const grip = () =>
+ screen.getByRole("button", {
+ name: "Move Total logins: drag the tile, or use the arrow keys",
+ });
+ fireEvent.keyDown(grip(), { key: "ArrowDown" });
+ expect(
+ screen.getByText("Total logins moved to the start of Trends."),
+ ).toBeTruthy();
+
+ fireEvent.keyDown(grip(), { key: "ArrowUp" });
+ expect(
+ screen.getByText("Total logins moved to the end of Overview."),
+ ).toBeTruthy();
+ });
+
it("reads a layout saved before sections, each tile in its declared section", () => {
window.localStorage.setItem(
LAYOUT_KEY,
From ec17e365d4fcfac7330eed123e27860f4d641e6b Mon Sep 17 00:00:00 2001
From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com>
Date: Mon, 5 Oct 2026 14:36:30 -0400
Subject: [PATCH 10/29] fix(frontend): draw a solution dashboard with the
metric charts the audit log uses
The kit carried two chart sets. The audit log draws with the metric charts
(KPIRow and StatTile, MetricAreaChart): colour from the --chart palette, a
hover readout, a legend, days lined up by label. A declared dashboard, in the
host's solution page and in the kit's own Dashboard, still drew with the first
set (StatChart, LineChart, AreaChart), so the two pages looked unrelated.
WidgetChart draws one resolved widget with the metric charts, and both the kit
Dashboard and the host's solution page draw a tile's values with it.
MetricFigure is StatTile's value, delta and sparkline without the card, for a
tile that already has its own header. BarList takes the audit log's look, and
the audit log draws its top lists with it instead of a host copy. The old
LineChart, AreaChart and StatChart stay exported, marked deprecated.
A percent metric is a share from 0 to 1, and the figure's `percent` expects 45
for 45%, so WidgetChart scales the share once.
Co-Authored-By: Claude Opus 5.5
---
.../codefly-ui/src/dashboard/charts.tsx | 37 ++++---
.../codefly-ui/src/dashboard/dashboard.tsx | 40 +-------
.../codefly-ui/src/dashboard/index.ts | 2 +
.../codefly-ui/src/dashboard/metric-tiles.tsx | 50 +++++++---
.../codefly-ui/src/dashboard/widget-chart.tsx | 99 +++++++++++++++++++
.../code/src/components/dashboard.tsx | 30 ++----
.../code/src/solutions/SolutionDashboard.tsx | 74 ++------------
.../__tests__/SolutionDashboard.test.tsx | 8 +-
.../services/frontend/code/ui-inventory.json | 26 ++++-
9 files changed, 204 insertions(+), 162 deletions(-)
create mode 100644 module/services/frontend/code/packages/codefly-ui/src/dashboard/widget-chart.tsx
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/charts.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/charts.tsx
index fdd60fd96..dca2af327 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/charts.tsx
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/charts.tsx
@@ -102,17 +102,21 @@ function cartesian({
);
}
+/** @deprecated A dashboard draws a line with `MetricLineChart` (or `WidgetChart` for a resolved widget). */
export function LineChart({ points, className, height = VIEW_H, axes, formatValue }: ChartProps) {
return cartesian({ points, className, height, axes, formatValue, fill: false, label: "line chart" });
}
+/** @deprecated A dashboard draws an area with `MetricAreaChart` (or `WidgetChart` for a resolved widget). */
export function AreaChart({ points, className, height = VIEW_H, axes, formatValue }: ChartProps) {
return cartesian({ points, className, height, axes, formatValue, fill: true, label: "area chart" });
}
// A ranked horizontal bar list — the categorical counterpart to the line/area
-// charts. Widths are relative to the largest value; labels are the group keys.
-// `format` writes a key and `formatValue` a value.
+// charts, and the one ranked list every dashboard draws (the audit log's top
+// event types and actors, a declared widget's groups). Widths are relative to
+// the largest value; labels are the group keys. `format` writes a key and
+// `formatValue` a value.
export function BarList({
points,
className,
@@ -127,20 +131,24 @@ export function BarList({
const max = Math.max(1, ...points.map((p) => p.value));
return (
- {points.map((p) => (
-
-
- {format ? format(p.key) : p.key}
-
-
- (
+ // Keys can repeat (two actors who share a display name), so the
+ // position keeps React's identity unique.
+
@@ -150,6 +158,7 @@ export function BarList({
// A single headline number with an inline sparkline — the KPI tile. The
// sparkline stays axis-less by construction (no `axes` prop), so the tile reads
// as one number, not a chart. `formatValue` writes the number.
+/** @deprecated A dashboard draws a headline number with `MetricFigure` or `StatTile` (or `WidgetChart`). */
export function StatChart({
total,
points,
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 d408cdda0..ab047b207 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
@@ -10,10 +10,9 @@
import type * as React from "react";
import { Card } from "../layout/card.js";
import { Section } from "../layout/page.js";
-import { AreaChart, BarList, LineChart, StatChart } from "./charts.js";
import { cn } from "./cn.js";
-import { formatShare } from "./format.js";
import type { DashboardView, DashboardWidgetView } from "./types.js";
+import { WidgetChart } from "./widget-chart.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
@@ -34,44 +33,13 @@ const GRID_COLS: Record<1 | 2 | 3 | 4, string> = {
function WidgetBody({ widget }: { widget: DashboardWidgetView }) {
const { series, visualization } = widget;
- // Undefined for a plain number, so each chart keeps its own default.
- const formatValue = widget.format === "percent" ? formatShare : undefined;
if (series.points.length === 0) {
return
No data yet.
;
}
- switch (visualization) {
- case "line":
- return ;
- case "area":
- return ;
- case "bar":
- return ;
- case "number":
- return series.total === null ? (
-
;
}
+ return ;
}
function WidgetCard({ widget, columns }: { widget: DashboardWidgetView; columns: 1 | 2 | 3 | 4 }) {
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 48f11ac8f..b2ee43fa0 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
@@ -78,6 +78,7 @@ export {
export {
MetricCard,
+ MetricFigure,
StatTile,
KPIRow,
formatMetricValue,
@@ -86,3 +87,4 @@ export {
} from "./metric-tiles.js";
export { TrendLineChart } from "./trend-line-chart.js";
+export { WidgetChart } from "./widget-chart.js";
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..8a3c1260d 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
@@ -221,6 +221,39 @@ function selectableCard(metric: Metric): {
};
}
+/**
+ * A metric's value, delta and trend sparkline, without the card around them.
+ * {@link StatTile} draws one under its label; a tile that already has its own
+ * header (a title and controls) draws one in its body, so a headline number
+ * looks the same in both.
+ */
+export function MetricFigure({
+ metric,
+ className,
+}: {
+ metric: Metric;
+ className?: string;
+}) {
+ return (
+
+
+
+
+
+ {hasTrend(metric.series) && (
+
+ )}
+
+ );
+}
+
/**
* A single headline number — label, value, optional delta and trend sparkline,
* with a freshness badge when the metric isn't `ready`. The compact building
@@ -245,22 +278,7 @@ export function StatTile({
{metric.state && }
-
-
-
-
-
- {hasTrend(metric.series) && (
-
- )}
-
+
);
}
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/widget-chart.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/widget-chart.tsx
new file mode 100644
index 000000000..b437ddaf8
--- /dev/null
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/widget-chart.tsx
@@ -0,0 +1,99 @@
+"use client";
+
+// One resolved widget's values, drawn with the kit's metric charts and tiles:
+// the same primitives a page that builds its own dashboard (the audit log, say)
+// draws with, so a declared dashboard looks like the rest of the app. The kit's
+// and the host's solution page both draw a tile's values with this,
+// and each says for itself what a tile with no values shows.
+
+import { BarList } from "./charts.js";
+import { formatShare } from "./format.js";
+import { AreaChart, LineChart } from "./metric-chart.js";
+import type { ChartSeries } from "./metric-geometry.js";
+import { type Metric, MetricFigure } from "./metric-tiles.js";
+import type { DashboardWidgetView } from "./types.js";
+
+// A tile's number as the metric figure writes it. A percent metric is a share
+// from 0 to 1, and the figure's `percent` expects it already scaled (45 →
+// "45%"), so the share is scaled here, once.
+function figureOf(widget: DashboardWidgetView, total: number): Metric {
+ const percent = widget.format === "percent";
+ return {
+ label: widget.title ?? widget.id,
+ value: percent ? total * 100 : total,
+ format: percent ? "percent" : "number",
+ series: widget.series.points.map((point) => point.value),
+ };
+}
+
+/**
+ * Draw a resolved widget's values. Expects values to draw: a caller checks for
+ * an empty series (and a `number` widget with no total) first, since what such
+ * a tile says differs between pages.
+ */
+export function WidgetChart({ widget }: { widget: DashboardWidgetView }) {
+ const { series, visualization } = widget;
+ // Undefined for a plain number, so each chart keeps its own default.
+ const formatValue = widget.format === "percent" ? formatShare : undefined;
+ const title = widget.title ?? widget.id;
+ const chartSeries: ChartSeries[] = [
+ {
+ name: title,
+ data: series.points.map((point) => ({
+ label: point.key,
+ value: point.value,
+ })),
+ },
+ ];
+ switch (visualization) {
+ case "line":
+ return (
+
+ );
+ case "area":
+ return (
+
+ );
+ case "bar":
+ return ;
+ case "number":
+ return series.total === null ? null : (
+
+ );
+ case "table":
+ return (
+
+ );
+ default: {
+ // Compile-time exhaustiveness: a new WidgetVisualization must be
+ // handled here or this assignment fails to type-check.
+ const _exhaustive: never = visualization;
+ return _exhaustive;
+ }
+ }
+}
diff --git a/module/services/frontend/code/src/components/dashboard.tsx b/module/services/frontend/code/src/components/dashboard.tsx
index ba53054ad..6c665bae3 100644
--- a/module/services/frontend/code/src/components/dashboard.tsx
+++ b/module/services/frontend/code/src/components/dashboard.tsx
@@ -12,6 +12,7 @@
*/
import {
+ BarList,
type ChartSeries,
KPIRow,
type Metric,
@@ -159,28 +160,6 @@ function CardWidget({
);
}
-function Bars({ items }: { items: BarsWidget["items"] }) {
- const max = items.reduce((m, item) => Math.max(m, item.value), 0) || 1;
- return (
-
- {items.map((item, i) => (
-
-
- {item.label}
- {item.value}
-
-
-
-
-
- ))}
-
- );
-}
-
function Widget({ widget }: { widget: DashboardWidget }) {
switch (widget.kind) {
case "sparkline":
@@ -192,7 +171,12 @@ function Widget({ widget }: { widget: DashboardWidget }) {
case "bars":
return (
-
+ ({
+ key: item.label,
+ value: item.value,
+ }))}
+ />
);
case "metrics":
diff --git a/module/services/frontend/code/src/solutions/SolutionDashboard.tsx b/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
index dcc52d390..e44aa8269 100644
--- a/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
+++ b/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
@@ -11,18 +11,14 @@ import {
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.
+// A tile's values are drawn by the shared kit, not host-internal components:
+// the same component a solution's own remote draws with, built on the metric
+// charts the audit log draws with, so every dashboard looks alike and there is
+// one charting implementation to maintain.
import {
- AreaChart,
- BarList,
- formatShare,
- LineChart,
SortableBoard,
type SortableGroupHandle,
- StatChart,
+ WidgetChart,
} from "@codefly-dev/ui/dashboard";
import { useQuery } from "@tanstack/react-query";
import { Ellipsis, GripVertical, Info, Pencil, Plus, X } from "lucide-react";
@@ -190,69 +186,11 @@ function withoutValues({
}
function WidgetValues({ widget }: { widget: ResolvedWidget }) {
- const { series, visualization } = widget;
- // A metric declared as a percent is a share from 0 to 1, written as a
- // percentage everywhere the tile shows a value; a plain number keeps each
- // chart's own default.
- const formatValue = widget.format === "percent" ? formatShare : undefined;
const missing = withoutValues(widget);
if (missing) {
return
{missing.tile}
;
}
- switch (visualization) {
- case "line":
- return (
-
- );
- case "area":
- return (
-
- );
- case "bar":
- return ;
- case "number":
- // A number with no total has said so above; this only narrows it.
- return series.total === null ? null : (
-
- );
- case "table":
- return (
-
- );
- default: {
- // Compile-time exhaustiveness: a new WidgetVisualization must be
- // handled here or this assignment fails to type-check.
- const _exhaustive: never = visualization;
- return _exhaustive;
- }
- }
+ return ;
}
// Where a dashboard's widgets resolve: the solution's graph, and whose
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 a63d019e3..000ad001d 100644
--- a/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx
+++ b/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx
@@ -197,9 +197,11 @@ describe("SolutionDashboards", () => {
expect(screen.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();
- expect(screen.getByText("Total logins")).toBeTruthy();
+ // Each widget's title is rendered from the declaration. A chart also names
+ // its hidden data table after it, so the tile's own title is asked for.
+ expect(tileTitles()).toEqual(
+ expect.arrayContaining(["Logins over time", "Total logins"]),
+ );
});
it("isolates a widget whose metric fails without blanking its siblings", async () => {
diff --git a/module/services/frontend/code/ui-inventory.json b/module/services/frontend/code/ui-inventory.json
index d209b7946..e673162c2 100644
--- a/module/services/frontend/code/ui-inventory.json
+++ b/module/services/frontend/code/ui-inventory.json
@@ -221,9 +221,9 @@
"react",
"../layout/card.js",
"../layout/page.js",
- "./charts.js",
"./cn.js",
- "./types.js"
+ "./types.js",
+ "./widget-chart.js"
],
"owner": "@codefly-dev/ui",
"disposition": "already canonical",
@@ -385,6 +385,28 @@
"coverage": "owner stories; browser/skin verification pending",
"remaining": "Verify explorer rendering, interactive accessibility and visual baselines under external skins."
},
+ {
+ "source": "packages/codefly-ui/src/dashboard/widget-chart.tsx",
+ "owner": "@codefly-dev/ui",
+ "disposition": "already canonical",
+ "reason": "Shared owner implementation: draws a resolved widget with the metric charts, for the kit Dashboard and the host's solution dashboards.",
+ "imports": [
+ "./charts.js",
+ "./format.js",
+ "./metric-chart.js",
+ "./metric-geometry.js",
+ "./metric-tiles.js",
+ "./types.js"
+ ],
+ "stories": [
+ {
+ "file": "packages/codefly-ui/stories/composites.stories.tsx",
+ "export": "DashboardCharts"
+ }
+ ],
+ "coverage": "owner stories; browser/skin verification pending",
+ "remaining": "Verify explorer rendering, interactive accessibility and visual baselines under external skins."
+ },
{
"source": "packages/codefly-ui/src/layout/alert-dialog.tsx",
"imports": [
From 7bf64b61d067b3431f72ff3a3a738da16752d7ec Mon Sep 17 00:00:00 2001
From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com>
Date: Tue, 6 Oct 2026 12:35:39 -0400
Subject: [PATCH 11/29] fix(frontend): regenerate the kit's preview stylesheet
for the dashboard classes
The kit's stories and dashboard components on this branch use utility classes
(opacity-70, inset-x-0, -top-1.5, -bottom-1.5 and others) that main's committed
preview stylesheet does not define, so preview-stylesheet.test.tsx failed on the
merge ("is not stale" and "defines every class the kit's stories render"); it
passes on main alone. Ran generate-preview-stylesheet.mjs; --check is clean.
Co-Authored-By: Claude Opus 5.5
---
.../codefly-ui/src/skin/preview.generated.css | 40 +++++++++++++------
1 file changed, 27 insertions(+), 13 deletions(-)
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 765c93b9c..657518b64 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
@@ -466,10 +466,18 @@
inset: 0;
}
+ .inset-x-0 {
+ inset-inline: 0;
+ }
+
.inset-y-0 {
inset-block: 0;
}
+ .-top-1\.5 {
+ top: calc(var(--appearance-spacing) * -1.5);
+ }
+
.top-0 {
top: 0;
}
@@ -522,6 +530,10 @@
right: calc(var(--appearance-spacing) * 4);
}
+ .-bottom-1\.5 {
+ bottom: calc(var(--appearance-spacing) * -1.5);
+ }
+
.bottom-0 {
bottom: 0;
}
@@ -530,10 +542,6 @@
bottom: calc(var(--appearance-spacing) * 4);
}
- .left-0 {
- left: 0;
- }
-
.left-1\/2 {
left: 50%;
}
@@ -1070,10 +1078,6 @@
width: calc(var(--appearance-spacing) * 8);
}
- .w-10 {
- width: calc(var(--appearance-spacing) * 10);
- }
-
.w-12 {
width: calc(var(--appearance-spacing) * 12);
}
@@ -1086,10 +1090,6 @@
width: calc(var(--appearance-spacing) * 24);
}
- .w-32 {
- width: calc(var(--appearance-spacing) * 32);
- }
-
.w-72 {
width: calc(var(--appearance-spacing) * 72);
}
@@ -1646,6 +1646,16 @@
}
}
+ .border-foreground\/20 {
+ border-color: var(--foreground);
+ }
+
+ @supports (color: color-mix(in lab, red, red)) {
+ .border-foreground\/20 {
+ border-color: color-mix(in oklab, var(--foreground) 20%, transparent);
+ }
+ }
+
.border-info\/30 {
border-color: var(--info);
}
@@ -1666,7 +1676,7 @@
}
}
- .border-primary\/30 {
+ .border-primary, .border-primary\/30 {
border-color: var(--primary);
}
@@ -2854,6 +2864,10 @@
opacity: .5;
}
+ .opacity-70 {
+ opacity: .7;
+ }
+
.opacity-75 {
opacity: .75;
}
From f37fd6ea4eaee22760e754f4cc2e844062a96db8 Mon Sep 17 00:00:00 2001
From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com>
Date: Tue, 6 Oct 2026 14:32:38 -0400
Subject: [PATCH 12/29] fix(sdk): keep a total unavailable when the only values
are the filled zeros
A per-day sum whose days with events all had an unknown value had no total.
Filling the day between them with a 0 gave the series one point, so the
total became 0 and the number tile read 0 over amounts it never observed.
A total now counts only points something was observed on: a series of
filled zeros alone has none, as it had none before the fill.
Co-Authored-By: Claude Opus 5.5
---
.../packages/saas-sdk/src/datagraph/run.ts | 26 +++++++++----------
.../code/packages/saas-sdk/test/run.test.ts | 16 ++++++++++++
2 files changed, 29 insertions(+), 13 deletions(-)
diff --git a/module/services/frontend/code/packages/saas-sdk/src/datagraph/run.ts b/module/services/frontend/code/packages/saas-sdk/src/datagraph/run.ts
index 133c2aebc..119312954 100644
--- a/module/services/frontend/code/packages/saas-sdk/src/datagraph/run.ts
+++ b/module/services/frontend/code/packages/saas-sdk/src/datagraph/run.ts
@@ -41,6 +41,10 @@ export function assertAuditScopeContract(
}
}
+// The zeros put on buckets the RPC returned none for: no event matched there.
+// A derived metric tells them apart from a returned bucket by this mark.
+const noEvents = new WeakSet();
+
function toSeries(
metricId: string,
points: MetricPoint[],
@@ -58,15 +62,15 @@ function toSeries(
// suppressing on partial blanked the stat tile of every metric over one.
// A non-additive scalar (avg, min, max, percentile, ratio) is only defined
// for a single complete group — if any group was dropped, the surviving
- // one is not the series' value, so it stays null.
- total:
- points.length === 0
- ? null
- : additive
- ? points.reduce((sum, point) => sum + point.value, 0)
- : partial || points.length !== 1
- ? null
- : points[0].value,
+ // one is not the series' value, so it stays null. The zeros put on days
+ // with no events observe nothing, so a series of only those has no total.
+ total: points.every((point) => noEvents.has(point))
+ ? null
+ : additive
+ ? points.reduce((sum, point) => sum + point.value, 0)
+ : partial || points.length !== 1
+ ? null
+ : points[0].value,
coverage: partial ? "partial" : points.length === 0 ? "empty" : "complete",
groupBy,
bucket,
@@ -89,10 +93,6 @@ const TIME_KEY = /^(\d{4})-(\d{2})-(\d{2})(.*)$/;
// buckets, and two keys far apart must not balloon one series.
const MAX_FILLED_BUCKETS = 100_000;
-// The zeros put on buckets the RPC returned none for: no event matched there.
-// A derived metric tells them apart from a returned bucket by this mark.
-const noEvents = new WeakSet();
-
function bucketDate(key: string): { date: Date; rest: string } | null {
const match = TIME_KEY.exec(key);
if (!match) return null;
diff --git a/module/services/frontend/code/packages/saas-sdk/test/run.test.ts b/module/services/frontend/code/packages/saas-sdk/test/run.test.ts
index 53ac17a00..666a97353 100644
--- a/module/services/frontend/code/packages/saas-sdk/test/run.test.ts
+++ b/module/services/frontend/code/packages/saas-sdk/test/run.test.ts
@@ -802,6 +802,22 @@ describe("a time series with days that have no events", () => {
expect(series.coverage).toBe("partial");
});
+ it("keeps the total unavailable when every day with events has an unknown value", async () => {
+ const { client } = fakeAuditClient(() => [
+ // Events on both days, none with the field: the RPC omits the value.
+ { key: day("2026-09-02"), count: 2, samples: { value: BigInt(0) } },
+ { key: day("2026-09-04"), count: 1, samples: { value: BigInt(0) } },
+ ]);
+
+ const series = await runMetric(client, perDay("sum"), (n) => n, context);
+
+ // The day between gets its 0, but that 0 observed nothing, so the sum
+ // of what was observed is unavailable rather than 0.
+ expect(series.points).toEqual([{ key: day("2026-09-03"), value: 0 }]);
+ expect(series.total).toBeNull();
+ expect(series.coverage).toBe("partial");
+ });
+
it("steps a week and a month bucket by a week and a month", async () => {
const weeks = await runMetric(
fakeAuditClient(() => [
From 2cb45d8bae7944878b72dffaf3c147a12fd6dc32 Mon Sep 17 00:00:00 2001
From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com>
Date: Tue, 6 Oct 2026 14:32:38 -0400
Subject: [PATCH 13/29] fix(frontend): keep a viewer's removed widgets when
every section they kept is retired
When a solution retired the last section a viewer had kept, the saved
layout fell back to the declared default, and every widget the viewer had
removed came back. The layout now gets the default's first section back
instead, the viewer's tiles go there, and a widget they removed stays
removed.
Co-Authored-By: Claude Opus 5.5
---
.../__tests__/dashboard-layout.test.ts | 40 ++++++++++---------
.../code/src/solutions/dashboard-layout.ts | 6 ++-
2 files changed, 26 insertions(+), 20 deletions(-)
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 cafefdddc..9fde27e84 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
@@ -718,24 +718,28 @@ describe("dashboard layout", () => {
]);
});
- it("falls back to the default when no section is left, or seenSections is not a list", () => {
- const fallback = defaultLayout(sectioned);
- for (const raw of [
- JSON.stringify({
- version: 2,
- sections: [{ id: "retired", tiles: ["w_net"] }],
- seen: ["w_items", "w_net"],
- seenSections: ["overview", "detail", "later"],
- }),
- JSON.stringify({
- version: 2,
- sections: [{ id: "overview", tiles: [] }],
- seen: [],
- seenSections: "overview",
- }),
- ]) {
- expect(parseLayout(raw, sectioned), raw).toEqual(fallback);
- }
+ it("gets a section back when none the viewer kept is left, and keeps their removals", () => {
+ // The viewer kept only "retired" and removed w_items; the solution
+ // has since retired that section.
+ const raw = JSON.stringify({
+ version: 2,
+ sections: [{ id: "retired", tiles: ["w_net"] }],
+ seen: ["w_items", "w_net"],
+ seenSections: ["overview", "detail", "later"],
+ });
+ expect(parseLayout(raw, sectioned)).toEqual([
+ { id: "overview", tiles: ["w_net"] },
+ ]);
+ });
+
+ it("falls back to the default when seenSections is not a list", () => {
+ const raw = JSON.stringify({
+ version: 2,
+ sections: [{ id: "overview", tiles: [] }],
+ seen: [],
+ seenSections: "overview",
+ });
+ expect(parseLayout(raw, sectioned)).toEqual(defaultLayout(sectioned));
});
});
diff --git a/module/services/frontend/code/src/solutions/dashboard-layout.ts b/module/services/frontend/code/src/solutions/dashboard-layout.ts
index bedad2c56..4e3053b31 100644
--- a/module/services/frontend/code/src/solutions/dashboard-layout.ts
+++ b/module/services/frontend/code/src/solutions/dashboard-layout.ts
@@ -580,7 +580,9 @@ function keptSections(
* section (or in the flat shape saved before sections) goes to its declared
* section if the layout has it, else to the last section. So does a widget
* declared since the layout was saved, so a viewer who customized still sees
- * what the solution adds.
+ * what the solution adds. When none of the sections the viewer kept is left
+ * (the solution retired them), the layout gets the default's first section
+ * back and the viewer's tiles go there: a widget they removed stays removed.
*/
export function parseLayout(raw: string | null, dashboard: Dashboard): Layout {
const fallback = defaultLayout(dashboard);
@@ -594,7 +596,7 @@ export function parseLayout(raw: string | null, dashboard: Dashboard): Layout {
const stored = storedLayout(saved);
if (!stored) return fallback;
const layout = keptSections(stored, dashboard);
- if (layout.length === 0) return fallback;
+ if (layout.length === 0) layout.push({ id: fallback[0].id, tiles: [] });
const placed = new Set();
// Whether a saved id is a declared widget not yet placed.
From e40e85f78da761218b0bcc07bb30c2008ba4370c Mon Sep 17 00:00:00 2001
From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com>
Date: Tue, 6 Oct 2026 14:32:38 -0400
Subject: [PATCH 14/29] fix(ui): let a dashboard's accent colour its line and
area charts
A dashboard's accent set only `--primary`. The bar list colours from it,
but the metric charts a line or area widget now draws with colour their
series from `--chart-1`, so with an accent the bars took it and the lines
did not. The accent now sets both tokens on the dashboard's subtree.
Co-Authored-By: Claude Opus 5.5
---
.../dashboard/__tests__/dashboard.test.tsx | 29 +++++++++++++++++++
.../codefly-ui/src/dashboard/dashboard.tsx | 8 +++--
.../codefly-ui/src/dashboard/types.ts | 2 +-
3 files changed, 36 insertions(+), 3 deletions(-)
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 38acf2b15..c56a0ed7f 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
@@ -50,6 +50,35 @@ describe("Dashboard", () => {
const { container } = render();
const section = container.querySelector("section") as HTMLElement;
expect(section.style.getPropertyValue("--primary")).toBe("hotpink");
+ // A line or area chart's one series colours from the first chart token.
+ expect(section.style.getPropertyValue("--chart-1")).toBe("hotpink");
+ });
+
+ it("draws a line widget's series in the first chart colour, which the accent sets", () => {
+ const { container } = render(
+ ,
+ );
+ expect(
+ container.querySelector('path[stroke="var(--chart-1)"]'),
+ ).toBeTruthy();
});
});
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 ab047b207..bc73bafbf 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
@@ -59,11 +59,15 @@ function WidgetCard({ widget, columns }: { widget: DashboardWidgetView; columns:
/**
* 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.
+ * token for this dashboard's subtree only, so every chart picks it up: the bar
+ * list colours from `--primary`, and a line or area chart's single series from
+ * `--chart-1`, so both are set.
*/
export function Dashboard({ data, className }: { data: DashboardView; className?: string }) {
const columns = data.columns ?? 2;
- const style = data.accent ? ({ "--primary": data.accent } as React.CSSProperties) : undefined;
+ const style = data.accent
+ ? ({ "--primary": data.accent, "--chart-1": data.accent } as React.CSSProperties)
+ : undefined;
const isGrid = (data.layout ?? "grid") === "grid";
return (
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 afcc3a349..1c0d79aec 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
@@ -52,7 +52,7 @@ export interface DashboardView {
layout?: DashboardLayoutKind;
/** Grid column count (default 2); ignored in a stack. */
columns?: 1 | 2 | 3 | 4;
- /** Optional accent (any CSS color) applied to charts via the primary token. */
+ /** Optional accent (any CSS color) applied to charts via the primary and first chart tokens. */
accent?: string;
widgets: DashboardWidgetView[];
}
From 2e4a1d28df467b2760aef06e7a4e5673a1d8f80a Mon Sep 17 00:00:00 2001
From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com>
Date: Tue, 6 Oct 2026 15:36:43 -0400
Subject: [PATCH 15/29] fix(frontend): title a number tile as the audit log
labels its numbers
A solution dashboard's number tile was titled as a card heading: larger,
darker and in the heading font. The audit log's number tiles (the kit's
StatTile) label the figure in small, muted body text. A number tile's title
now reads the same way; chart, bar and table tiles keep the card heading,
as the audit log's chart cards do.
Co-Authored-By: Claude Opus 5.5
---
.../frontend/code/src/solutions/SolutionDashboard.tsx | 11 ++++++++++-
.../solutions/__tests__/SolutionDashboard.test.tsx | 11 +++++++++++
2 files changed, 21 insertions(+), 1 deletion(-)
diff --git a/module/services/frontend/code/src/solutions/SolutionDashboard.tsx b/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
index e44aa8269..909459fd9 100644
--- a/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
+++ b/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
@@ -262,7 +262,16 @@ function WidgetCard({
{grip}
-
+
{widget.title ?? widget.metric}
{info}
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 000ad001d..6b86fcc9d 100644
--- a/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx
+++ b/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx
@@ -326,6 +326,17 @@ describe("SolutionDashboards", () => {
expect(tile("Total logins").classList).not.toContain("row-span-2");
});
+ it("titles a number tile as the audit log labels its numbers, and a chart as a card", () => {
+ renderDashboards();
+
+ const title = (name: string) =>
+ tile(name).querySelector('[data-slot="card-title"]')?.classList;
+ // The label StatTile writes above a headline number.
+ expect(title("Total logins")).toContain("type-metric-label");
+ expect(title("Total logins")).toContain("text-muted-foreground");
+ expect(title("Logins over time")).not.toContain("type-metric-label");
+ });
+
it("reads as loading until the org context resolves, never as empty", () => {
authState.organizationId = undefined;
From 1660fb352b95ae91d8f657e12ebeeae9b9cb7604 Mon Sep 17 00:00:00 2001
From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com>
Date: Tue, 6 Oct 2026 15:57:56 -0400
Subject: [PATCH 16/29] fix(ui): draw a lone chart series in the palette's last
colour
A chart of one series drew it in `--chart-1`. The default skin's palette
runs light to dark, so a solution dashboard's line was a faint grey (87%
lightness) beside the audit log's chart, which reads dark because its
topmost series, the total outline, takes the last colour. A lone series now
takes that last colour too; two series or more keep the palette order.
A dashboard's accent now sets `--chart-5`, the colour its one-series charts
draw in, instead of `--chart-1`.
Co-Authored-By: Claude Opus 5.5
---
.../dashboard/__tests__/dashboard.test.tsx | 8 +++---
.../dashboard/__tests__/metric-chart.test.tsx | 26 +++++++++++++++++++
.../codefly-ui/src/dashboard/dashboard.tsx | 4 +--
.../codefly-ui/src/dashboard/metric-chart.tsx | 22 +++++++++++-----
.../codefly-ui/src/dashboard/types.ts | 2 +-
5 files changed, 48 insertions(+), 14 deletions(-)
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 c56a0ed7f..27db1cbab 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
@@ -50,11 +50,11 @@ describe("Dashboard", () => {
const { container } = render();
const section = container.querySelector("section") as HTMLElement;
expect(section.style.getPropertyValue("--primary")).toBe("hotpink");
- // A line or area chart's one series colours from the first chart token.
- expect(section.style.getPropertyValue("--chart-1")).toBe("hotpink");
+ // A line or area chart's one series colours from the palette's last token.
+ expect(section.style.getPropertyValue("--chart-5")).toBe("hotpink");
});
- it("draws a line widget's series in the first chart colour, which the accent sets", () => {
+ it("draws a line widget's series in the palette's last colour, which the accent sets", () => {
const { container } = render(
{
/>,
);
expect(
- container.querySelector('path[stroke="var(--chart-1)"]'),
+ container.querySelector('path[stroke="var(--chart-5)"]'),
).toBeTruthy();
});
});
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/metric-chart.test.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/metric-chart.test.tsx
index e3628c094..3639366a5 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/metric-chart.test.tsx
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/metric-chart.test.tsx
@@ -58,3 +58,29 @@ describe("the metric chart's x-axis labels", () => {
expect(texts).not.toContain("Sep 29");
});
});
+
+describe("the metric chart's series colours", () => {
+ const strokes = (container: HTMLElement) =>
+ [...container.querySelectorAll("path[stroke]")].map((path) =>
+ path.getAttribute("stroke"),
+ );
+
+ // The palette runs light to dark, so a lone series in the first colour was
+ // a faint grey line; the audit log's stacked chart reads dark because its
+ // topmost series, the total outline, takes the last colour.
+ it("draws a lone series in the palette's last colour", () => {
+ const { container } = render(
+ ,
+ );
+ expect(strokes(container)).toContain("var(--chart-5)");
+ expect(strokes(container)).not.toContain("var(--chart-1)");
+ });
+
+ it("keeps the palette order for two series or more", () => {
+ const { container } = render();
+ expect(strokes(container)).toEqual(
+ expect.arrayContaining(["var(--chart-1)", "var(--chart-2)"]),
+ );
+ expect(strokes(container)).not.toContain("var(--chart-5)");
+ });
+});
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 bc73bafbf..c7beceaeb 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
@@ -61,12 +61,12 @@ function WidgetCard({ widget, columns }: { widget: DashboardWidgetView; columns:
* `fromDashboardData(runDashboard(...))`). `accent` overrides the `--primary`
* token for this dashboard's subtree only, so every chart picks it up: the bar
* list colours from `--primary`, and a line or area chart's single series from
- * `--chart-1`, so both are set.
+ * the palette's last colour, `--chart-5`, so both are set.
*/
export function Dashboard({ data, className }: { data: DashboardView; className?: string }) {
const columns = data.columns ?? 2;
const style = data.accent
- ? ({ "--primary": data.accent, "--chart-1": data.accent } as React.CSSProperties)
+ ? ({ "--primary": data.accent, "--chart-5": data.accent } as React.CSSProperties)
: undefined;
const isGrid = (data.layout ?? "grid") === "grid";
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/metric-chart.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/metric-chart.tsx
index 6377ca678..9f8b849f9 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/metric-chart.tsx
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/metric-chart.tsx
@@ -53,8 +53,14 @@ const fullFormat = new Intl.NumberFormat("en-US");
* Categorical series colour in fixed order from the appearance palette. The kit
* ships five chart tokens; a dashboard with more than five series should
* aggregate the tail into an "Other" bucket rather than lean on a sixth hue.
+ *
+ * A chart of one series (`count` 1) draws it in the palette's last colour: the
+ * one a full stack's topmost series, and so its total outline, takes. The first
+ * colour is the lightest in a palette ordered light to dark, too faint to carry
+ * a chart alone.
*/
-export function chartSeriesColor(index: number): string {
+export function chartSeriesColor(index: number, count?: number): string {
+ if (count === 1) return `var(--chart-${PALETTE_SIZE})`;
return `var(--chart-${(index % PALETTE_SIZE) + 1})`;
}
@@ -332,7 +338,9 @@ function Tooltip({
>
{s.name}
@@ -493,7 +501,7 @@ function LineOverlay(geo: FrameGeometry, index: number) {
key={s.name}
x={geo.xs[index]}
y={geo.scaleY(s.values[index] as number)}
- color={chartSeriesColor(i)}
+ color={chartSeriesColor(i, geo.resolved.length)}
/>
),
)}
@@ -510,7 +518,7 @@ export function LineChart(props: MetricChartProps) {
geo.resolved.map((s, i) => {
const points = seriesPoints(s.values, geo);
if (points.length === 0) return ;
- const color = chartSeriesColor(i);
+ const color = chartSeriesColor(i, geo.resolved.length);
const last = points[points.length - 1];
return (
@@ -548,7 +556,7 @@ export function AreaChart(props: MetricChartProps) {
const tops = seriesPoints(s.top, geo);
const bases = seriesPoints(s.base, geo);
if (tops.length === 0) return ;
- const color = chartSeriesColor(i);
+ const color = chartSeriesColor(i, geo.resolved.length);
const last = tops[tops.length - 1];
return (
@@ -573,7 +581,7 @@ export function AreaChart(props: MetricChartProps) {
return geo.resolved.map((s, i) => {
const points = seriesPoints(s.values, geo);
if (points.length === 0) return ;
- const color = chartSeriesColor(i);
+ const color = chartSeriesColor(i, geo.resolved.length);
const last = points[points.length - 1];
return (
@@ -655,7 +663,7 @@ export function BarChart(props: MetricChartProps) {
);
const baseY = geo.scaleY(0);
return geo.resolved.map((s, si) => {
- const color = chartSeriesColor(si);
+ const color = chartSeriesColor(si, geo.resolved.length);
return (
{geo.labels.map((label, 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 1c0d79aec..fb841aa92 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
@@ -52,7 +52,7 @@ export interface DashboardView {
layout?: DashboardLayoutKind;
/** Grid column count (default 2); ignored in a stack. */
columns?: 1 | 2 | 3 | 4;
- /** Optional accent (any CSS color) applied to charts via the primary and first chart tokens. */
+ /** Optional accent (any CSS color) applied to charts via the primary token and a lone series' chart token. */
accent?: string;
widgets: DashboardWidgetView[];
}
From e6fe65ce260f66a12a2bac4a1796dae33c555e2f Mon Sep 17 00:00:00 2001
From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com>
Date: Tue, 6 Oct 2026 16:25:30 -0400
Subject: [PATCH 17/29] fix(frontend): write a number tile's title in the
foreground colour
A number tile's title took StatTile's muted grey with its label size, and
beside the figure it read too faint to scan. It keeps the label size and
weight and now takes the foreground colour.
Co-Authored-By: Claude Opus 5.5
---
.../frontend/code/src/solutions/SolutionDashboard.tsx | 8 ++++----
.../src/solutions/__tests__/SolutionDashboard.test.tsx | 4 ++--
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/module/services/frontend/code/src/solutions/SolutionDashboard.tsx b/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
index 909459fd9..cd4fc9d93 100644
--- a/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
+++ b/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
@@ -264,11 +264,11 @@ function WidgetCard({
{grip}
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 6b86fcc9d..a4c8800bb 100644
--- a/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx
+++ b/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx
@@ -331,9 +331,9 @@ describe("SolutionDashboards", () => {
const title = (name: string) =>
tile(name).querySelector('[data-slot="card-title"]')?.classList;
- // The label StatTile writes above a headline number.
+ // The size StatTile labels a headline number at, not muted.
expect(title("Total logins")).toContain("type-metric-label");
- expect(title("Total logins")).toContain("text-muted-foreground");
+ expect(title("Total logins")).not.toContain("text-muted-foreground");
expect(title("Logins over time")).not.toContain("type-metric-label");
});
From c779cc963e43314c14a2040a3824ded5ddf6cfea Mon Sep 17 00:00:00 2001
From: Codefly Automation
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Sun, 11 Oct 2026 21:11:59 -0400
Subject: [PATCH 18/29] refactor(ui): drop this branch's second renderer in
favour of #1043's
#1043 made the kit's draw every widget with the metric tier and
the host compose it through slots, with a gate refusing a re-implementation.
This branch had drawn the same widgets through its own path, which goes:
- widget-chart.tsx (WidgetChart) dispatched a widget to a mark, which is
's job; its inventory entry goes with it;
- the lone-series colour (chartSeriesColor(i, count) taking --chart-5)
contradicts #1043's accent, which is the first series' --chart-1;
- formatShare and the formatValue props on the deprecated basic charts
served WidgetChart's percent path; a widget's format now reaches every
mark through the kit's formatMetricValue, and the share is scaled once
in runDashboard (following commits);
- the audit page's bar list keeps main's own drawing; restyling it was part
of matching the solution dashboard to the audit log, which #1043 did by
moving both onto the metric tier.
The kit's dashboard and types tests are main's again here; this branch's
cases are rebuilt on #1043's renderer in the following commits.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../src/dashboard/__tests__/charts.test.tsx | 37 +------
.../dashboard/__tests__/dashboard.test.tsx | 68 -------------
.../src/dashboard/__tests__/format.test.ts | 18 +---
.../dashboard/__tests__/metric-chart.test.tsx | 26 -----
.../src/dashboard/__tests__/types.test.ts | 14 ---
.../codefly-ui/src/dashboard/format.ts | 9 --
.../codefly-ui/src/dashboard/metric-chart.tsx | 22 ++---
.../codefly-ui/src/dashboard/widget-chart.tsx | 99 -------------------
.../code/src/components/dashboard.tsx | 30 ++++--
.../services/frontend/code/ui-inventory.json | 26 +----
10 files changed, 34 insertions(+), 315 deletions(-)
delete mode 100644 module/services/frontend/code/packages/codefly-ui/src/dashboard/widget-chart.tsx
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/charts.test.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/charts.test.tsx
index 53559f061..42a18a171 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/charts.test.tsx
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/charts.test.tsx
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { Axis, Svg } from "../atoms.js";
-import { AreaChart, BarList, LineChart, StatChart } from "../charts.js";
+import { AreaChart, LineChart, StatChart } from "../charts.js";
import type { SeriesPoint } from "../types.js";
type ElementLike = {
@@ -102,38 +102,3 @@ describe("StatChart", () => {
expect(byType(el, LineChart)).toHaveLength(1);
});
});
-
-// A caller that knows what a value means writes it; without one each chart
-// writes the number as it always has.
-describe("value formatting", () => {
- const percent = (value: number) => `${Math.round(value * 100)}%`;
-
- // Every string a tree renders as text, in order.
- function texts(node: unknown): string[] {
- if (typeof node === "string") return [node];
- if (Array.isArray(node)) return node.flatMap(texts);
- if (!node || typeof node !== "object") return [];
- return texts((node as ElementLike).props?.children);
- }
-
- it("StatChart writes its total with the caller's formatter", () => {
- expect(texts(StatChart({ total: 0.4, points: [{ key: "all", value: 0.4 }], formatValue: percent }))).toContain("40%");
- expect(texts(StatChart({ total: 1234, points: [{ key: "all", value: 1234 }] }))).toContain((1234).toLocaleString());
- });
-
- it("BarList writes each value with the caller's formatter", () => {
- const bars = [
- { key: "won", value: 0.4 },
- { key: "lost", value: 0.6 },
- ];
- const written = texts(BarList({ points: bars, formatValue: percent }));
- expect(written).toContain("40%");
- expect(written).toContain("60%");
- });
-
- it("LineChart hands the formatter to its y axis", () => {
- const [axis] = byType(LineChart({ points: pts, axes: true, formatValue: percent }), Axis);
- const y = axis?.props?.y as { format?: (value: number) => string } | undefined;
- expect(y?.format?.(0.5)).toBe("50%");
- });
-});
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 c1f543368..981a3be7b 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
@@ -145,35 +145,6 @@ describe("Dashboard", () => {
);
const section = container.querySelector("section") as HTMLElement;
expect(section.style.getPropertyValue("--primary")).toBe("hotpink");
- // A line or area chart's one series colours from the palette's last token.
- expect(section.style.getPropertyValue("--chart-5")).toBe("hotpink");
- });
-
- it("draws a line widget's series in the palette's last colour, which the accent sets", () => {
- const { container } = render(
- ,
- );
- expect(
- container.querySelector('path[stroke="var(--chart-5)"]'),
- ).toBeTruthy();
});
it("says so, rather than drawing an empty grid, when the view has no widgets", () => {
@@ -746,42 +717,3 @@ describe("slots", () => {
expect(screen.queryByText("No widgets.")).toBeNull();
});
});
-
-it("writes a percent widget's values as percentages and a plain one as a number", () => {
- render(
- ,
- );
- expect(screen.getByText("40%")).toBeTruthy();
- expect(screen.getByText("25%")).toBeTruthy();
- expect(screen.getByText("75%")).toBeTruthy();
- expect(screen.getByText((2.25).toLocaleString())).toBeTruthy();
- expect(screen.queryByText("0.4")).toBeNull();
-});
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/format.test.ts b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/format.test.ts
index 175342817..46a2d1086 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/format.test.ts
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/format.test.ts
@@ -1,10 +1,5 @@
import { describe, expect, it } from "vitest";
-import {
- formatAxisKey,
- formatAxisValue,
- formatShare,
- parseTimeKey,
-} from "../format.js";
+import { formatAxisKey, formatAxisValue, parseTimeKey } from "../format.js";
describe("formatAxisValue", () => {
it("keeps small numbers plain and compacts large ones", () => {
@@ -65,14 +60,3 @@ describe("parseTimeKey", () => {
expect(parseTimeKey("42")).toBeNull();
});
});
-
-describe("formatShare", () => {
- // A ratio metric's value is a share from 0 to 1; a viewer reads it as a
- // percentage, to one decimal at most.
- it("writes a share from 0 to 1 as a percentage", () => {
- expect(formatShare(0.4)).toBe("40%");
- expect(formatShare(17 / 22)).toBe("77.3%");
- expect(formatShare(0)).toBe("0%");
- expect(formatShare(1)).toBe("100%");
- });
-});
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/metric-chart.test.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/metric-chart.test.tsx
index 3639366a5..e3628c094 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/metric-chart.test.tsx
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/metric-chart.test.tsx
@@ -58,29 +58,3 @@ describe("the metric chart's x-axis labels", () => {
expect(texts).not.toContain("Sep 29");
});
});
-
-describe("the metric chart's series colours", () => {
- const strokes = (container: HTMLElement) =>
- [...container.querySelectorAll("path[stroke]")].map((path) =>
- path.getAttribute("stroke"),
- );
-
- // The palette runs light to dark, so a lone series in the first colour was
- // a faint grey line; the audit log's stacked chart reads dark because its
- // topmost series, the total outline, takes the last colour.
- it("draws a lone series in the palette's last colour", () => {
- const { container } = render(
- ,
- );
- expect(strokes(container)).toContain("var(--chart-5)");
- expect(strokes(container)).not.toContain("var(--chart-1)");
- });
-
- it("keeps the palette order for two series or more", () => {
- const { container } = render();
- expect(strokes(container)).toEqual(
- expect.arrayContaining(["var(--chart-1)", "var(--chart-2)"]),
- );
- expect(strokes(container)).not.toContain("var(--chart-5)");
- });
-});
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 309405a93..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
@@ -35,20 +35,6 @@ describe("fromDashboardData", () => {
expect(view.widgets[0].series?.total).toBe(3);
});
- it("carries a widget's value format through", () => {
- const view = fromDashboardData({
- widgets: [
- {
- id: "rate",
- visualization: "number",
- series: { points: [{ key: "all", value: 0.4 }], total: 0.4 },
- format: "percent",
- },
- ],
- });
- expect(view.widgets[0].format).toBe("percent");
- });
-
it("defaults layout to grid when the source omits it", () => {
const view = fromDashboardData({ widgets: [] });
expect(view.layout).toBe("grid");
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/format.ts b/module/services/frontend/code/packages/codefly-ui/src/dashboard/format.ts
index 20cfb9350..08f5d6e05 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/format.ts
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/format.ts
@@ -16,15 +16,6 @@ export function formatAxisValue(value: number): string {
return valueFormat.format(value);
}
-// A share from 0 to 1 written as a percentage, to one decimal: 0.4 → "40%",
-// 0.7727 → "77.3%". What a widget whose metric declares `format: "percent"`
-// writes for each value, on the tile, the bars, the table and the y axis.
-const shareFormat = new Intl.NumberFormat(undefined, { style: "percent", maximumFractionDigits: 1 });
-
-export function formatShare(value: number): string {
- return shareFormat.format(value);
-}
-
// Series keys that are time buckets arrive as ISO timestamps from the audit RPC
// (`YYYY-MM-DDThh:mm:ss±hh`); anything else is a plain category key. Gate on the
// ISO shape so a non-date key ("US East", "42") is never coerced through Date.
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/metric-chart.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/metric-chart.tsx
index 9f8b849f9..6377ca678 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/metric-chart.tsx
+++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/metric-chart.tsx
@@ -53,14 +53,8 @@ const fullFormat = new Intl.NumberFormat("en-US");
* Categorical series colour in fixed order from the appearance palette. The kit
* ships five chart tokens; a dashboard with more than five series should
* aggregate the tail into an "Other" bucket rather than lean on a sixth hue.
- *
- * A chart of one series (`count` 1) draws it in the palette's last colour: the
- * one a full stack's topmost series, and so its total outline, takes. The first
- * colour is the lightest in a palette ordered light to dark, too faint to carry
- * a chart alone.
*/
-export function chartSeriesColor(index: number, count?: number): string {
- if (count === 1) return `var(--chart-${PALETTE_SIZE})`;
+export function chartSeriesColor(index: number): string {
return `var(--chart-${(index % PALETTE_SIZE) + 1})`;
}
@@ -338,9 +332,7 @@ function Tooltip({
>
{s.name}
@@ -501,7 +493,7 @@ function LineOverlay(geo: FrameGeometry, index: number) {
key={s.name}
x={geo.xs[index]}
y={geo.scaleY(s.values[index] as number)}
- color={chartSeriesColor(i, geo.resolved.length)}
+ color={chartSeriesColor(i)}
/>
),
)}
@@ -518,7 +510,7 @@ export function LineChart(props: MetricChartProps) {
geo.resolved.map((s, i) => {
const points = seriesPoints(s.values, geo);
if (points.length === 0) return ;
- const color = chartSeriesColor(i, geo.resolved.length);
+ const color = chartSeriesColor(i);
const last = points[points.length - 1];
return (
@@ -556,7 +548,7 @@ export function AreaChart(props: MetricChartProps) {
const tops = seriesPoints(s.top, geo);
const bases = seriesPoints(s.base, geo);
if (tops.length === 0) return ;
- const color = chartSeriesColor(i, geo.resolved.length);
+ const color = chartSeriesColor(i);
const last = tops[tops.length - 1];
return (
@@ -581,7 +573,7 @@ export function AreaChart(props: MetricChartProps) {
return geo.resolved.map((s, i) => {
const points = seriesPoints(s.values, geo);
if (points.length === 0) return ;
- const color = chartSeriesColor(i, geo.resolved.length);
+ const color = chartSeriesColor(i);
const last = points[points.length - 1];
return (
@@ -663,7 +655,7 @@ export function BarChart(props: MetricChartProps) {
);
const baseY = geo.scaleY(0);
return geo.resolved.map((s, si) => {
- const color = chartSeriesColor(si, geo.resolved.length);
+ const color = chartSeriesColor(si);
return (
{geo.labels.map((label, i) => {
diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/widget-chart.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/widget-chart.tsx
deleted file mode 100644
index b437ddaf8..000000000
--- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/widget-chart.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-"use client";
-
-// One resolved widget's values, drawn with the kit's metric charts and tiles:
-// the same primitives a page that builds its own dashboard (the audit log, say)
-// draws with, so a declared dashboard looks like the rest of the app. The kit's
-// and the host's solution page both draw a tile's values with this,
-// and each says for itself what a tile with no values shows.
-
-import { BarList } from "./charts.js";
-import { formatShare } from "./format.js";
-import { AreaChart, LineChart } from "./metric-chart.js";
-import type { ChartSeries } from "./metric-geometry.js";
-import { type Metric, MetricFigure } from "./metric-tiles.js";
-import type { DashboardWidgetView } from "./types.js";
-
-// A tile's number as the metric figure writes it. A percent metric is a share
-// from 0 to 1, and the figure's `percent` expects it already scaled (45 →
-// "45%"), so the share is scaled here, once.
-function figureOf(widget: DashboardWidgetView, total: number): Metric {
- const percent = widget.format === "percent";
- return {
- label: widget.title ?? widget.id,
- value: percent ? total * 100 : total,
- format: percent ? "percent" : "number",
- series: widget.series.points.map((point) => point.value),
- };
-}
-
-/**
- * Draw a resolved widget's values. Expects values to draw: a caller checks for
- * an empty series (and a `number` widget with no total) first, since what such
- * a tile says differs between pages.
- */
-export function WidgetChart({ widget }: { widget: DashboardWidgetView }) {
- const { series, visualization } = widget;
- // Undefined for a plain number, so each chart keeps its own default.
- const formatValue = widget.format === "percent" ? formatShare : undefined;
- const title = widget.title ?? widget.id;
- const chartSeries: ChartSeries[] = [
- {
- name: title,
- data: series.points.map((point) => ({
- label: point.key,
- value: point.value,
- })),
- },
- ];
- switch (visualization) {
- case "line":
- return (
-
- );
- case "area":
- return (
-
- );
- case "bar":
- return ;
- case "number":
- return series.total === null ? null : (
-
- );
- case "table":
- return (
-
- );
- default: {
- // Compile-time exhaustiveness: a new WidgetVisualization must be
- // handled here or this assignment fails to type-check.
- const _exhaustive: never = visualization;
- return _exhaustive;
- }
- }
-}
diff --git a/module/services/frontend/code/src/components/dashboard.tsx b/module/services/frontend/code/src/components/dashboard.tsx
index 6c665bae3..ba53054ad 100644
--- a/module/services/frontend/code/src/components/dashboard.tsx
+++ b/module/services/frontend/code/src/components/dashboard.tsx
@@ -12,7 +12,6 @@
*/
import {
- BarList,
type ChartSeries,
KPIRow,
type Metric,
@@ -160,6 +159,28 @@ function CardWidget({
);
}
+function Bars({ items }: { items: BarsWidget["items"] }) {
+ const max = items.reduce((m, item) => Math.max(m, item.value), 0) || 1;
+ return (
+
+ {items.map((item, i) => (
+
+
+ {item.label}
+ {item.value}
+
+
+
+
+
+ ))}
+
+ );
+}
+
function Widget({ widget }: { widget: DashboardWidget }) {
switch (widget.kind) {
case "sparkline":
@@ -171,12 +192,7 @@ function Widget({ widget }: { widget: DashboardWidget }) {
case "bars":
return (
- ({
- key: item.label,
- value: item.value,
- }))}
- />
+
);
case "metrics":
diff --git a/module/services/frontend/code/ui-inventory.json b/module/services/frontend/code/ui-inventory.json
index ac6dc9006..997f18084 100644
--- a/module/services/frontend/code/ui-inventory.json
+++ b/module/services/frontend/code/ui-inventory.json
@@ -255,9 +255,9 @@
"react",
"../layout/card.js",
"../layout/page.js",
+ "./charts.js",
"./cn.js",
- "./types.js",
- "./widget-chart.js"
+ "./types.js"
],
"owner": "@codefly-dev/ui",
"disposition": "already canonical",
@@ -419,28 +419,6 @@
"coverage": "owner stories; browser/skin verification pending",
"remaining": "Verify explorer rendering, interactive accessibility and visual baselines under external skins."
},
- {
- "source": "packages/codefly-ui/src/dashboard/widget-chart.tsx",
- "owner": "@codefly-dev/ui",
- "disposition": "already canonical",
- "reason": "Shared owner implementation: draws a resolved widget with the metric charts, for the kit Dashboard and the host's solution dashboards.",
- "imports": [
- "./charts.js",
- "./format.js",
- "./metric-chart.js",
- "./metric-geometry.js",
- "./metric-tiles.js",
- "./types.js"
- ],
- "stories": [
- {
- "file": "packages/codefly-ui/stories/composites.stories.tsx",
- "export": "DashboardCharts"
- }
- ],
- "coverage": "owner stories; browser/skin verification pending",
- "remaining": "Verify explorer rendering, interactive accessibility and visual baselines under external skins."
- },
{
"source": "packages/codefly-ui/src/layout/accordion.tsx",
"imports": [
From 98564bf44aeddd2b672ebe8ca3848564cf6c15f7 Mon Sep 17 00:00:00 2001
From: Codefly Automation
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Sun, 11 Oct 2026 21:12:12 -0400
Subject: [PATCH 19/29] feat(ui): group a dashboard into sections and write
every mark in its format
The kit's view model gains sections: DashboardView.sections ({id, title,
description?}[], drawn in the order listed) and DashboardWidgetView.section.
's default layout heads each section with the new exported
DashboardSectionHeader (an
under the dashboard's
), lays each
section's widgets out exactly as before, draws a widget naming no section
first and untitled, and keeps a section no widget names, saying it is
empty. A view with sections is never "no widgets": it goes to the layout,
and a host's `layout` slot receives each tile's section. A view without
sections draws as it did. fromDashboardData carries both fields.
DashboardSectionHeader takes the host's controls without the host drawing
a heading of its own: a leading handle, actions, an `editing` node drawn in
place of the heading, a `label` naming the heading when its title is a
control, and children under the description.
A widget's format now reaches a chart's axis, readout and data table and a
table's values, through formatMetricValue, the function StatTile writes a
scalar with; before, only the scalar honoured it. The kit's `percent`
contract is unchanged: the value is already a percentage.
The kit's preview stylesheet is regenerated; a no-change control on main's
sources reproduced main's committed bytes first. The new classes are those
of SortableBoard, the menu hint and the section layout.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../code/packages/codefly-ui/README.md | 13 +-
.../dashboard/__tests__/dashboard.test.tsx | 207 ++++++++++++++++-
.../src/dashboard/__tests__/types.test.ts | 35 +++
.../codefly-ui/src/dashboard/dashboard.tsx | 215 +++++++++++++++++-
.../codefly-ui/src/dashboard/index.ts | 12 +-
.../codefly-ui/src/dashboard/types.ts | 32 ++-
.../codefly-ui/src/skin/preview.generated.css | 22 +-
7 files changed, 518 insertions(+), 18 deletions(-)
diff --git a/module/services/frontend/code/packages/codefly-ui/README.md b/module/services/frontend/code/packages/codefly-ui/README.md
index 95543fdca..a60201657 100644
--- a/module/services/frontend/code/packages/codefly-ui/README.md
+++ b/module/services/frontend/code/packages/codefly-ui/README.md
@@ -43,8 +43,15 @@ and the v1 subset — is [CATALOG.md](./CATALOG.md).
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`.
+ renderer rather than beside it. A view's `sections` group its widgets under
+ titled headings in the order listed (a widget's `section` names its own);
+ `DashboardSectionHeader` is that heading, exported for a layout of the
+ consumer's own — `SortableBoard`, say, which lets a viewer drag tiles between
+ sections. A widget's `format` reaches its tile, its chart's axis, readout and
+ data table, and its table alike; `percent` there is already a percentage, and
+ `runDashboard` is where a data graph's share is scaled to one. 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` → ``.
@@ -115,7 +122,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`, `DashboardWidget`, metric tiles + charts, `fromDashboardData` (React-only) |
+| `@codefly-dev/ui/dashboard` | `Dashboard`, `DashboardWidget`, `DashboardSectionHeader`, metric tiles + charts, `SortableGrid`/`SortableBoard`, `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 981a3be7b..e9b4eef41 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,7 +1,7 @@
// @vitest-environment happy-dom
import { cleanup, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
-import { Dashboard } from "../dashboard.js";
+import { Dashboard, DashboardSectionHeader } from "../dashboard.js";
import type {
DashboardView,
DashboardWidgetView,
@@ -717,3 +717,208 @@ describe("slots", () => {
expect(screen.queryByText("No widgets.")).toBeNull();
});
});
+
+// A widget's `format` is written by every mark it is drawn with, through the
+// same function a scalar's tile writes its number with. The values here are
+// already percentage points: `runDashboard` is where a declared share is
+// scaled, once (its own test pins 0.4 → 40).
+describe("a percent widget", () => {
+ const rates: WidgetSeries = {
+ points: [
+ { key: "won", value: 40 },
+ { key: "lost", value: 60 },
+ ],
+ total: 100,
+ };
+
+ it.each(["line", "area", "bar"] as const)(
+ "writes a %s chart's values as percentages in its data table",
+ (visualization) => {
+ show({
+ id: "w",
+ title: "Outcomes",
+ visualization,
+ series: rates,
+ format: "percent",
+ });
+ const table = screen.getByRole("table", { name: "Outcomes" });
+ expect(within(table).getByText("40%")).toBeTruthy();
+ expect(within(table).getByText("60%")).toBeTruthy();
+ },
+ );
+
+ it("writes a table's values as percentages", () => {
+ show({
+ id: "w",
+ title: "Outcomes",
+ visualization: "table",
+ series: rates,
+ format: "percent",
+ });
+ const table = screen.getByRole("table", { name: "Outcomes" });
+ expect(within(table).getByText("40%")).toBeTruthy();
+ expect(within(table).queryByText("40")).toBeNull();
+ });
+
+ it("leaves a plain number's chart and table as numbers", () => {
+ show(
+ { id: "c", title: "Chart", visualization: "bar", series: rates },
+ { id: "t", title: "Table", visualization: "table", series: rates },
+ );
+ for (const name of ["Chart", "Table"]) {
+ const table = screen.getByRole("table", { name });
+ expect(within(table).getByText("40")).toBeTruthy();
+ expect(within(table).queryByText("40%")).toBeNull();
+ }
+ });
+});
+
+describe("sections", () => {
+ const sections = [
+ { id: "totals", title: "Totals", description: "All time" },
+ { id: "trends", title: "Trends" },
+ { id: "later", title: "Later" },
+ ];
+ const sectioned: DashboardWidgetView[] = [
+ {
+ id: "trend",
+ title: "Sign-ins per day",
+ visualization: "line",
+ series: daily,
+ section: "trends",
+ },
+ {
+ id: "total",
+ title: "Sign-ins",
+ visualization: "number",
+ series: daily,
+ section: "totals",
+ },
+ { id: "loose", title: "Loose", visualization: "number", series: daily },
+ ];
+
+ it("draws each section under its own heading, in the view's order, with its widgets", () => {
+ const { container } = render(
+ ,
+ );
+ expect(
+ screen
+ .getAllByRole("heading", { level: 3 })
+ .filter((heading) => heading.dataset.slot === "dashboard-section-title")
+ .map((heading) => heading.textContent),
+ ).toEqual(["Totals", "Trends", "Later"]);
+ expect(screen.getByText("All time")).toBeTruthy();
+ const drawn = [
+ ...container.querySelectorAll('[data-slot="dashboard-section"]'),
+ ] as HTMLElement[];
+ expect(within(drawn[0]).getByText("Sign-ins")).toBeTruthy();
+ expect(
+ within(drawn[1]).getByRole("table", { name: "Sign-ins per day" }),
+ ).toBeTruthy();
+ });
+
+ it("draws a widget that names no section above every section, untitled", () => {
+ const { container } = render(
+ ,
+ );
+ const loose = screen.getByText("Loose");
+ const first = container.querySelector(
+ '[data-slot="dashboard-section"]',
+ ) as HTMLElement;
+ expect(within(first).queryByText("Loose")).toBeNull();
+ expect(
+ loose.compareDocumentPosition(first) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ });
+
+ it("keeps a declared section no widget names, and says it is empty", () => {
+ render();
+ expect(screen.getByText("No widgets in this section.")).toBeTruthy();
+ });
+
+ it("draws its sections, not the empty state, when no widget is shown", () => {
+ render();
+ expect(screen.queryByText("No widgets.")).toBeNull();
+ expect(screen.getAllByText("No widgets in this section.")).toHaveLength(3);
+ });
+
+ it("hands a layout of the host's own each tile's section, and calls it with no tiles when there are sections", () => {
+ const seen: (string | undefined)[][] = [];
+ const layout = (tiles: { widget: DashboardWidgetView }[]) => {
+ seen.push(tiles.map((tile) => tile.widget.section));
+ return
host layout
;
+ };
+ const { rerender } = render(
+ ,
+ );
+ rerender();
+ expect(seen).toEqual([["trends", "totals", undefined], []]);
+ });
+
+ it("draws a view without sections as before", () => {
+ const { container } = render(
+ ({
+ ...widget,
+ section: undefined,
+ })),
+ }}
+ />,
+ );
+ expect(
+ container.querySelector('[data-slot="dashboard-section"]'),
+ ).toBeNull();
+ expect(screen.getByText("Loose")).toBeTruthy();
+ });
+});
+
+describe("DashboardSectionHeader", () => {
+ it("draws what is being edited in place of the heading", () => {
+ render(
+ }
+ />,
+ );
+ expect(screen.queryByRole("heading", { name: "Totals" })).toBeNull();
+ expect(screen.getByRole("textbox", { name: "Section title" })).toBeTruthy();
+ });
+
+ it("puts a leading handle and actions beside the title", () => {
+ render(
+ Move}
+ actions={}
+ >
+
Empty.
+ ,
+ );
+ expect(
+ screen.getByRole("heading", { name: "Totals", level: 3 }),
+ ).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Move" })).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Add" })).toBeTruthy();
+ expect(screen.getByText("Empty.")).toBeTruthy();
+ });
+
+ it("names a section heading by its label when its title is a control", () => {
+ render(
+
+ Totals
+
+ }
+ />,
+ );
+ expect(
+ screen.getByRole("heading", { name: "Totals", level: 3 }),
+ ).toBeTruthy();
+ expect(
+ screen.getByRole("button", { name: "Rename section Totals" }),
+ ).toBeTruthy();
+ });
+});
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..27fcff286 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,38 @@ describe("fromDashboardData", () => {
expect(view.widgets).toEqual([]);
});
});
+
+describe("fromDashboardData's sections", () => {
+ it("carries the sections and each widget's section through", () => {
+ const view = fromDashboardData({
+ sections: [{ id: "totals", title: "Totals", description: "All time" }],
+ widgets: [
+ {
+ id: "total",
+ visualization: "number",
+ series: { points: [{ key: "all", value: 1 }], total: 1 },
+ section: "totals",
+ },
+ ],
+ });
+ expect(view.sections).toEqual([
+ { id: "totals", title: "Totals", description: "All time" },
+ ]);
+ expect(view.widgets[0].section).toBe("totals");
+ });
+
+ it("forwards a percent widget's values as they are: the producer has scaled them", () => {
+ const view = fromDashboardData({
+ widgets: [
+ {
+ id: "rate",
+ visualization: "number",
+ series: { points: [{ key: "all", value: 40 }], total: 40 },
+ format: "percent",
+ },
+ ],
+ });
+ expect(view.widgets[0]).toMatchObject({ format: "percent" });
+ expect(view.widgets[0].series?.total).toBe(40);
+ });
+});
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 91ede8d7e..08c5113bb 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
@@ -28,8 +28,14 @@ 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 {
+ formatMetricValue,
+ KPIRow,
+ type Metric,
+ StatTile,
+} from "./metric-tiles.js";
import type {
+ DashboardSectionView,
DashboardView,
DashboardWidgetView,
SeriesPoint,
@@ -171,6 +177,24 @@ function widgetMetric(
};
}
+/**
+ * How a widget's chart and table write a value, or `undefined` for a plain
+ * number, so each mark keeps its own default.
+ *
+ * This is the one place a widget's `format` reaches a chart or a table, and it
+ * writes through `formatMetricValue` — the function a scalar's `StatTile`
+ * writes its number with — so a widget reads the same wherever its value is
+ * drawn. It does no scaling: a `percent` value is already a percentage here
+ * (see `DashboardWidgetView.format`).
+ */
+function widgetValueFormat(
+ widget: DashboardWidgetView,
+): ((value: number) => string) | undefined {
+ const { format } = widget;
+ if (format === undefined || format === "number") return undefined;
+ return (value) => formatMetricValue(value, format);
+}
+
/** One series on the shared label axis the metric charts consume. */
function chartSeries(
widget: DashboardWidgetView,
@@ -193,18 +217,31 @@ function WidgetBody({
series: WidgetSeries;
}) {
const title = widgetTitle(widget);
+ const formatValue = widgetValueFormat(widget);
switch (widget.visualization) {
case "line":
return (
-
+
);
case "area":
return (
-
+
);
case "bar":
return (
-
+
);
case "number":
// A scalar is drawn as a tile by DashboardWidget, never as a card
@@ -222,7 +259,9 @@ function WidgetBody({
))}
@@ -380,10 +419,16 @@ export interface DashboardSlots {
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.
+ * whose tiles the viewer can reorder. Tiles arrive in the view's order,
+ * each with its widget's `section`; a layout that draws the view's
+ * sections heads each with {@link DashboardSectionHeader}.
*/
layout?: (tiles: DashboardTile[]) => ReactNode;
- /** Shown in place of the layout when the view holds no widgets. */
+ /**
+ * Shown in place of the layout when the view holds no widgets and no
+ * sections. A view with sections goes to the layout even with no widgets,
+ * because its sections are still drawn.
+ */
empty?: ReactNode;
}
@@ -469,6 +514,150 @@ function DefaultLayout({
);
}
+export interface DashboardSectionHeaderProps {
+ /** The section's title, drawn as its heading. */
+ title: ReactNode;
+ /**
+ * The heading's accessible name, for a `title` that carries a control —
+ * a button that renames the section, say — whose own name would otherwise
+ * name the heading.
+ */
+ label?: string;
+ /** What the section is for, under its title. */
+ description?: ReactNode;
+ /**
+ * Drawn in place of the heading while the viewer edits the title — a field,
+ * say. The heading goes meanwhile, so a field is never inside a heading.
+ */
+ editing?: ReactNode;
+ /** Before the title: a handle the section is dragged by. */
+ leading?: ReactNode;
+ /** Controls opposite the title. */
+ actions?: ReactNode;
+ /** Under the title and description: a note about the section's contents. */
+ children?: ReactNode;
+ className?: string;
+}
+
+/**
+ * The heading of one section of a dashboard: its title, what it is for, and
+ * whatever controls a host puts beside it. {@link Dashboard} draws a declared
+ * section with it; a host that lays the sections out itself — so a viewer can
+ * drag tiles between them — draws each one's heading with it too, so a section
+ * reads the same with and without the host's controls.
+ *
+ * The title is an `
+ );
+}
+
+/** The tiles of one section, or of the untitled group above every section. */
+interface TileGroup {
+ /** Undefined for the untitled group. */
+ section?: DashboardSectionView;
+ tiles: DashboardTile[];
+}
+
+/**
+ * The tiles in their sections, in the order the view lists the sections, each
+ * section's tiles in the view's order. A tile naming no section, or one the
+ * view does not hold, goes in an untitled group drawn first; that group is
+ * left out when it is empty and there are sections, and a declared section
+ * with no tiles is kept, because the declaration says it is there.
+ */
+function groupTiles(
+ tiles: DashboardTile[],
+ sections: readonly DashboardSectionView[],
+): TileGroup[] {
+ const declared = new Set(sections.map((section) => section.id));
+ const untitled = tiles.filter(
+ (tile) =>
+ tile.widget.section === undefined || !declared.has(tile.widget.section),
+ );
+ return [
+ ...(untitled.length > 0 || sections.length === 0
+ ? [{ tiles: untitled }]
+ : []),
+ ...sections.map((section) => ({
+ section,
+ tiles: tiles.filter((tile) => tile.widget.section === section.id),
+ })),
+ ];
+}
+
+function SectionedLayout({
+ groups,
+ ...layout
+}: {
+ groups: TileGroup[];
+ columns: 1 | 2 | 3 | 4;
+ declaredColumns?: 1 | 2 | 3 | 4;
+ grid: boolean;
+}) {
+ return (
+
+ );
+}
+
/**
* Render a resolved dashboard. Pass a `DashboardView` (from your own data or via
* `fromDashboardData(runDashboard(...))`). `accent` overrides the `--primary`
@@ -499,6 +688,7 @@ export function Dashboard({
} as React.CSSProperties)
: undefined;
const isGrid = (data.layout ?? "grid") === "grid";
+ const sections = data.sections ?? [];
const tiles: DashboardTile[] = data.widgets.map((widget) => ({
id: widget.id,
@@ -526,12 +716,21 @@ export function Dashboard({
className={className}
style={style}
>
- {tiles.length === 0 ? (
+ {/* A view with sections is never "no widgets": its sections are still
+ there to be drawn, each saying it is empty. */}
+ {tiles.length === 0 && sections.length === 0 ? (
(slots?.empty ?? (
No widgets.
))
) : slots?.layout ? (
slots.layout(tiles)
+ ) : sections.length > 0 ? (
+
) : (
({ ...section })),
widgets: data.widgets.map((widget) => ({
id: widget.id,
visualization: widget.visualization,
title: widget.title,
series: widget.series,
+ section: widget.section,
format: widget.format,
unit: widget.unit,
higherIsBetter: widget.higherIsBetter,
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 044f7676b..85296aa31 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
@@ -542,6 +542,10 @@
bottom: calc(var(--appearance-spacing) * 4);
}
+ .left-0 {
+ left: 0;
+ }
+
.left-1\/2 {
left: 50%;
}
@@ -1078,6 +1082,10 @@
width: calc(var(--appearance-spacing) * 8);
}
+ .w-10 {
+ width: calc(var(--appearance-spacing) * 10);
+ }
+
.w-12 {
width: calc(var(--appearance-spacing) * 12);
}
@@ -1090,6 +1098,10 @@
width: calc(var(--appearance-spacing) * 24);
}
+ .w-32 {
+ width: calc(var(--appearance-spacing) * 32);
+ }
+
.w-72 {
width: calc(var(--appearance-spacing) * 72);
}
@@ -1429,6 +1441,12 @@
margin-block-end: calc(var(--appearance-spacing) * calc(1 - var(--tw-space-y-reverse)));
}
+ :where(.space-y-3 > :not(:last-child)) {
+ --tw-space-y-reverse: 0;
+ margin-block-start: calc(calc(var(--appearance-spacing) * 3) * var(--tw-space-y-reverse));
+ margin-block-end: calc(calc(var(--appearance-spacing) * 3) * calc(1 - var(--tw-space-y-reverse)));
+ }
+
:where(.space-y-4 > :not(:last-child)) {
--tw-space-y-reverse: 0;
margin-block-start: calc(calc(var(--appearance-spacing) * 4) * var(--tw-space-y-reverse));
@@ -2890,10 +2908,6 @@
opacity: .5;
}
- .opacity-70 {
- opacity: .7;
- }
-
.opacity-75 {
opacity: .75;
}
From 7e2b3faae6e4a8e5c23e4df7ac15261e0220f057 Mon Sep 17 00:00:00 2001
From: Codefly Automation
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Sun, 11 Oct 2026 21:12:12 -0400
Subject: [PATCH 20/29] fix(sdk): scale a percent metric's share once, in
runDashboard; carry sections
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A data graph's `format: "percent"` reads a metric's value as a share from 0
to 1, because that is what a ratio produces. The kit's view reads its
`percent` as percentage points. This branch had put the graph's share
semantics on ResolvedWidget.format, which fromDashboardData forwards
structurally, so a remote drawing runDashboard's output would have written
0.4 as "0.4%".
runDashboard is now the one place the two meet: a widget whose metric is
declared percent carries its series multiplied by 100 and format "percent"
(WidgetValueFormat, the kit's convention); byMetric keeps the shares, and
nothing downstream scales again. A test pins 0.4 → 40 on the widget and
0.4 on byMetric, and fails with the scaling removed.
DashboardData also carries the dashboard's sections and each widget's
section, so a remote drawing its own dashboard with the kit gets them.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../saas-sdk/src/datagraph/dashboard.ts | 50 +++++++++--
.../code/packages/saas-sdk/src/index.ts | 1 +
.../code/packages/saas-sdk/src/schema.ts | 2 +
.../packages/saas-sdk/test/dashboard.test.ts | 87 +++++++++++++++++++
4 files changed, 134 insertions(+), 6 deletions(-)
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 abe4fe981..7755f1555 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,7 +1,7 @@
import type {
DashboardLayout,
+ DashboardSection,
DataGraph,
- MetricValueFormat,
WidgetVisualization,
} from "../schema.js";
import { resolveMetrics } from "./run.js";
@@ -24,15 +24,47 @@ export function defineDataGraph(graph: T): T {
/** Metric ids declared on a data graph, as a literal union (or `string`). */
export type MetricId = T["metrics"][number]["id"];
+/**
+ * How a resolved widget's values are written. `percent` means the series is in
+ * percentage points — 40 is "40%" — which is the kit's `DashboardWidgetView`
+ * convention, so a widget crosses `fromDashboardData` as it is.
+ */
+export type WidgetValueFormat = "number" | "percent";
+
/** A dashboard widget bound to its resolved metric series. */
export interface ResolvedWidget {
id: string;
visualization: WidgetVisualization;
title?: string;
+ /** The declared section the widget is drawn in, if the dashboard has sections. */
+ section?: string;
metricId: string;
+ /**
+ * The metric's series as the widget draws it. For a metric declared
+ * `format: "percent"` its values are percentage points, not the metric's
+ * shares (see {@link WidgetValueFormat}); `byMetric` keeps the shares.
+ */
series: MetricSeries;
- /** How the widget's values are written: its metric's declared format. */
- format?: MetricValueFormat;
+ /** How the widget's values are written; absent for a plain number. */
+ format?: WidgetValueFormat;
+}
+
+/**
+ * A share from 0 to 1 as percentage points: the one place a data graph's
+ * `percent`, which reads a metric's value as a share, becomes the percentage a
+ * dashboard draws. A ratio is not always a share ("versions per memo" is one
+ * that is not), so a metric says so by declaring the format, and nothing
+ * downstream scales again: the kit's `format: "percent"` takes 40 as "40%".
+ */
+function asPercentage(series: MetricSeries): MetricSeries {
+ return {
+ ...series,
+ points: series.points.map((point) => ({
+ ...point,
+ value: point.value * 100,
+ })),
+ total: series.total === null ? null : series.total * 100,
+ };
}
/**
@@ -45,6 +77,8 @@ export interface DashboardData {
id: string;
title?: string;
layout: DashboardLayout;
+ /** The sections the widgets are grouped into, in declared order. */
+ sections?: readonly DashboardSection[];
widgets: ResolvedWidget[];
byMetric: Record, MetricSeries>;
}
@@ -81,14 +115,17 @@ export async function runDashboard(
`widget '${widget.id}' is bound to unknown metric: ${widget.metric}`,
);
}
+ const percent =
+ graph.metrics.find((metric) => metric.id === widget.metric)?.format ===
+ "percent";
return {
id: widget.id,
visualization: widget.visualization,
title: widget.title,
+ section: widget.section,
metricId: widget.metric,
- series,
- format: graph.metrics.find((metric) => metric.id === widget.metric)
- ?.format,
+ series: percent ? asPercentage(series) : series,
+ format: percent ? "percent" : undefined,
};
});
@@ -96,6 +133,7 @@ export async function runDashboard(
id: dashboard.id,
title: dashboard.title,
layout: dashboard.layout,
+ 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 21fda6450..1fb1774c5 100644
--- a/module/services/frontend/code/packages/saas-sdk/src/index.ts
+++ b/module/services/frontend/code/packages/saas-sdk/src/index.ts
@@ -6,6 +6,7 @@ export {
type MetricId,
type ResolvedWidget,
runDashboard,
+ type WidgetValueFormat,
} from "./datagraph/dashboard.js";
export {
assertAuditScopeContract,
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 998c00ea0..f9227eb69 100644
--- a/module/services/frontend/code/packages/saas-sdk/src/schema.ts
+++ b/module/services/frontend/code/packages/saas-sdk/src/schema.ts
@@ -41,6 +41,8 @@ export type MetricOperation = "sum" | "ratio" | "difference";
* How a metric's value is written wherever the dashboard shows it. `number`,
* the default, writes it as it is. `percent` reads it as a share from 0 to 1
* and writes it times 100 with a percent sign, so 0.4 reads "40%".
+ * `runDashboard` does that scaling, once, on the widgets it resolves (see
+ * `ResolvedWidget.format`); nothing after it scales again.
*/
export type MetricValueFormat = "number" | "percent";
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 6dc91d64e..547b22dd2 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
@@ -121,6 +121,93 @@ describe("runDashboard", () => {
]);
});
+ // The one conversion boundary: a data graph's `percent` reads a value as a
+ // share, the kit's reads it as percentage points, and runDashboard is where
+ // the one becomes the other — once, so 0.4 is drawn "40%", never "0.4%".
+ it("resolves a percent metric's shares into percentage points, and leaves byMetric as shares", async () => {
+ const shares = defineDataGraph({
+ events: [{ name: "closed", type: "deal.closed.v1" }],
+ metrics: [
+ {
+ id: "closed",
+ kind: "source",
+ filter: { event: "closed" },
+ groupBy: "event_type",
+ aggregation: "count",
+ },
+ {
+ id: "won",
+ kind: "source",
+ filter: { event: "closed", actor: "winner" },
+ groupBy: "event_type",
+ aggregation: "count",
+ },
+ {
+ id: "win_rate",
+ kind: "derived",
+ operation: "ratio",
+ inputs: ["won", "closed"],
+ format: "percent",
+ },
+ {
+ id: "per_close",
+ kind: "derived",
+ operation: "ratio",
+ inputs: ["won", "closed"],
+ },
+ ],
+ dashboards: [
+ {
+ id: "deals",
+ layout: "grid",
+ widgets: [
+ { id: "rate", metric: "win_rate", visualization: "number" },
+ { id: "plain", metric: "per_close", visualization: "number" },
+ ],
+ },
+ ],
+ });
+ const { client } = fakeAuditClient((request) => [
+ { key: "all", count: request.actorId === "winner" ? 2 : 5 },
+ ]);
+
+ const data = await runDashboard(client, shares, "deals", context);
+ const [rate, plain] = data.widgets;
+
+ expect(rate.format).toBe("percent");
+ expect(rate.series.points.map((point) => point.value)).toEqual([40]);
+ expect(rate.series.total).toBe(40);
+ expect(data.byMetric.win_rate.total).toBe(0.4);
+ expect(plain.format).toBeUndefined();
+ expect(plain.series.total).toBe(0.4);
+ });
+
+ it("carries the dashboard's sections and each widget's section", async () => {
+ const sectioned = defineDataGraph({
+ ...graph,
+ dashboards: [
+ {
+ ...graph.dashboards[0],
+ sections: [{ id: "totals", title: "Totals" }],
+ widgets: graph.dashboards[0].widgets.map((widget) => ({
+ ...widget,
+ section: "totals",
+ })),
+ },
+ ],
+ });
+ const { client } = fakeAuditClient(() => [
+ { key: "user.signed_in.v1", count: 7 },
+ ]);
+
+ const data = await runDashboard(client, sectioned, "activity", context);
+
+ expect(data.sections).toEqual([{ id: "totals", title: "Totals" }]);
+ expect(data.widgets.map((widget) => widget.section)).toEqual(
+ data.widgets.map(() => "totals"),
+ );
+ });
+
it("rejects an unknown dashboard id", async () => {
const { client } = fakeAuditClient(() => []);
await expect(
From 95766c1256a320b9aab8b41b0cf80cbb73dd2937 Mon Sep 17 00:00:00 2001
From: Codefly Automation
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Sun, 11 Oct 2026 21:12:25 -0400
Subject: [PATCH 21/29] feat(frontend): compose a viewer's sections through the
kit's dashboard slots
SolutionDashboard rebuilds this branch's arrangement on #1043's file
instead of beside it. The viewer's layout (dashboard-layout.ts, version 2,
still the labelled browser-storage STOPGAP) is resolved into the view the
kit's takes: the viewer's sections in their order under their
titles, each shown widget in the section the viewer put it in. The host's
controls go into the renderer's slots:
- `layout`: the kit's SortableBoard, one group per section, each headed by
the kit's DashboardSectionHeader with the grip, the rename title, the
section's + and its options in it; tiles move between sections by drag
or arrow keys, and an arrow key that moves nothing saves nothing;
- `renderWidget`: SolutionWidget, still #1043's per-widget query mounting
the kit's DashboardWidget; its query is now the shared useWidgetSeries,
so a + menu asks the same key (and a refusal it meets deletes the same
snapshots) only while it is open, and says "No data yet." or "Telemetry
incomplete." beside a removed widget whose tile would have nothing;
- `actions`: the dashboard's + when it declares no sections.
A widget's format is bound from the resolved answer with its series,
because runDashboard is where a share became percentage points. A line,
area, bar or table tile spans two grid rows and every tile fills its cell,
so two number tiles stack beside a chart. "Add section" and the status
region sit after the renderer.
The branch's host tests are carried over onto #1043's file; those that read
its own card now read the kit's markup (section headings by their slot, a
tile's emptiness by the kit's "No data" / "Partial data" badges). A percent
ratio is asserted as "40%" on the tile, the bar chart's data table and a
table widget through the real SDK path.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
module/services/frontend/AGENTS.md | 22 +-
.../code/src/solutions/SolutionDashboard.tsx | 782 ++++++++++--
.../__tests__/SolutionDashboard.test.tsx | 1116 ++++++++++++++++-
3 files changed, 1810 insertions(+), 110 deletions(-)
diff --git a/module/services/frontend/AGENTS.md b/module/services/frontend/AGENTS.md
index dc11c2293..6ef5699b4 100644
--- a/module/services/frontend/AGENTS.md
+++ b/module/services/frontend/AGENTS.md
@@ -133,14 +133,30 @@ renderer's slots:
`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 viewer's arrangement** — the declared sections, and within and between
+ them the tiles: reorder or move a tile by drag or arrow keys, remove one, add
+ it back from a +, rename, reorder or remove a section, add one of their own.
+ It is saved per viewer and organization (`dashboard-layout.ts`, version 2,
+ still the labelled browser-storage STOPGAP) and resolved here into the view
+ the kit draws — the viewer's sections, in the viewer's order, under the
+ viewer's titles, each widget in the section the viewer put it in. The
+ renderer's `layout` slot lays it out as the kit's `SortableBoard`, each
+ section headed by the kit's `DashboardSectionHeader` with the host's grip,
+ rename, + and options in it. Every + offers only the declared widgets the
+ viewer removed, asks each one's query only while it is open (the tile's own
+ query, so a tile added back draws from that answer), and says, muted, when
+ the tile would have nothing to show ("No data yet.", "Telemetry
+ incomplete."). A chart, bar or table tile spans two grid rows, so two number
+ tiles stack beside it;
- **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.
+A widget's `format` comes from the same resolved answer as its series, because
+`runDashboard` is where a declared share became percentage points; binding it
+from the declaration instead would pair a percentage with a share's format.
+
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
diff --git a/module/services/frontend/code/src/solutions/SolutionDashboard.tsx b/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
index 330d45656..cd81681e0 100644
--- a/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
+++ b/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
@@ -4,8 +4,13 @@ import type {
Dashboard as DashboardDeclaration,
DataGraph,
MetricWidget,
+ WidgetVisualization,
} from "@codefly/saas-plugin-manifest";
-import { createSaasClient, runDashboard } from "@codefly-dev/saas-sdk";
+import {
+ createSaasClient,
+ type ResolvedWidget,
+ runDashboard,
+} from "@codefly-dev/saas-sdk";
// The dashboard is DRAWN by the shared kit's `` — 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:
@@ -15,9 +20,12 @@ import { createSaasClient, runDashboard } from "@codefly-dev/saas-sdk";
// renderer's own slots.
import {
Dashboard,
+ DashboardSectionHeader,
+ type DashboardSectionView,
DashboardWidget,
type DashboardWidgetView,
- SortableGrid,
+ SortableBoard,
+ type SortableGroupHandle,
type WidgetReadFailure,
} from "@codefly-dev/ui/dashboard";
import {
@@ -25,8 +33,15 @@ import {
useQuery,
useQueryClient,
} from "@tanstack/react-query";
-import { GripVertical, Info, Plus, X } from "lucide-react";
-import { type ReactNode, useState, useSyncExternalStore } from "react";
+import { Ellipsis, GripVertical, Info, Pencil, Plus, X } from "lucide-react";
+import {
+ type KeyboardEvent,
+ type ReactNode,
+ useEffect,
+ useRef,
+ useState,
+ useSyncExternalStore,
+} from "react";
import { flushSync } from "react-dom";
import { resolveActor } from "@/features/audit/model/transforms";
import {
@@ -47,6 +62,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
+ Input,
Popover,
PopoverContent,
PopoverTitle,
@@ -54,15 +70,29 @@ import {
} from "@/shared/ui";
import {
addableTiles,
+ addSection,
addTile,
+ canRemoveSection,
+ type Layout,
+ type LayoutSection,
layoutKey,
+ layoutTiles,
moveBy,
+ moveSection,
+ moveSectionBy,
+ moveTileToSection,
+ newSectionId,
parseLayout,
readSavedLayout,
+ removeSection,
removeTile,
+ renameSection,
+ SECTION_TITLE_MAX,
+ sectionTitle,
serializeLayout,
swapTiles,
tileWidget,
+ UNSECTIONED,
writeSavedLayout,
} from "./dashboard-layout";
import {
@@ -83,6 +113,17 @@ import {
// the DataGraph — it can never widen a query past the viewer's own org.
const sdk = createSaasClient(apiTransport);
+// The tiles about twice as tall as a number: a chart with axes, a bar chart or
+// a table. In the grid each spans two rows, so two numbers stack beside it
+// rather than one number leaving the rest of the row empty. The grid sizes the
+// rows from what the tiles hold, and every tile fills its cell.
+const TALL: ReadonlySet = new Set([
+ "line",
+ "area",
+ "bar",
+ "table",
+]);
+
// Dashboard id of the throwaway single-widget graph each card resolves. The card
// resolves its own widget in isolation, so this id is never surfaced.
const SINGLE_WIDGET_DASHBOARD = "widget";
@@ -91,48 +132,47 @@ const SINGLE_WIDGET_DASHBOARD = "widget";
// resolves only that widget's metric (and its derived inputs). Resolving each
// widget through its own call is what isolates failures: a widget whose metric's
// audit query errors fails alone, instead of blanking every sibling that shares
-// the dashboard's single batched resolution.
+// the dashboard's single batched resolution. The throwaway dashboard declares
+// no sections, so its widget names none.
function singleWidgetGraph(graph: DataGraph, widget: MetricWidget): DataGraph {
return {
events: graph.events,
metrics: graph.metrics,
dashboards: [
- { id: SINGLE_WIDGET_DASHBOARD, layout: "grid", widgets: [widget] },
+ {
+ id: SINGLE_WIDGET_DASHBOARD,
+ layout: "grid",
+ widgets: [{ ...widget, section: undefined }],
+ },
],
};
}
-// 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,
- actions,
-}: {
+// Where a dashboard's widgets resolve: the solution's graph, and whose
+// dashboard it is.
+interface WidgetSource {
graph: DataGraph;
- widget: MetricWidget;
solutionId: string;
dashboardId: string;
orgId: string;
- actions: ReactNode;
-}) {
+}
+
+// One widget's resolution. The tile asks for it when drawn, and a + menu asks
+// for each removed widget while it is open, with the same key: a tile added
+// back from the menu draws from the answer the menu got, and a refusal either
+// of them meets deletes the same snapshots.
+function useWidgetSeries(
+ { graph, solutionId, dashboardId, orgId }: WidgetSource,
+ widget: MetricWidget,
+ enabled = true,
+) {
// 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 client = useQueryClient();
const source = widgetSource({ solutionId, dashboardId, widget, orgId });
- const { data, isError, error } = useQuery({
+ return useQuery({
queryKey: [...source, graph],
queryFn: () =>
runDashboard(
@@ -152,8 +192,31 @@ function SolutionWidget({
}
throw cause;
}),
- enabled: orgId !== "",
+ enabled: enabled && orgId !== "",
});
+}
+
+// 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({
+ source,
+ widget,
+ actions,
+ className,
+}: {
+ source: WidgetSource;
+ widget: MetricWidget;
+ actions: ReactNode;
+ className?: string;
+}) {
+ const { data, isError, error } = useWidgetSeries(source, widget);
const outcome = readOutcome(isError, error);
// Whatever the cache still holds. A refusal has already emptied it, which is
@@ -167,13 +230,36 @@ function SolutionWidget({
widget={{
...declaredWidget(widget),
series: retained,
+ // The format travels with the series it describes: `runDashboard`
+ // is where a declared share became percentage points, so the two
+ // are read off the same answer and can never disagree.
+ format: data?.format,
failure: WIDGET_FAILURE[outcome],
}}
actions={actions}
+ className={className}
/>
);
}
+/**
+ * What the + menu says, muted, beside a removed widget whose tile would have
+ * nothing to show; undefined when it would. It asks what the kit's tile asks:
+ * a series with no points is no data for every visualization, and a number
+ * also needs a total. A partial series is said to be partial, because that is
+ * why it has nothing, and missing telemetry is not the same as no events.
+ */
+function withoutValues({
+ series,
+ visualization,
+}: ResolvedWidget): string | undefined {
+ const empty = series.points.length === 0 || series.coverage === "empty";
+ const noTotal = visualization === "number" && series.total === null;
+ if (!empty && !noTotal) return undefined;
+ if (series.coverage === "partial") return "Telemetry incomplete.";
+ return empty ? "No data yet." : "Total unavailable.";
+}
+
/**
* Every cached revision of one widget's source — the query key without the
* declaration that produced it.
@@ -293,7 +379,7 @@ function noSubscription() {
function useDashboardLayout(
storageKey: string,
dashboard: DashboardDeclaration,
-): [string[], (next: readonly string[]) => void] {
+): [Layout, (next: Layout) => void] {
const stored = useSyncExternalStore(
noSubscription,
() => readSavedLayout(browserStorage(), storageKey),
@@ -308,7 +394,7 @@ function useDashboardLayout(
edited?.key === storageKey ? edited.raw : stored,
dashboard,
);
- const save = (next: readonly string[]) => {
+ const save = (next: Layout) => {
const raw = serializeLayout(next, dashboard);
setEdited({ key: storageKey, raw });
writeSavedLayout(browserStorage(), storageKey, raw);
@@ -323,6 +409,21 @@ const ARROW_STEP: Partial> = {
ArrowRight: 1,
};
+// Sections are one column, so only up and down move one.
+const SECTION_STEP: Partial> = {
+ ArrowUp: -1,
+ ArrowDown: 1,
+};
+
+// The section a tile is in, and its place there.
+function placeOf(
+ layout: Layout,
+ tileId: string,
+): { section: LayoutSection; index: number } | undefined {
+ const section = layout.find(({ tiles }) => tiles.includes(tileId));
+ return section && { section, index: section.tiles.indexOf(tileId) };
+}
+
// How many of the newest events the ⓘ lists.
const RECENT_EVENTS = 5;
@@ -492,27 +593,56 @@ function MetricInfo({
);
}
+// A removed widget in a + menu, and, beside its title, what its tile would say
+// in place of values it does not have. It asks the tile's own query, only while
+// the menu is open. Until there is an answer, and when the query fails, it says
+// nothing: a failure is not "no data".
+function AddableTile({
+ source,
+ widget,
+ label,
+ open,
+ onAdd,
+}: {
+ source: WidgetSource;
+ widget: MetricWidget;
+ label: string;
+ open: boolean;
+ onAdd: () => void;
+}) {
+ const { data, isError } = useWidgetSeries(source, widget, open);
+ const hint = isError || !data ? undefined : withoutValues(data);
+ return (
+
+ {label}
+
+ );
+}
+
// Lists what the viewer can put back on the dashboard: the declared widgets
// they removed. A preference never adds a metric the dashboard does not draw
-// (ADR 0007).
+// (ADR 0007). Each section has its own, which puts the widget back there.
function AddTileMenu({
label,
+ source,
addable,
onAdd,
}: {
label: string;
- addable: { id: string; label: string }[];
+ source: WidgetSource;
+ addable: { id: string; label: string; widget: MetricWidget }[];
onAdd: (tileId: string) => void;
}) {
+ // Nothing is asked about a removed widget until the viewer opens the menu.
+ const [open, setOpen] = useState(false);
return (
-
+
}
@@ -526,9 +656,14 @@ function AddTileMenu({
) : (
addable.map((tile) => (
- onAdd(tile.id)}>
- {tile.label}
-
+ onAdd(tile.id)}
+ />
))
)}
@@ -536,8 +671,160 @@ function AddTileMenu({
);
}
-// A dashboard the viewer can arrange: drag a tile to reorder (or focus its grip
-// and use the arrow keys), × to remove one, + to add one back.
+// A section's title while the viewer renames it, focused with the title
+// selected. Enter or leaving the field keeps what was typed; Escape keeps the
+// title as it was. `refocus` says the viewer is still here (they pressed a
+// key), so focus goes back to where the rename began.
+function SectionTitleInput({
+ title,
+ onDone,
+}: {
+ title: string;
+ onDone: (typed: string | null, refocus: boolean) => void;
+}) {
+ const input = useRef(null);
+ // Leaving the field after Enter or Escape is not a second answer.
+ const done = useRef(false);
+ const finish = (typed: string | null, refocus: boolean) => {
+ if (done.current) return;
+ done.current = true;
+ onDone(typed, refocus);
+ };
+ useEffect(() => {
+ input.current?.focus();
+ input.current?.select();
+ }, []);
+ return (
+ {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ finish(event.currentTarget.value, true);
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ event.stopPropagation();
+ finish(null, true);
+ }
+ }}
+ onBlur={(event) => finish(event.currentTarget.value, false)}
+ />
+ );
+}
+
+// A section's options: rename it, or remove it. The last section left cannot
+// be removed.
+function SectionMenu({
+ sectionId,
+ title,
+ removable,
+ onRename,
+ onRemove,
+}: {
+ sectionId: string;
+ title: string;
+ removable: boolean;
+ onRename: () => void;
+ onRemove: () => void;
+}) {
+ // Renaming puts a focused field in the title's place. The menu hands the
+ // focus back to its trigger only while nothing else has taken it, so the
+ // field keeps it; an explicit `finalFocus` would take it back regardless.
+ return (
+
+
+ }
+ >
+
+
+
+ Rename section
+
+ Remove section
+
+
+
+ );
+}
+
+// A section's title as the way to rename it, with a pencil on hover or focus to
+// say so. The kit's section header names its heading by the title alone.
+function SectionTitleButton({
+ sectionId,
+ title,
+ onRename,
+}: {
+ sectionId: string;
+ title: string;
+ onRename: () => void;
+}) {
+ return (
+
+ );
+}
+
+// What follows the pointer while a section is dragged: its title and what it
+// is for, not its tiles. Not a heading: the section's own heading stays where
+// it is meanwhile.
+function SectionPreview({
+ title,
+ description,
+}: {
+ title: string;
+ description?: string;
+}) {
+ return (
+
+
{title}
+ {description && (
+
{description}
+ )}
+
+ );
+}
+
+// The name a screen reader gives the untitled section of a dashboard that
+// declares none, once the viewer has added sections beside it.
+const UNTITLED_SECTION = "the untitled section";
+
+// A dashboard the viewer can arrange: drag a tile onto another to swap the two
+// (or focus its grip and use the arrow keys), × to remove one, + to add one
+// back. On a dashboard with sections a tile can be dragged to another section,
+// or onto the slot at the end of one. The viewer can rename, reorder or remove
+// a section, and add their own after the last; a section of their own starts
+// empty and holds only declared widgets.
+//
+// The kit draws all of it. The viewer's arrangement is resolved here into the
+// view the kit's `` takes — the sections in the viewer's order
+// under the viewer's titles, each widget in the section the viewer put it in —
+// and the host's controls go into the renderer's slots: the + into its header,
+// each widget through `renderWidget`, and the kit's `SortableBoard`, headed by
+// the kit's `DashboardSectionHeader`, through `layout`.
function SolutionDashboard({
graph,
dashboard,
@@ -549,20 +836,136 @@ function SolutionDashboard({
}) {
const { user, organizationId } = useAuth();
const orgId = organizationId ?? "";
+ const root = useRef(null);
const storageKey = scopedDashboardDraftKey(
layoutKey(solutionId, dashboard.id),
{ organizationId, userId: user?.id },
);
const [layout, save] = useDashboardLayout(storageKey, dashboard);
- const shown = layout.flatMap((tileId) => {
+ const declaredSections = dashboard.sections ?? [];
+ const shown = layoutTiles(layout).flatMap((tileId) => {
const widget = tileWidget(dashboard, tileId);
return widget ? [{ tileId, widget }] : [];
});
const widgets = new Map(shown.map(({ tileId, widget }) => [tileId, widget]));
+ const source: WidgetSource = {
+ graph,
+ solutionId,
+ dashboardId: dashboard.id,
+ orgId,
+ };
+ const addable = addableTiles(graph, dashboard, layout).flatMap((tile) => {
+ const widget = tileWidget(dashboard, tile.id);
+ return widget ? [{ ...tile, widget }] : [];
+ });
+ // The section whose title is a field, while the viewer renames it, and
+ // where the focus goes back to once they are done: the options menu the
+ // rename was chosen from, or the title.
+ const [renaming, setRenaming] = useState<{
+ id: string;
+ back: "data-section-options" | "data-section-title";
+ } | null>(null);
+ // What a screen reader last heard about the sections: an add, a rename or
+ // a removal. (The board announces a tile's moves itself.)
+ const [announcement, setAnnouncement] = useState("");
const titleOf = (tileId: string) => {
const widget = widgets.get(tileId);
return widget?.title ?? widget?.metric ?? tileId;
};
+ const nameOf = (section: LayoutSection) =>
+ sectionTitle(dashboard, section) ?? UNTITLED_SECTION;
+ const describedBy = (sectionId: string) =>
+ declaredSections.find(({ id }) => id === sectionId)?.description;
+ // A tile moved into another section is drawn anew there, so its grip is
+ // found again rather than kept; so is a section's options button.
+ const marked = (attribute: string, id: string) =>
+ [
+ ...(root.current?.querySelectorAll(`[${attribute}]`) ?? []),
+ ].find((element) => element.getAttribute(attribute) === id);
+ const gripOf = (tileId: string) => marked("data-tile-grip", tileId);
+ const addNewSection = () => {
+ const id = newSectionId(layout);
+ save(addSection(layout, id));
+ setRenaming({ id, back: "data-section-title" });
+ setAnnouncement("Added a section at the end.");
+ };
+ const finishRename = (
+ section: LayoutSection,
+ typed: string | null,
+ refocus: boolean,
+ ) => {
+ const next =
+ typed === null
+ ? layout
+ : renameSection(layout, dashboard, section.id, typed);
+ const before = nameOf(section);
+ const after = next.find(({ id }) => id === section.id);
+ const back = renaming?.back ?? "data-section-title";
+ // The field goes away, which drops its focus; commit first so the
+ // title or the options can take it back.
+ flushSync(() => {
+ setRenaming(null);
+ if (after && nameOf(after) !== before) {
+ save(next);
+ setAnnouncement(`Renamed ${before} to ${nameOf(after)}.`);
+ }
+ });
+ if (refocus) marked(back, section.id)?.focus();
+ };
+ // The section grip's arrow keys move the section one place up or down.
+ const moveSectionWithKey = (
+ section: LayoutSection,
+ event: KeyboardEvent,
+ ) => {
+ const step = SECTION_STEP[event.key];
+ if (step === undefined) return;
+ event.preventDefault();
+ const next = moveSectionBy(layout, section.id, step);
+ const at = next.findIndex(({ id }) => id === section.id);
+ if (at === layout.indexOf(section)) return;
+ const neighbour = step < 0 ? next[at + 1] : next[at - 1];
+ // Moving a section moves its DOM node, which drops focus; commit first
+ // so the grip can take focus back.
+ flushSync(() => {
+ save(next);
+ setAnnouncement(
+ `${nameOf(section)} moved ${step < 0 ? "above" : "below"} ${nameOf(neighbour)}.`,
+ );
+ });
+ marked("data-section-grip", section.id)?.focus();
+ };
+ const remove = (section: LayoutSection) => {
+ const at = layout.indexOf(section);
+ const into = layout[at - 1] ?? layout[at + 1];
+ if (!into || !canRemoveSection(layout, section.id)) return;
+ const moved = section.tiles.some((tileId) => widgets.has(tileId));
+ // The section's options go with it; the focus goes to the section that
+ // took its tiles, or to "Add section" when that one has no options. The
+ // menu, unmounted with the section, queues handing the focus back
+ // somewhere as it goes, so this waits until after it.
+ flushSync(() => {
+ save(removeSection(layout, section.id));
+ setAnnouncement(
+ moved
+ ? `Removed ${nameOf(section)}; its widgets moved to ${nameOf(into)}.`
+ : `Removed ${nameOf(section)}.`,
+ );
+ });
+ queueMicrotask(() =>
+ (
+ marked("data-section-options", into.id) ??
+ root.current?.querySelector("[data-add-section]")
+ )?.focus(),
+ );
+ };
+ const addMenu = (label: string, sectionId: string) => (
+ save(addTile(layout, dashboard, tileId, sectionId))}
+ />
+ );
// 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.
@@ -577,15 +980,34 @@ function SolutionDashboard({
size="icon-xs"
className="cursor-grab text-muted-foreground"
aria-label={`Move ${title}: drag the tile, or use the arrow keys`}
+ data-tile-grip={tileId}
onKeyDown={(event) => {
const step = ARROW_STEP[event.key];
if (step === undefined) return;
event.preventDefault();
- const grip = event.currentTarget;
+ const next = moveBy(layout, tileId, step);
+ const from = placeOf(layout, tileId);
+ const to = placeOf(next, tileId);
+ // Past either end of the dashboard the tile stays, and a
+ // key that moves nothing saves nothing.
+ if (
+ !from ||
+ !to ||
+ (to.section.id === from.section.id && to.index === from.index)
+ ) {
+ return;
+ }
// 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();
+ flushSync(() => {
+ save(next);
+ if (to.section.id !== from.section.id) {
+ setAnnouncement(
+ `${title} moved to the ${step < 0 ? "end" : "start"} of ${nameOf(to.section)}.`,
+ );
+ }
+ });
+ gripOf(tileId)?.focus();
}}
>
@@ -625,11 +1047,8 @@ function SolutionDashboard({
return (
{glyph()}
@@ -641,65 +1060,218 @@ function SolutionDashboard({
);
};
+ // A section's heading on the board: the kit's, with the host's controls in
+ // it — the grip it is dragged by, its title as the way to rename it, its
+ // own + and its options. Only the grip drags.
+ const sectionHeader = (
+ placed: LayoutSection,
+ title: string,
+ handle: SortableGroupHandle,
+ empty: boolean,
+ ) => (
+
+ setRenaming({ id: placed.id, back: "data-section-title" })
+ }
+ />
+ }
+ editing={
+ renaming?.id === placed.id ? (
+ finishRename(placed, typed, refocus)}
+ />
+ ) : undefined
+ }
+ description={describedBy(placed.id)}
+ leading={
+
+ }
+ actions={
+ <>
+ {addMenu(`Add a widget back to ${title}`, placed.id)}
+
+ setRenaming({ id: placed.id, back: "data-section-options" })
+ }
+ onRemove={() => remove(placed)}
+ />
+ >
+ }
+ >
+ {empty && (
+
+ No widgets in this section. Drag a tile here, or add one back with +.
+
+ )}
+
+ );
+
+ // The viewer's arrangement as the view the kit draws: its titled sections in
+ // the viewer's order, under the viewer's titles, and each shown widget in
+ // the section the viewer put it in. The untitled section of a dashboard that
+ // declares none is no section at all to the kit; its widgets come first.
+ const sections: DashboardSectionView[] = layout.flatMap((placed) => {
+ const title = sectionTitle(dashboard, placed);
+ return title === undefined
+ ? []
+ : [{ id: placed.id, title, description: describedBy(placed.id) }];
+ });
+ const titled = new Set(sections.map(({ id }) => id));
+ const view: DashboardWidgetView[] = layout.flatMap((placed) =>
+ placed.tiles.flatMap((tileId) => {
+ const widget = widgets.get(tileId);
+ return widget
+ ? [
+ {
+ ...declaredWidget(widget),
+ section: titled.has(placed.id) ? placed.id : undefined,
+ },
+ ]
+ : [];
+ }),
+ );
return (
- 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.
-
- ),
- }}
- />
+
+ {
+ const declared = widgets.get(widget.id);
+ if (!declared) return null;
+ return (
+
+ );
+ },
+ // The kit's SortableBoard rather than its default layout: the
+ // tiles are dragged onto each other to swap, and onto another
+ // section. The sections draw in the viewer's order, the first on
+ // top, each dragged by the grip in its header; the untitled one of
+ // a dashboard that declares none stays first.
+ layout: (tiles) => {
+ const nodes = new Map(tiles.map((tile) => [tile.id, tile.node]));
+ return (
+ {
+ const title = sectionTitle(dashboard, placed);
+ const ids = placed.tiles.filter((tileId) =>
+ widgets.has(tileId),
+ );
+ return {
+ id: placed.id,
+ ids,
+ label: nameOf(placed),
+ fixed: placed.id === UNSECTIONED,
+ preview:
+ title === undefined ? undefined : (
+
+ ),
+ header:
+ title === undefined
+ ? undefined
+ : (handle: SortableGroupHandle) =>
+ sectionHeader(
+ placed,
+ title,
+ handle,
+ ids.length === 0,
+ ),
+ className:
+ dashboard.layout === "stack"
+ ? "flex flex-col gap-4"
+ : "grid grid-cols-1 gap-4 sm:grid-cols-2",
+ };
+ })}
+ onSwap={(dragged, target) =>
+ save(swapTiles(layout, dragged, target))
+ }
+ onMove={
+ declaredSections.length > 0 || layout.length > 1
+ ? (dragged, sectionId) =>
+ save(moveTileToSection(layout, dragged, sectionId))
+ : undefined
+ }
+ onMoveGroup={(sectionId, index) =>
+ save(moveSection(layout, sectionId, index))
+ }
+ itemLabel={titleOf}
+ itemClassName={(tileId) => {
+ const widget = widgets.get(tileId);
+ return widget &&
+ dashboard.layout !== "stack" &&
+ TALL.has(widget.visualization)
+ ? "row-span-2"
+ : undefined;
+ }}
+ renderItem={(id) => nodes.get(id) ?? null}
+ renderOverlay={renderDragged}
+ />
+ );
+ },
+ empty: (
+
+ No widgets are shown. Add one back with the + button.
+
+ ),
+ }}
+ />
+
+
+ {announcement}
+
+
);
}
@@ -709,7 +1281,7 @@ function SolutionDashboard({
* widget's failure never blanks its siblings. A solution ships only the
* declaration; all charting lives here in the host. Each viewer arranges each
* dashboard for themselves, starting from the declared widgets in declared
- * order.
+ * order, each in its declared section.
*/
export function SolutionDashboards({
graph,
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 e1376b1f3..72273acb0 100644
--- a/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx
+++ b/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx
@@ -1,10 +1,13 @@
import type { DataGraph } from "@codefly/saas-plugin-manifest";
import { Code, ConnectError } from "@connectrpc/connect";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
act,
cleanup,
fireEvent,
+ render,
screen,
+ waitFor,
within,
} from "@testing-library/react";
import { HttpResponse, http } from "msw";
@@ -151,8 +154,36 @@ function tile(title: string): HTMLElement {
return found;
}
-function renderDashboards() {
- return renderInApp();
+function renderDashboards(declared: DataGraph = graph) {
+ return renderInApp(
+ ,
+ );
+}
+
+// A mouse drag on the page, pressed on a tile and moved on the document, where
+// dnd-kit listens once a drag begins. Past the 5px a drag needs, it is in
+// flight; `moveTo` aims it (at a slot the drag brought up, too), `drop` ends it.
+function pickUp(element: HTMLElement) {
+ const { left, top } = element.getBoundingClientRect();
+ let at = { clientX: left + 50, clientY: top + 50 };
+ fireEvent.mouseDown(element, { ...at, button: 0 });
+ fireEvent.mouseMove(document, {
+ clientX: at.clientX + 10,
+ clientY: at.clientY + 10,
+ });
+ return {
+ moveTo(target: HTMLElement) {
+ const rect = target.getBoundingClientRect();
+ const to = { clientX: rect.left + 50, clientY: rect.top + 50 };
+ fireEvent.mouseMove(document, {
+ clientX: (at.clientX + to.clientX) / 2,
+ clientY: (at.clientY + to.clientY) / 2,
+ });
+ fireEvent.mouseMove(document, to);
+ at = to;
+ },
+ drop: () => fireEvent.mouseUp(document, at),
+ };
}
describe("SolutionDashboards", () => {
@@ -665,6 +696,118 @@ describe("SolutionDashboards", () => {
});
});
+ it("writes a metric declared as a percent as a percentage, and a plain ratio as a number", async () => {
+ const rates: DataGraph = {
+ events: [
+ { name: "passed", type: "review.passed.v1" },
+ { name: "completed", type: "review.completed.v1" },
+ ],
+ metrics: [
+ {
+ id: "passed",
+ kind: "source",
+ filter: { event: "passed" },
+ groupBy: "category",
+ aggregation: "count",
+ },
+ {
+ id: "completed",
+ kind: "source",
+ filter: { event: "completed" },
+ groupBy: "category",
+ aggregation: "count",
+ },
+ {
+ id: "pass_rate",
+ kind: "derived",
+ operation: "ratio",
+ inputs: ["passed", "completed"],
+ format: "percent",
+ },
+ {
+ id: "completed_per_pass",
+ kind: "derived",
+ operation: "ratio",
+ inputs: ["completed", "passed"],
+ },
+ ],
+ dashboards: [
+ {
+ id: "activity",
+ title: "Activity",
+ layout: "grid",
+ widgets: [
+ {
+ id: "w_rate",
+ metric: "pass_rate",
+ visualization: "number",
+ title: "Pass rate",
+ },
+ {
+ id: "w_per_pass",
+ metric: "completed_per_pass",
+ visualization: "number",
+ title: "Completed per pass",
+ },
+ {
+ id: "w_rate_bars",
+ metric: "pass_rate",
+ visualization: "bar",
+ title: "Pass rate by stage",
+ },
+ {
+ id: "w_rate_table",
+ metric: "pass_rate",
+ visualization: "table",
+ title: "Pass rate table",
+ },
+ ],
+ },
+ ],
+ };
+ server.use(
+ http.post(
+ rpc("AuditService", "AggregateAuditLog"),
+ async ({ request }) => {
+ const body = (await request.json()) as { eventType?: string };
+ const count = body.eventType === "review.passed.v1" ? "4" : "10";
+ return HttpResponse.json({
+ buckets: [{ key: "lifecycle", count }],
+ });
+ },
+ ),
+ );
+
+ renderDashboards(rates);
+
+ // 4 passed of 10 completed: a share of 0.4, scaled once by the SDK and
+ // written as a percentage by every mark — the tile, the bar chart's data
+ // table and the table widget alike.
+ expect(await within(tile("Pass rate")).findByText("40%")).toBeTruthy();
+ const bars = await screen.findByRole("table", {
+ name: "Pass rate by stage",
+ });
+ expect(within(bars).getByText("40%")).toBeTruthy();
+ const table = await screen.findByRole("table", { name: "Pass rate table" });
+ expect(within(table).getByText("40%")).toBeTruthy();
+ expect(
+ await within(tile("Completed per pass")).findByText(
+ (2.5).toLocaleString("en-US"),
+ ),
+ ).toBeTruthy();
+ expect(screen.queryByText("0.4")).toBeNull();
+ expect(screen.queryByText("0.4%")).toBeNull();
+ });
+
+ it("makes a chart or a bar list two rows tall, so two numbers stack beside it", () => {
+ renderDashboards();
+
+ // A tile is its list item, the grid item the span applies to.
+ expect(tile("Logins over time").classList).toContain("row-span-2");
+ expect(tile("Top event types").classList).toContain("row-span-2");
+ expect(tile("Total logins").classList).not.toContain("row-span-2");
+ });
+
it("reads as loading until the org context resolves, never as empty", () => {
authState.organizationId = undefined;
@@ -850,6 +993,31 @@ describe("a viewer's layout", () => {
]);
});
+ it("reads the version-1 layout saved before sections, and saves version 2 on the next change", () => {
+ window.localStorage.setItem(
+ LAYOUT_KEY,
+ JSON.stringify({
+ version: 1,
+ tiles: ["w_stat", "w_line"],
+ seen: ["w_line", "w_bar", "w_stat"],
+ }),
+ );
+ renderDashboards();
+ // w_bar was removed then, and stays removed.
+ expect(tileTitles()).toEqual(["Total logins", "Logins over time"]);
+ fireEvent.click(
+ screen.getByRole("button", { name: "Remove Logins over time" }),
+ );
+ expect(
+ JSON.parse(window.localStorage.getItem(LAYOUT_KEY) ?? "null"),
+ ).toEqual({
+ version: 2,
+ sections: [{ id: "", tiles: ["w_stat"] }],
+ seen: ["w_line", "w_bar", "w_stat"],
+ seenSections: [""],
+ });
+ });
+
it("ignores a stored tile the dashboard does not declare", () => {
window.localStorage.setItem(
LAYOUT_KEY,
@@ -957,6 +1125,26 @@ describe("a viewer's layout", () => {
expect(tileTitles()).toEqual(DEFAULT_ORDER);
expect(window.localStorage.getItem(LAYOUT_KEY)).toBeNull();
});
+
+ it("draws a dashboard without sections as one untitled group, with one + and no slot", () => {
+ renderDashboards();
+ expect(
+ document.querySelector('[data-slot="dashboard-section-title"]'),
+ ).toBeNull();
+ expect(
+ screen.getAllByRole("button", { name: /^Add a widget back to / }),
+ ).toEqual([
+ screen.getByRole("button", { name: "Add a widget back to Activity" }),
+ ]);
+ const drop = drag("Logins over time", middle("Total logins"));
+ expect(screen.queryByText("Move here")).toBeNull();
+ drop();
+ expect(tileTitles()).toEqual([
+ "Total logins",
+ "Top event types",
+ "Logins over time",
+ ]);
+ });
});
// happy-dom keeps focus on a node React moves, where a browser drops it, so
@@ -978,6 +1166,18 @@ describe("a viewer's layout", () => {
expect(tileTitles()).toEqual(DEFAULT_ORDER);
});
+ // A save pins the viewer to their own layout, so a key that moves nothing
+ // must not write one.
+ it("saves nothing when the arrow keys cannot move the tile", () => {
+ renderDashboards();
+ const grip = screen.getByRole("button", {
+ name: "Move Logins over time: drag the tile, or use the arrow keys",
+ });
+ fireEvent.keyDown(grip, { key: "ArrowUp" });
+ expect(tileTitles()).toEqual(DEFAULT_ORDER);
+ expect(window.localStorage.getItem(LAYOUT_KEY)).toBeNull();
+ });
+
it("keeps each viewer's layout to themselves", () => {
const { unmount } = renderDashboards();
fireEvent.click(
@@ -1012,3 +1212,915 @@ describe("a viewer's layout", () => {
expect(tileTitles()).toEqual(["Top event types", "Total logins"]);
});
});
+
+describe("what a + menu says about a removed widget", () => {
+ type Buckets = Array<{ key: string; count: string }>;
+ const DAYS: Buckets = [
+ { key: "2026-08-01", count: "3" },
+ { key: "2026-08-02", count: "5" },
+ ];
+
+ // Answers each widget's query with the rows `rows` gives for its request,
+ // or a 500 for "fail", and counts the queries by what they group by.
+ function auditTrail(
+ rows: (request: {
+ groupBy?: string;
+ eventType?: string;
+ }) => Buckets | "fail" | Promise,
+ ) {
+ const asked: Record = {};
+ server.use(
+ http.post(
+ rpc("AuditService", "AggregateAuditLog"),
+ async ({ request }) => {
+ const body = (await request.json()) as {
+ groupBy?: string;
+ eventType?: string;
+ };
+ const groupBy = body.groupBy ?? "";
+ asked[groupBy] = (asked[groupBy] ?? 0) + 1;
+ const buckets = await rows(body);
+ return buckets === "fail"
+ ? new HttpResponse(null, { status: 500 })
+ : HttpResponse.json({ buckets });
+ },
+ ),
+ );
+ return asked;
+ }
+
+ // A layout the viewer saved with only these tiles shown: the others were
+ // removed before this page drew them, so nothing has asked about them yet.
+ function showOnly(tiles: string[], dashboard = graph.dashboards[0]) {
+ window.localStorage.setItem(
+ LAYOUT_KEY,
+ JSON.stringify({
+ version: 2,
+ sections: [{ id: "", tiles }],
+ seen: dashboard.widgets.map((widget) => widget.id),
+ seenSections: [""],
+ }),
+ );
+ }
+
+ // The page, with the query client in reach, so a test can wait until
+ // every answer is in.
+ function renderWatched() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ render(
+
+
+ ,
+ );
+ return client;
+ }
+
+ const openMenu = () =>
+ fireEvent.click(
+ screen.getByRole("button", { name: "Add a widget back to Activity" }),
+ );
+
+ // An item that says nothing beside its title.
+ const expectBare = (item: HTMLElement, title: string) => {
+ expect(item.textContent).toBe(title);
+ expect(item.hasAttribute("aria-describedby")).toBe(false);
+ };
+
+ it("says No data yet. beside a removed widget whose metric returned no rows, and it can still be added", async () => {
+ auditTrail(({ groupBy }) => (groupBy === "event_type" ? [] : DAYS));
+ showOnly(["w_line", "w_stat"]);
+ renderDashboards();
+ openMenu();
+ const item = await screen.findByRole("menuitem", {
+ name: "Top event types",
+ description: "No data yet.",
+ });
+ expect(item.textContent).toBe("Top event typesNo data yet.");
+
+ // The tile it adds back draws at once, from the answer the menu got: the
+ // kit's card badges it, and its chart says it where the bars would be.
+ fireEvent.click(item);
+ const added = tile("Top event types");
+ expect(within(added).getAllByText("No data")).toHaveLength(2);
+ expect(added.querySelector('[data-slot="skeleton"]')).toBeNull();
+ });
+
+ it("says telemetry is incomplete beside a removed derived widget missing an input", async () => {
+ const derived: DataGraph = {
+ events: [
+ { name: "login", type: "auth.login.v1" },
+ { name: "logout", type: "auth.logout.v1" },
+ ],
+ metrics: [
+ {
+ id: "logins",
+ kind: "source",
+ filter: { event: "login" },
+ groupBy: "time",
+ bucket: "day",
+ aggregation: "count",
+ },
+ {
+ id: "logouts",
+ kind: "source",
+ filter: { event: "logout" },
+ groupBy: "time",
+ bucket: "day",
+ aggregation: "count",
+ },
+ {
+ id: "net_logins",
+ kind: "derived",
+ operation: "difference",
+ inputs: ["logins", "logouts"],
+ },
+ ],
+ dashboards: [
+ {
+ id: "activity",
+ title: "Activity",
+ layout: "grid",
+ widgets: [
+ {
+ id: "w_logins",
+ metric: "logins",
+ visualization: "line",
+ title: "Logins",
+ },
+ {
+ id: "w_net",
+ metric: "net_logins",
+ visualization: "number",
+ title: "Net logins",
+ },
+ ],
+ },
+ ],
+ };
+ // Logins were recorded and no logout ever was, so every day lacks one
+ // of the two inputs.
+ auditTrail(({ eventType }) => (eventType === "auth.logout.v1" ? [] : DAYS));
+ showOnly(["w_logins"], derived.dashboards[0]);
+ renderDashboards(derived);
+ openMenu();
+ const item = await screen.findByRole("menuitem", {
+ name: "Net logins",
+ description: "Telemetry incomplete.",
+ });
+
+ // The tile says the same, in the kit's own words.
+ fireEvent.click(item);
+ expect(within(tile("Net logins")).getByText("Partial data")).toBeTruthy();
+ });
+
+ it("says nothing beside a removed widget that has data", async () => {
+ auditTrail(({ groupBy }) =>
+ groupBy === "event_type" ? [{ key: "saas.auth.login", count: "42" }] : [],
+ );
+ showOnly(["w_stat"]);
+ const client = renderWatched();
+ openMenu();
+ // The removed widget with no rows says so: the menu has its answers.
+ await screen.findByRole("menuitem", {
+ name: "Logins over time",
+ description: "No data yet.",
+ });
+ await waitFor(() => expect(client.isFetching()).toBe(0));
+ expectBare(
+ screen.getByRole("menuitem", { name: "Top event types" }),
+ "Top event types",
+ );
+ });
+
+ it("says nothing while the answer is on its way, and draws no spinner", async () => {
+ let answer: (rows: Buckets) => void = () => {};
+ const pending = new Promise((resolve) => {
+ answer = resolve;
+ });
+ const asked = auditTrail(({ groupBy }) =>
+ groupBy === "event_type" ? pending : DAYS,
+ );
+ showOnly(["w_line", "w_stat"]);
+ renderDashboards();
+ openMenu();
+ await waitFor(() => expect(asked.event_type).toBe(1));
+ expectBare(
+ screen.getByRole("menuitem", { name: "Top event types" }),
+ "Top event types",
+ );
+ expect(
+ screen
+ .getByRole("menu")
+ .querySelector(
+ '[data-slot="skeleton"], [aria-busy], [role="progressbar"], [role="status"]',
+ ),
+ ).toBeNull();
+
+ // Once the answer is in, the item says what it is.
+ await act(async () => answer([]));
+ expect(
+ await screen.findByRole("menuitem", {
+ name: "Top event types",
+ description: "No data yet.",
+ }),
+ ).toBeTruthy();
+ });
+
+ it("says nothing when a removed widget's query fails, rather than no data", async () => {
+ auditTrail(({ groupBy }) => (groupBy === "event_type" ? "fail" : []));
+ showOnly(["w_stat"]);
+ const client = renderWatched();
+ openMenu();
+ await screen.findByRole("menuitem", {
+ name: "Logins over time",
+ description: "No data yet.",
+ });
+ await waitFor(() => expect(client.isFetching()).toBe(0));
+ expect(
+ client
+ .getQueryCache()
+ .getAll()
+ .filter((query) => query.state.status === "error"),
+ ).toHaveLength(1);
+ expectBare(
+ screen.getByRole("menuitem", { name: "Top event types" }),
+ "Top event types",
+ );
+ });
+
+ it("asks nothing about a removed widget until a menu opens", async () => {
+ const asked = auditTrail(({ groupBy }) =>
+ groupBy === "event_type"
+ ? [{ key: "saas.auth.login", count: "42" }]
+ : DAYS,
+ );
+ showOnly(["w_line", "w_stat"]);
+ renderDashboards();
+ // Both drawn tiles have their answers; the removed one was not asked.
+ await screen.findByText("8");
+ await waitFor(() => expect(asked).toEqual({ time: 2 }));
+
+ openMenu();
+ await waitFor(() => expect(asked).toEqual({ time: 2, event_type: 1 }));
+ });
+});
+
+describe("a dashboard in sections", () => {
+ // The acceptance graph with its dashboard split into an overview band (the
+ // total) above a trends band (the two charts).
+ const sectioned: DataGraph = {
+ ...graph,
+ dashboards: [
+ {
+ ...graph.dashboards[0],
+ sections: [
+ {
+ id: "overview",
+ title: "Overview",
+ description:
+ "The headline read: how much the application was used.",
+ },
+ { id: "trends", title: "Trends" },
+ ],
+ widgets: graph.dashboards[0].widgets.map((widget) => ({
+ ...widget,
+ section: widget.id === "w_stat" ? "overview" : "trends",
+ })),
+ },
+ ],
+ };
+
+ beforeEach(() => {
+ server.use(
+ aggregateHandler(
+ [
+ { key: "2026-08-01", count: "3" },
+ { key: "2026-08-02", count: "5" },
+ ],
+ [{ key: "saas.auth.login", count: "42" }],
+ ),
+ );
+ });
+
+ // The heading of each section, in the order they are drawn: the kit's
+ // section header, which the card and stat-tile headings are not.
+ const sectionHeadings = () => [
+ ...document.querySelectorAll(
+ '[data-slot="dashboard-section-title"]',
+ ),
+ ];
+ const sectionTitles = () =>
+ sectionHeadings().map((heading) => heading.textContent);
+ const section = (title: string) => {
+ const found = sectionHeadings()
+ .find((heading) => heading.textContent === title)
+ ?.closest("[data-sortable-group]");
+ if (!found) throw new Error(`no section titled ${title}`);
+ return found;
+ };
+ // The tile titles in one section, in order.
+ const tilesIn = (title: string) =>
+ [...section(title).querySelectorAll("[data-sortable-id]")].map(
+ (item) =>
+ item.querySelector(
+ '[data-slot="card-heading"], [data-slot="metric-label"]',
+ )?.textContent ?? "",
+ );
+ const arrangement = () =>
+ Object.fromEntries(sectionTitles().map((title) => [title, tilesIn(title)]));
+ const saved = () =>
+ JSON.parse(window.localStorage.getItem(LAYOUT_KEY) ?? "null")?.sections;
+
+ it("draws each section in declared order, under its title and description", () => {
+ renderDashboards(sectioned);
+ expect(sectionTitles()).toEqual(["Overview", "Trends"]);
+ expect(
+ within(section("Overview")).getByText(
+ "The headline read: how much the application was used.",
+ ),
+ ).toBeTruthy();
+ expect(arrangement()).toEqual({
+ Overview: ["Total logins"],
+ Trends: ["Logins over time", "Top event types"],
+ });
+ // Each section has its own +; the dashboard's header has none.
+ expect(
+ screen
+ .getAllByRole("button", { name: /^Add a widget back to / })
+ .map((button) => button.getAttribute("aria-label")),
+ ).toEqual(["Add a widget back to Overview", "Add a widget back to Trends"]);
+ });
+
+ it("puts a removed widget back from a section's + into that section, and offers no undrawn metric", async () => {
+ renderDashboards(sectioned);
+ fireEvent.click(
+ screen.getByRole("button", { name: "Remove Top event types" }),
+ );
+ fireEvent.click(
+ screen.getByRole("button", { name: "Add a widget back to Overview" }),
+ );
+ // "Logins by category" is declared in the graph but drawn by no widget:
+ // no section's + offers it (ADR 0007).
+ const items = await screen.findAllByRole("menuitem");
+ expect(items.map((item) => item.textContent)).toEqual(["Top event types"]);
+ fireEvent.click(screen.getByRole("menuitem", { name: "Top event types" }));
+ expect(arrangement()).toEqual({
+ Overview: ["Total logins", "Top event types"],
+ Trends: ["Logins over time"],
+ });
+ expect(saved()).toEqual([
+ { id: "overview", tiles: ["w_stat", "w_bar"] },
+ { id: "trends", tiles: ["w_line"] },
+ ]);
+ // Every widget is back, so another section's + has nothing to offer.
+ fireEvent.click(
+ screen.getByRole("button", { name: "Add a widget back to Trends" }),
+ );
+ const none = await screen.findByRole("menuitem", {
+ name: "Every widget is on this dashboard",
+ });
+ expect(none.getAttribute("aria-disabled")).toBe("true");
+ });
+
+ it("says a section is empty, and moves a tile across sections with the arrow keys", () => {
+ renderDashboards(sectioned);
+ const grip = () =>
+ screen.getByRole("button", {
+ name: "Move Total logins: drag the tile, or use the arrow keys",
+ });
+ grip().focus();
+ // The only tile of the first section moves down into the next, first.
+ fireEvent.keyDown(grip(), { key: "ArrowDown" });
+ expect(arrangement()).toEqual({
+ Overview: [],
+ Trends: ["Total logins", "Logins over time", "Top event types"],
+ });
+ expect(
+ within(section("Overview")).getByText(/No widgets in this section/),
+ ).toBeTruthy();
+ // The tile is drawn anew in its new section; its grip keeps the focus.
+ expect(document.activeElement).toBe(grip());
+
+ fireEvent.keyDown(grip(), { key: "ArrowUp" });
+ expect(arrangement()).toEqual({
+ Overview: ["Total logins"],
+ Trends: ["Logins over time", "Top event types"],
+ });
+ expect(document.activeElement).toBe(grip());
+ });
+
+ it("says where the arrow keys moved a tile into another section", () => {
+ renderDashboards(sectioned);
+ const grip = () =>
+ screen.getByRole("button", {
+ name: "Move Total logins: drag the tile, or use the arrow keys",
+ });
+ fireEvent.keyDown(grip(), { key: "ArrowDown" });
+ expect(
+ screen.getByText("Total logins moved to the start of Trends."),
+ ).toBeTruthy();
+
+ fireEvent.keyDown(grip(), { key: "ArrowUp" });
+ expect(
+ screen.getByText("Total logins moved to the end of Overview."),
+ ).toBeTruthy();
+ });
+
+ it("reads a layout saved before sections, each tile in its declared section", () => {
+ window.localStorage.setItem(
+ LAYOUT_KEY,
+ JSON.stringify({
+ version: 1,
+ tiles: ["w_bar", "w_stat", "metric:logins_by_category", "w_line"],
+ seen: ["w_line", "w_bar", "w_stat"],
+ }),
+ );
+ renderDashboards(sectioned);
+ // A tile the dashboard does not draw is dropped, whatever it was.
+ expect(arrangement()).toEqual({
+ Overview: ["Total logins"],
+ Trends: ["Top event types", "Logins over time"],
+ });
+ });
+
+ it("appends a widget the solution declared after the save to its section", () => {
+ window.localStorage.setItem(
+ LAYOUT_KEY,
+ JSON.stringify({
+ version: 2,
+ sections: [
+ { id: "overview", tiles: ["w_stat"] },
+ { id: "trends", tiles: ["w_line"] },
+ ],
+ seen: ["w_line", "w_stat"],
+ seenSections: ["overview", "trends"],
+ }),
+ );
+ renderDashboards(sectioned);
+ expect(arrangement()).toEqual({
+ Overview: ["Total logins"],
+ Trends: ["Logins over time", "Top event types"],
+ });
+ });
+
+ describe("dragging", () => {
+ // happy-dom lays nothing out, so each tile (and each slot) gets a cell by
+ // its place in its section: two columns of 100×100 cells, 10px apart,
+ // each section a band 1000px below the one before.
+ const realRect = HTMLElement.prototype.getBoundingClientRect;
+ beforeEach(() => {
+ HTMLElement.prototype.getBoundingClientRect = function (
+ this: HTMLElement,
+ ) {
+ const list = this.parentElement;
+ const group = list?.parentElement;
+ const placed =
+ this.tagName === "LI" && group?.dataset.sortableGroup !== undefined;
+ const index = placed ? [...(list?.children ?? [])].indexOf(this) : 0;
+ const band =
+ placed && group?.parentElement
+ ? [...group.parentElement.children].indexOf(group)
+ : 0;
+ const left = (index % 2) * 110;
+ const top = Math.floor(index / 2) * 110 + band * 1000;
+ return {
+ x: left,
+ y: top,
+ left,
+ top,
+ right: left + 100,
+ bottom: top + 100,
+ width: 100,
+ height: 100,
+ toJSON: () => ({}),
+ } as DOMRect;
+ };
+ });
+ afterEach(async () => {
+ HTMLElement.prototype.getBoundingClientRect = realRect;
+ // dnd-kit keeps swallowing clicks for 50ms after a drag ends.
+ await new Promise((resolve) => setTimeout(resolve, 60));
+ });
+
+ it("swaps a tile dropped on a tile in another section, and saves both sections", () => {
+ const { unmount } = renderDashboards(sectioned);
+ const drag = pickUp(tile("Total logins"));
+ drag.moveTo(tile("Top event types"));
+ expect(window.localStorage.getItem(LAYOUT_KEY)).toBeNull();
+ drag.drop();
+ const swapped = {
+ Overview: ["Top event types"],
+ Trends: ["Logins over time", "Total logins"],
+ };
+ expect(arrangement()).toEqual(swapped);
+ expect(saved()).toEqual([
+ { id: "overview", tiles: ["w_bar"] },
+ { id: "trends", tiles: ["w_line", "w_stat"] },
+ ]);
+
+ unmount();
+ renderDashboards(sectioned);
+ expect(arrangement()).toEqual(swapped);
+ });
+
+ it("moves a tile dropped on a section's slot to the end of that section, and saves it", () => {
+ const { unmount } = renderDashboards(sectioned);
+ expect(screen.queryByText("Move here")).toBeNull();
+ const drag = pickUp(tile("Logins over time"));
+ // Every section ends with a slot while the drag is in flight.
+ expect(within(section("Overview")).getByText("Move here")).toBeTruthy();
+ expect(within(section("Trends")).getByText("Move here")).toBeTruthy();
+ drag.moveTo(within(section("Overview")).getByText("Move here"));
+ drag.drop();
+ expect(screen.queryByText("Move here")).toBeNull();
+ const moved = {
+ Overview: ["Total logins", "Logins over time"],
+ Trends: ["Top event types"],
+ };
+ expect(arrangement()).toEqual(moved);
+ expect(saved()).toEqual([
+ { id: "overview", tiles: ["w_stat", "w_line"] },
+ { id: "trends", tiles: ["w_bar"] },
+ ]);
+
+ unmount();
+ renderDashboards(sectioned);
+ expect(arrangement()).toEqual(moved);
+ });
+ });
+
+ describe("a viewer's own sections", () => {
+ const options = (title: string) =>
+ screen.getByRole("button", { name: `Section options for ${title}` });
+ const choose = async (title: string, item: string) => {
+ fireEvent.click(options(title));
+ fireEvent.click(await screen.findByRole("menuitem", { name: item }));
+ };
+ const titleField = () =>
+ screen.getByRole("textbox", { name: "Section title" });
+ const type = (value: string) =>
+ fireEvent.change(titleField(), { target: { value } });
+
+ it("renames a section from its options: Enter keeps the title, the description stays", async () => {
+ const { unmount } = renderDashboards(sectioned);
+ await choose("Overview", "Rename section");
+ // The field holds the title, focused and selected, and keeps the
+ // focus once the menu has closed and settled: a menu hands the focus
+ // back to its trigger a moment after it is gone.
+ await vi.waitFor(() => expect(screen.queryByRole("menu")).toBeNull());
+ await act(() => new Promise((resolve) => setTimeout(resolve, 20)));
+ const field = titleField();
+ expect(field.value).toBe("Overview");
+ expect(document.activeElement).toBe(field);
+ expect([field.selectionStart, field.selectionEnd]).toEqual([0, 8]);
+
+ type(" Headlines ");
+ fireEvent.keyDown(titleField(), { key: "Enter" });
+ expect(sectionTitles()).toEqual(["Headlines", "Trends"]);
+ expect(
+ within(section("Headlines")).getByText(
+ "The headline read: how much the application was used.",
+ ),
+ ).toBeTruthy();
+ expect(screen.getByText("Renamed Overview to Headlines.")).toBeTruthy();
+ expect(document.activeElement).toBe(options("Headlines"));
+ expect(saved()[0]).toEqual({
+ id: "overview",
+ title: "Headlines",
+ tiles: ["w_stat"],
+ });
+
+ unmount();
+ renderDashboards(sectioned);
+ expect(sectionTitles()).toEqual(["Headlines", "Trends"]);
+ });
+
+ it("renames a section from its title, which starts no drag", () => {
+ renderDashboards(sectioned);
+ const title = screen.getByRole("button", {
+ name: "Rename section Trends",
+ });
+ // The heading is named by the title alone.
+ expect(title.closest("h3")).toBe(section("Trends").querySelector("h3"));
+ const { left, top } = title.getBoundingClientRect();
+ fireEvent.mouseDown(title, { clientX: left, clientY: top, button: 0 });
+ fireEvent.mouseMove(document, { clientX: left + 40, clientY: top + 40 });
+ fireEvent.mouseUp(document, { clientX: left + 40, clientY: top + 40 });
+ expect(section("Trends").dataset.dragging).toBeUndefined();
+
+ fireEvent.click(title);
+ const field = titleField();
+ expect(field.value).toBe("Trends");
+ expect(document.activeElement).toBe(field);
+ type("Changes");
+ fireEvent.keyDown(titleField(), { key: "Enter" });
+ expect(sectionTitles()).toEqual(["Overview", "Changes"]);
+ expect(document.activeElement).toBe(
+ screen.getByRole("button", { name: "Rename section Changes" }),
+ );
+ });
+
+ it("keeps the title on Escape, or when the field is left blank", async () => {
+ renderDashboards(sectioned);
+ await choose("Trends", "Rename section");
+ type("Changes");
+ fireEvent.keyDown(titleField(), { key: "Escape" });
+ expect(sectionTitles()).toEqual(["Overview", "Trends"]);
+ expect(document.activeElement).toBe(options("Trends"));
+
+ await choose("Trends", "Rename section");
+ type(" ");
+ fireEvent.keyDown(titleField(), { key: "Enter" });
+ expect(sectionTitles()).toEqual(["Overview", "Trends"]);
+ // Nothing changed, so nothing was saved.
+ expect(window.localStorage.getItem(LAYOUT_KEY)).toBeNull();
+ });
+
+ it("keeps what was typed when the viewer leaves the field", async () => {
+ renderDashboards(sectioned);
+ await choose("Trends", "Rename section");
+ type("Changes");
+ fireEvent.blur(titleField());
+ expect(sectionTitles()).toEqual(["Overview", "Changes"]);
+ });
+
+ it("adds a section after the last, straight into renaming it, and keeps it", async () => {
+ const { unmount } = renderDashboards(sectioned);
+ fireEvent.click(screen.getByRole("button", { name: "Add section" }));
+ expect(screen.getByText("Added a section at the end.")).toBeTruthy();
+ const field = titleField();
+ expect(field.value).toBe("New section");
+ expect(document.activeElement).toBe(field);
+ type("Watch list");
+ fireEvent.keyDown(titleField(), { key: "Enter" });
+
+ expect(arrangement()).toEqual({
+ Overview: ["Total logins"],
+ Trends: ["Logins over time", "Top event types"],
+ "Watch list": [],
+ });
+ // A section of the viewer's own says what it is for only when empty.
+ expect(
+ within(section("Watch list")).getByText(/No widgets in this section/),
+ ).toBeTruthy();
+ expect(saved()[2]).toMatchObject({ title: "Watch list", tiles: [] });
+ expect(saved()[2].id).toMatch(/^custom:[0-9a-f]{16}$/);
+
+ // It adds nothing: its + offers only a widget the viewer removed.
+ fireEvent.click(
+ screen.getByRole("button", { name: "Remove Top event types" }),
+ );
+ fireEvent.click(
+ screen.getByRole("button", { name: "Add a widget back to Watch list" }),
+ );
+ const items = await screen.findAllByRole("menuitem");
+ expect(items.map((item) => item.textContent)).toEqual([
+ "Top event types",
+ ]);
+ fireEvent.click(
+ screen.getByRole("menuitem", { name: "Top event types" }),
+ );
+ unmount();
+ renderDashboards(sectioned);
+ expect(arrangement()).toEqual({
+ Overview: ["Total logins"],
+ Trends: ["Logins over time"],
+ "Watch list": ["Top event types"],
+ });
+ });
+
+ it("removes a section, moving its tiles to the section beside it, and keeps it removed", async () => {
+ const { unmount } = renderDashboards(sectioned);
+ // The first section's tiles go to the start of the one below.
+ await choose("Overview", "Remove section");
+ expect(arrangement()).toEqual({
+ Trends: ["Total logins", "Logins over time", "Top event types"],
+ });
+ expect(
+ screen.getByText("Removed Overview; its widgets moved to Trends."),
+ ).toBeTruthy();
+ await vi.waitFor(() =>
+ expect(document.activeElement).toBe(options("Trends")),
+ );
+
+ // The last section left cannot be removed.
+ fireEvent.click(options("Trends"));
+ const item = await screen.findByRole("menuitem", {
+ name: "Remove section",
+ });
+ expect(item.getAttribute("aria-disabled")).toBe("true");
+
+ unmount();
+ renderDashboards(sectioned);
+ expect(arrangement()).toEqual({
+ Trends: ["Total logins", "Logins over time", "Top event types"],
+ });
+ });
+
+ it("moves a section with the arrow keys on its grip, and keeps the order", () => {
+ const { unmount } = renderDashboards(sectioned);
+ const grip = () =>
+ screen.getByRole("button", {
+ name: "Move Trends: drag the section, or use the arrow keys",
+ });
+ grip().focus();
+ fireEvent.keyDown(grip(), { key: "ArrowUp" });
+ expect(sectionTitles()).toEqual(["Trends", "Overview"]);
+ expect(arrangement()).toEqual({
+ Trends: ["Logins over time", "Top event types"],
+ Overview: ["Total logins"],
+ });
+ expect(screen.getByText("Trends moved above Overview.")).toBeTruthy();
+ expect(document.activeElement).toBe(grip());
+ // Already first: it stays.
+ fireEvent.keyDown(grip(), { key: "ArrowUp" });
+ expect(sectionTitles()).toEqual(["Trends", "Overview"]);
+
+ unmount();
+ renderDashboards(sectioned);
+ expect(sectionTitles()).toEqual(["Trends", "Overview"]);
+ fireEvent.keyDown(grip(), { key: "ArrowDown" });
+ expect(sectionTitles()).toEqual(["Overview", "Trends"]);
+ });
+
+ it("adds sections after the untitled group of a dashboard without sections, which has no options", async () => {
+ renderDashboards();
+ expect(
+ screen.queryByRole("button", { name: /^Section options for / }),
+ ).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Add section" }));
+ type("Mine");
+ fireEvent.keyDown(titleField(), { key: "Enter" });
+ // The untitled group keeps its tiles, above; the dashboard's own +
+ // still adds to it, and the new section has its own.
+ expect(sectionTitles()).toEqual(["Mine"]);
+ expect(tileTitles()).toEqual(DEFAULT_ORDER);
+ expect(
+ screen
+ .getAllByRole("button", { name: /^Add a widget back to / })
+ .map((button) => button.getAttribute("aria-label")),
+ ).toEqual(["Add a widget back to Activity", "Add a widget back to Mine"]);
+ // Only the viewer's section can move or be renamed.
+ expect(
+ screen.getAllByRole("button", { name: /drag the section/ }),
+ ).toHaveLength(1);
+
+ const grip = screen.getByRole("button", {
+ name: "Move Total logins: drag the tile, or use the arrow keys",
+ });
+ grip.focus();
+ fireEvent.keyDown(grip, { key: "ArrowDown" });
+ expect(tilesIn("Mine")).toEqual(["Total logins"]);
+ // Removed, its tiles go back up to the untitled group.
+ await choose("Mine", "Remove section");
+ expect(sectionTitles()).toEqual([]);
+ expect(tileTitles()).toEqual(DEFAULT_ORDER);
+ });
+
+ describe("dragging", () => {
+ // happy-dom lays nothing out, so the sections start TOP down the page,
+ // under the dashboard's own header; each is a band 1000px below the
+ // one before, 900px tall with a 60px header at its top, and its tiles
+ // (and slot) two columns of 100×100 cells under the header.
+ const TOP = 200;
+ const realRect = HTMLElement.prototype.getBoundingClientRect;
+ const bandOf = (element: Element | null | undefined) =>
+ element?.parentElement
+ ? [...element.parentElement.children].indexOf(element)
+ : 0;
+ beforeEach(() => {
+ HTMLElement.prototype.getBoundingClientRect = function (
+ this: HTMLElement,
+ ) {
+ let rect = { left: 0, top: 0, right: 100, bottom: 100 };
+ const list = this.parentElement;
+ if (this.dataset.sortableGroup !== undefined) {
+ const top = TOP + bandOf(this) * 1000;
+ rect = { left: 0, top, right: 400, bottom: top + 900 };
+ } else if (this.dataset.sortableGroupHeader !== undefined) {
+ const top = TOP + bandOf(this.parentElement) * 1000;
+ rect = { left: 0, top, right: 400, bottom: top + 60 };
+ } else if (
+ this.tagName === "LI" &&
+ list?.parentElement?.dataset.sortableGroup !== undefined
+ ) {
+ const index = [...list.children].indexOf(this);
+ const left = (index % 2) * 110;
+ const top =
+ TOP +
+ bandOf(list.parentElement) * 1000 +
+ 100 +
+ Math.floor(index / 2) * 110;
+ rect = { left, top, right: left + 100, bottom: top + 100 };
+ }
+ return {
+ ...rect,
+ x: rect.left,
+ y: rect.top,
+ width: rect.right - rect.left,
+ height: rect.bottom - rect.top,
+ toJSON: () => ({}),
+ } as DOMRect;
+ };
+ });
+ afterEach(async () => {
+ HTMLElement.prototype.getBoundingClientRect = realRect;
+ // dnd-kit keeps swallowing clicks for 50ms after a drag ends.
+ await new Promise((resolve) => setTimeout(resolve, 60));
+ });
+
+ // A section picked up by its grip and aimed at a height from the top of
+ // the sections (negative is above them).
+ function pickUpSection(title: string) {
+ const grip = screen.getByRole("button", {
+ name: `Move ${title}: drag the section, or use the arrow keys`,
+ });
+ const band = bandOf(grip.closest("[data-sortable-group]"));
+ let at = { clientX: 20, clientY: TOP + band * 1000 + 20 };
+ fireEvent.mouseDown(grip, { ...at, button: 0 });
+ fireEvent.mouseMove(document, { ...at, clientY: at.clientY + 10 });
+ return {
+ moveTo(height: number) {
+ const clientY = TOP + height;
+ fireEvent.mouseMove(document, {
+ clientX: at.clientX,
+ clientY: (at.clientY + clientY) / 2,
+ });
+ at = { clientX: at.clientX, clientY };
+ fireEvent.mouseMove(document, at);
+ },
+ drop: () => fireEvent.mouseUp(document, at),
+ };
+ }
+
+ it("puts a section dropped on the top half of another above it, and keeps the order", () => {
+ const { unmount } = renderDashboards(sectioned);
+ const drag = pickUpSection("Trends");
+ // No slot while a section is dragged; nothing moves until the drop.
+ expect(screen.queryByText("Move here")).toBeNull();
+ drag.moveTo(200);
+ expect(sectionTitles()).toEqual(["Overview", "Trends"]);
+ expect(window.localStorage.getItem(LAYOUT_KEY)).toBeNull();
+ drag.drop();
+ const moved = {
+ Trends: ["Logins over time", "Top event types"],
+ Overview: ["Total logins"],
+ };
+ expect(arrangement()).toEqual(moved);
+ expect(saved().map((s: { id: string }) => s.id)).toEqual([
+ "trends",
+ "overview",
+ ]);
+
+ unmount();
+ renderDashboards(sectioned);
+ expect(arrangement()).toEqual(moved);
+ });
+
+ it("puts a section dropped above the first one, over the dashboard's header, first", () => {
+ renderDashboards(sectioned);
+ const drag = pickUpSection("Trends");
+ drag.moveTo(-120);
+ drag.drop();
+ expect(sectionTitles()).toEqual(["Trends", "Overview"]);
+ });
+
+ it("puts a section dropped on the bottom half of another below it", () => {
+ renderDashboards(sectioned);
+ fireEvent.click(screen.getByRole("button", { name: "Add section" }));
+ fireEvent.keyDown(titleField(), { key: "Enter" });
+ expect(sectionTitles()).toEqual(["Overview", "Trends", "New section"]);
+ const drag = pickUpSection("Overview");
+ drag.moveTo(1000 + 700);
+ drag.drop();
+ expect(sectionTitles()).toEqual(["Trends", "Overview", "New section"]);
+ });
+
+ it("moves a tile dropped on a new section's slot into it, and still swaps tiles", () => {
+ renderDashboards(sectioned);
+ fireEvent.click(screen.getByRole("button", { name: "Add section" }));
+ type("Mine");
+ fireEvent.keyDown(titleField(), { key: "Enter" });
+ const drag = pickUp(tile("Top event types"));
+ drag.moveTo(within(section("Mine")).getByText("Move here"));
+ drag.drop();
+ expect(arrangement()).toEqual({
+ Overview: ["Total logins"],
+ Trends: ["Logins over time"],
+ Mine: ["Top event types"],
+ });
+
+ const swap = pickUp(tile("Total logins"));
+ swap.moveTo(tile("Top event types"));
+ swap.drop();
+ expect(arrangement()).toEqual({
+ Overview: ["Top event types"],
+ Trends: ["Logins over time"],
+ Mine: ["Total logins"],
+ });
+ });
+ });
+ });
+});
From 31cfe26d7de995b040dbb6f66649370aa640ade6 Mon Sep 17 00:00:00 2001
From: Codefly Automation
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Sun, 11 Oct 2026 21:12:30 -0400
Subject: [PATCH 22/29] docs: describe sections, the percent boundary and
per-day zeros
AUDIT_METRICS.md says where a time-grouped count is zero-filled and what is
not, that a widget's format reaches every mark, that the data graph's
percent (a share) becomes the kit's (percentage points) once in
runDashboard, and how a view's sections are drawn. The manifest schema doc
names the same boundary and the zero fill beside the `format` key.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
module/AUDIT_METRICS.md | 34 +++++++++++++++++++++++----
module/docs/plugin-manifest-schema.md | 6 +++++
2 files changed, 35 insertions(+), 5 deletions(-)
diff --git a/module/AUDIT_METRICS.md b/module/AUDIT_METRICS.md
index 02fdd4225..d960984e3 100644
--- a/module/AUDIT_METRICS.md
+++ b/module/AUDIT_METRICS.md
@@ -204,6 +204,18 @@ events means partial telemetry. Older servers without samples cannot establish
numeric completeness.
SDK series expose `coverage: complete | partial | empty` and nullable `total`.
+A count, distinct count or sum grouped by **time** is zero-filled where the series
+is produced, in the SDK's `runMetric`: every day (or week, or month) between the
+first and the last bucket the aggregate returned, that it did not return, gets a
+`0`, because no event matched there. Without it a chart spaces points by
+position, so a day with no events vanished and the line ran straight over it.
+An average, minimum, maximum or percentile has no value over no events and is
+not filled; a bucket the aggregate *did* return whose value was dropped as
+unknown stays out and keeps the series partial; days before the first bucket and
+after the last are not filled; and a series made only of filled zeros has no
+total. A derived metric reads a day no input had events on as no events, not as
+missing telemetry: a ratio there has no point and the series stays complete.
+
Missing derived operands and zero denominators stay unknown. Partial series keep
observed chart points with a visible label and withhold the total. Distinct
counts, averages, percentiles and ratios have no scalar total across multiple
@@ -228,11 +240,23 @@ does not have to ask which half of the kit its dashboard will get.
| `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 | — |
-A scalar widget's `format` names how its value is written, and `percent` takes a
-value that is **already a percentage**: 45 renders "45%". It is not a share —
-0.4 renders "0.4%". A producer whose own `percent` means a 0-to-1 share scales it
-before it reaches the view, because the field is bound structurally and nothing
-on the wire distinguishes the two conventions.
+A widget's `format` names how its values are written — a scalar's number, a
+chart's axis, readout and data table, and a table's values all write through the
+one function `StatTile` uses — and the view's `percent` takes a value that is
+**already a percentage**: 45 renders "45%". It is not a share — 0.4 renders
+"0.4%". A data graph's `format: "percent"` means the opposite, a share from 0 to
+1, because that is what a ratio metric produces. The two meet at exactly one
+place: `runDashboard` multiplies a percent metric's points and total by 100 on
+the widget it resolves (`byMetric` keeps the shares), so a declared 0.4 is drawn
+"40%" on every mark, and nothing after it scales again. A producer of its own
+with share semantics must do the same before the view.
+
+A view may group its widgets into **sections**: `sections` lists them in the
+order they are drawn (`id`, `title`, an optional `description`) and a widget's
+`section` names the one it is drawn in. Each section is headed by the kit's
+`DashboardSectionHeader`; a declared section with no widgets is still drawn and
+says so; a widget naming no section is drawn first, untitled. A view without
+sections draws as it always has.
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
diff --git a/module/docs/plugin-manifest-schema.md b/module/docs/plugin-manifest-schema.md
index be2ec9a77..1ae39d846 100644
--- a/module/docs/plugin-manifest-schema.md
+++ b/module/docs/plugin-manifest-schema.md
@@ -96,6 +96,12 @@ has three node kinds:
(the default) writes its value as it is, and `percent` reads it as a share
from 0 to 1 and writes it as a percentage (0.4 reads 40%) on every widget
that draws it. A ratio is not always a share, so the host never infers one.
+ The share is scaled to a percentage once, by the SDK's `runDashboard`, on the
+ widget it resolves; the dashboard kit's own `format: "percent"` takes the
+ scaled value (see `AUDIT_METRICS.md`, "How a resolved series is drawn").
+ A count, distinct count or sum grouped by `time` reads `0` on a day (week,
+ month) with no events between its first and last bucket, rather than
+ skipping it.
- **`dashboards`** — a `layout` of `widgets`, each binding a `visualization` to
one metric. These metric-bound widgets are distinct from `ui.widgets`, which
are presentation slots contributed to host surfaces; the two never mix.
From fe679cc435b97d97f06e332f1d9dd093ab7c91d9 Mon Sep 17 00:00:00 2001
From: Codefly Automation
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Sun, 11 Oct 2026 21:49:46 -0400
Subject: [PATCH 23/29] fix(accounts): key audit time buckets in UTC, whatever
the session zone
AggregateAuditLog keyed a time bucket with to_char(date_trunc(grain,
created_at), '...OF') in the database session's zone. Under a zone with
daylight saving the offset moves ("+01" then "+02"), so:
- the SDK's zero fill, which has to name the buckets the aggregate did not
return, could not know which offset a missing day takes (review m1: a
filled day copied the first key's offset and could parse as the day
before);
- the kit labels keys in UTC, so a "+02" midnight already read as 22:00
on the previous day.
Buckets are now UTC days, weeks and months, keyed "...T00:00:00+00" by
construction, as the entitlements usage buckets already are. A session in
UTC, the default, gets the same keys as before.
TestTimeBucketKeysAreUTCInAnySessionZone sets the session to Europe/Paris
around the 2026 change to summer time and asserts day, week and month
keys; with the old expression it fails on "+01"/"+02" keys. Run against
postgres:17-alpine, as CI's audit-postgres service is.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../pkg/infra/auditmetricstest/audit_test.go | 47 +++++++++++++++++++
.../accounts/code/pkg/infra/postgres_audit.go | 9 +++-
2 files changed, 55 insertions(+), 1 deletion(-)
diff --git a/module/services/accounts/code/pkg/infra/auditmetricstest/audit_test.go b/module/services/accounts/code/pkg/infra/auditmetricstest/audit_test.go
index 12cce5c4d..9fd96692c 100644
--- a/module/services/accounts/code/pkg/infra/auditmetricstest/audit_test.go
+++ b/module/services/accounts/code/pkg/infra/auditmetricstest/audit_test.go
@@ -598,3 +598,50 @@ func TestPostgresAuditPolicyAsNonOwner(t *testing.T) {
require.Empty(t, rows)
}
}
+
+// A time bucket's key is a UTC midnight with a "+00" offset in every session
+// zone. Keyed in the session's zone, a bucket carries that zone's offset, which
+// moves across daylight saving, and the SDK's zero fill, which has to name the
+// days the aggregate did not return, cannot know which offset a missing day
+// takes; the kit labels keys in UTC, so a "+02" midnight read as the day before.
+func TestTimeBucketKeysAreUTCInAnySessionZone(t *testing.T) {
+ dsn := os.Getenv("AUDIT_METRICS_TEST_DSN")
+ if dsn == "" {
+ if os.Getenv("AUDIT_METRICS_REQUIRE_DB") == "1" {
+ t.Fatal("database gate requires AUDIT_METRICS_TEST_DSN")
+ }
+ t.Skip("set AUDIT_METRICS_TEST_DSN to a disposable PostgreSQL database")
+ }
+ ctx := context.Background()
+ conn, err := pgx.Connect(ctx, dsn)
+ require.NoError(t, err)
+ defer func() { require.NoError(t, conn.Close(ctx)) }()
+ tx, err := conn.Begin(ctx)
+ require.NoError(t, err)
+ defer func() { require.NoError(t, tx.Rollback(ctx)) }()
+ // Either side of the Central European change to summer time (29 March 2026,
+ // 01:00 UTC). 22:30 UTC on the 29th is already the 30th in Paris.
+ _, err = tx.Exec(ctx, `SET LOCAL TimeZone = 'Europe/Paris';
+ CREATE TEMP TABLE audit_events (org_id text, resource text, resource_id text, event_type text, actor_id text, created_at timestamptz, payload jsonb);
+ INSERT INTO audit_events VALUES
+ ('org-a','collection','source-a','saas.document.ingested','reader','2026-03-28T12:00:00Z','{}'),
+ ('org-a','collection','source-a','saas.document.ingested','reader','2026-03-29T22:30:00Z','{}'),
+ ('org-a','collection','source-a','saas.document.ingested','reader','2026-04-01T12:00:00Z','{}');`)
+ require.NoError(t, err)
+ ctx = txbind.BindRequest(ctx, tx, "", "")
+ store := &infra.PostgresStore{}
+ q := business.AuditQuery{OrgID: "org-a", Resource: "collection", ResourceID: "source-a", EventType: "saas.document.ingested"}
+ for bucket, want := range map[string][]string{
+ "day": {"2026-03-28T00:00:00+00", "2026-03-29T00:00:00+00", "2026-04-01T00:00:00+00"},
+ "week": {"2026-03-23T00:00:00+00", "2026-03-30T00:00:00+00"},
+ "month": {"2026-03-01T00:00:00+00", "2026-04-01T00:00:00+00"},
+ } {
+ rows, err := store.AggregateAuditLog(ctx, q, business.AuditAggregationSpec{GroupBy: []string{"time"}, Bucket: bucket})
+ require.NoError(t, err, bucket)
+ keys := make([]string, 0, len(rows))
+ for _, row := range rows {
+ keys = append(keys, row.Key)
+ }
+ require.ElementsMatch(t, want, keys, bucket)
+ }
+}
diff --git a/module/services/accounts/code/pkg/infra/postgres_audit.go b/module/services/accounts/code/pkg/infra/postgres_audit.go
index ac2fc3417..990778b48 100644
--- a/module/services/accounts/code/pkg/infra/postgres_audit.go
+++ b/module/services/accounts/code/pkg/infra/postgres_audit.go
@@ -448,7 +448,14 @@ func auditDimensionExpr(dim, bucket string, addArg func(any) int) (string, error
case "week", "month", "day":
grain = bucket
}
- return fmt.Sprintf("to_char(date_trunc('%s', created_at), 'YYYY-MM-DD\"T\"HH24:MI:SSOF')", grain), nil
+ // Buckets are UTC days, weeks and months, whatever zone the session
+ // runs in, so every key carries the same "+00" offset. In the session's
+ // own zone a key would carry that zone's offset, which changes across
+ // daylight saving: a client that has to name a bucket the aggregate did
+ // not return (the SDK's zero fill) could not know which offset it takes,
+ // and the kit labels keys in UTC, so a "+02" midnight already read as
+ // 22:00 the day before.
+ return fmt.Sprintf("to_char(date_trunc('%s', created_at AT TIME ZONE 'UTC'), 'YYYY-MM-DD\"T\"HH24:MI:SS\"+00\"')", grain), nil
default:
return "", fmt.Errorf("audit: unhandled group dimension %q", dim)
}
From 50322d093b00bb57369dda501ba68ef7c739cadb Mon Sep 17 00:00:00 2001
From: Codefly Automation
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Sun, 11 Oct 2026 21:49:46 -0400
Subject: [PATCH 24/29] fix(sdk): fill a derived metric's inputs, and every
series through the range end
Review M1: each input of a derived metric was zero-filled only between
its own first and last bucket. A failure rate whose failures start after
the requests, or never happen, dropped those days and read "partial"
(the tile's "Partial data", the + menu's "Telemetry incomplete."), which
is the case this PR said it fixed. A complete, time-grouped count,
distinct count or sum is now marked fillable, and combineDerived reads a
fillable input's missing time bucket as that input's no-events 0. Keys
from every input are put in time order, the buckets between two inputs'
ranges are combined too, and a sum or difference of fillable inputs is
fillable in turn, so it can feed a rate. A partial input, an aggregation
with no value over no events, and a non-date key keep a missing operand
unknown. The prototype was the reviewer's; the union grid and the nested
case are added here.
Review m2: the fill stopped at the last returned bucket, so the days since
the last event vanished and the newest point and the bucket delta read an
old day as the latest. It now runs through the bucket holding the end of
the range: just before an exclusive `to`, or now for an open query.
Review m1: a fill copies the returned keys' suffix, which the RPC now
keys in UTC (previous commit). Keys that disagree about their offset are
left as they came rather than given a guessed one.
Tests: the reviewer's two M1 cases, the trailing-days case, and a shared
percent metric scaled once per widget, adopted; plus time order, union
grid, nested sum, partial input, exclusive `to`, week buckets, and the
offset refusal. Existing fill tests pin their clock before their own
days. Each mechanism was removed in turn and reds its tests.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../packages/saas-sdk/src/datagraph/run.ts | 87 +++++-
.../packages/saas-sdk/test/dashboard.test.ts | 56 +++-
.../code/packages/saas-sdk/test/run.test.ts | 262 +++++++++++++++++-
3 files changed, 391 insertions(+), 14 deletions(-)
diff --git a/module/services/frontend/code/packages/saas-sdk/src/datagraph/run.ts b/module/services/frontend/code/packages/saas-sdk/src/datagraph/run.ts
index 119312954..2895a9e65 100644
--- a/module/services/frontend/code/packages/saas-sdk/src/datagraph/run.ts
+++ b/module/services/frontend/code/packages/saas-sdk/src/datagraph/run.ts
@@ -45,6 +45,13 @@ export function assertAuditScopeContract(
// A derived metric tells them apart from a returned bucket by this mark.
const noEvents = new WeakSet();
+// The complete time series whose aggregation is 0 over no events: every bucket
+// the RPC returned was read, so a time bucket such a series lacks is one it had
+// no events in. A derived metric reads that bucket as a 0 (see combineDerived),
+// which is what lets a rate whose numerator starts later than its denominator,
+// or never happens at all, read 0 on those days instead of missing telemetry.
+const fillable = new WeakSet();
+
function toSeries(
metricId: string,
points: MetricPoint[],
@@ -123,26 +130,39 @@ function nextBucket(date: Date, bucket: MetricBucket): Date {
/**
* The audit RPC returns no bucket for a time bucket with no events, and a
* chart spaces its points evenly, so a day with none vanished and the line ran
- * straight over it. This puts a 0 at every bucket between the first and the
- * last one the RPC returned that it did not return. A bucket it did return,
- * whose value was dropped as unknown, stays out: that is not a bucket with no
- * events. A key that does not read as a date, or one off the bucket's step
- * from the first, leaves the points as they are.
+ * straight over it. This puts a 0 at every bucket from the first one the RPC
+ * returned through the last one, or through the bucket holding `through` (the
+ * end of the range asked about) when that is later, that it did not return —
+ * so the days since the last event read 0 too, and the latest point is the
+ * latest bucket rather than the last day anything happened.
+ *
+ * A bucket it did return, whose value was dropped as unknown, stays out: that
+ * is not a bucket with no events. A key that does not read as a date, or one
+ * off the bucket's step from the first, leaves the points as they are. So do
+ * returned keys that disagree about what follows the date: the RPC keys UTC
+ * buckets ("…T00:00:00+00"), so a filled key copies that suffix, and keys
+ * carrying different offsets would leave a filled bucket with no offset this
+ * function could know.
*/
export function fillEmptyBuckets(
points: readonly MetricPoint[],
returnedKeys: readonly string[],
bucket: MetricBucket,
+ through?: Date,
): MetricPoint[] {
const unchanged = [...points];
const returned = returnedKeys.map(bucketDate);
if (returned.length === 0 || returned.includes(null)) return unchanged;
const parsed = returned as { date: Date; rest: string }[];
+ if (parsed.some(({ rest }) => rest !== parsed[0].rest)) return unchanged;
const day = (date: Date) => date.toISOString().slice(0, 10);
const seen = new Set(parsed.map(({ date }) => day(date)));
const times = parsed.map(({ date }) => date.getTime());
const first = parsed[times.indexOf(Math.min(...times))];
- const last = Math.max(...times);
+ const last = Math.max(
+ ...times,
+ through && !Number.isNaN(through.getTime()) ? through.getTime() : 0,
+ );
const grid: string[] = [];
for (let at = first.date; at.getTime() <= last; at = nextBucket(at, bucket)) {
@@ -206,14 +226,17 @@ export async function runMetric(
partial = true;
points.push({ key: bucket.key, value });
}
- return toSeries(
- metric.id,
+ const zeroFilled =
metric.groupBy === "time" &&
- ZERO_OVER_NO_EVENTS.includes(metric.aggregation)
+ ZERO_OVER_NO_EVENTS.includes(metric.aggregation);
+ const series = toSeries(
+ metric.id,
+ zeroFilled
? fillEmptyBuckets(
points,
response.buckets.map((bucket) => bucket.key),
metric.bucket ?? "day",
+ rangeEnd(context),
)
: points,
metric.groupBy,
@@ -221,6 +244,14 @@ export async function runMetric(
metric.aggregation === "count" || metric.aggregation === "sum",
partial,
);
+ if (zeroFilled && !partial) fillable.add(series);
+ return series;
+}
+
+// The last instant the query covers: just before an explicit exclusive `to`,
+// otherwise now, because an open-ended query runs through today.
+function rangeEnd(context: MetricContext): Date {
+ return context.to ? new Date(context.to.getTime() - 1) : new Date();
}
// Combine the resolved series of a derived metric's inputs. Inputs are aligned
@@ -271,12 +302,36 @@ function combineDerived(
}
}
}
+ // A fillable input's missing time bucket is a 0 (see `fillable`), so keys
+ // from every input are combined; time keys then go in time order, because
+ // first-seen order would put a later input's earlier days at the end.
+ if (dimension.groupBy === "time" && keys.every((key) => bucketDate(key))) {
+ const at = (key: string) => (bucketDate(key) as { date: Date }).date;
+ keys.sort((a, b) => at(a).getTime() - at(b).getTime());
+ // When every input reads a missing bucket as no events, a bucket
+ // between one input's range and another's is no events for all of
+ // them, so the combination covers it too.
+ if (inputs.every((input) => fillable.has(input))) {
+ const grid = fillEmptyBuckets(
+ keys.map((key) => ({ key, value: 0 })),
+ keys,
+ dimension.bucket ?? "day",
+ ).map((point) => point.key);
+ keys.splice(0, keys.length, ...grid);
+ }
+ }
const pointAt = (input: MetricSeries, key: string): MetricPoint | undefined =>
input.points.find((point) => point.key === key);
let partial = inputs.some((input) => input.coverage === "partial");
const points: MetricPoint[] = [];
for (const key of keys) {
- const operands = inputs.map((input) => pointAt(input, key));
+ const operands = inputs.map((input) => {
+ const found = pointAt(input, key);
+ if (found || !fillable.has(input) || !bucketDate(key)) return found;
+ const zero = { key, value: 0 };
+ noEvents.add(zero);
+ return zero;
+ });
const empty = operands.every(
(point) => point === undefined || noEvents.has(point),
);
@@ -315,7 +370,7 @@ function combineDerived(
points.push(point);
}
- return toSeries(
+ const series = toSeries(
metric.id,
points,
dimension.groupBy,
@@ -323,6 +378,16 @@ function combineDerived(
additive,
partial,
);
+ // A sum or difference of buckets with no events is 0 in turn, so it can
+ // feed another derived metric the same way; a ratio of them has no value.
+ if (
+ metric.operation !== "ratio" &&
+ !partial &&
+ inputs.every((input) => fillable.has(input))
+ ) {
+ fillable.add(series);
+ }
+ return series;
}
function indexMetrics(metrics: readonly Metric[]): Map {
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 547b22dd2..849e3d321 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
@@ -1,4 +1,4 @@
-import { describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import { defineDataGraph, runDashboard } from "../src/datagraph/dashboard.js";
import { fakeAuditClient } from "./support.js";
@@ -41,8 +41,13 @@ const graph = defineDataGraph({
],
});
+afterEach(() => vi.useRealTimers());
+
describe("runDashboard", () => {
it("produces render-ready widget data bound to each metric", async () => {
+ // Today is the last day returned, so the series is exactly those days.
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(new Date("2026-01-02T12:00:00Z"));
const { client } = fakeAuditClient((request) =>
request.groupBy === "time"
? [
@@ -182,6 +187,55 @@ describe("runDashboard", () => {
expect(plain.series.total).toBe(0.4);
});
+ it("scales a shared percent metric once per widget, never twice", async () => {
+ const shared = defineDataGraph({
+ events: [{ name: "signed_in", type: "user.signed_in.v1" }],
+ metrics: [
+ {
+ id: "won",
+ kind: "source",
+ filter: { event: "signed_in", actor: "won" },
+ groupBy: "event_type",
+ aggregation: "count",
+ },
+ {
+ id: "all",
+ kind: "source",
+ filter: { event: "signed_in", actor: "all" },
+ groupBy: "event_type",
+ aggregation: "count",
+ },
+ {
+ id: "rate",
+ kind: "derived",
+ operation: "ratio",
+ inputs: ["won", "all"],
+ format: "percent",
+ },
+ ],
+ dashboards: [
+ {
+ id: "d",
+ layout: "grid",
+ widgets: [
+ { id: "a", metric: "rate", visualization: "number" },
+ { id: "b", metric: "rate", visualization: "table" },
+ ],
+ },
+ ],
+ });
+ const { client } = fakeAuditClient((request) => [
+ { key: "all", count: request.actorId === "won" ? 2 : 5 },
+ ]);
+ const data = await runDashboard(client, shared, "d", context);
+ expect(data.widgets.map((widget) => widget.series.total)).toEqual([40, 40]);
+ expect(data.widgets.map((widget) => widget.format)).toEqual([
+ "percent",
+ "percent",
+ ]);
+ expect(data.byMetric.rate.total).toBeCloseTo(0.4);
+ });
+
it("carries the dashboard's sections and each widget's section", async () => {
const sectioned = defineDataGraph({
...graph,
diff --git a/module/services/frontend/code/packages/saas-sdk/test/run.test.ts b/module/services/frontend/code/packages/saas-sdk/test/run.test.ts
index 666a97353..3ee380420 100644
--- a/module/services/frontend/code/packages/saas-sdk/test/run.test.ts
+++ b/module/services/frontend/code/packages/saas-sdk/test/run.test.ts
@@ -1,7 +1,11 @@
import { create } from "@bufbuild/protobuf";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AggregateAuditLogResponseSchema } from "../generated/typescript/src/gen/saas/accounts/v1/audit_pb.js";
-import { describe, expect, it } from "vitest";
-import { runDataGraph, runMetric } from "../src/datagraph/run.js";
+import {
+ fillEmptyBuckets,
+ runDataGraph,
+ runMetric,
+} from "../src/datagraph/run.js";
import type {
DataGraph,
MetricOperation,
@@ -708,6 +712,15 @@ describe("derived additive totals", () => {
// The audit RPC returns no bucket for a day with no events. Plotted as it came
// back, a day with none vanished and the line ran straight over it.
describe("a time series with days that have no events", () => {
+ // The clock stands before every day these tests return, so only the days
+ // between the first and the last are filled. The days since the last one,
+ // through the end of the range, have tests of their own below.
+ beforeEach(() => {
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(new Date("2020-01-01T00:00:00Z"));
+ });
+ afterEach(() => vi.useRealTimers());
+
const day = (date: string) => `${date}T00:00:00+00`;
const perDay = (
aggregation: SourceMetric["aggregation"],
@@ -941,3 +954,248 @@ describe("a time series with days that have no events", () => {
expect(resolved.spread.coverage).toBe("complete");
});
});
+
+// A rate's inputs rarely start on the same day: failures begin later than
+// requests, or never happen at all. A day the numerator had no events on is a
+// 0, not missing telemetry, wherever it falls in the denominator's range.
+describe("a derived metric over inputs with different first and last days", () => {
+ beforeEach(() => {
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(new Date("2020-01-01T00:00:00Z"));
+ });
+ afterEach(() => vi.useRealTimers());
+
+ const day = (date: string) => `${date}T00:00:00+00`;
+ const perDay = (id: string, actor: string): SourceMetric => ({
+ id,
+ kind: "source",
+ filter: { event: "signed_in", actor },
+ groupBy: "time",
+ bucket: "day",
+ aggregation: "count",
+ });
+ const graph = (...derived: DataGraph["metrics"][number][]): DataGraph => ({
+ events: [{ name: "signed_in", type: "user.signed_in.v1" }],
+ metrics: [perDay("failed", "failed"), perDay("all", "all"), ...derived],
+ dashboards: [],
+ });
+ const rate = {
+ id: "rate",
+ kind: "derived",
+ operation: "ratio",
+ inputs: ["failed", "all"],
+ } as const;
+
+ it("reads 0, and stays complete, on a denominator day before the numerator's first event", async () => {
+ const { client } = fakeAuditClient((request) =>
+ request.actorId === "failed"
+ ? [{ key: day("2026-09-03"), count: 1 }]
+ : [
+ { key: day("2026-09-02"), count: 2 },
+ { key: day("2026-09-03"), count: 4 },
+ { key: day("2026-09-04"), count: 4 },
+ ],
+ );
+ const resolved = await runDataGraph(client, graph(rate), context);
+ expect(resolved.rate.points).toEqual([
+ { key: day("2026-09-02"), value: 0 },
+ { key: day("2026-09-03"), value: 0.25 },
+ { key: day("2026-09-04"), value: 0 },
+ ]);
+ expect(resolved.rate.coverage).toBe("complete");
+ });
+
+ it("reads 0% on every day of a ratio whose numerator has no events at all", async () => {
+ const { client } = fakeAuditClient((request) =>
+ request.actorId === "failed"
+ ? []
+ : [
+ { key: day("2026-09-02"), count: 2 },
+ { key: day("2026-09-04"), count: 4 },
+ ],
+ );
+ const resolved = await runDataGraph(client, graph(rate), context);
+ expect(resolved.rate.coverage).toBe("complete");
+ // 3 September had no requests either: a rate of nothing has no value.
+ expect(resolved.rate.points.map((point) => point.value)).toEqual([0, 0]);
+ });
+
+ it("puts the days of a later-starting input in time order, not first-seen order", async () => {
+ const { client } = fakeAuditClient((request) =>
+ request.actorId === "failed"
+ ? [{ key: day("2026-09-01"), count: 1 }]
+ : [{ key: day("2026-09-03"), count: 2 }],
+ );
+ const resolved = await runDataGraph(
+ client,
+ graph({
+ id: "total",
+ kind: "derived",
+ operation: "sum",
+ inputs: ["all", "failed"],
+ }),
+ context,
+ );
+ expect(resolved.total.points).toEqual([
+ { key: day("2026-09-01"), value: 1 },
+ { key: day("2026-09-02"), value: 0 },
+ { key: day("2026-09-03"), value: 2 },
+ ]);
+ expect(resolved.total.coverage).toBe("complete");
+ });
+
+ it("reads a sum of no events as 0 when it feeds another derived metric", async () => {
+ const { client } = fakeAuditClient((request) =>
+ request.actorId === "failed"
+ ? []
+ : [
+ { key: day("2026-09-02"), count: 2 },
+ { key: day("2026-09-03"), count: 4 },
+ ],
+ );
+ const resolved = await runDataGraph(
+ client,
+ graph(
+ {
+ id: "failures",
+ kind: "derived",
+ operation: "sum",
+ inputs: ["failed", "failed"],
+ },
+ {
+ id: "rate",
+ kind: "derived",
+ operation: "ratio",
+ inputs: ["failures", "all"],
+ },
+ ),
+ context,
+ );
+ expect(resolved.rate.coverage).toBe("complete");
+ expect(resolved.rate.points.map((point) => point.value)).toEqual([0, 0]);
+ });
+
+ it("still reads a missing operand as unknown when that input is partial", async () => {
+ const { client } = fakeAuditClient((request) =>
+ request.actorId === "failed"
+ ? [{ key: "user.signed_in.v1", count: 1 }]
+ : [{ key: day("2026-09-02"), count: 2 }],
+ );
+ const resolved = await runDataGraph(
+ client,
+ {
+ ...graph(rate),
+ metrics: [
+ { ...perDay("failed", "failed"), groupBy: "time" },
+ perDay("all", "all"),
+ rate,
+ ],
+ },
+ context,
+ );
+ // A key that is not a date is never read as a day with no events.
+ expect(resolved.rate.coverage).toBe("partial");
+ });
+});
+
+// The query is open-ended, so its range ends today: the days since the last
+// event are days with no events like the ones between, and the latest point is
+// the latest day, not the last day anything happened.
+describe("a time series whose range runs past its last event", () => {
+ afterEach(() => vi.useRealTimers());
+
+ const perDay = (bucket: SourceMetric["bucket"] = "day"): SourceMetric => ({
+ id: "per_day",
+ kind: "source",
+ filter: { event: "signed_in" },
+ groupBy: "time",
+ bucket,
+ aggregation: "count",
+ });
+ const days = (series: { points: { key: string }[] }) =>
+ series.points.map((point) => point.key.slice(0, 10));
+
+ it("reads 0 on each day since the last event, up to today", async () => {
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(new Date("2026-09-05T12:00:00Z"));
+ const { client } = fakeAuditClient(() => [
+ { key: "2026-09-01T00:00:00+00", count: 3 },
+ { key: "2026-09-02T00:00:00+00", count: 5 },
+ ]);
+ const series = await runMetric(client, perDay(), (n) => `user.${n}.v1`, {
+ orgId: "org_1",
+ });
+ expect(days(series)).toEqual([
+ "2026-09-01",
+ "2026-09-02",
+ "2026-09-03",
+ "2026-09-04",
+ "2026-09-05",
+ ]);
+ expect(series.points.slice(2).map((point) => point.value)).toEqual([
+ 0, 0, 0,
+ ]);
+ expect(series.total).toBe(8);
+ });
+
+ it("stops before an explicit end of range, which is exclusive", async () => {
+ const { client } = fakeAuditClient(() => [
+ { key: "2026-09-01T00:00:00+00", count: 3 },
+ ]);
+ const series = await runMetric(client, perDay(), (n) => `user.${n}.v1`, {
+ orgId: "org_1",
+ to: new Date("2026-09-04T00:00:00Z"),
+ });
+ expect(days(series)).toEqual(["2026-09-01", "2026-09-02", "2026-09-03"]);
+ });
+
+ it("runs a week bucket through the week holding today", async () => {
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(new Date("2026-09-16T12:00:00Z"));
+ const { client } = fakeAuditClient(() => [
+ { key: "2026-08-31T00:00:00+00", count: 3 },
+ ]);
+ const series = await runMetric(
+ client,
+ perDay("week"),
+ (n) => `user.${n}.v1`,
+ { orgId: "org_1" },
+ );
+ expect(days(series)).toEqual(["2026-08-31", "2026-09-07", "2026-09-14"]);
+ });
+});
+
+// The RPC keys UTC buckets, so every key carries "+00" and a filled day copies
+// it. Keys that disagree about their offset leave a filled day with an offset
+// that cannot be known from them, so nothing is invented.
+describe("filling keys that carry different offsets", () => {
+ it("leaves the points as they came rather than give a filled day a guessed offset", () => {
+ const points = [
+ { key: "2026-03-28T00:00:00+01", value: 1 },
+ { key: "2026-04-01T00:00:00+02", value: 1 },
+ ];
+ const filled = fillEmptyBuckets(
+ points,
+ points.map((point) => point.key),
+ "day",
+ );
+ expect(filled).toEqual(points);
+ });
+
+ it("fills keys that share one offset, copying it", () => {
+ const points = [
+ { key: "2026-03-28T00:00:00+00", value: 1 },
+ { key: "2026-03-30T00:00:00+00", value: 1 },
+ ];
+ const filled = fillEmptyBuckets(
+ points,
+ points.map((point) => point.key),
+ "day",
+ );
+ expect(filled.map((point) => point.key)).toEqual([
+ "2026-03-28T00:00:00+00",
+ "2026-03-29T00:00:00+00",
+ "2026-03-30T00:00:00+00",
+ ]);
+ });
+});
From 442896fc0664b128c9ef788c9d470b79bbb87535 Mon Sep 17 00:00:00 2001
From: Codefly Automation
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Sun, 11 Oct 2026 21:49:46 -0400
Subject: [PATCH 25/29] feat(ui): title a section's widgets one level under the
section
Review m3: a section's title and its widgets' titles were all
, so
heading navigation read a section's widgets as its peers. A widget inside
a section is now titled
; a widget drawn above every section, and
every widget of a view without sections, keeps
.
- Card takes titleAs ("h3" default, "h4"); StatTile's labelAs takes "h4".
- DashboardWidget takes heading (WidgetHeading, "h3" default).
- decides the level and passes it to renderWidget as a second
argument, { heading }, so a host resolving its own widgets titles them
the same way. A slot that ignores it keeps
.
Also: stories for a sectioned and DashboardSectionHeader,
listed in ui-inventory.json, and a README note of what a remote will see
in 0.12.7 (review m6): a widget's format, compact and currency included,
now reaches chart axes, readouts, data tables and tables.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../code/packages/codefly-ui/README.md | 20 ++++++
.../dashboard/__tests__/dashboard.test.tsx | 33 +++++++++
.../codefly-ui/src/dashboard/dashboard.tsx | 32 +++++++--
.../codefly-ui/src/dashboard/index.ts | 1 +
.../codefly-ui/src/dashboard/metric-tiles.tsx | 5 +-
.../packages/codefly-ui/src/layout/card.tsx | 12 +++-
.../codefly-ui/stories/composites.stories.tsx | 70 ++++++++++++++++++-
.../services/frontend/code/ui-inventory.json | 8 +++
8 files changed, 172 insertions(+), 9 deletions(-)
diff --git a/module/services/frontend/code/packages/codefly-ui/README.md b/module/services/frontend/code/packages/codefly-ui/README.md
index a60201657..1b9549c87 100644
--- a/module/services/frontend/code/packages/codefly-ui/README.md
+++ b/module/services/frontend/code/packages/codefly-ui/README.md
@@ -75,6 +75,26 @@ and the v1 subset — is [CATALOG.md](./CATALOG.md).
a menu. It commits nothing: a move is reported and the consumer confirms,
writes or refuses it. See *A board commits no move*.
+### Dashboard changes a remote will see in 0.12.7
+
+- **A widget's `format` now reaches every mark, not only a scalar.** A line,
+ area or bar chart writes its axis, crosshair readout and data table in it, and
+ a table writes its values in it. A remote that declared `compact` or
+ `currency` on a chart or table widget, which only scalars honoured before,
+ now reads "1.2K" or "$1.2K" there. `percent` there is percentage points, as
+ it always was for a scalar.
+- **Sections.** `DashboardView.sections` and `DashboardWidgetView.section`
+ group widgets under `DashboardSectionHeader`. A widget inside a section is
+ titled `
`, one level under the section's `
`, and stays `
` above
+ every section; a view without sections draws as before.
+- **`renderWidget` gets a second argument**, `{ heading }`: the level the
+ widget's title takes where the dashboard draws it. Pass it on to
+ `DashboardWidget`'s new `heading` prop. A slot that ignores it keeps `
`.
+- `Card` takes `titleAs` (`"h3"` by default, or `"h4"`), and `StatTile`'s
+ `labelAs` also accepts `"h4"`.
+- `SortableBoard` (several groups, tiles moved between them) is exported beside
+ `SortableGrid`, which is now a one-group board with unchanged props.
+
### Status, card headers, lists and empty states
A solution page is built from kit components, never from hand-laid utilities:
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 e9b4eef41..514a72e42 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
@@ -831,6 +831,39 @@ describe("sections", () => {
).toBeTruthy();
});
+ // Heading navigation reads a section's widgets as its children: the
+ // section's title is the
under the dashboard's
, so its widgets'
+ // titles are
. A widget drawn above every section stays an
.
+ it("puts a section's widget headings one level under the section's heading", () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole("heading", { name: "Totals" }).tagName).toBe("H3");
+ expect(screen.getByRole("heading", { name: "Sign-ins" }).tagName).toBe(
+ "H4",
+ );
+ expect(
+ screen.getByRole("heading", { name: "Sign-ins per day" }).tagName,
+ ).toBe("H4");
+ expect(screen.getByRole("heading", { name: "Loose" }).tagName).toBe("H3");
+ });
+
+ it("tells a renderWidget slot the heading level its widget takes", () => {
+ const levels: Record = {};
+ render(
+ {
+ levels[widget.id] = heading;
+ return null;
+ },
+ }}
+ />,
+ );
+ expect(levels).toEqual({ trend: "h4", total: "h4", loose: "h3" });
+ });
+
it("keeps a declared section no widget names, and says it is empty", () => {
render();
expect(screen.getByText("No widgets in this section.")).toBeTruthy();
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 08c5113bb..1d5e8c382 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
@@ -290,9 +290,18 @@ export interface DashboardWidgetProps {
* passes none.
*/
actions?: ReactNode;
+ /**
+ * The widget title's heading level: `"h3"` under the dashboard's own
+ * `
`, `"h4"` inside a section, whose own title is the `
`. A
+ * `renderWidget` slot is told which (see {@link DashboardSlots}).
+ */
+ heading?: WidgetHeading;
className?: string;
}
+/** The heading element a widget's title is drawn as. */
+export type WidgetHeading = "h3" | "h4";
+
/**
* 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
@@ -309,6 +318,7 @@ export interface DashboardWidgetProps {
export function DashboardWidget({
widget,
actions,
+ heading = "h3",
className,
}: DashboardWidgetProps) {
const title = widgetTitle(widget);
@@ -350,7 +360,7 @@ export function DashboardWidget({
@@ -373,6 +383,7 @@ export function DashboardWidget({
title={title}
description={state ? : undefined}
actions={actions}
+ titleAs={heading}
className={className}
>
{refused ? (
@@ -414,9 +425,14 @@ export interface DashboardSlots {
/**
* 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.
+ * the series and then mounts `DashboardWidget` with it, passing on
+ * `heading` — the level this widget's title takes where the dashboard
+ * draws it (`"h4"` inside a section).
*/
- renderWidget?: (widget: DashboardWidgetView) => ReactNode;
+ renderWidget?: (
+ widget: DashboardWidgetView,
+ context: { heading: WidgetHeading },
+ ) => 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,
@@ -689,16 +705,24 @@ export function Dashboard({
: undefined;
const isGrid = (data.layout ?? "grid") === "grid";
const sections = data.sections ?? [];
+ const sectionIds = new Set(sections.map((section) => section.id));
+ // A widget in a section is titled one level under the section's
; a
+ // widget drawn above every section stays under the dashboard's
.
+ const headingOf = (widget: DashboardWidgetView): WidgetHeading =>
+ widget.section !== undefined && sectionIds.has(widget.section)
+ ? "h4"
+ : "h3";
const tiles: DashboardTile[] = data.widgets.map((widget) => ({
id: widget.id,
widget,
- node: slots?.renderWidget?.(widget) ?? (
+ node: slots?.renderWidget?.(widget, { heading: headingOf(widget) }) ?? (
// A span is a request for grid width, so it applies to anything the
// grid lays out. A scalar that declares one is kept out of the KPI
// row for exactly that reason — see `inKpiRow`.
{title != null && (
-
+
{title}
-
+
)}
{description != null && (
),
};
+// A dashboard in two declared sections, and one section no widget names. A
+// widget inside a section is titled one level under the section's heading.
+const days = [
+ { key: "2026-09-01T00:00:00+00", value: 3 },
+ { key: "2026-09-02T00:00:00+00", value: 0 },
+ { key: "2026-09-03T00:00:00+00", value: 7 },
+];
+export const DashboardSections = {
+ render: () => (
+
+ ),
+};
+
+// The heading a host draws over a section of its own layout: a handle before
+// the title, controls after it, and a note under it.
+export const SectionHeader = {
+ render: () => (
+ ⋮⋮}
+ actions={
+
+ }
+ >
+
+ No widgets in this section.
+
+
+ ),
+};
+
export const EmptyDashboard = {
render: () => (
diff --git a/module/services/frontend/code/ui-inventory.json b/module/services/frontend/code/ui-inventory.json
index 997f18084..4c6e5d4ca 100644
--- a/module/services/frontend/code/ui-inventory.json
+++ b/module/services/frontend/code/ui-inventory.json
@@ -270,6 +270,14 @@
{
"file": "packages/codefly-ui/stories/composites.stories.tsx",
"export": "EmptyDashboard"
+ },
+ {
+ "file": "packages/codefly-ui/stories/composites.stories.tsx",
+ "export": "DashboardSections"
+ },
+ {
+ "file": "packages/codefly-ui/stories/composites.stories.tsx",
+ "export": "SectionHeader"
}
],
"coverage": "owner stories; browser/skin verification pending",
From 0afad20aabd3ad07dc98de36b236e7cc1096b0d5 Mon Sep 17 00:00:00 2001
From: Codefly Automation
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Sun, 11 Oct 2026 21:49:46 -0400
Subject: [PATCH 26/29] fix(frontend): pass a section widget's heading on; read
filled zeros as no activity
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- SolutionWidget passes renderWidget's heading to DashboardWidget, so a
widget in a section is an
on the host's page too.
- The ⓘ's data range and freshness read a day counted 0 as a day with no
events: with the fill running through today, the last filled day had
become "the latest event".
- The + menu's "Telemetry incomplete." test now misses an average, which
has no value over no events; a count it missed before now reads 0, which
is the review's M1 fix.
- Adopted from the review: a refusal met by a + menu deletes the tile's
retained values (200, removed, 403 in the menu, then 503; and a 403
landing after the menu closed), and stored layouts the manifest has
moved past.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../code/src/solutions/SolutionDashboard.tsx | 7 +-
.../SolutionDashboard.menu-refusal.test.tsx | 190 ++++++++++++++++++
.../__tests__/SolutionDashboard.test.tsx | 25 ++-
.../__tests__/dashboard-layout.test.ts | 105 ++++++++++
.../solutions/__tests__/metric-info.test.ts | 21 ++
.../code/src/solutions/metric-info.ts | 6 +-
6 files changed, 349 insertions(+), 5 deletions(-)
create mode 100644 module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.menu-refusal.test.tsx
diff --git a/module/services/frontend/code/src/solutions/SolutionDashboard.tsx b/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
index cd81681e0..42ce473b0 100644
--- a/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
+++ b/module/services/frontend/code/src/solutions/SolutionDashboard.tsx
@@ -26,6 +26,7 @@ import {
type DashboardWidgetView,
SortableBoard,
type SortableGroupHandle,
+ type WidgetHeading,
type WidgetReadFailure,
} from "@codefly-dev/ui/dashboard";
import {
@@ -209,11 +210,13 @@ function SolutionWidget({
source,
widget,
actions,
+ heading,
className,
}: {
source: WidgetSource;
widget: MetricWidget;
actions: ReactNode;
+ heading?: WidgetHeading;
className?: string;
}) {
const { data, isError, error } = useWidgetSeries(source, widget);
@@ -237,6 +240,7 @@ function SolutionWidget({
failure: WIDGET_FAILURE[outcome],
}}
actions={actions}
+ heading={heading}
className={className}
/>
);
@@ -1170,7 +1174,7 @@ function SolutionDashboard({
UNSECTIONED,
)
: undefined,
- renderWidget: (widget) => {
+ renderWidget: (widget, { heading }) => {
const declared = widgets.get(widget.id);
if (!declared) return null;
return (
@@ -1178,6 +1182,7 @@ function SolutionDashboard({
source={source}
widget={declared}
actions={controls(widget.id)}
+ heading={heading}
className="h-full"
/>
);
diff --git a/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.menu-refusal.test.tsx b/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.menu-refusal.test.tsx
new file mode 100644
index 000000000..60330b789
--- /dev/null
+++ b/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.menu-refusal.test.tsx
@@ -0,0 +1,190 @@
+import type { DataGraph } from "@codefly/saas-plugin-manifest";
+import {
+ act,
+ cleanup,
+ fireEvent,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import { HttpResponse, http } from "msw";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { renderInApp, rpc } from "@/test/container";
+import { server } from "@/test/setup";
+
+import { SolutionDashboards } from "../SolutionDashboard";
+
+const { authState } = vi.hoisted(() => ({
+ authState: {
+ organizationId: "org-1" as string | undefined,
+ user: { id: "user-1" },
+ },
+}));
+vi.mock("@/lib/auth", () => ({ useAuth: () => authState }));
+
+beforeEach(() => window.localStorage.clear());
+afterEach(() => {
+ cleanup();
+ window.localStorage.clear();
+});
+
+// Two sections; the number (by event type, 37) is the only tile that can draw 37.
+const graph: DataGraph = {
+ events: [{ name: "login", type: "auth.login.v1" }],
+ metrics: [
+ {
+ id: "per_day",
+ kind: "source",
+ filter: { event: "login" },
+ groupBy: "time",
+ bucket: "day",
+ aggregation: "count",
+ },
+ {
+ id: "by_type",
+ kind: "source",
+ filter: { event: "login" },
+ groupBy: "event_type",
+ aggregation: "count",
+ },
+ ],
+ dashboards: [
+ {
+ id: "activity",
+ title: "Activity",
+ layout: "grid",
+ sections: [
+ { id: "overview", title: "Overview" },
+ { id: "trends", title: "Trends" },
+ ],
+ widgets: [
+ {
+ id: "w_stat",
+ metric: "by_type",
+ visualization: "number",
+ title: "Total logins",
+ section: "overview",
+ },
+ {
+ id: "w_line",
+ metric: "per_day",
+ visualization: "line",
+ title: "Logins over time",
+ section: "trends",
+ },
+ ],
+ },
+ ],
+};
+
+type Mode = "ok" | 403 | 503;
+let mode: Mode = "ok";
+let gate: Promise | null = null;
+function audit() {
+ server.use(
+ http.post(rpc("AuditService", "AggregateAuditLog"), async ({ request }) => {
+ const body = (await request.json()) as { groupBy?: string };
+ if (gate) await gate;
+ if (mode === 403)
+ return HttpResponse.json(
+ { code: "permission_denied" },
+ { status: 403 },
+ );
+ if (mode === 503)
+ return HttpResponse.json({ code: "unavailable" }, { status: 503 });
+ return HttpResponse.json({
+ buckets:
+ body.groupBy === "event_type"
+ ? [{ key: "saas.auth.login", count: "37" }]
+ : [{ key: "2026-08-01", count: "5" }],
+ });
+ }),
+ );
+}
+
+const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
+
+// A + menu asks the same query a tile does, so a refusal it meets must delete
+// the tile's retained values exactly as a tile's own refusal does (#1043):
+// nothing restorable may be left for a later ordinary failure to hand back.
+describe("a refusal met by a + menu", () => {
+ it("deletes the tile's retained values: 200, removed, 403 in the menu, then 503 on add-back never shows them", async () => {
+ mode = "ok";
+ audit();
+ const { client } = renderInApp(
+ ,
+ );
+ expect(await screen.findAllByText("37")).not.toHaveLength(0);
+
+ fireEvent.click(
+ screen.getByRole("button", { name: "Remove Total logins" }),
+ );
+ expect(screen.queryAllByText("37")).toHaveLength(0);
+
+ // The viewer loses access; the only read is the + menu's.
+ mode = 403;
+ fireEvent.click(
+ screen.getByRole("button", { name: "Add a widget back to Trends" }),
+ );
+ const item = await screen.findByRole("menuitem", { name: "Total logins" });
+ await waitFor(() => expect(client.isFetching()).toBe(0));
+ await act(settle);
+ // No hint: a refusal is not "No data yet".
+ expect(item.hasAttribute("aria-describedby")).toBe(false);
+ expect(item.textContent).toBe("Total logins");
+
+ // Then an ordinary failure, with no authorized read between.
+ mode = 503;
+ fireEvent.click(item);
+ await act(async () => {
+ await client.refetchQueries({ queryKey: ["solution-widget"] });
+ await settle();
+ });
+ expect(screen.queryAllByText("37")).toHaveLength(0);
+ });
+
+ it("deletes them when the refusal lands after the menu has closed", async () => {
+ mode = "ok";
+ gate = null;
+ audit();
+ const { client } = renderInApp(
+ ,
+ );
+ expect(await screen.findAllByText("37")).not.toHaveLength(0);
+ fireEvent.click(
+ screen.getByRole("button", { name: "Remove Total logins" }),
+ );
+
+ let release = () => {};
+ gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ mode = 403;
+ fireEvent.click(
+ screen.getByRole("button", { name: "Add a widget back to Overview" }),
+ );
+ await screen.findByRole("menuitem", { name: "Total logins" });
+ await waitFor(() => expect(client.isFetching()).toBeGreaterThan(0));
+ // Close the menu: the observer goes away while the read is in flight.
+ fireEvent.keyDown(document.activeElement ?? document.body, {
+ key: "Escape",
+ });
+ await waitFor(() => expect(screen.queryByRole("menuitem")).toBeNull());
+ release();
+ gate = null;
+ await waitFor(() => expect(client.isFetching()).toBe(0));
+ await act(settle);
+
+ mode = 503;
+ fireEvent.click(
+ screen.getByRole("button", { name: "Add a widget back to Overview" }),
+ );
+ fireEvent.click(
+ await screen.findByRole("menuitem", { name: "Total logins" }),
+ );
+ await act(async () => {
+ await client.refetchQueries({ queryKey: ["solution-widget"] });
+ await settle();
+ });
+ expect(screen.queryAllByText("37")).toHaveLength(0);
+ });
+});
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 72273acb0..62cf209ee 100644
--- a/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx
+++ b/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx
@@ -1328,7 +1328,8 @@ describe("what a + menu says about a removed widget", () => {
filter: { event: "logout" },
groupBy: "time",
bucket: "day",
- aggregation: "count",
+ aggregation: "avg",
+ field: "payload:seconds",
},
{
id: "net_logins",
@@ -1359,8 +1360,9 @@ describe("what a + menu says about a removed widget", () => {
},
],
};
- // Logins were recorded and no logout ever was, so every day lacks one
- // of the two inputs.
+ // Logins were recorded and no logout ever was. The second input is an
+ // average, which has no value over no events (a count would read 0
+ // there), so every day lacks one of the two inputs.
auditTrail(({ eventType }) => (eventType === "auth.logout.v1" ? [] : DAYS));
showOnly(["w_logins"], derived.dashboards[0]);
renderDashboards(derived);
@@ -1533,6 +1535,23 @@ describe("a dashboard in sections", () => {
const saved = () =>
JSON.parse(window.localStorage.getItem(LAYOUT_KEY) ?? "null")?.sections;
+ it("titles each section's widgets one level under the section's heading", () => {
+ renderDashboards(sectioned);
+ const overview = sectionHeadings().find(
+ (heading) => heading.textContent === "Overview",
+ );
+ expect(overview?.tagName).toBe("H3");
+ expect(
+ within(section("Overview")).getByRole("heading", { name: "Total logins" })
+ .tagName,
+ ).toBe("H4");
+ expect(
+ within(section("Trends")).getByRole("heading", {
+ name: "Logins over time",
+ }).tagName,
+ ).toBe("H4");
+ });
+
it("draws each section in declared order, under its title and description", () => {
renderDashboards(sectioned);
expect(sectionTitles()).toEqual(["Overview", "Trends"]);
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 9fde27e84..ac0fe0068 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
@@ -750,3 +750,108 @@ describe("dashboard layout", () => {
expect(writeSavedLayout(null, "k", "{}")).toBe(false);
});
});
+
+describe("stale and invalid stored layouts", () => {
+ const w = (id: string, section?: string) => ({
+ id,
+ metric: "m",
+ visualization: "number" as const,
+ ...(section ? { section } : {}),
+ });
+ const two: Dashboard = {
+ id: "d",
+ layout: "grid",
+ sections: [
+ { id: "a", title: "A" },
+ { id: "b", title: "B" },
+ ],
+ widgets: [w("w1", "a"), w("w2", "b"), w("w3", "b")],
+ };
+
+ // Stored layouts the declaration has moved past, and stored values that cannot
+ // be trusted: each reads as something the dashboard still declares.
+ describe("stored layouts the manifest has moved past", () => {
+ it("puts the tiles of a retired section in their declared section", () => {
+ const raw = serializeLayout(
+ [
+ { id: "a", tiles: ["w1"] },
+ { id: "b", tiles: ["w3", "w2"] },
+ ],
+ two,
+ );
+ const now: Dashboard = {
+ ...two,
+ sections: [{ id: "a", title: "A" }],
+ widgets: two.widgets.map((x) => ({ ...x, section: "a" })),
+ };
+ expect(parseLayout(raw, now)).toEqual([
+ { id: "a", tiles: ["w1", "w3", "w2"] },
+ ]);
+ });
+
+ it("keeps a viewer's own section when the dashboard drops every declared one", () => {
+ const raw = serializeLayout(
+ [
+ { id: "a", tiles: ["w1"] },
+ { id: "custom:0123456789abcdef", title: "Mine", tiles: ["w2"] },
+ { id: "b", tiles: ["w3"] },
+ ],
+ two,
+ );
+ const none: Dashboard = {
+ ...two,
+ sections: undefined,
+ widgets: two.widgets.map(({ section: _, ...x }) => x),
+ };
+ expect(parseLayout(raw, none)).toEqual([
+ { id: "", tiles: ["w1", "w3"] },
+ { id: "custom:0123456789abcdef", title: "Mine", tiles: ["w2"] },
+ ]);
+ });
+
+ it("falls back to the default for corrupted, future-version and malformed entries", () => {
+ const fallback = parseLayout(null, two);
+ for (const raw of [
+ "{",
+ JSON.stringify({
+ version: 3,
+ sections: [],
+ seen: [],
+ seenSections: [],
+ }),
+ JSON.stringify({
+ version: 2,
+ sections: [{ id: 1, tiles: [] }],
+ seen: [],
+ seenSections: [],
+ }),
+ JSON.stringify({
+ version: 2,
+ sections: "a",
+ seen: [],
+ seenSections: [],
+ }),
+ JSON.stringify({ version: 2, sections: [], seen: [] }),
+ ]) {
+ expect(parseLayout(raw, two), raw).toEqual(fallback);
+ }
+ });
+
+ it("drops a malformed viewer section and an unknown widget, sending tiles home", () => {
+ const raw = JSON.stringify({
+ version: 2,
+ sections: [
+ { id: "custom:XYZ", title: "Bad id", tiles: ["w2"] },
+ { id: "a", tiles: ["w1", "ghost", "metric:m"] },
+ { id: "b", tiles: ["w3"] },
+ ],
+ seen: ["w1", "w2", "w3"],
+ seenSections: ["a", "b"],
+ });
+ expect(parseLayout(raw, two)).toEqual([
+ { id: "a", tiles: ["w1"] },
+ { id: "b", tiles: ["w3", "w2"] },
+ ]);
+ });
+ });
+});
diff --git a/module/services/frontend/code/src/solutions/__tests__/metric-info.test.ts b/module/services/frontend/code/src/solutions/__tests__/metric-info.test.ts
index c9ca71592..5d28fc6b8 100644
--- a/module/services/frontend/code/src/solutions/__tests__/metric-info.test.ts
+++ b/module/services/frontend/code/src/solutions/__tests__/metric-info.test.ts
@@ -251,6 +251,27 @@ describe("activitySummary", () => {
it("is null when nothing matched", () => {
expect(activitySummary([{ points: [] }, { points: [] }])).toBeNull();
});
+
+ it("reads a day counted 0 as a day with no events, which bounds nothing", () => {
+ const summary = activitySummary([
+ {
+ points: [
+ { key: "2026-09-02T00:00:00+00", value: 0 },
+ { key: "2026-09-03T00:00:00+00", value: 4 },
+ { key: "2026-09-04T00:00:00+00", value: 0 },
+ { key: "2026-09-05T00:00:00+00", value: 0 },
+ ],
+ },
+ ]);
+ expect(summary?.first.toISOString()).toBe("2026-09-03T00:00:00.000Z");
+ expect(summary?.last.toISOString()).toBe("2026-09-03T00:00:00.000Z");
+ expect(summary?.events).toBe(4);
+ expect(
+ activitySummary([
+ { points: [{ key: "2026-09-02T00:00:00+00", value: 0 }] },
+ ]),
+ ).toBeNull();
+ });
});
describe("recentEventsQueries", () => {
diff --git a/module/services/frontend/code/src/solutions/metric-info.ts b/module/services/frontend/code/src/solutions/metric-info.ts
index 4d3a63102..3d45fd4c3 100644
--- a/module/services/frontend/code/src/solutions/metric-info.ts
+++ b/module/services/frontend/code/src/solutions/metric-info.ts
@@ -217,11 +217,15 @@ export function activityGraph(graph: DataGraph, metricId: string): DataGraph {
/**
* From the per-day counts: the first and last day with events, and how many
* events there are in all. Null when nothing matched.
+ *
+ * A day counted 0 had no events: the SDK puts a 0 on each day of the range the
+ * audit RPC returned no bucket for, through today, so a day that is only there
+ * as a 0 bounds neither the range nor the freshness.
*/
export function activitySummary(
series: readonly { points: readonly { key: string; value: number }[] }[],
): { first: Date; last: Date; events: number } | null {
- const points = series.flatMap((s) => s.points);
+ const points = series.flatMap((s) => s.points).filter((p) => p.value > 0);
const days = points
.map((p) => parseTimeKey(p.key))
.filter((day): day is Date => day !== null)
From 6c96c133fed60612bb5904efb1ad2b78f9abc10f Mon Sep 17 00:00:00 2001
From: Codefly Automation
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Sun, 11 Oct 2026 21:49:46 -0400
Subject: [PATCH 27/29] chore(manifest): @codefly/saas-plugin-manifest 0.1.2
Review m5: 0.1.1 is the version v0.0.92 to v0.0.94 carried, and this PR
adds exported types (DashboardSection, MetricValueFormat) and changes what
the validator accepts. A patch, as the kit's additive changes are. The
root pin and the lockfile follow.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
module/services/frontend/code/package-lock.json | 4 ++--
module/services/frontend/code/package.json | 2 +-
.../frontend/code/packages/saas-plugin-manifest/package.json | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/module/services/frontend/code/package-lock.json b/module/services/frontend/code/package-lock.json
index 8e38d4a5f..18223cf75 100644
--- a/module/services/frontend/code/package-lock.json
+++ b/module/services/frontend/code/package-lock.json
@@ -18,7 +18,7 @@
"@codefly-dev/saas-sdk": "0.3.25",
"@codefly-dev/saas-ui": "0.12.7",
"@codefly-dev/ui": "0.12.7",
- "@codefly/saas-plugin-manifest": "0.1.1",
+ "@codefly/saas-plugin-manifest": "0.1.2",
"@codefly/saas-settings": "0.1.0",
"@connectrpc/connect": "^2.1.1",
"@connectrpc/connect-web": "^2.1.1",
@@ -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"
diff --git a/module/services/frontend/code/package.json b/module/services/frontend/code/package.json
index 79d24462a..a7dfa4706 100644
--- a/module/services/frontend/code/package.json
+++ b/module/services/frontend/code/package.json
@@ -41,7 +41,7 @@
"@codefly-dev/saas-sdk": "0.3.25",
"@codefly-dev/saas-ui": "0.12.7",
"@codefly-dev/ui": "0.12.7",
- "@codefly/saas-plugin-manifest": "0.1.1",
+ "@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/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,
From 41766e2a2ad7c26f3f16534b9a153596d259d417 Mon Sep 17 00:00:00 2001
From: Codefly Automation
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Sun, 11 Oct 2026 21:49:47 -0400
Subject: [PATCH 28/29] docs: UTC buckets, the fill through the range end and
across a rate's inputs
AUDIT_METRICS.md states that time buckets are UTC, that the fill runs
through the end of the range (the current bucket being in progress), how a
derived metric reads an input's day with no events, and the heading level
of a section's widgets. The manifest schema doc says the same of format's
neighbour, the zero fill.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
module/AUDIT_METRICS.md | 41 ++++++++++++++++++++-------
module/docs/plugin-manifest-schema.md | 5 ++--
2 files changed, 33 insertions(+), 13 deletions(-)
diff --git a/module/AUDIT_METRICS.md b/module/AUDIT_METRICS.md
index d960984e3..fd87791c6 100644
--- a/module/AUDIT_METRICS.md
+++ b/module/AUDIT_METRICS.md
@@ -204,17 +204,34 @@ events means partial telemetry. Older servers without samples cannot establish
numeric completeness.
SDK series expose `coverage: complete | partial | empty` and nullable `total`.
+Time buckets are **UTC** days, weeks and months, whatever zone the database
+session runs in, and every key reads `YYYY-MM-DDT00:00:00+00`. Keyed in the
+session's zone, a key's offset would move across daylight saving, so neither a
+bucket the aggregate did not return nor the kit's UTC label could be placed.
+
A count, distinct count or sum grouped by **time** is zero-filled where the series
-is produced, in the SDK's `runMetric`: every day (or week, or month) between the
-first and the last bucket the aggregate returned, that it did not return, gets a
+is produced, in the SDK's `runMetric`: every day (or week, or month) from the
+first bucket the aggregate returned through the end of the range — the bucket
+holding `to`, or today for an open-ended query — that it did not return gets a
`0`, because no event matched there. Without it a chart spaces points by
-position, so a day with no events vanished and the line ran straight over it.
-An average, minimum, maximum or percentile has no value over no events and is
-not filled; a bucket the aggregate *did* return whose value was dropped as
-unknown stays out and keeps the series partial; days before the first bucket and
-after the last are not filled; and a series made only of filled zeros has no
-total. A derived metric reads a day no input had events on as no events, not as
-missing telemetry: a ratio there has no point and the series stays complete.
+position, so a day with no events vanished and the line ran straight over it,
+and the days since the last event vanished too, so the newest point and the
+"vs previous bucket" delta read an old day as the latest. The current bucket is
+in progress, so its value is so far. An average, minimum, maximum or percentile
+has no value over no events and is not filled; a bucket the aggregate *did*
+return whose value was dropped as unknown stays out and keeps the series
+partial; days before the first bucket are not filled; returned keys that
+disagree about their offset are left as they are rather than given a guessed
+one; and a series made only of filled zeros has no total.
+
+A derived metric over such series reads a day an input has no bucket for as
+that input's `0`, wherever the day falls: a failure rate whose failures start
+after the requests, or never happen, reads `0` on those days and stays
+complete, rather than dropping them as missing telemetry. A day no input had
+events on has no ratio (0/0) and keeps the series complete; a sum or difference
+of such series is filled the same way and can feed another derived metric. An
+input that is partial, or whose aggregation has no value over no events, keeps
+a missing day unknown.
Missing derived operands and zero denominators stay unknown. Partial series keep
observed chart points with a visible label and withhold the total. Distinct
@@ -254,8 +271,10 @@ with share semantics must do the same before the view.
A view may group its widgets into **sections**: `sections` lists them in the
order they are drawn (`id`, `title`, an optional `description`) and a widget's
`section` names the one it is drawn in. Each section is headed by the kit's
-`DashboardSectionHeader`; a declared section with no widgets is still drawn and
-says so; a widget naming no section is drawn first, untitled. A view without
+`DashboardSectionHeader`, whose title is an `
`, and a widget inside a
+section is titled `
`, so heading navigation reads it as the section's child;
+a declared section with no widgets is still drawn and says so; a widget naming
+no section is drawn first, untitled, and keeps its `
`. A view without
sections draws as it always has.
Two of those cells are the contract, not presentation. A `null` total renders as
diff --git a/module/docs/plugin-manifest-schema.md b/module/docs/plugin-manifest-schema.md
index 1ae39d846..ca68d48cf 100644
--- a/module/docs/plugin-manifest-schema.md
+++ b/module/docs/plugin-manifest-schema.md
@@ -100,8 +100,9 @@ has three node kinds:
widget it resolves; the dashboard kit's own `format: "percent"` takes the
scaled value (see `AUDIT_METRICS.md`, "How a resolved series is drawn").
A count, distinct count or sum grouped by `time` reads `0` on a day (week,
- month) with no events between its first and last bucket, rather than
- skipping it.
+ month) with no events, from its first bucket through today (or the end of
+ the range asked for), rather than skipping it; a derived metric over such
+ counts reads an input's day with no events as that input's `0`.
- **`dashboards`** — a `layout` of `widgets`, each binding a `visualization` to
one metric. These metric-bound widgets are distinct from `ui.widgets`, which
are presentation slots contributed to host surfaces; the two never mix.
From d7b49018395d742c5ad8b1a89a76199d8992fde0 Mon Sep 17 00:00:00 2001
From: Codefly Automation
<312536060+antoinetoussaint-byte@users.noreply.github.com>
Date: Sun, 11 Oct 2026 21:52:20 -0400
Subject: [PATCH 29/29] test(frontend): build the stale-layout fixture without
an unused binding
The adopted stale-layout test dropped a widget's section by destructuring
it into an unused name, which added a lint warning (40 against main's 39).
It now sets the section to undefined.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../code/src/solutions/__tests__/dashboard-layout.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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 ac0fe0068..18bf45955 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
@@ -801,7 +801,7 @@ describe("stale and invalid stored layouts", () => {
const none: Dashboard = {
...two,
sections: undefined,
- widgets: two.widgets.map(({ section: _, ...x }) => x),
+ widgets: two.widgets.map((x) => ({ ...x, section: undefined })),
};
expect(parseLayout(raw, none)).toEqual([
{ id: "", tiles: ["w1", "w3"] },