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 ( +
    + {line && ( +
    + )} + {header !== undefined && ( +
    + {header} +
    + )} +
      {children}
    +
    + ); +} + +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" ? (
    - {renderOverlay(dragged)} + {renderOverlay(dragged.id)} +
    + ) : liftedGroup ? ( +
    + {liftedGroup.preview ?? liftedGroup.label ?? liftedGroup.id}
    ) : 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) => ( +

    + + {group.title} +

    + ), + preview:

    {group.title}

    , + className: "grid grid-cols-2 gap-4", + }))} + onSwap={(dragged, target) => + setGroups((current) => + current.map((group) => ({ + ...group, + ids: group.ids.map((id) => + id === dragged ? target : id === target ? dragged : id, + ), + })), + ) + } + onMove={(dragged, to) => + setGroups((current) => + current.map((group) => ({ + ...group, + ids: [ + ...group.ids.filter((id) => id !== dragged), + ...(group.id === to ? [dragged] : []), + ], + })), + ) + } + onMoveGroup={(moved, index) => + setGroups((current) => { + const next = current.filter((group) => group.id !== moved); + const group = current.find(({ id }) => id === moved); + if (group) next.splice(index, 0, group); + return next; + }) + } + renderItem={(id) => ( + +

    {id}

    +
    + )} + /> + ); +} +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, + )}
    - {shown.length === 0 ? ( + {sections.length === 0 && layout.length === 1 && shown.length === 0 ? (

    No widgets are shown. Add one back with the + button.

    ) : ( - // The kit's SortableGrid rather than its Grid/Stack: the tiles are - // dragged onto each other to swap. The classes are those Grid - // cols={2} and Stack draw with. - tileId)} + // The kit's SortableBoard rather than its Grid/Stack: the tiles are + // dragged onto each other to swap, and onto another section. The + // classes are those Grid cols={2} and Stack draw with. The sections + // draw in the viewer's order, the first on top, each dragged by the + // grip in its header; a dashboard without sections is one untitled + // one, which stays first. + { + const declared = sections.find(({ id }) => id === placed.id); + 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) => ( + + moveSectionWithKey(placed, event) + } + > + + + } + title={title} + description={declared?.description} + empty={ids.length === 0} + rename={ + renaming?.id === placed.id ? ( + + finishRename(placed, typed, refocus) + } + /> + ) : undefined + } + onRename={() => + setRenaming({ + id: placed.id, + back: "data-section-title", + }) + } + add={addMenu( + `Add a widget back to ${title}`, + placed.id, + )} + options={ + + setRenaming({ + id: placed.id, + back: "data-section-options", + }) + } + onRemove={() => remove(placed)} + /> + } + /> + ), + 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={ + sections.length > 0 || layout.length > 1 + ? (dragged, sectionId) => + save(moveTileToSection(layout, dragged, sectionId)) + : undefined + } + onMoveGroup={(sectionId, index) => + save(moveSection(layout, sectionId, index)) + } itemLabel={titleOf} renderItem={renderTile} renderOverlay={renderDragged} - className={ - dashboard.layout === "stack" - ? "flex flex-col gap-4" - : "grid grid-cols-1 gap-4 sm:grid-cols-2" - } /> )} + +

    + {announcement} +

    ); } @@ -671,7 +1098,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 8a64814bb..d826b0114 100644 --- a/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx +++ b/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx @@ -1,5 +1,11 @@ import type { DataGraph } from "@codefly/saas-plugin-manifest"; -import { cleanup, fireEvent, screen, within } from "@testing-library/react"; +import { + act, + cleanup, + fireEvent, + screen, + within, +} from "@testing-library/react"; import { HttpResponse, http } from "msw"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { renderInApp, rpc } from "@/test/container"; @@ -134,8 +140,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", () => { @@ -381,6 +415,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, @@ -488,6 +547,24 @@ 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(screen.queryAllByRole("heading", { level: 3 })).toEqual([]); + 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 @@ -543,3 +620,638 @@ describe("a viewer's layout", () => { expect(tileTitles()).toEqual(["Top event types", "Total logins"]); }); }); + +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. + const sectionTitles = () => + screen + .queryAllByRole("heading", { level: 3 }) + .map((heading) => heading.textContent); + const section = (title: string) => { + const found = screen + .getByRole("heading", { level: 3, name: 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-title"]')?.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("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"], + }); + }); + }); + }); +}); diff --git a/module/services/frontend/code/src/solutions/__tests__/dashboard-layout.test.ts b/module/services/frontend/code/src/solutions/__tests__/dashboard-layout.test.ts index 57a8d8637..cafefdddc 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 @@ -2,17 +2,28 @@ import type { Dashboard, DataGraph } from "@codefly/saas-plugin-manifest"; import { describe, expect, it } from "vitest"; import { addableTiles, + addSection, addTile, + canRemoveSection, defaultLayout, + type Layout, layoutKey, moveBy, - moveTile, + moveSection, + moveSectionBy, + moveTileToSection, + NEW_SECTION_TITLE, + newSectionId, parseLayout, readSavedLayout, + removeSection, removeTile, + renameSection, + sectionTitle, serializeLayout, swapTiles, tileWidget, + UNSECTIONED, writeSavedLayout, } from "../dashboard-layout"; @@ -110,15 +121,48 @@ const blocked = { }, }; +// The one untitled section of a dashboard that declares none. +function flat(...tiles: string[]): Layout { + return [{ id: UNSECTIONED, tiles }]; +} + +// `main` split into an overview band (the net number) above a detail band +// (the items made), and a section no widget is in yet. +const sectioned: Dashboard = { + ...main, + sections: [ + { id: "overview", title: "Overview", description: "The headline read." }, + { id: "detail", title: "Detail" }, + { id: "later", title: "Later" }, + ], + widgets: [ + { ...main.widgets[0], section: "detail" }, + { ...main.widgets[1], section: "overview" }, + ], +}; + +function bands(overview: string[], detail: string[], later: string[] = []) { + return [ + { id: "overview", tiles: overview }, + { id: "detail", tiles: detail }, + { id: "later", tiles: later }, + ]; +} + +// Ids of sections a viewer added, shaped as newSectionId makes them. +const MINE = "custom:0123456789abcdef"; +const YOURS = "custom:fedcba9876543210"; + describe("dashboard layout", () => { it("starts from the declared widgets in declared order", () => { - expect(defaultLayout(main)).toEqual(["w_items", "w_net"]); - expect(parseLayout(null, main)).toEqual(["w_items", "w_net"]); + expect(defaultLayout(main)).toEqual(flat("w_items", "w_net")); + expect(parseLayout(null, main)).toEqual(flat("w_items", "w_net")); }); it("resolves a declared widget as declared", () => { expect(tileWidget(main, "w_items")).toBe(main.widgets[0]); expect(tileWidget(main, "w_returns")).toBeUndefined(); + expect(tileWidget(main, "metric:per_day")).toBeUndefined(); expect(tileWidget(main, "gone")).toBeUndefined(); }); @@ -126,53 +170,53 @@ describe("dashboard layout", () => { // Metrics this dashboard does not draw (returns, per_day) are never offered. expect(addableTiles(graph, main, defaultLayout(main))).toEqual([]); const layout = removeTile(defaultLayout(main), "w_items"); - expect(layout).toEqual(["w_net"]); + expect(layout).toEqual(flat("w_net")); expect(addableTiles(graph, main, layout)).toEqual([ { id: "w_items", label: "Items made" }, ]); - const back = addTile(layout, "w_items"); - expect(back).toEqual(["w_net", "w_items"]); + const back = addTile(layout, main, "w_items", UNSECTIONED); + expect(back).toEqual(flat("w_net", "w_items")); expect(addableTiles(graph, main, back)).toEqual([]); - expect(addTile(back, "w_net")).toEqual(back); - expect(addableTiles(graph, main, []).map((tile) => tile.id)).toEqual([ + expect(addTile(back, main, "w_net", UNSECTIONED)).toEqual(back); + expect(addableTiles(graph, main, flat()).map((tile) => tile.id)).toEqual([ "w_items", "w_net", ]); }); + it("never adds a tile the dashboard does not declare", () => { + for (const id of ["w_returns", "metric:per_day", "gone"]) { + expect(addTile(flat("w_net"), main, id, UNSECTIONED), id).toEqual( + flat("w_net"), + ); + } + }); + it("labels an offered widget by its title, else its metric", () => { const layout = removeTile(defaultLayout(main), "w_net"); const offered = addableTiles(graph, main, layout); expect(offered.find((tile) => tile.id === "w_net")?.label).toBe("net"); expect( - addableTiles(graph, main, []).find((tile) => tile.id === "w_items") + addableTiles(graph, main, flat()).find((tile) => tile.id === "w_items") ?.label, ).toBe("Items made"); }); - it("moves a tile to an index clamped to the layout", () => { - const layout = ["a", "b", "c", "d"]; - expect(moveTile(layout, "a", 2)).toEqual(["b", "c", "a", "d"]); - expect(moveTile(layout, "d", -5)).toEqual(["d", "a", "b", "c"]); - expect(moveTile(layout, "b", 99)).toEqual(["a", "c", "d", "b"]); - expect(moveTile(layout, "x", 0)).toEqual(layout); - }); - it("moves a tile one place with the keyboard, stopping at either end", () => { - const layout = ["a", "b", "c"]; - expect(moveBy(layout, "b", -1)).toEqual(["b", "a", "c"]); - expect(moveBy(layout, "b", 1)).toEqual(["a", "c", "b"]); + const layout = flat("a", "b", "c"); + expect(moveBy(layout, "b", -1)).toEqual(flat("b", "a", "c")); + expect(moveBy(layout, "b", 1)).toEqual(flat("a", "c", "b")); expect(moveBy(layout, "a", -1)).toEqual(layout); expect(moveBy(layout, "c", 1)).toEqual(layout); expect(moveBy(layout, "x", 1)).toEqual(layout); }); it("swaps a dropped tile with the tile it is dropped on, moving no other", () => { - const layout = ["a", "b", "c", "d"]; - expect(swapTiles(layout, "a", "b")).toEqual(["b", "a", "c", "d"]); + const layout = flat("a", "b", "c", "d"); + expect(swapTiles(layout, "a", "b")).toEqual(flat("b", "a", "c", "d")); // Two rows down a two-column grid: b and d stay where they are. - expect(swapTiles(layout, "a", "c")).toEqual(["c", "b", "a", "d"]); - expect(swapTiles(layout, "d", "a")).toEqual(["d", "b", "c", "a"]); + expect(swapTiles(layout, "a", "c")).toEqual(flat("c", "b", "a", "d")); + expect(swapTiles(layout, "d", "a")).toEqual(flat("d", "b", "c", "a")); expect(swapTiles(layout, "a", "a")).toEqual(layout); expect(swapTiles(layout, "a", "gone")).toEqual(layout); expect(swapTiles(layout, "gone", "a")).toEqual(layout); @@ -184,9 +228,26 @@ describe("dashboard layout", () => { ); }); + it("writes version 2: sections with their tiles, and what the dashboard declared", () => { + expect( + JSON.parse(serializeLayout(bands(["w_net"], ["w_items"]), sectioned)), + ).toEqual({ + version: 2, + sections: bands(["w_net"], ["w_items"]), + seen: ["w_items", "w_net"], + seenSections: ["overview", "detail", "later"], + }); + expect(JSON.parse(serializeLayout(flat("w_net"), main))).toEqual({ + version: 2, + sections: flat("w_net"), + seen: ["w_items", "w_net"], + seenSections: [UNSECTIONED], + }); + }); + it("round-trips a saved layout", () => { const storage = memoryStorage(); - const layout = ["w_net", "w_items"]; + const layout = flat("w_net", "w_items"); expect(writeSavedLayout(storage, "k", serializeLayout(layout, main))).toBe( true, ); @@ -195,33 +256,63 @@ describe("dashboard layout", () => { }); it("keeps a saved empty layout empty", () => { - expect(parseLayout(serializeLayout([], main), main)).toEqual([]); + expect(parseLayout(serializeLayout(flat(), main), main)).toEqual(flat()); }); it("drops tiles the dashboard does not declare, and repeats", () => { const raw = JSON.stringify({ - version: 1, - tiles: ["w_net", "w_gone", "metric:gone", "w_net", "w_items"], + version: 2, + sections: [ + { + id: UNSECTIONED, + tiles: ["w_net", "w_gone", "metric:gone", "w_net", "w_items"], + }, + ], seen: ["w_items", "w_net"], + seenSections: [UNSECTIONED], }); - expect(parseLayout(raw, main)).toEqual(["w_net", "w_items"]); + expect(parseLayout(raw, main)).toEqual(flat("w_net", "w_items")); }); it("appends a widget declared after the save, but not one the viewer removed", () => { const before = withWidgets([main.widgets[0]]); - const raw = serializeLayout([], before); - expect(parseLayout(raw, main)).toEqual(["w_net"]); + const raw = serializeLayout(flat(), before); + expect(parseLayout(raw, main)).toEqual(flat("w_net")); + }); + + it("reads the flat version-1 layout saved before sections", () => { + // What the host wrote before sections: a list of widget ids and the + // widgets declared then. w_items was removed; nothing brings it back. + const raw = JSON.stringify({ + version: 1, + tiles: ["w_net"], + seen: ["w_items", "w_net"], + }); + expect(parseLayout(raw, main)).toEqual(flat("w_net")); + const reordered = JSON.stringify({ + version: 1, + tiles: ["w_net", "w_items"], + seen: ["w_items", "w_net"], + }); + expect(parseLayout(reordered, main)).toEqual(flat("w_net", "w_items")); }); - it("ignores a stored metric tile or another dashboard's widget", () => { - // A layout from before ADR 0007 was applied, or one edited by hand, may - // hold ids this dashboard does not declare; none of them is drawn. + it("drops a stored metric tile or another dashboard's widget from a version-1 layout", () => { + // A version-1 layout from before ADR 0007 was applied, or one edited by + // hand, may hold ids this dashboard does not declare; none of them is + // drawn, and each repeat is dropped. const raw = JSON.stringify({ version: 1, - tiles: ["metric:per_day", "w_returns", "w_items", "metric:items"], + tiles: [ + "metric:per_day", + "w_returns", + "w_items", + "metric:items", + "w_items", + ], seen: ["w_items", "w_net"], }); - expect(parseLayout(raw, main)).toEqual(["w_items"]); + expect(parseLayout(raw, main)).toEqual(flat("w_items")); }); it("falls back to the default for an unreadable or untrusted entry", () => { @@ -230,7 +321,33 @@ describe("dashboard layout", () => { "not json", "null", "[]", - JSON.stringify({ version: 2, tiles: [], seen: [] }), + JSON.stringify({ version: 3, sections: [], seen: [], seenSections: [] }), + JSON.stringify({ version: 2, tiles: [], seen: [], seenSections: [] }), + JSON.stringify({ + version: 2, + sections: [{ id: "a" }], + seen: [], + seenSections: [], + }), + JSON.stringify({ + version: 2, + sections: [{ tiles: [] }], + seen: [], + seenSections: [], + }), + JSON.stringify({ + version: 2, + sections: [null], + seen: [], + seenSections: [], + }), + JSON.stringify({ version: 2, sections: [], seenSections: [] }), + // Version 2 always records the sections it was made against. + JSON.stringify({ + version: 2, + sections: [{ id: UNSECTIONED, tiles: ["w_net"] }], + seen: ["w_items", "w_net"], + }), JSON.stringify({ version: 1, tiles: "w_net", seen: [] }), JSON.stringify({ version: 1, tiles: ["w_net"] }), JSON.stringify({ version: 1, tiles: [1], seen: [] }), @@ -239,6 +356,389 @@ describe("dashboard layout", () => { } }); + describe("in sections", () => { + it("starts each declared widget in its declared section, sections in declared order", () => { + expect(defaultLayout(sectioned)).toEqual(bands(["w_net"], ["w_items"])); + expect(parseLayout(null, sectioned)).toEqual( + bands(["w_net"], ["w_items"]), + ); + }); + + it("swaps two tiles across sections, moving no other", () => { + const layout = bands(["a", "b"], ["c", "d"]); + expect(swapTiles(layout, "a", "d")).toEqual( + bands(["d", "b"], ["c", "a"]), + ); + }); + + it("moves a tile to the end of a section, its own or another", () => { + const layout = bands(["a", "b"], ["c", "d"]); + expect(moveTileToSection(layout, "a", "detail")).toEqual( + bands(["b"], ["c", "d", "a"]), + ); + expect(moveTileToSection(layout, "a", "overview")).toEqual( + bands(["b", "a"], ["c", "d"]), + ); + expect(moveTileToSection(layout, "a", "later")).toEqual( + bands(["b"], ["c", "d"], ["a"]), + ); + expect(moveTileToSection(layout, "a", "gone")).toEqual(layout); + expect(moveTileToSection(layout, "gone", "detail")).toEqual(layout); + }); + + it("puts a removed widget back at the end of the section whose + was used", () => { + const layout = removeTile(defaultLayout(sectioned), "w_items"); + expect(layout).toEqual(bands(["w_net"], [])); + // Only the removed widget is offered, whichever section asks. + expect(addableTiles(graph, sectioned, layout)).toEqual([ + { id: "w_items", label: "Items made" }, + ]); + expect(addTile(layout, sectioned, "w_items", "overview")).toEqual( + bands(["w_net", "w_items"], []), + ); + expect(addTile(layout, sectioned, "w_items", "later")).toEqual( + bands(["w_net"], [], ["w_items"]), + ); + // A tile already shown stays where it is. + expect(addTile(layout, sectioned, "w_net", "later")).toEqual(layout); + }); + + it("crosses into the neighbouring section with the keyboard at a section's end", () => { + const layout = bands(["a", "b"], ["c"]); + // The last of a section moves down into the next, as its first... + expect(moveBy(layout, "b", 1)).toEqual(bands(["a"], ["b", "c"])); + // ...and the first of a section up into the one before, as its last. + expect(moveBy(layout, "c", -1)).toEqual(bands(["a", "b", "c"], [])); + // An empty section is a place too. + expect(moveBy(layout, "c", 1)).toEqual(bands(["a", "b"], [], ["c"])); + expect(moveBy(layout, "a", -1)).toEqual(layout); + expect(moveBy(bands([], [], ["x"]), "x", 1)).toEqual( + bands([], [], ["x"]), + ); + }); + + it("round-trips each tile's section", () => { + const layout = bands(["w_items"], [], ["w_net"]); + expect( + parseLayout(serializeLayout(layout, sectioned), sectioned), + ).toEqual(layout); + }); + + it("reads a version-1 layout by putting each tile in its declared section", () => { + const raw = JSON.stringify({ + version: 1, + tiles: ["metric:per_day", "w_items", "w_net"], + seen: ["w_items", "w_net"], + }); + expect(parseLayout(raw, sectioned)).toEqual( + bands(["w_net"], ["w_items"]), + ); + }); + + it("sends a tile whose section is gone to its declared section, and drops the section", () => { + const raw = JSON.stringify({ + version: 2, + sections: [ + { id: "retired", tiles: ["w_net", "metric:per_day"] }, + { id: "detail", tiles: ["w_items"] }, + ], + seen: ["w_items", "w_net"], + seenSections: ["retired", "detail"], + }); + expect(parseLayout(raw, sectioned)).toEqual( + bands(["w_net"], ["w_items"]), + ); + }); + + it("appends a widget declared after the save to its declared section", () => { + const before: Dashboard = { + ...sectioned, + widgets: [sectioned.widgets[0]], + }; + const raw = serializeLayout(bands(["w_items"], []), before); + expect(parseLayout(raw, sectioned)).toEqual( + bands(["w_items", "w_net"], []), + ); + }); + + it("does not bring back a widget the viewer removed", () => { + const raw = serializeLayout(bands([], ["w_items"]), sectioned); + expect(parseLayout(raw, sectioned)).toEqual(bands([], ["w_items"])); + }); + + it("moves a layout saved without sections into the sections declared since", () => { + const raw = serializeLayout(flat("w_items"), main); + expect(parseLayout(raw, sectioned)).toEqual(bands([], ["w_items"])); + }); + }); + + describe("the viewer's own sections", () => { + // A section the viewer added, with its tiles. + const own = (id: string, title: string, tiles: string[] = []) => ({ + id, + title, + tiles, + }); + const read = (layout: Layout, dashboard = sectioned) => + parseLayout(serializeLayout(layout, dashboard), dashboard); + + it("adds an empty section after the last, never under a declared id", () => { + const layout = bands(["a"], ["b"]); + const id = newSectionId(layout); + expect(id).toMatch(/^custom:[0-9a-f]{16}$/); + expect(addSection(layout, id)).toEqual([ + ...layout, + own(id, NEW_SECTION_TITLE), + ]); + // A declared id, one not shaped as a viewer's, or one the layout + // already has, adds nothing. + expect(addSection(layout, "overview")).toEqual(layout); + expect(addSection(layout, "custom:a1")).toEqual(layout); + const added = addSection(layout, MINE); + expect(addSection(added, MINE)).toEqual(added); + expect(newSectionId(added)).not.toBe(MINE); + }); + + it("adds no tile, so the page offers nothing new", () => { + const layout = addSection(defaultLayout(sectioned), MINE); + expect(layout.at(-1)).toEqual(own(MINE, NEW_SECTION_TITLE)); + expect(addableTiles(graph, sectioned, layout)).toEqual([]); + // It is filled by dragging a tile in. + expect(moveTileToSection(layout, "w_items", MINE).at(-1)).toEqual( + own(MINE, NEW_SECTION_TITLE, ["w_items"]), + ); + }); + + it("renames a section, trimmed and cut to 60 characters", () => { + const layout = [...bands(["a"], ["b"]), own(MINE, "Mine")]; + expect( + renameSection(layout, sectioned, MINE, " Watch list ")[3], + ).toEqual(own(MINE, "Watch list")); + const long = renameSection(layout, sectioned, "detail", "x".repeat(70)); + expect(long[1]).toEqual({ + id: "detail", + title: "x".repeat(60), + tiles: ["b"], + }); + expect(sectionTitle(sectioned, long[1])).toBe("x".repeat(60)); + // Untouched, a declared section goes by its declared title. + expect(sectionTitle(sectioned, long[0])).toBe("Overview"); + }); + + it("keeps the title a blank rename would clear", () => { + const layout = [...bands(["a"], []), own(MINE, "Mine")]; + expect(renameSection(layout, sectioned, MINE, " ")).toEqual(layout); + expect(renameSection(layout, sectioned, "overview", "")).toEqual(layout); + }); + + it("follows the declared title again once a section is renamed back to it", () => { + const renamed = renameSection( + bands(["a"], []), + sectioned, + "overview", + "Headlines", + ); + expect(renamed[0]).toEqual({ + id: "overview", + title: "Headlines", + tiles: ["a"], + }); + expect( + renameSection(renamed, sectioned, "overview", " Overview ")[0], + ).toEqual({ id: "overview", tiles: ["a"] }); + }); + + it("never renames or removes the untitled section", () => { + const layout = addSection(flat("a"), MINE); + expect(renameSection(layout, main, UNSECTIONED, "Top")).toEqual(layout); + expect(canRemoveSection(layout, UNSECTIONED)).toBe(false); + expect(removeSection(layout, UNSECTIONED)).toEqual(layout); + expect(sectionTitle(main, layout[0])).toBeUndefined(); + }); + + it("moves a removed section's tiles to the end of the section above", () => { + const layout = [...bands(["a"], ["b", "c"]), own(MINE, "Mine", ["d"])]; + expect(removeSection(layout, "detail")).toEqual([ + { id: "overview", tiles: ["a", "b", "c"] }, + { id: "later", tiles: [] }, + own(MINE, "Mine", ["d"]), + ]); + expect(removeSection(layout, MINE)).toEqual( + bands(["a"], ["b", "c"], ["d"]), + ); + // On a dashboard without sections the one above is the untitled one. + expect( + removeSection([...flat("a"), own(MINE, "Mine", ["b"])], MINE), + ).toEqual(flat("a", "b")); + }); + + it("moves the first section's tiles to the start of the one below", () => { + expect(removeSection(bands(["a", "b"], ["c"]), "overview")).toEqual([ + { id: "detail", tiles: ["a", "b", "c"] }, + { id: "later", tiles: [] }, + ]); + }); + + it("does not remove the last section left", () => { + const last = [{ id: "detail", tiles: ["a"] }]; + expect(canRemoveSection(last, "detail")).toBe(false); + expect(removeSection(last, "detail")).toEqual(last); + expect(canRemoveSection(bands([], []), "detail")).toBe(true); + expect(canRemoveSection(bands([], []), "gone")).toBe(false); + }); + + it("moves a section to a place, and one place with the keyboard", () => { + const layout = [...bands(["a"], ["b"]), own(MINE, "Mine")]; + const ids = (moved: Layout) => moved.map((section) => section.id); + expect(ids(moveSection(layout, MINE, 0))).toEqual([ + MINE, + "overview", + "detail", + "later", + ]); + // Its tiles go with it. + expect(moveSection(layout, "overview", 2)[2]).toEqual({ + id: "overview", + tiles: ["a"], + }); + expect(ids(moveSection(layout, "overview", 99))).toEqual([ + "detail", + "later", + MINE, + "overview", + ]); + expect(ids(moveSectionBy(layout, "detail", -1))).toEqual([ + "detail", + "overview", + "later", + MINE, + ]); + expect(moveSectionBy(layout, "overview", -1)).toEqual(layout); + expect(moveSectionBy(layout, MINE, 1)).toEqual(layout); + expect(moveSection(layout, "gone", 0)).toEqual(layout); + }); + + it("keeps the untitled section first when sections move", () => { + const layout = [...flat("a"), own(MINE, "One"), own(YOURS, "Two")]; + const ids = (moved: Layout) => moved.map((section) => section.id); + expect(ids(moveSection(layout, YOURS, 0))).toEqual([ + UNSECTIONED, + YOURS, + MINE, + ]); + expect(moveSectionBy(layout, MINE, -1)).toEqual(layout); + expect(moveSection(layout, UNSECTIONED, 2)).toEqual(layout); + }); + + it("round-trips added, renamed and reordered sections", () => { + const layout = [ + { id: "detail", title: "Items", tiles: ["w_items"] }, + own(MINE, "Mine", ["w_net"]), + { id: "overview", tiles: [] }, + { id: "later", tiles: [] }, + ]; + expect(read(layout)).toEqual(layout); + // On a dashboard without sections, too. + const plain = [...flat("w_items"), own(MINE, "Mine", ["w_net"])]; + expect(read(plain, main)).toEqual(plain); + }); + + it("keeps a removed declared section removed", () => { + const removed = removeSection(defaultLayout(sectioned), "overview"); + expect(read(removed)).toEqual([ + { id: "detail", tiles: ["w_net", "w_items"] }, + { id: "later", tiles: [] }, + ]); + }); + + it("adds a section declared since after the declared one before it, or first", () => { + // Saved when the dashboard declared only detail, and a section of the + // viewer's own after it. + const before: Dashboard = { + ...sectioned, + sections: [{ id: "detail", title: "Detail" }], + widgets: sectioned.widgets.map((w) => ({ ...w, section: "detail" })), + }; + const raw = serializeLayout( + [{ id: "detail", tiles: ["w_items", "w_net"] }, own(MINE, "Mine")], + before, + ); + expect(parseLayout(raw, sectioned)).toEqual([ + { id: "overview", tiles: [] }, + { id: "detail", tiles: ["w_items", "w_net"] }, + { id: "later", tiles: [] }, + own(MINE, "Mine"), + ]); + }); + + it("puts a widget declared since in its declared section, else the last one", () => { + const before: Dashboard = { ...sectioned, widgets: [] }; + const kept = serializeLayout( + [...bands([], []), own(MINE, "Mine")], + before, + ); + expect(parseLayout(kept, sectioned)).toEqual([ + ...bands(["w_net"], ["w_items"]), + own(MINE, "Mine"), + ]); + // With overview removed, its new widget goes to the last section. + const removed = serializeLayout( + [{ id: "detail", tiles: [] }, own(MINE, "Mine")], + before, + ); + expect(parseLayout(removed, sectioned)).toEqual([ + { id: "detail", tiles: ["w_items"] }, + own(MINE, "Mine", ["w_net"]), + ]); + }); + + it("drops a malformed section of the viewer's own, and sends its tiles home", () => { + const raw = JSON.stringify({ + version: 2, + sections: [ + { id: "overview", tiles: [] }, + { id: "detail", title: 7, tiles: [] }, + { id: "later", tiles: [] }, + { id: MINE, title: "Mine", tiles: [] }, + { id: "custom:", title: "Empty token", tiles: [] }, + { id: "custom:a1", title: "Short token", tiles: [] }, + { id: "custom:0123456789ABCDEF", title: "Upper case", tiles: [] }, + { id: YOURS, tiles: ["w_net"] }, + { id: "custom:00000000000000c3", title: " ", tiles: [] }, + { id: MINE, title: "Again", tiles: ["w_items"] }, + ], + seen: ["w_items", "w_net"], + seenSections: ["overview", "detail", "later"], + }); + expect(parseLayout(raw, sectioned)).toEqual([ + // A declared section with a title that is not text keeps its own. + ...bands(["w_net"], [], []), + // A repeated id is one section. + own(MINE, "Mine", ["w_items"]), + ]); + }); + + 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("reads nothing and writes nothing when storage is missing or blocked", () => { expect(readSavedLayout(blocked, "k")).toBeNull(); expect(readSavedLayout(null, "k")).toBeNull(); diff --git a/module/services/frontend/code/src/solutions/dashboard-layout.ts b/module/services/frontend/code/src/solutions/dashboard-layout.ts index f94bffd49..bedad2c56 100644 --- a/module/services/frontend/code/src/solutions/dashboard-layout.ts +++ b/module/services/frontend/code/src/solutions/dashboard-layout.ts @@ -5,22 +5,267 @@ import type { } from "@codefly/saas-plugin-manifest"; /** - * One viewer's arrangement of a solution dashboard: which of its declared - * widgets it shows, in what order. Pure data in, data out — no React, no DOM; - * the caller hands in the storage. + * One viewer's arrangement of a solution dashboard: which sections it has, + * which of its declared widgets each shows, in what order. Pure data in, data + * out — no React, no DOM; the caller hands in the storage. * - * A layout is a list of declared widget ids. It is a preference in the sense of - * ADR 0007: it can reorder, remove and re-add what the dashboard declares, and - * never add a metric the dashboard does not draw. It carries no query of its - * own, so a saved layout can never make a widget ask for data the declaration - * does not already describe. + * A layout is a list of sections, each with the ids of its tiles in order. It + * starts as the dashboard's sections in declared order; a dashboard that + * declares none has one untitled section. The viewer can rename a section, + * remove one, and add their own after the last (`custom:`). A declared + * section id is a logical id and can never contain a colon, so a viewer's + * section never collides with one the solution declares. + * + * A tile is a declared widget, by its id. A layout is a preference in the + * sense of ADR 0007: it can reorder, remove and re-add what the dashboard + * declares, and group it into sections, but never add a metric the dashboard + * does not draw. It carries no query of its own, so a saved layout can never + * make a widget ask for data the declaration does not already describe; a + * section of the viewer's own is a title and a list of those same tiles. + */ + +const LAYOUT_VERSION = 2; +// Saved before dashboards had sections: one flat list of tiles. +const FLAT_LAYOUT_VERSION = 1; +const CUSTOM_SECTION = "custom:"; +// A section of the viewer's own, as newSectionId makes them: the prefix and +// 16 hex characters. +const CUSTOM_SECTION_ID = /^custom:[0-9a-f]{16}$/; + +/** + * The id of the one section of a dashboard that declares none. A section id + * is a logical id, which is never empty, so the two never collide. It has no + * title, and cannot be renamed or removed. + */ +export const UNSECTIONED = ""; + +/** What a section the viewer adds is called until they rename it. */ +export const NEW_SECTION_TITLE = "New section"; + +/** The longest section title a viewer can give, in characters. */ +export const SECTION_TITLE_MAX = 60; + +/** One section of a layout: its tiles, in the order they are shown. */ +export interface LayoutSection { + id: string; + /** + * The viewer's title for the section: one they added, or a declared one + * they renamed. Absent for a declared section under its declared title. + */ + title?: string; + tiles: string[]; +} + +export type Layout = LayoutSection[]; + +// The sections a dashboard declares, in declared order, or the one untitled +// section. +function sectionIds(dashboard: Dashboard): string[] { + return dashboard.sections && dashboard.sections.length > 0 + ? dashboard.sections.map((section) => section.id) + : [UNSECTIONED]; +} + +function declaredTitle( + dashboard: Dashboard, + sectionId: string, +): string | undefined { + return dashboard.sections?.find((section) => section.id === sectionId)?.title; +} + +// The section a declared widget is declared in: the one it names, or the +// untitled one of a dashboard that declares none. Undefined for an id the +// dashboard does not declare. +function declaredSection( + dashboard: Dashboard, + tileId: string, +): string | undefined { + const widget = dashboard.widgets.find((w) => w.id === tileId); + if (!widget) return undefined; + const section = widget.section ?? UNSECTIONED; + return sectionIds(dashboard).includes(section) ? section : undefined; +} + +// Where a declared widget goes in the declared layout: its declared section, +// or the last section should the widget name none the dashboard declares +// (which the manifest validator refuses). +function homeSection(dashboard: Dashboard, tileId: string): string { + const ids = sectionIds(dashboard); + return declaredSection(dashboard, tileId) ?? ids[ids.length - 1]; +} + +/** + * The declared widgets, in declared order, each in its declared section: the + * layout every viewer starts with. + */ +export function defaultLayout(dashboard: Dashboard): Layout { + return sectionIds(dashboard).map((id) => ({ + id, + tiles: dashboard.widgets + .filter((widget) => homeSection(dashboard, widget.id) === id) + .map((widget) => widget.id), + })); +} + +/** Every tile a layout shows, section by section. */ +export function layoutTiles(layout: readonly LayoutSection[]): string[] { + return layout.flatMap((section) => section.tiles); +} + +function copy(layout: readonly LayoutSection[]): Layout { + return layout.map((section) => ({ ...section, tiles: [...section.tiles] })); +} + +/** + * The title a section is drawn under: the viewer's, else the declared one. + * Undefined for the untitled section of a dashboard that declares none. */ +export function sectionTitle( + dashboard: Dashboard, + section: LayoutSection, +): string | undefined { + return section.title ?? declaredTitle(dashboard, section.id); +} + +// A title as the viewer typed it, trimmed and cut to the longest allowed; null +// when nothing is left of it. +function titleText(title: string): string | null { + const text = Array.from(title.trim()) + .slice(0, SECTION_TITLE_MAX) + .join("") + .trim(); + return text === "" ? null : text; +} + +function isCustomSection(sectionId: string): boolean { + return CUSTOM_SECTION_ID.test(sectionId); +} -const LAYOUT_VERSION = 1; +/** + * A fresh id for a section the viewer adds, which no section of the layout + * has. From `crypto.getRandomValues`, which a page served over plain http has + * too; `crypto.randomUUID` needs a secure context. + */ +export function newSectionId(layout: readonly LayoutSection[]): string { + let id: string; + do { + const token = Array.from(crypto.getRandomValues(new Uint8Array(8)), (b) => + b.toString(16).padStart(2, "0"), + ).join(""); + id = `${CUSTOM_SECTION}${token}`; + } while (layout.some((section) => section.id === id)); + return id; +} -/** The declared widgets, in declared order: the layout every viewer starts with. */ -export function defaultLayout(dashboard: Dashboard): string[] { - return dashboard.widgets.map((widget) => widget.id); +/** + * Appends an empty section of the viewer's own, titled "New section". It + * holds no tile until the viewer drags one in or puts a removed one back, so + * it adds nothing to the page. An id that is not a custom section id, or that + * the layout already has, adds nothing. + */ +export function addSection( + layout: readonly LayoutSection[], + sectionId: string, +): Layout { + const next = copy(layout); + if ( + isCustomSection(sectionId) && + !next.some((section) => section.id === sectionId) + ) { + next.push({ id: sectionId, title: NEW_SECTION_TITLE, tiles: [] }); + } + return next; +} + +/** + * Gives a section the viewer's title, trimmed and cut to 60 characters. A + * blank title changes nothing, and neither does renaming the untitled section. + * A declared section renamed back to its declared title follows the + * declaration again, so a title the solution changes later shows. + */ +export function renameSection( + layout: readonly LayoutSection[], + dashboard: Dashboard, + sectionId: string, + title: string, +): Layout { + const next = copy(layout); + const text = titleText(title); + const at = next.findIndex((section) => section.id === sectionId); + if (at === -1 || text === null || sectionId === UNSECTIONED) return next; + const { id, tiles } = next[at]; + next[at] = + text === declaredTitle(dashboard, id) + ? { id, tiles } + : { id, title: text, tiles }; + return next; +} + +/** + * Whether the viewer can remove a section: any but the last one left, and + * never the untitled section of a dashboard that declares none. + */ +export function canRemoveSection( + layout: readonly LayoutSection[], + sectionId: string, +): boolean { + return ( + sectionId !== UNSECTIONED && + layout.length > 1 && + layout.some((section) => section.id === sectionId) + ); +} + +/** + * Removes a section. Its tiles go to the end of the section above it, or to + * the start of the one below when it is the first, so nothing the viewer had + * on the dashboard is lost. A section that cannot be removed stays. + */ +export function removeSection( + layout: readonly LayoutSection[], + sectionId: string, +): Layout { + const next = copy(layout); + if (!canRemoveSection(layout, sectionId)) return next; + const at = next.findIndex((section) => section.id === sectionId); + const [removed] = next.splice(at, 1); + if (at > 0) next[at - 1].tiles.push(...removed.tiles); + else next[0].tiles.unshift(...removed.tiles); + return next; +} + +/** + * Moves a section, with its tiles, to `toIndex`, clamped to the layout. The + * untitled section of a dashboard that declares none stays first: it does not + * move, and nothing goes before it. An unknown section is a no-op. + */ +export function moveSection( + layout: readonly LayoutSection[], + sectionId: string, + toIndex: number, +): Layout { + const next = copy(layout); + const from = next.findIndex((section) => section.id === sectionId); + if (from === -1 || sectionId === UNSECTIONED) return next; + const first = next[0].id === UNSECTIONED ? 1 : 0; + const [section] = next.splice(from, 1); + next.splice(Math.max(first, Math.min(next.length, toIndex)), 0, section); + return next; +} + +/** + * The keyboard alternative to dragging a section: one place up (-1) or down + * (+1). Past either end it stays. + */ +export function moveSectionBy( + layout: readonly LayoutSection[], + sectionId: string, + delta: number, +): Layout { + const from = layout.findIndex((section) => section.id === sectionId); + return from === -1 + ? copy(layout) + : moveSection(layout, sectionId, from + delta); } /** @@ -35,16 +280,17 @@ export function tileWidget( } /** - * What the + menu offers, in declared order: every declared widget the layout - * does not show. + * What a + menu offers, in declared order: every declared widget the layout + * does not show, whichever section the menu belongs to. */ export function addableTiles( graph: DataGraph, dashboard: Dashboard, - layout: readonly string[], + layout: readonly LayoutSection[], ): { id: string; label: string }[] { + const shown = layoutTiles(layout); return dashboard.widgets - .filter((widget) => !layout.includes(widget.id)) + .filter((widget) => !shown.includes(widget.id)) .map((widget) => { const metric = graph.metrics.find((m) => m.id === widget.metric); return { @@ -54,58 +300,118 @@ export function addableTiles( }); } -export function addTile(layout: readonly string[], tileId: string): string[] { - return layout.includes(tileId) ? [...layout] : [...layout, tileId]; +/** + * Puts a removed widget back at the end of a section. A tile already shown is + * left where it is, and an id the dashboard does not declare adds nothing. + */ +export function addTile( + layout: readonly LayoutSection[], + dashboard: Dashboard, + tileId: string, + sectionId: string, +): Layout { + const next = copy(layout); + if (!tileWidget(dashboard, tileId) || layoutTiles(next).includes(tileId)) { + return next; + } + next.find((section) => section.id === sectionId)?.tiles.push(tileId); + return next; } export function removeTile( - layout: readonly string[], + layout: readonly LayoutSection[], tileId: string, -): string[] { - return layout.filter((id) => id !== tileId); +): Layout { + return layout.map((section) => ({ + ...section, + tiles: section.tiles.filter((tile) => tile !== tileId), + })); } -/** Moves one tile to `toIndex`, clamped to the layout. An unknown id is a no-op. */ -export function moveTile( - layout: readonly string[], +// Moves one tile to `toIndex` in a section, clamped to the section. An unknown +// tile or section is a no-op. +function place( + layout: readonly LayoutSection[], tileId: string, + sectionId: string, toIndex: number, -): string[] { - const from = layout.indexOf(tileId); - if (from === -1) return [...layout]; - const to = Math.max(0, Math.min(layout.length - 1, toIndex)); - const next = layout.filter((id) => id !== tileId); - next.splice(to, 0, tileId); +): Layout { + if ( + !layoutTiles(layout).includes(tileId) || + !layout.some((section) => section.id === sectionId) + ) { + return copy(layout); + } + const next = removeTile(layout, tileId); + const target = next.find((section) => section.id === sectionId); + target?.tiles.splice( + Math.max(0, Math.min(target.tiles.length, toIndex)), + 0, + tileId, + ); return next; } -/** The keyboard alternative to dragging: one place earlier (-1) or later (+1). */ +/** Moves a tile to the end of a section, from its own or another. */ +export function moveTileToSection( + layout: readonly LayoutSection[], + tileId: string, + sectionId: string, +): Layout { + return place(layout, tileId, sectionId, Number.POSITIVE_INFINITY); +} + +/** + * The keyboard alternative to dragging: one place earlier (-1) or later (+1). + * Past either end of its section the tile crosses into the neighbouring one, + * at the end nearer where it came from; past either end of the layout it stays. + */ export function moveBy( - layout: readonly string[], + layout: readonly LayoutSection[], tileId: string, delta: number, -): string[] { - const from = layout.indexOf(tileId); - return from === -1 ? [...layout] : moveTile(layout, tileId, from + delta); +): Layout { + const at = layout.findIndex((section) => section.tiles.includes(tileId)); + if (at === -1) return copy(layout); + const { id, tiles } = layout[at]; + const index = tiles.indexOf(tileId) + delta; + if (index >= 0 && index < tiles.length) { + return place(layout, tileId, id, index); + } + const neighbour = layout[at + Math.sign(delta)]; + if (!neighbour) return copy(layout); + return place( + layout, + tileId, + neighbour.id, + delta < 0 ? neighbour.tiles.length : 0, + ); } /** - * Dropping a tile on another swaps the two; no other tile moves. On a grid, - * moving a tile into another's place instead would shift every tile between - * them, which wraps across rows: a sideways drop would move two tiles and a - * downward one three. + * Dropping a tile on another swaps the two, in one section or across two; no + * other tile moves. On a grid, moving a tile into another's place instead + * would shift every tile between them, which wraps across rows: a sideways + * drop would move two tiles and a downward one three. */ export function swapTiles( - layout: readonly string[], + layout: readonly LayoutSection[], draggedId: string, targetId: string, -): string[] { - const from = layout.indexOf(draggedId); - const to = layout.indexOf(targetId); - const next = [...layout]; - if (from === -1 || to === -1) return next; - next[from] = targetId; - next[to] = draggedId; +): Layout { + const next = copy(layout); + const find = (tileId: string) => { + for (const section of next) { + const index = section.tiles.indexOf(tileId); + if (index !== -1) return { tiles: section.tiles, index }; + } + return null; + }; + const from = find(draggedId); + const to = find(targetId); + if (!from || !to) return next; + from.tiles[from.index] = targetId; + to.tiles[to.index] = draggedId; return next; } @@ -146,18 +452,20 @@ export function writeSavedLayout( } /** - * A layout as stored: its tiles, plus the declared widgets it was made - * against (`seen`), so a widget the solution declares later can be told apart - * from one the viewer removed. + * A layout as stored: its sections, each with its tiles and the viewer's title + * if they gave one, plus the declared widgets (`seen`) and sections + * (`seenSections`) it was made against, so a widget or section the solution + * declares later can be told apart from one the viewer removed. */ export function serializeLayout( - layout: readonly string[], + layout: readonly LayoutSection[], dashboard: Dashboard, ): string { return JSON.stringify({ version: LAYOUT_VERSION, - tiles: layout, - seen: defaultLayout(dashboard), + sections: layout.map(({ id, title, tiles }) => ({ id, title, tiles })), + seen: dashboard.widgets.map((widget) => widget.id), + seenSections: sectionIds(dashboard), }); } @@ -167,20 +475,114 @@ function isStringArray(value: unknown): value is string[] { ); } +// A stored section: its id, its tiles, and whatever it holds as a title, which +// is checked where it is read. +interface StoredSection { + id: string | null; + title?: unknown; + tiles: string[]; +} + +function isStoredSection( + value: unknown, +): value is StoredSection & { id: string } { + if (value === null || typeof value !== "object") return false; + const { id, tiles } = value as Record; + return typeof id === "string" && isStringArray(tiles); +} + +// A stored layout's sections, `seen` and `seenSections`, or null when it +// cannot be trusted. A layout saved in the flat shape of version 1 is one +// section with no id: none of its tiles has a place the layout says, so each +// goes to its declared section, and it was made against no section. +function storedLayout(saved: unknown): { + sections: StoredSection[]; + seen: string[]; + seenSections: string[]; +} | null { + const { version, sections, tiles, seen, seenSections } = (saved ?? + {}) as Record; + if (!isStringArray(seen)) return null; + if (version === FLAT_LAYOUT_VERSION) { + return isStringArray(tiles) + ? { sections: [{ id: null, tiles }], seen, seenSections: [] } + : null; + } + if ( + version !== LAYOUT_VERSION || + !Array.isArray(sections) || + !sections.every(isStoredSection) || + !isStringArray(seenSections) + ) { + return null; + } + return { sections, seen, seenSections }; +} + +// The sections a stored layout keeps, in its order, with no tiles yet: each +// declared section it holds, under the viewer's title if they gave one, and +// each of the viewer's own whose id and title are well formed. A declared +// section it was made against but does not hold is one the viewer removed, and +// stays removed. One declared since goes after the nearest section declared +// before it that the layout has, or first when there is none. The untitled +// section of a dashboard that declares none is never removed. +function keptSections( + stored: NonNullable>, + dashboard: Dashboard, +): Layout { + const declared = sectionIds(dashboard); + const sections: Layout = []; + const has = (id: string) => sections.some((section) => section.id === id); + for (const { id, title } of stored.sections) { + if (id === null || has(id)) continue; + const text = typeof title === "string" ? titleText(title) : null; + if (declared.includes(id)) { + sections.push( + text === null || + id === UNSECTIONED || + text === declaredTitle(dashboard, id) + ? { id, tiles: [] } + : { id, title: text, tiles: [] }, + ); + } else if (isCustomSection(id) && text !== null) { + sections.push({ id, title: text, tiles: [] }); + } + } + declared.forEach((id, index) => { + if (has(id)) return; + if (id !== UNSECTIONED && stored.seenSections.includes(id)) return; + let at = 0; + for (let before = index - 1; before >= 0; before--) { + const found = sections.findIndex( + (section) => section.id === declared[before], + ); + if (found !== -1) { + at = found + 1; + break; + } + } + sections.splice(at, 0, { id, tiles: [] }); + }); + return sections; +} + /** * The layout a stored value describes, or the default when there is none or it * cannot be trusted. A saved empty layout stays empty: the viewer removed every - * tile. + * tile. A layout always has at least one section. * - * Tiles the dashboard does not declare are dropped, and so are repeats: an id - * the dashboard no longer declares, or anything else a stored value holds, never - * reaches the page. A widget declared since the layout was saved is appended, so - * a viewer who customized still sees what the solution adds. + * The sections are the ones the viewer kept, under their titles, in their + * order, with each section the solution has declared since (see keptSections); + * a section the layout holds that is neither declared nor well formed as the + * viewer's own is dropped. Tiles the dashboard does not declare are dropped, + * and so are repeats: an id the dashboard no longer declares, or anything else + * a stored value holds, never reaches the page. A tile saved in a dropped + * 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. */ -export function parseLayout( - raw: string | null, - dashboard: Dashboard, -): string[] { +export function parseLayout(raw: string | null, dashboard: Dashboard): Layout { const fallback = defaultLayout(dashboard); if (raw === null) return fallback; let saved: unknown; @@ -189,22 +591,44 @@ export function parseLayout( } catch { return fallback; } - const { version, tiles, seen } = (saved ?? {}) as Record; - if ( - version !== LAYOUT_VERSION || - !isStringArray(tiles) || - !isStringArray(seen) - ) { - return fallback; + const stored = storedLayout(saved); + if (!stored) return fallback; + const layout = keptSections(stored, dashboard); + if (layout.length === 0) return fallback; + + const placed = new Set(); + // Whether a saved id is a declared widget not yet placed. + const placeable = (tileId: string) => + tileWidget(dashboard, tileId) !== undefined && !placed.has(tileId); + const put = (sectionId: string, tileId: string) => { + layout.find((section) => section.id === sectionId)?.tiles.push(tileId); + placed.add(tileId); + }; + const kept = (id: string | null): id is string => + id !== null && layout.some((section) => section.id === id); + const home = (tileId: string): string => { + const declared = declaredSection(dashboard, tileId); + return declared !== undefined && kept(declared) + ? declared + : layout[layout.length - 1].id; + }; + + for (const section of stored.sections) { + if (!kept(section.id)) continue; + for (const tileId of section.tiles) { + if (placeable(tileId)) put(section.id, tileId); + } } - const layout: string[] = []; - for (const tileId of tiles) { - if (tileWidget(dashboard, tileId) && !layout.includes(tileId)) { - layout.push(tileId); + for (const section of stored.sections) { + if (kept(section.id)) continue; + for (const tileId of section.tiles) { + if (placeable(tileId)) put(home(tileId), tileId); } } - for (const id of fallback) { - if (!seen.includes(id) && !layout.includes(id)) layout.push(id); + for (const widget of dashboard.widgets) { + if (!stored.seen.includes(widget.id) && !placed.has(widget.id)) { + put(home(widget.id), widget.id); + } } return layout; } From fb30d0f2748f0c3545dfd1b30313c205c69cb89a Mon Sep 17 00:00:00 2001 From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:53:34 -0400 Subject: [PATCH 04/29] feat(frontend): say in a dashboard's + menu which removed tiles have no data yet A + menu lists the declared widgets the viewer removed, but nothing in it said whether a tile would have anything to show once added back. Each item now says so, muted, beside the widget's title, with the tile's own rule: - "No data yet." when the widget's metric returned no rows; - "Telemetry incomplete." when a derived metric lacks an input, where the tile says "Telemetry unavailable or incomplete." (or, for a number, "Incomplete telemetry; total unavailable."); - "Total unavailable." for a number with no total across groups; - nothing when there is data, while the answer is loading, or when the query fails, so an error never reads as "no data". The rule is one function the tile and the menu share. An item asks the tile's own query, through the same hook and query key, and only while its menu is open, as the tile's info popover does; so nothing is asked about a removed widget on page load, and a tile added back draws at once from the menu's answer. Items stay selectable either way, and the menus still offer only removed declared widgets (ADR 0007). The kit's DropdownMenuItem gains an optional `hint`: short muted text after the label. It describes the item rather than naming it, so the label alone stays the item's accessible name. A MenuHints story shows it, and the UI inventory lists the story. @codefly-dev/ui stays at 0.11.5, already bumped in this branch. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/compound-controls.test.tsx | 29 ++ .../codefly-ui/src/layout/dropdown-menu.tsx | 27 +- .../codefly-ui/stories/primitives.stories.tsx | 15 + .../code/src/solutions/SolutionDashboard.tsx | 160 ++++++++--- .../__tests__/SolutionDashboard.test.tsx | 260 ++++++++++++++++++ .../services/frontend/code/ui-inventory.json | 4 + 6 files changed, 456 insertions(+), 39 deletions(-) diff --git a/module/services/frontend/code/packages/codefly-ui/src/layout/__tests__/compound-controls.test.tsx b/module/services/frontend/code/packages/codefly-ui/src/layout/__tests__/compound-controls.test.tsx index 5e2843b8d..c208677a0 100644 --- a/module/services/frontend/code/packages/codefly-ui/src/layout/__tests__/compound-controls.test.tsx +++ b/module/services/frontend/code/packages/codefly-ui/src/layout/__tests__/compound-controls.test.tsx @@ -10,6 +10,12 @@ import { import { createRef } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { CardRoot, CardContent } from "../card-root.js"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "../dropdown-menu.js"; import { TabsRoot, TabsList, TabsTrigger, TabsContent } from "../tabs-root.js"; import { Sheet, @@ -112,6 +118,29 @@ it("restores Sheet trigger focus after Escape", async () => { await waitFor(() => expect(document.activeElement).toBe(trigger)); }); +it("names a menu item by its label and describes it by its hint", async () => { + render( + + Add a report + + Weekly requests + Monthly invoices + + , + ); + const hinted = await screen.findByRole("menuitem", { + name: "Monthly invoices", + description: "Empty", + }); + // Shown after the label, too. + expect(hinted.textContent).toBe("Monthly invoicesEmpty"); + expect( + screen + .getByRole("menuitem", { name: "Weekly requests" }) + .hasAttribute("aria-describedby"), + ).toBe(false); +}); + function SidebarState() { const { open } = useSidebar(); return {open ? "Expanded" : "Collapsed"}; diff --git a/module/services/frontend/code/packages/codefly-ui/src/layout/dropdown-menu.tsx b/module/services/frontend/code/packages/codefly-ui/src/layout/dropdown-menu.tsx index 192dd4983..5f5517fa0 100644 --- a/module/services/frontend/code/packages/codefly-ui/src/layout/dropdown-menu.tsx +++ b/module/services/frontend/code/packages/codefly-ui/src/layout/dropdown-menu.tsx @@ -3,6 +3,7 @@ import { Menu as MenuPrimitive } from "@base-ui/react/menu"; import { CheckIcon, ChevronRightIcon } from "lucide-react"; import type * as React from "react"; +import { useId } from "react"; import { cn } from "./cn.js"; function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) { @@ -79,22 +80,46 @@ function DropdownMenuItem({ className, inset, variant = "default", + hint, + children, ...props }: MenuPrimitive.Item.Props & { inset?: boolean; variant?: "default" | "destructive"; + /** + * Short muted text after the label: something worth knowing about the item + * before choosing it, such as its state. It describes the item rather than + * naming it, so the label alone stays the item's accessible name. + */ + hint?: React.ReactNode; }) { + const hintId = useId(); return ( + > + {children} + {/* Hidden from the item's name, which is computed from its content; + `aria-describedby` still reads it, as it reads a hidden element. */} + {hint && ( + + )} + ); } diff --git a/module/services/frontend/code/packages/codefly-ui/stories/primitives.stories.tsx b/module/services/frontend/code/packages/codefly-ui/stories/primitives.stories.tsx index f1e4913a4..925bf8bea 100644 --- a/module/services/frontend/code/packages/codefly-ui/stories/primitives.stories.tsx +++ b/module/services/frontend/code/packages/codefly-ui/stories/primitives.stories.tsx @@ -221,6 +221,21 @@ export const Menu = { ), }; +// An item can say something about itself after its label, muted: a state worth +// knowing before choosing it. The label alone stays its accessible name. +export const MenuHints = { + render: () => ( + + }> + Add a report + + + Weekly requests + Monthly invoices + + + ), +}; export const Empty = { render: () => ( - {series.coverage === "partial" - ? "Telemetry unavailable or incomplete." - : "No data yet."} -

    - ); + const missing = withoutValues(widget); + if (missing) { + return

    {missing.tile}

    ; } switch (visualization) { case "line": @@ -168,13 +194,8 @@ function WidgetValues({ widget }: { widget: ResolvedWidget }) { case "bar": return ; case "number": - return series.total === null ? ( -

    - {series.coverage === "partial" - ? "Incomplete telemetry; total unavailable." - : "Total unavailable across groups."} -

    - ) : ( + // A number with no total has said so above; this only narrows it. + return series.total === null ? null : ( ); case "table": @@ -201,30 +222,28 @@ function WidgetValues({ widget }: { widget: ResolvedWidget }) { } } -function WidgetCard({ - graph, - widget, - solutionId, - dashboardId, - orgId, - grip, - info, - remove, -}: { +// 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; - grip: ReactNode; - info: ReactNode; - remove: ReactNode; -}) { +} + +// One widget's resolution. The tile asks for it when drawn, and a + menu 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. +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 { data, isPending, isError } = useQuery({ + return useQuery({ queryKey: [ "solution-widget", solutionId, @@ -240,8 +259,33 @@ function WidgetCard({ SINGLE_WIDGET_DASHBOARD, { orgId }, ).then((resolved) => resolved.widgets[0]), - enabled: orgId !== "", + enabled: enabled && orgId !== "", }); +} + +function WidgetCard({ + graph, + widget, + solutionId, + dashboardId, + orgId, + grip, + info, + remove, +}: { + graph: DataGraph; + widget: MetricWidget; + solutionId: string; + dashboardId: string; + orgId: string; + grip: ReactNode; + info: ReactNode; + remove: ReactNode; +}) { + const { data, isPending, isError } = useWidgetSeries( + { graph, solutionId, dashboardId, orgId }, + widget, + ); return ( @@ -508,20 +552,50 @@ 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)?.menu; + 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). 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 ( - + ) : ( addable.map((tile) => ( - onAdd(tile.id)}> - {tile.label} - + onAdd(tile.id)} + /> )) )} @@ -761,7 +840,11 @@ function SolutionDashboard({ return widget ? [{ tileId, widget }] : []; }); const widgets = new Map(shown.map(({ tileId, widget }) => [tileId, widget])); - const addable = addableTiles(graph, dashboard, layout); + const source = { 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. @@ -863,6 +946,7 @@ function SolutionDashboard({ const addMenu = (label: string, sectionId: string) => ( save(addTile(layout, dashboard, tileId, sectionId))} /> 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 d826b0114..a5894ce39 100644 --- a/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx +++ b/module/services/frontend/code/src/solutions/__tests__/SolutionDashboard.test.tsx @@ -1,9 +1,12 @@ import type { DataGraph } from "@codefly/saas-plugin-manifest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, cleanup, fireEvent, + render, screen, + waitFor, within, } from "@testing-library/react"; import { HttpResponse, http } from "msw"; @@ -621,6 +624,263 @@ describe("a viewer's layout", () => { }); }); +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. + fireEvent.click(item); + const added = tile("Top event types"); + expect(within(added).getByText("No data yet.")).toBeTruthy(); + 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 its own words. + fireEvent.click(item); + expect( + within(tile("Net logins")).getByText( + "Telemetry unavailable or incomplete.", + ), + ).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). diff --git a/module/services/frontend/code/ui-inventory.json b/module/services/frontend/code/ui-inventory.json index d94330102..d209b7946 100644 --- a/module/services/frontend/code/ui-inventory.json +++ b/module/services/frontend/code/ui-inventory.json @@ -644,6 +644,10 @@ { "file": "packages/codefly-ui/stories/primitives.stories.tsx", "export": "Menu" + }, + { + "file": "packages/codefly-ui/stories/primitives.stories.tsx", + "export": "MenuHints" } ], "coverage": "owner stories; browser/skin verification pending", From 397f77643e99f91562086e2ea6d4674b61d05a3e Mon Sep 17 00:00:00 2001 From: crisfiorini-oss <301773486+crisfiorini-oss@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:37:34 -0400 Subject: [PATCH 05/29] feat(manifest): let a metric say its value is a percentage A rate on a solution dashboard read as a bare fraction: a win rate of four in ten showed "0.4", and 17 of 22 showed "0.773". The host could not write it as a percentage on its own, because a ratio is not always a share: "versions per memo" is a ratio too, and reads 2.2. A metric, source or derived, may now name a `format`. `number`, the default, writes the value as it is. `percent` reads it as a share from 0 to 1 and writes it as a percentage, to one decimal: 0.4 reads "40%", 17/22 reads "77.3%". - data-graph.ts and plugin.codefly.schema.json allow `format` on both metric kinds and refuse any other value; the conformance suite pins that they agree. saas-sdk mirrors the type (`MetricValueFormat`). - runDashboard puts each widget's metric format on the resolved widget, and the kit's fromDashboardData carries it to its view model. - The kit's StatChart, BarList and Line/AreaChart take an optional `formatValue`: the tile's number, each bar's value and the y-axis ticks. `formatShare` writes a share as a percentage. The kit's shared and the host's solution dashboard both use it for a percent widget, tables included, and leave a plain number to each chart's own default. - docs/plugin-manifest-schema.md names the key. A host that predates `format` refuses a graph that names it, as one that predates sections refuses sections; both ship in the same release. Co-Authored-By: Claude Opus 5.5 --- module/docs/plugin-manifest-schema.md | 5 +- .../src/dashboard/__tests__/charts.test.tsx | 37 ++++++++- .../dashboard/__tests__/dashboard.test.tsx | 39 +++++++++ .../src/dashboard/__tests__/format.test.ts | 18 +++- .../src/dashboard/__tests__/types.test.ts | 14 ++++ .../codefly-ui/src/dashboard/charts.tsx | 30 +++++-- .../codefly-ui/src/dashboard/dashboard.tsx | 15 ++-- .../codefly-ui/src/dashboard/format.ts | 9 ++ .../codefly-ui/src/dashboard/index.ts | 8 +- .../codefly-ui/src/dashboard/types.ts | 10 +++ .../plugin.codefly.schema.json | 10 ++- .../saas-plugin-manifest/src/data-graph.ts | 20 ++++- .../saas-plugin-manifest/src/index.ts | 1 + .../test/data-graph.test.ts | 26 ++++++ .../test/schema-conformance.test.ts | 15 ++++ .../saas-sdk/src/datagraph/dashboard.ts | 5 ++ .../code/packages/saas-sdk/src/index.ts | 1 + .../code/packages/saas-sdk/src/schema.ts | 11 +++ .../packages/saas-sdk/test/dashboard.test.ts | 17 ++++ .../code/src/solutions/SolutionDashboard.tsx | 31 +++++-- .../__tests__/SolutionDashboard.test.tsx | 82 +++++++++++++++++++ .../src/solutions/__tests__/registry.test.ts | 17 ++++ 22 files changed, 395 insertions(+), 26 deletions(-) diff --git a/module/docs/plugin-manifest-schema.md b/module/docs/plugin-manifest-schema.md index 3e94f6219..7df3d432a 100644 --- a/module/docs/plugin-manifest-schema.md +++ b/module/docs/plugin-manifest-schema.md @@ -92,7 +92,10 @@ has three node kinds: - **`metrics`** — a `source` metric filters one event and compiles to an `AuditService.AggregateAuditLog` query (its `groupBy`/`bucket`/`aggregation` are exactly that RPC's dimensions); a `derived` metric combines other metrics - (`sum`, `ratio`, `difference`). + (`sum`, `ratio`, `difference`). Either kind may name a `format`: `number` + (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. - **`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. 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 600a9200d..4e497b65d 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, LineChart, StatChart } from "../charts.js"; +import { AreaChart, BarList, LineChart, StatChart } from "../charts.js"; import type { SeriesPoint } from "../types.js"; type ElementLike = { type?: unknown; props?: { children?: unknown } & Record }; @@ -95,3 +95,38 @@ 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 cd9ffb4a2..38acf2b15 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 @@ -75,3 +75,42 @@ it("preserves partial telemetry and unavailable totals from the SDK view", () => expect(screen.getByText("Total unavailable")).toBeTruthy(); expect(screen.queryByText("0")).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 46a2d1086..175342817 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,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { formatAxisKey, formatAxisValue, parseTimeKey } from "../format.js"; +import { + formatAxisKey, + formatAxisValue, + formatShare, + parseTimeKey, +} from "../format.js"; describe("formatAxisValue", () => { it("keeps small numbers plain and compacts large ones", () => { @@ -60,3 +65,14 @@ 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__/types.test.ts b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/types.test.ts index 02f7d839d..a23d42cb8 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 @@ -29,6 +29,20 @@ 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/charts.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/charts.tsx index 003f3267e..fdd60fd96 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 @@ -43,6 +43,8 @@ interface ChartProps { className?: string; height?: number; axes?: ChartAxes; + /** Writes a y-axis tick; compact numbers when omitted. */ + formatValue?: (value: number) => string; } // The shared cartesian body for line and area. `fill` adds the area band under @@ -56,10 +58,11 @@ function cartesian({ className, height, axes, + formatValue, fill, label, }: Required> & - Pick & { fill: boolean; label: string }) { + Pick & { fill: boolean; label: string }) { const { x: showX, y: showY } = resolveAxes(axes); if (!showX && !showY) { @@ -92,31 +95,34 @@ function cartesian({ pt.key) } : undefined} /> ); } -export function LineChart({ points, className, height = VIEW_H, axes }: ChartProps) { - return cartesian({ points, className, height, axes, fill: false, label: "line chart" }); +export function LineChart({ points, className, height = VIEW_H, axes, formatValue }: ChartProps) { + return cartesian({ points, className, height, axes, formatValue, fill: false, label: "line chart" }); } -export function AreaChart({ points, className, height = VIEW_H, axes }: ChartProps) { - return cartesian({ points, className, height, axes, fill: true, label: "area chart" }); +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. export function BarList({ points, className, format, + formatValue, }: { points: SeriesPoint[]; className?: string; format?: (key: string) => string; + formatValue?: (value: number) => string; }) { const max = Math.max(1, ...points.map((p) => p.value)); return ( @@ -132,7 +138,9 @@ export function BarList({ style={{ width: `${(p.value / max) * 100}%`, backgroundColor: "var(--primary)" }} /> - {p.value.toLocaleString()} + + {formatValue ? formatValue(p.value) : p.value.toLocaleString()} +
    ))} @@ -141,20 +149,24 @@ 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. +// as one number, not a chart. `formatValue` writes the number. export function StatChart({ total, points, className, + formatValue, }: { total: number; points: SeriesPoint[]; className?: string; + formatValue?: (value: number) => string; }) { const style: React.CSSProperties = { color: "var(--primary)" }; return (
    - {total.toLocaleString()} + + {formatValue ? formatValue(total) : total.toLocaleString()} + {/* A trend needs two points. One point (a single all-time bucket, say) would draw as a flat line that reads as "no change". */} {points.length >= 2 && ( diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/dashboard.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/dashboard.tsx index 978723a30..d408cdda0 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 @@ -12,6 +12,7 @@ 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"; // Column-span utility classes, matching the responsive grid below so a spanning @@ -33,21 +34,23 @@ 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 ; + return ; case "area": - return ; + return ; case "bar": - return ; + return ; case "number": return series.total === null ? (

    Total unavailable

    ) : ( - + ); case "table": return ( @@ -57,7 +60,9 @@ function WidgetBody({ widget }: { widget: DashboardWidgetView }) { {series.points.map((p) => ( {p.key} - {p.value.toLocaleString()} + + {formatValue ? formatValue(p.value) : p.value.toLocaleString()} + ))} 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 }) { {point.key} - {point.value.toLocaleString()} + {formatValue + ? formatValue(point.value) + : point.value.toLocaleString()} ))} 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. +
    +
    + + {format ? format(p.key) : p.key} + + + {formatValue ? formatValue(p.value) : p.value.toLocaleString()} + +
    +
    +
    - - - {formatValue ? formatValue(p.value) : p.value.toLocaleString()} - +
    ))}
    @@ -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 ? ( -

    Total unavailable

    - ) : ( - - ); - case "table": - return ( -
    - - - {series.points.map((p) => ( - - - - - ))} - -
    {p.key} - {formatValue ? formatValue(p.value) : p.value.toLocaleString()} -
    -
    - ); - default: - return null; + if (visualization === "number" && series.total === null) { + return

    Total unavailable

    ; } + 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 ( +
    + + + {series.points.map((point) => ( + + + + + ))} + +
    + {point.key} + + {formatValue + ? formatValue(point.value) + : point.value.toLocaleString()} +
    +
    + ); + default: { + // Compile-time exhaustiveness: a new WidgetVisualization must be + // handled here or this assignment fails to type-check. + const _exhaustive: never = visualization; + return _exhaustive; + } + } +} 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 ( - - - {series.points.map((point) => ( - - - - - ))} - -
    {point.key} - {formatValue - ? formatValue(point.value) - : point.value.toLocaleString()} -
    - ); - default: { - // Compile-time exhaustiveness: a new WidgetVisualization must be - // handled here or this assignment fails to type-check. - const _exhaustive: never = visualization; - return _exhaustive; - } - } + 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 ( -
    - - - {series.points.map((point) => ( - - - - - ))} - -
    - {point.key} - - {formatValue - ? formatValue(point.value) - : point.value.toLocaleString()} -
    -
    - ); - default: { - // Compile-time exhaustiveness: a new WidgetVisualization must be - // handled here or this assignment fails to type-check. - const _exhaustive: never = visualization; - return _exhaustive; - } - } -} 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({ {p.key} - {p.value.toLocaleString()} + {formatValue + ? formatValue(p.value) + : p.value.toLocaleString()} ))} @@ -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 `

    `, one level under the dashboard's own `

    `. + */ +export function DashboardSectionHeader({ + title, + label, + description, + editing, + leading, + actions, + children, + className, +}: DashboardSectionHeaderProps) { + return ( +
    + {leading} +
    + {editing ?? ( +

    + {title} +

    + )} + {description != null && ( +

    {description}

    + )} + {children} +
    + {actions != null && ( +
    {actions}
    + )} +
    + ); +} + +/** 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 ( +
    + {groups.map(({ section, tiles }) => + section ? ( +
    + + {tiles.length === 0 ? ( +

    + No widgets in this section. +

    + ) : ( + + )} +
    + ) : ( + + ), + )} +
    + ); +} + /** * 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 ( ); }; + // 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} - </h3> + )} {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"] },