diff --git a/module/AUDIT_METRICS.md b/module/AUDIT_METRICS.md index 473dec252..0b0adfbad 100644 --- a/module/AUDIT_METRICS.md +++ b/module/AUDIT_METRICS.md @@ -194,7 +194,8 @@ never the SDK binding input. Regenerate the export before generating the SDK. ## Unknown and partial results -Empty queries have no points and render **No data yet**. Numeric sums with no +Empty queries have no points and render as no data, in the form the widget's +visualization has for it (see *How a resolved series is drawn*). Numeric sums with no numeric observations omit their value, just like averages/percentiles; an emitted zero stays zero. `samples[alias]` reports observations before reduction (so repeated logical IDs are not mistaken for missing telemetry). Fewer samples than @@ -212,6 +213,44 @@ The host DSL also withholds totals after top-N truncation. Authorization/RPC errors remain errors, never empty successful results. Existing consumers of the SDK must handle `total: null` when adopting this contract. +## How a resolved series is drawn + +Every dashboard built from these series — the host's own pages, a solution's +declared dashboard, and a solution remote that mounts the kit itself — is drawn +by one renderer, `` from `@codefly-dev/ui/dashboard`, with the kit's +**metric tier**. There is no second, plainer rendering: a consuming solution +does not have to ask which half of the kit its dashboard will get. + +| Widget | Drawn as | Empty series | `coverage: "partial"` | `total: null` | +| --- | --- | --- | --- | --- | +| `number` | `StatTile`: the series total, in the widget's `format` and `unit`; a sparkline and a delta against the previous bucket when the metric is bucketed over **time** | `—` with a **No data** badge, never `0` | `—` with a **Partial data** badge | `—` with a **No total** badge when coverage is complete | +| `line` / `area` / `bar` | the kit's metric chart over a `ChartSeries` — axes from the appearance palette, a crosshair readout, a legend for two or more series, and every exact value in a visually-hidden data table | the chart's own **No data** plot, plus a **No data** badge on the card | the observed points still plot, with a **Partial data** badge on the card | nothing: a series widget shows no total | +| `table` | key/value rows | **No data yet.** | a **Partial data** badge on the card | — | + +Two of those cells are the contract, not presentation. A `null` total renders as +a dash and never as `0`, because "cannot say" and "nothing happened" are +different answers and only one of them is in the data; and a series with no +points is no data rather than a zero, because the aggregate omits a bucket +instead of emitting an empty one. A sparkline and a bucket delta are drawn +**only** for a time-bucketed series: a line through counts grouped by event type +or region would assert an order, and a delta between two of them a change over +time, that a categorical grouping does not have. + +A host composes its own features around that renderer — ``'s slots +take header actions, a layout (the host's solution dashboard passes a +drag-to-reorder grid using the kit's shared layout), a per-widget renderer for a host that resolves each +widget in its own query, and an empty state. A consumer re-implementing any part +of the drawing is refused by a gate: `src/components/__tests__/kit.test.ts` in +the frontend. + +The declaration can add dashboard `columns`, widget `span`, and named +`sections` with their own heading and column count. The defaults need none of +these: adjacent scalar widgets form compact four-column rows, series use two +columns, and cards keep their natural height. A stack stays single-column. +Section and widget order come from the declaration until a viewer customizes +within a section. See [the layout contract](docs/plugin-manifest-schema.md#dashboard-layout) +for responsive spans, persistence and the compatibility limit of legacy hosts. + ## Independent validation Run `go test -race ./pkg/infra/auditmetricstest` from accounts/code for pure diff --git a/module/docs/plugin-manifest-schema.md b/module/docs/plugin-manifest-schema.md index f6a75e0b0..63ad0d313 100644 --- a/module/docs/plugin-manifest-schema.md +++ b/module/docs/plugin-manifest-schema.md @@ -97,6 +97,54 @@ has three node kinds: one metric. These metric-bound widgets are distinct from `ui.widgets`, which are presentation slots contributed to host surfaces; the two never mix. +### Dashboard layout + +Layout fields are **additive**. Existing declarations need no edits. A grid +packs consecutive `number` widgets into four-column rows and series into +two-column rows, collapsing to one column on narrow screens. Runs retain widget +order; a scalar is never moved ahead of a preceding chart. Cards align at their +natural height, so a short or empty card does not stretch to its neighbour. +`stack` keeps one widget per row and ignores column/span hints. + +- `Dashboard.columns?: 1 | 2 | 3 | 4` overrides the default for both kinds of row. +- `MetricWidget.span?: 1 | 2 | 3 | 4` applies to scalars and series, clamped to + the containing row's columns and the current responsive breakpoint. +- `Dashboard.sections?: { id, title, description?, columns? }[]` declares named + bands in section order. Each widget may name one with `section`; the id must + exist. A section inherits the dashboard column count unless it overrides it. + Widgets without a section appear first; within each section, widget order is + preserved. Empty sections draw nothing. + +For example, an existing dashboard can add: + +```json +{ + "id": "overview", + "layout": "grid", + "columns": 2, + "sections": [ + { "id": "headline", "title": "Headline numbers", "columns": 4 }, + { "id": "trends", "title": "Over time" } + ], + "widgets": [ + { "id": "total", "metric": "total_events", "visualization": "number", "section": "headline" }, + { "id": "trend", "metric": "events_per_day", "visualization": "line", "section": "trends", "span": 2 } + ] +} +``` + +`runDashboard` and `fromDashboardData` carry these fields to the kit unchanged. +The host's sortable view uses the same geometry. Dragging swaps widget IDs; +keyboard arrows move one place within the widget's declared section. A viewer's +preference does not change declared section membership. + +**Independent rollout:** hosts with this validator ignore unknown presentation +fields on dashboards, sections and widgets. Unknown metric/query fields still +fail validation. Previously released hosts used exact-key validation and will +reject the new fields: upgrade those hosts before adding layout fields, or keep +sending the unchanged declaration to them. Additive types cannot retrofit an +already-deployed validator. + On a registered solution's page the host renders every declared dashboard in a **Dashboard** tab beside the solution's own **App** tab, never stacked above the solution; the solution stays mounted while the dashboard is open. A @@ -104,7 +152,10 @@ solution that declares no dashboard gets no tab bar. Each viewer can arrange a dashboard for themselves: reorder its declared widgets, remove them, and re-add the ones they removed. The declared widgets, in declared order, are where every viewer starts, and a widget declared later reaches viewers who -already rearranged. A viewer's arrangement is a preference (ADR 0007): it never +already rearranged. Saved layouts use widget IDs, not positions, and record the +IDs known at save time. An unchanged saved default follows a new declaration; +a customized order keeps surviving IDs, appends newly declared IDs once, and +keeps intentionally removed widgets hidden. A viewer's arrangement is a preference (ADR 0007): it never adds a metric the dashboard does not draw, so a metric declared only as a derived metric's input stays off the page. diff --git a/module/services/frontend/AGENTS.md b/module/services/frontend/AGENTS.md index 00759fb49..bf0ba54e6 100644 --- a/module/services/frontend/AGENTS.md +++ b/module/services/frontend/AGENTS.md @@ -115,6 +115,40 @@ whether `/s/{id}` renders, and it stays installation-blind. observation speaks for the present is the host's to decide, and a copy of that window here would drift from it. +## Drawing a solution's declared dashboard + +`src/solutions/SolutionDashboard.tsx` **mounts** the shared kit's +`` (`@codefly-dev/ui/dashboard`); it draws nothing itself. Which +tier each widget and each coverage state is drawn with is the module's +[AUDIT_METRICS.md](../../AUDIT_METRICS.md) ("How a resolved series is drawn") — +one renderer for the host's pages, a declared solution dashboard and a solution +remote alike. + +What this file owns is only what is the host's, composed through that +renderer's slots: + +- **the audit queries**, one per widget, so a widget whose metric errors fails + alone instead of blanking its siblings. Each lives in its own component + (`SolutionWidget`) that resolves the series and then mounts the kit's + `DashboardWidget`, because React fixes the number of hooks a component may + call and a renderer taking the whole view cannot hold a varying number of + queries; +- **the viewer's arrangement** — reorder by drag or arrow keys, remove, add + back, saved per viewer and organization (`dashboard-layout.ts`), handed to + the renderer's `layout` slot as the kit's `SortableGrid`; +- **the ⓘ** that says what a tile counts, from which audit events, over what + range and who caused the newest ones (`metric-info.ts`, the principal + directory, the recent-events drill-down), handed to each widget as its + header actions. + +Re-implementing any part of the drawing is refused by +`src/components/__tests__/kit.test.ts`: a declaration taking a name +`@codefly-dev/ui` exports must render the kit's component of that name or carry +a reviewed entry, `class-variance-authority` is not a dependency of this app so +a kit-shaped primitive cannot be built here, and the two chart surfaces +(`src/components/charts/chart.tsx`, +`src/features/dashboard/ui/charts/line-chart.tsx`) stay re-exports. + ## Loading a remote, and its CSP `/s/[solutionId]` (`src/app/(dashboard)/s/[solutionId]/page.tsx`) loads the diff --git a/module/services/frontend/code/next-env.d.ts b/module/services/frontend/code/next-env.d.ts index a419cbe4e..ce4e94a6b 100644 --- a/module/services/frontend/code/next-env.d.ts +++ b/module/services/frontend/code/next-env.d.ts @@ -1,7 +1,7 @@ /// /// -import "./.next/dev/types/routes.d.ts"; -import "./.next/dev/types/root-params.d.ts"; +import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/module/services/frontend/code/package-lock.json b/module/services/frontend/code/package-lock.json index b10edc9c7..1bc610f5c 100644 --- a/module/services/frontend/code/package-lock.json +++ b/module/services/frontend/code/package-lock.json @@ -15,10 +15,10 @@ "@bufbuild/protobuf": "^2.11.0", "@codefly-dev/saas-plugin-contract": "2.4.2", "@codefly-dev/saas-plugin-react": "0.4.3", - "@codefly-dev/saas-sdk": "0.3.17", - "@codefly-dev/saas-ui": "0.12.6", - "@codefly-dev/ui": "0.12.6", - "@codefly/saas-plugin-manifest": "0.1.1", + "@codefly-dev/saas-sdk": "0.3.18", + "@codefly-dev/saas-ui": "0.12.7", + "@codefly-dev/ui": "0.12.7", + "@codefly/saas-plugin-manifest": "0.1.2", "@codefly/saas-settings": "0.1.0", "@connectrpc/connect": "^2.1.1", "@connectrpc/connect-web": "^2.1.1", @@ -34,7 +34,6 @@ "@simplewebauthn/browser": "^13.3.0", "@tanstack/react-query": "^5.60.0", "@tanstack/react-table": "^8.21.3", - "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "codefly": "https://github.com/codefly-dev/sdk-js/archive/9f3bb78682a0c2b9a70631d35c2ae0094c281ac5.tar.gz", "js-yaml": "^4.3.2", @@ -15234,7 +15233,7 @@ }, "packages/codefly-ui": { "name": "@codefly-dev/ui", - "version": "0.12.6", + "version": "0.12.7", "license": "Apache-2.0", "dependencies": { "@base-ui/react": "^1.3.0", @@ -15315,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" @@ -15348,7 +15347,7 @@ }, "packages/saas-sdk": { "name": "@codefly-dev/saas-sdk", - "version": "0.3.17", + "version": "0.3.18", "license": "Apache-2.0", "dependencies": { "@bufbuild/protobuf": "^2.11.0", @@ -15382,7 +15381,7 @@ }, "packages/saas-ui": { "name": "@codefly-dev/saas-ui", - "version": "0.12.6", + "version": "0.12.7", "license": "Apache-2.0", "devDependencies": { "@bufbuild/protobuf": "^2.11.0", diff --git a/module/services/frontend/code/package.json b/module/services/frontend/code/package.json index 7f3f13676..475f3589a 100644 --- a/module/services/frontend/code/package.json +++ b/module/services/frontend/code/package.json @@ -38,10 +38,10 @@ "@bufbuild/protobuf": "^2.11.0", "@codefly-dev/saas-plugin-contract": "2.4.2", "@codefly-dev/saas-plugin-react": "0.4.3", - "@codefly-dev/saas-sdk": "0.3.17", - "@codefly-dev/saas-ui": "0.12.6", - "@codefly-dev/ui": "0.12.6", - "@codefly/saas-plugin-manifest": "0.1.1", + "@codefly-dev/saas-sdk": "0.3.18", + "@codefly-dev/saas-ui": "0.12.7", + "@codefly-dev/ui": "0.12.7", + "@codefly/saas-plugin-manifest": "0.1.2", "@codefly/saas-settings": "0.1.0", "@connectrpc/connect": "^2.1.1", "@connectrpc/connect-web": "^2.1.1", @@ -57,7 +57,6 @@ "@simplewebauthn/browser": "^13.3.0", "@tanstack/react-query": "^5.60.0", "@tanstack/react-table": "^8.21.3", - "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "codefly": "https://github.com/codefly-dev/sdk-js/archive/9f3bb78682a0c2b9a70631d35c2ae0094c281ac5.tar.gz", "js-yaml": "^4.3.2", diff --git a/module/services/frontend/code/packages/codefly-ui/README.md b/module/services/frontend/code/packages/codefly-ui/README.md index 070eee474..0ff7b2105 100644 --- a/module/services/frontend/code/packages/codefly-ui/README.md +++ b/module/services/frontend/code/packages/codefly-ui/README.md @@ -36,6 +36,15 @@ and the v1 subset — is [CATALOG.md](./CATALOG.md). (`Banner`, `EmptyState`, `ErrorState`) and overlays (`Dialog`, `AlertDialog`, `Notice`, `Tooltip`, `DropdownMenu`); `dashboard` is ``, charts, `fromDashboardData`; `chat` is ``. React only: no plugin runtime, no host context. + `` paints with the **metric tier** — `StatTile` in compact rows for scalar + widgets, `metric-chart`'s line/area/bar over `ChartSeries` for series ones — + which is the tier the host's own operations pages use, so a solution's + dashboard and the host's own are the same picture. `DashboardWidget` is that + renderer for one widget, exported for a consumer that resolves each widget in + its own query; `slots` take header actions, a layout, a per-widget renderer + and an empty state, so a consumer composes its own features around the + renderer rather than beside it. What each widget and each coverage state + draws is tabulated in the module's `AUDIT_METRICS.md`. This is the surface a solution fe-remote consumes. `` is fed by `@codefly-dev/saas-sdk`'s `useChatStream` — the hook owns the SSE/WS transport, the component stays pure, the same split as `runDashboard` → ``. @@ -106,7 +115,7 @@ solution measured where it had to reach past the kit. | `@codefly-dev/ui/plugin-host/ui` | Client UI adapters (`PluginErrorBoundary`) | | `@codefly-dev/ui/skin` | `resolveSkin`, skin types | | `@codefly-dev/ui/layout` | `Card`/`Section`/`Tabs` + shadcn primitives (React-only) | -| `@codefly-dev/ui/dashboard` | `Dashboard`, charts, `fromDashboardData` (React-only) | +| `@codefly-dev/ui/dashboard` | `Dashboard`, `DashboardWidget`, metric tiles + charts, `fromDashboardData` (React-only) | | `@codefly-dev/ui/chat` | `Chat` (React-only) | | `@codefly-dev/ui/content` | `Content`, `Markdown`, `JsonView`, `CodeBlock`, `TextBlock` (React-only) | | `@codefly-dev/ui/board` | `Board` (React-only) | @@ -633,3 +642,18 @@ Version 0.12.3 refreshes the default preview palette from appearance contract 2. Version 0.12.4 also makes destructive/danger Badge text mix 30% toward the active foreground, retaining its status hue while meeting small-text contrast on its tinted background in the tested default and supplied light/dark skins. + +### Declared dashboard geometry + +`DashboardView.sections` names bands with `id`, `title`, optional `description` +and `columns`; a widget's `section` refers to one. Ungrouped widgets appear first, +then sections in declared order. Consecutive scalars default to four columns, +series to two, with natural-height cards. `columns` overrides both defaults and +`span` applies to every widget, clamped responsively. `stack` ignores both hints. +`fromDashboardData` preserves these fields from the SDK. + +The `layout` slot receives the tiles and a `renderLayout(nodesById)` callback. +Pass that callback to `SortableGrid.renderLayout` to retain exactly the default +geometry under one drag context, including drops across scalar/chart bands. +`SortableGrid.canSwap` can constrain drops to declared sections. Keep widget IDs +stable across declaration changes; never persist positional indexes. diff --git a/module/services/frontend/code/packages/codefly-ui/package.json b/module/services/frontend/code/packages/codefly-ui/package.json index 18f4fc37c..056f89478 100644 --- a/module/services/frontend/code/packages/codefly-ui/package.json +++ b/module/services/frontend/code/packages/codefly-ui/package.json @@ -1,6 +1,6 @@ { "name": "@codefly-dev/ui", - "version": "0.12.6", + "version": "0.12.7", "description": "The shared, versioned Codefly SaaS frontend kit: the plugin host and the tokens-as-data skin mechanism, standalone so the host and Module-Federation remotes import and dedupe one copy.", "repository": { "type": "git", diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/dashboard.test.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/dashboard.test.tsx index cd9ffb4a2..a148c8144 100644 --- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/dashboard.test.tsx +++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/dashboard.test.tsx @@ -1,77 +1,446 @@ // @vitest-environment happy-dom -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, render, screen, within } from "@testing-library/react"; import { afterEach, describe, expect, it } from "vitest"; import { Dashboard } from "../dashboard.js"; -import type { DashboardView } from "../types.js"; +import type { + DashboardView, + DashboardWidgetView, + WidgetSeries, +} from "../types.js"; afterEach(cleanup); -const emptySeries = { points: [], total: 0 }; +// Two daily buckets: a time-bucketed series, so it earns a trend. +const daily: WidgetSeries = { + points: [ + { key: "2026-09-01T00:00:00Z", value: 3 }, + { key: "2026-09-02T00:00:00Z", value: 5 }, + ], + total: 8, +}; + +// The same counts grouped by a category: an ordered line through these would +// assert a direction the data does not have. +const byCategory: WidgetSeries = { + points: [ + { key: "identity", value: 3 }, + { key: "security", value: 5 }, + ], + total: 8, +}; + +const empty: WidgetSeries = { points: [], total: 0, coverage: "empty" }; + +function view(...widgets: DashboardWidgetView[]): DashboardView { + return { widgets }; +} + +function show(...widgets: DashboardWidgetView[]) { + return render(); +} describe("Dashboard", () => { - it("renders its header through Section and each widget through Card", () => { - const data: DashboardView = { - title: "Traffic", - description: "last 7 days", - widgets: [ - { - id: "w1", - title: "Visits", - visualization: "number", - series: emptySeries, - }, - ], - }; - const { container } = render(); - - // Section owns the header markup — its
root and heading classes. - const section = container.querySelector("section"); - expect(section).not.toBeNull(); + it("renders its header through Section and each series widget through Card", () => { + const { container } = render( + , + ); + + // Section owns the header markup — its
root and heading. + expect(container.querySelector("section")).not.toBeNull(); expect( screen.getByRole("heading", { name: "Traffic", level: 2 }), ).toBeTruthy(); expect(screen.getByText("last 7 days")).toBeTruthy(); - // Card owns the surface — one card per widget, painted by the primitive. - const card = container.querySelector( - ".rounded-lg.border.bg-card.p-4.text-card-foreground.shadow-sm", - ); - expect(card).not.toBeNull(); + // Card owns the surface — one card per series widget, painted by the + // primitive, with the widget's title as its heading. + expect( + container.querySelector( + ".rounded-lg.border.bg-card.p-4.text-card-foreground.shadow-sm", + ), + ).not.toBeNull(); expect( screen.getByRole("heading", { name: "Visits", level: 3 }), ).toBeTruthy(); }); it("scopes the accent override onto the Section root", () => { - const data: DashboardView = { - accent: "hotpink", - widgets: [{ id: "w1", visualization: "number", series: emptySeries }], - }; - const { container } = render(); + const { container } = render( + , + ); const section = container.querySelector("section") as HTMLElement; expect(section.style.getPropertyValue("--primary")).toBe("hotpink"); }); + + it("says so, rather than drawing an empty grid, when the view has no widgets", () => { + show(); + expect(screen.getByText("No widgets.")).toBeTruthy(); + }); }); -it("preserves partial telemetry and unavailable totals from the SDK view", () => { - render( - { + it("draws a metric chart with an accessible data table", () => { + const { container } = render( + , + ); + const table = screen.getByRole("table", { name: "Events" }); + expect(within(table).getByRole("row", { name: /identity/ })).toBeTruthy(); + expect(within(table).getByText("3")).toBeTruthy(); + expect(within(table).getByText("5")).toBeTruthy(); + expect(container.querySelector("svg")?.getAttribute("aria-hidden")).toBe( + "true", + ); + }); + + it("draws the chart's own empty state instead of an axis with nothing on it", () => { + const { container } = render( + , + ); + expect( + container.querySelector("figure")?.getAttribute("aria-label"), + ).toBe("Events: no data"); + expect(container.querySelector("svg")).toBeNull(); + // Said twice on purpose, and in two registers: the card badges the + // coverage beside its title, and the plot says it where the geometry + // would have been. + expect(screen.getAllByText("No data")).toHaveLength(2); + }); + + it("badges a partial series on the card, from `series.coverage`", () => { + render( + , + ); + expect(screen.getByText("Partial data")).toBeTruthy(); + // A partial series keeps its observed points: the chart still draws. + expect(screen.getByRole("table", { name: "Events" })).toBeTruthy(); + }); + }, +); + +describe("a number widget", () => { + it("is a stat tile showing the series total in the widget's format", () => { + show({ + id: "w", + title: "Revenue", + visualization: "number", + series: { points: [{ key: "all", value: 4_200_000 }], total: 4_200_000 }, + format: "currency", + }); + expect(screen.getByText("Revenue")).toBeTruthy(); + expect(screen.getByText("$4.2M")).toBeTruthy(); + }); + + it("appends the widget's unit", () => { + show({ + id: "w", + title: "Throughput", + visualization: "number", + series: { points: [{ key: "all", value: 42 }], total: 42 }, + unit: "req/s", + }); + expect(screen.getByText("req/s")).toBeTruthy(); + }); + + it("sparklines a time-bucketed metric and deltas its last bucket", () => { + const { container } = show({ + id: "w", + title: "Logins", + visualization: "number", + series: daily, + }); + expect(screen.getByText("8")).toBeTruthy(); + expect(container.querySelector("svg")).not.toBeNull(); + // 3 → 5 between the last two buckets. + expect(screen.getByText(/66\.7%/).textContent).toContain( + "vs previous bucket", + ); + }); + + it("draws neither a trend nor a delta for a metric grouped by category", () => { + // A sparkline over "identity, security" would read as a direction, and a + // delta between them as a change over time. Neither is true of a + // categorical grouping, whose order is the aggregate's, not time's. + const { container } = show({ + id: "w", + title: "Logins", + visualization: "number", + series: byCategory, + }); + expect(screen.getByText("8")).toBeTruthy(); + expect(container.querySelector("svg")).toBeNull(); + expect(screen.queryByText(/%/)).toBeNull(); + }); + + it("gives no delta for a rise from zero, which is not a percentage", () => { + show({ + id: "w", + visualization: "number", + series: { + points: [ + { key: "2026-09-01T00:00:00Z", value: 0 }, + { key: "2026-09-02T00:00:00Z", value: 5 }, ], - }} - />, - ); - expect(screen.getByRole("status").textContent).toBe("Partial telemetry"); - expect(screen.getByText("Total unavailable")).toBeTruthy(); - expect(screen.queryByText("0")).toBeNull(); + total: 5, + }, + }); + expect(screen.queryByText(/%/)).toBeNull(); + }); + + it("shows a dash and says no total, never a 0, when the total is withheld", () => { + // `total: null` means the pipeline cannot name one number — a distinct + // count or an average across groups. Rendering it as 0 would report + // "nothing happened" where the answer is "cannot say". + show({ + id: "w", + title: "Distinct actors", + visualization: "number", + series: { ...byCategory, total: null }, + }); + expect(screen.getByText("—")).toBeTruthy(); + expect(screen.getByText("No total")).toBeTruthy(); + expect(screen.queryByText("0")).toBeNull(); + }); + + it("badges a partial series, which is why its total is withheld", () => { + show({ + id: "w", + title: "Logins", + visualization: "number", + series: { ...byCategory, total: null, coverage: "partial" }, + }); + expect(screen.getByText("Partial data")).toBeTruthy(); + expect(screen.getByText("—")).toBeTruthy(); + expect(screen.queryByText("No total")).toBeNull(); + }); + + it("reads an empty series as no data, not as a zero", () => { + // The audit aggregate omits a bucket rather than emitting a zero one, so + // a series with no points means nothing matched. + show({ id: "w", title: "Logins", visualization: "number", series: empty }); + expect(screen.getByText("No data")).toBeTruthy(); + expect(screen.getByText("—")).toBeTruthy(); + expect(screen.queryByText("0")).toBeNull(); + }); + + it("keeps a real zero a zero", () => { + show({ + id: "w", + title: "Failures", + visualization: "number", + series: { points: [{ key: "all", value: 0 }], total: 0 }, + }); + expect(screen.getByText("0")).toBeTruthy(); + expect(screen.queryByText("—")).toBeNull(); + }); +}); + +describe("a table widget", () => { + it("lists each point's key and value", () => { + show({ + id: "w", + title: "By type", + visualization: "table", + series: byCategory, + }); + const table = screen.getByRole("table", { name: "By type" }); + expect(within(table).getByText("identity")).toBeTruthy(); + expect(within(table).getByText("5")).toBeTruthy(); + }); + + it("says there is nothing yet for an empty series", () => { + show({ id: "w", title: "By type", visualization: "table", series: empty }); + expect(screen.getByText("No data yet.")).toBeTruthy(); + }); +}); + +describe("a widget still being resolved, or that failed", () => { + it("waits on a null series rather than drawing it as empty", () => { + const { container } = show( + { id: "chart", title: "Logins", visualization: "line", series: null }, + { id: "tile", title: "Total", visualization: "number", series: null }, + ); + expect(container.querySelectorAll('[data-slot="skeleton"]')).toHaveLength( + 2, + ); + expect(screen.queryByText("No data")).toBeNull(); + }); + + it("says a failed widget failed, and does not wait forever on its missing series", () => { + // A failed resolution leaves no series behind, so a renderer that tested + // for the series first would draw every failure as an endless wait. + const { container } = show( + { + id: "chart", + title: "Logins", + visualization: "line", + series: null, + failed: true, + }, + { + id: "tile", + title: "Total", + visualization: "number", + series: null, + failed: true, + }, + ); + expect(screen.getByText("Unable to load.")).toBeTruthy(); + expect(screen.getByText("Provider unavailable")).toBeTruthy(); + expect(container.querySelector('[data-slot="skeleton"]')).toBeNull(); + }); +}); + +describe("the default layout", () => { + it("gathers adjacent scalars into one KPI row and keeps declared order", () => { + const { container } = show( + { id: "a", title: "A", visualization: "number", series: daily }, + { id: "b", title: "B", visualization: "number", series: daily }, + { id: "c", title: "C", visualization: "line", series: daily }, + { id: "d", title: "D", visualization: "number", series: daily }, + ); + const grid = container.querySelector(".grid.items-start"); + expect(grid?.className).toContain("lg:grid-cols-4"); + const tiles = [...container.querySelectorAll("[data-dashboard-tile]")]; + expect(tiles).toHaveLength(4); + expect(tiles[2].className).toContain("sm:col-span-2"); + expect(tiles[2].className).toContain("col-start-1"); + expect(tiles[3].className).toContain("col-start-1"); + expect( + [...container.querySelectorAll('[data-slot="metric-label"], h3')].map( + (node) => node.textContent, + ), + ).toEqual(["A", "B", "C", "D"]); + }); + + it("stacks every widget in a row of its own for a stack layout", () => { + const { container } = render( + , + ); + expect(container.querySelector(".flex.flex-col")?.className).toContain( + "flex flex-col", + ); + expect(container.querySelector('[class*="grid-cols-4"]')).toBeNull(); + }); +}); + +// The seams a host composes its own features through. They are what let the +// host's solution dashboard keep its reorderable grid, its + menu and its +// per-widget queries while this renderer still does the drawing. +describe("slots", () => { + const widgets: DashboardWidgetView[] = [ + { id: "a", title: "A", visualization: "line", series: daily }, + { id: "b", title: "B", visualization: "number", series: daily }, + ]; + + it("puts actions in the dashboard's own header", () => { + render( + Add }} + />, + ); + expect(screen.getByRole("button", { name: "Add" })).toBeTruthy(); + }); + + it("hands every widget to renderWidget, in the view's order", () => { + render( +
  • {widget.id}
  • }} + />, + ); + expect( + screen.getAllByRole("listitem").map((item) => item.textContent), + ).toEqual(["a", "b"]); + }); + + it("hands the tiles, each with its widget, to a layout of the host's own", () => { + render( + ( +
      + {tiles.map((tile) => ( +
    • + {tile.node} +
    • + ))} +
    + ), + }} + />, + ); + const items = screen.getAllByRole("listitem"); + expect(items.map((item) => item.dataset.visualization)).toEqual([ + "line", + "number", + ]); + // The nodes are the kit's own cards: the chart's data table is in them. + expect(within(items[0]).getByRole("table", { name: "A" })).toBeTruthy(); + }); + + it("shows the host's own empty state in place of the layout", () => { + render( + Add one back with +.

    }} + />, + ); + expect(screen.getByText("Add one back with +.")).toBeTruthy(); + expect(screen.queryByText("No widgets.")).toBeNull(); + }); }); diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/sortable-grid.test.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/sortable-grid.test.tsx index 04b972c53..2dac84832 100644 --- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/sortable-grid.test.tsx +++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/sortable-grid.test.tsx @@ -252,3 +252,17 @@ describe("SortableGrid", () => { expect(animate).not.toHaveBeenCalled(); }); }); + +it("does not turn a drop on another section into a swap with the nearest allowed tile", () => { + const onSwap = vi.fn(); + render( + id} + canSwap={(a, b) => ["a", "b"].includes(a) === ["a", "b"].includes(b)} + />, + ); + dragTo("a", center("d"))(); + expect(onSwap).not.toHaveBeenCalled(); +}); diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/types.test.ts b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/types.test.ts index 02f7d839d..57cd03399 100644 --- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/types.test.ts +++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/types.test.ts @@ -25,8 +25,14 @@ describe("fromDashboardData", () => { expect(view.columns).toBe(3); expect(view.accent).toBe("#7c3aed"); expect(view.widgets).toHaveLength(1); - expect(view.widgets[0]).toMatchObject({ id: "trend", visualization: "line" }); - expect(view.widgets[0].series.total).toBe(3); + expect(view.widgets[0]).toMatchObject({ + id: "trend", + visualization: "line", + }); + // `fromDashboardData` bridges an ALREADY-resolved result, so the series + // it produces is never the `null` the view model allows for a widget + // still being resolved. + expect(view.widgets[0].series?.total).toBe(3); }); it("defaults layout to grid when the source omits it", () => { @@ -35,3 +41,24 @@ describe("fromDashboardData", () => { expect(view.widgets).toEqual([]); }); }); + +it("preserves sections, columns and widget spans from the SDK", () => { + const data = { + columns: 3 as const, + sections: [{ id: "summary", title: "Summary", columns: 4 as const }], + widgets: [ + { + id: "total", + visualization: "number" as const, + span: 2 as const, + section: "summary", + series: { points: [], total: 0 }, + }, + ], + }; + const view = fromDashboardData(data); + expect(view.columns).toBe(3); + expect(view.sections).toEqual(data.sections); + expect(view.widgets[0]).toMatchObject({ span: 2, section: "summary" }); + expect(fromDashboardData(data, { columns: 1 }).columns).toBe(1); +}); diff --git a/module/services/frontend/code/packages/codefly-ui/src/dashboard/dashboard.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/dashboard.tsx index 978723a30..f284268c6 100644 --- a/module/services/frontend/code/packages/codefly-ui/src/dashboard/dashboard.tsx +++ b/module/services/frontend/code/packages/codefly-ui/src/dashboard/dashboard.tsx @@ -6,13 +6,35 @@ // solution's Module-Federation remote render identical dashboards from the same // package instance. Data resolution (metric → audit query) is the job of // `@codefly-dev/saas-sdk`'s `runDashboard`; use `fromDashboardData` to bridge. +// +// It paints with the kit's METRIC tier — `StatTile` rows for scalars, +// `metric-chart`'s line/area/bar over `ChartSeries` for series — which is the +// tier the host's own operations pages are drawn with. Before, this renderer +// and the host's solution dashboard both used the older `charts.tsx` tier, so a +// solution's Dashboard tab read plainer than the host's own pages from the same +// kit, and there were two renderers to keep in step rather than one. import type * as React from "react"; +import type { ReactNode } from "react"; import { Card } from "../layout/card.js"; import { Section } from "../layout/page.js"; -import { AreaChart, BarList, LineChart, StatChart } from "./charts.js"; +import { Skeleton } from "../layout/skeleton.js"; import { cn } from "./cn.js"; -import type { DashboardView, DashboardWidgetView } from "./types.js"; +import { parseTimeKey } from "./format.js"; +import { + AreaChart as MetricAreaChart, + BarChart as MetricBarChart, + LineChart as MetricLineChart, +} from "./metric-chart.js"; +import type { ChartSeries } from "./metric-geometry.js"; +import { type MetricState, MetricStateBadge } from "./metric-state.js"; +import { type Metric, StatTile } from "./metric-tiles.js"; +import type { + DashboardView, + DashboardWidgetView, + SeriesPoint, + WidgetSeries, +} from "./types.js"; // Column-span utility classes, matching the responsive grid below so a spanning // card widens in step with it. A span is clamped to the grid's column count by @@ -31,33 +53,129 @@ const GRID_COLS: Record<1 | 2 | 3 | 4, string> = { 4: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4", }; -function WidgetBody({ widget }: { widget: DashboardWidgetView }) { - const { series, visualization } = widget; - if (series.points.length === 0) { - return
    No data yet.
    ; +/** The widget's own title, or its id when the declaration gave none. */ +function widgetTitle(widget: DashboardWidgetView): string { + return widget.title ?? widget.id; +} + +/** + * Whether the series is bucketed over time, so its points are a trend rather + * than a set of categories. Only a time-bucketed series earns a sparkline and + * a bucket-over-bucket delta: drawing a line through counts grouped by event + * type or region would assert an order and a direction the data does not have. + */ +function isTimeBucketed(points: SeriesPoint[]): boolean { + return points.length > 1 && points.every((p) => parseTimeKey(p.key) !== null); +} + +/** + * The badge a resolved series earns, or `undefined` when it is simply ready. + * + * `partial` outranks a withheld total because it is the *reason* the total is + * withheld (a partial series keeps its observed points and gives up its total), + * and a reader who sees "No total" alone learns less than one who sees + * "Partial data". An empty series is `no_data`, never a zero: the audit + * aggregate omits a bucket rather than emitting one, so no points means nothing + * matched. + */ +function seriesState(series: WidgetSeries): MetricState | undefined { + if (series.coverage === "partial") return "partial"; + if (series.coverage === "empty" || series.points.length === 0) { + return "no_data"; } - switch (visualization) { + if (series.total === null) return "no_total"; + return undefined; +} + +/** + * Change from the second-to-last bucket to the last, as the signed fraction + * `StatTile` renders. A change from zero is not a percentage, so it is no + * delta at all rather than an infinite one. + */ +function lastBucketDelta(points: SeriesPoint[]): number | undefined { + if (points.length < 2) return undefined; + const previous = points[points.length - 2].value; + const latest = points[points.length - 1].value; + if (previous === 0) return undefined; + return (latest - previous) / Math.abs(previous); +} + +/** A resolved scalar widget as the `Metric` a tile binds to. */ +function widgetMetric( + widget: DashboardWidgetView, + series: WidgetSeries, +): Metric { + const trend = isTimeBucketed(series.points); + const delta = trend ? lastBucketDelta(series.points) : undefined; + return { + id: widget.id, + label: widgetTitle(widget), + // A withheld total is passed through as null, so the tile shows a dash + // and its badge says what withheld it. Coercing it to 0 here would + // report "nothing happened" where the pipeline said "cannot say". + value: series.total, + format: widget.format, + unit: widget.unit, + higherIsBetter: widget.higherIsBetter, + series: trend ? series.points.map((p) => p.value) : undefined, + delta, + // Only named when there is a delta: `MetricDelta` reads a label with no + // delta as "no data