diff --git a/module/AUDIT_METRICS.md b/module/AUDIT_METRICS.md index 02fdd4225..fd87791c6 100644 --- a/module/AUDIT_METRICS.md +++ b/module/AUDIT_METRICS.md @@ -204,6 +204,35 @@ 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) 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, +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 counts, averages, percentiles and ratios have no scalar total across multiple @@ -228,11 +257,25 @@ 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`, 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 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 f6a75e0b0..ca68d48cf 100644 --- a/module/docs/plugin-manifest-schema.md +++ b/module/docs/plugin-manifest-schema.md @@ -92,29 +92,56 @@ 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. + 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, 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. + 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 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. - -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 +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 +`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/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) } 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/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/codefly-ui/README.md b/module/services/frontend/code/packages/codefly-ui/README.md index 95543fdca..1b9549c87 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` → ``. @@ -68,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: @@ -115,7 +142,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..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 @@ -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,241 @@ 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(); + }); + + // 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(); + }); + + 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__/sortable-grid.test.tsx b/module/services/frontend/code/packages/codefly-ui/src/dashboard/__tests__/sortable-grid.test.tsx index 04b972c53..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 @@ -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. @@ -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. @@ -134,6 +148,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 +273,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/__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..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 @@ -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()} ))} @@ -251,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 @@ -270,6 +318,7 @@ export interface DashboardWidgetProps { export function DashboardWidget({ widget, actions, + heading = "h3", className, }: DashboardWidgetProps) { const title = widgetTitle(widget); @@ -311,7 +360,7 @@ export function DashboardWidget({ @@ -334,6 +383,7 @@ export function DashboardWidget({ title={title} description={state ? : undefined} actions={actions} + titleAs={heading} className={className} > {refused ? ( @@ -375,15 +425,26 @@ 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. + * 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 +530,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,16 +704,25 @@ export function Dashboard({ } as React.CSSProperties) : 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`. - {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 ? ( + ) : ( , + "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,143 @@ 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 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; +}; + +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 +283,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 +322,96 @@ 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, + className, children, }: { id: string; + group: string; sorting: boolean; + className?: string; 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 @@ -219,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. @@ -232,22 +453,138 @@ 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, + itemClassName, 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 +593,190 @@ 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.id)} +
    + ) : liftedGroup ? (
    - {renderOverlay(dragged)} + {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/src/dashboard/types.ts b/module/services/frontend/code/packages/codefly-ui/src/dashboard/types.ts index 9d1b1c7ec..e0333932a 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 @@ -65,13 +65,23 @@ export interface DashboardWidgetView { /** Grid columns this widget spans (1–4); ignored in a stack. */ span?: 1 | 2 | 3 | 4; /** - * How a scalar widget's value is rendered. Defaults to a plain number. + * The id of the {@link DashboardSectionView} this widget is drawn in. A + * widget that names none, or a section the view does not hold, is drawn + * above every section, untitled. + */ + section?: string; + /** + * How the widget's values are written — a scalar's number, a chart's axis, + * readout and data table, a table's values. Defaults to a plain number. * * `"percent"` takes a value that is ALREADY a percentage: 45 renders "45%". * It does not take a share — 0.4 renders "0.4%", not "40%". A producer whose * own `percent` means a 0-to-1 share must scale it before it reaches here, * because this field is bound structurally (see `fromDashboardData`) and * nothing on the wire can tell the two conventions apart. + * `@codefly-dev/saas-sdk`'s `runDashboard` is such a producer and does that + * scaling itself, once: a data graph declares `percent` on a metric whose + * value is a share, and the widget it resolves carries percentage points. */ format?: MetricFormat; /** Suffix after a scalar widget's value, e.g. "req/s". */ @@ -80,11 +90,27 @@ export interface DashboardWidgetView { higherIsBetter?: boolean; } +/** + * A titled group of a dashboard's widgets. Sections are drawn in the order the + * view lists them, each with the widgets that name it, and a section no widget + * names is still drawn, empty. + */ +export interface DashboardSectionView { + id: string; + title: string; + description?: string; +} + /** The fully-resolved dashboard the renderer paints. */ export interface DashboardView { title?: string; description?: string; layout?: DashboardLayoutKind; + /** + * The sections the widgets are grouped into, in the order they are drawn. + * A view without sections draws its widgets as one untitled group. + */ + sections?: DashboardSectionView[]; /** 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. */ @@ -100,6 +126,7 @@ interface ResolvedWidgetLike { visualization: WidgetVisualization; title?: string; series: WidgetSeries; + section?: string; format?: MetricFormat; unit?: string; higherIsBetter?: boolean; @@ -107,6 +134,7 @@ interface ResolvedWidgetLike { interface DashboardDataLike { title?: string; layout?: DashboardLayoutKind; + sections?: readonly DashboardSectionView[]; widgets: ResolvedWidgetLike[]; } @@ -125,11 +153,13 @@ export function fromDashboardData( layout: data.layout ?? "grid", columns: extra?.columns, accent: extra?.accent, + sections: data.sections?.map((section) => ({ ...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/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/card.tsx b/module/services/frontend/code/packages/codefly-ui/src/layout/card.tsx index c2295e963..3ee96ae99 100644 --- a/module/services/frontend/code/packages/codefly-ui/src/layout/card.tsx +++ b/module/services/frontend/code/packages/codefly-ui/src/layout/card.tsx @@ -23,6 +23,13 @@ export interface CardProps { description?: ReactNode; /** Trailing controls rendered opposite the title (buttons, menus). */ actions?: ReactNode; + /** + * The title's heading element. `"h3"` by default; `"h4"` for a card that + * sits under a heading of its own below the page's sections — a widget in + * a dashboard section, say — so heading navigation reads it as that + * heading's child rather than its peer. + */ + titleAs?: "h3" | "h4"; children?: ReactNode; className?: string; } @@ -32,6 +39,7 @@ export function Card({ title, description, actions, + titleAs: Title = "h3", children, className, }: CardProps) { @@ -47,9 +55,9 @@ export function Card({
    {title != null && ( -

    + {title} - </h3> + )} {description != null && (

    + > + {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/src/skin/preview.generated.css b/module/services/frontend/code/packages/codefly-ui/src/skin/preview.generated.css index 20b57248a..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 @@ -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; } @@ -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)); @@ -1650,6 +1668,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); } @@ -1670,7 +1698,7 @@ } } - .border-primary\/30 { + .border-primary, .border-primary\/30 { border-color: var(--primary); } 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..8fae9e935 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,10 +2,12 @@ import { useState } from "react"; import { Chat, type ChatMessage } from "../src/chat/index.js"; import { Dashboard, + DashboardSectionHeader, + SortableBoard, SortableGrid, type WidgetVisualization, } from "../src/dashboard/index.js"; -import { Card } from "../src/layout/index.js"; +import { Button, Card } from "../src/layout/index.js"; export default { title: "Shared UI/Composites" }; export const Conversation = { render: function ConversationStory() { @@ -77,6 +79,73 @@ export const DashboardCharts = { /> ), }; +// 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: () => ( @@ -108,3 +177,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/codefly-ui/stories/primitives.stories.tsx b/module/services/frontend/code/packages/codefly-ui/stories/primitives.stories.tsx index 962cc921d..139aa04eb 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 @@ -228,6 +228,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: () => ( .., 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"], @@ -370,6 +376,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 +403,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 8662c12ec..8623a203a 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; @@ -176,6 +187,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 +209,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[]; } @@ -249,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", @@ -413,6 +445,7 @@ function validateSourceMetric(value: Record): void { "aggregation", "field", "percentile", + "format", ], `metric '${String(value.id)}'`, ); @@ -522,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)}'`; @@ -565,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( @@ -577,7 +615,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 +631,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 +715,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 +769,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..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 @@ -32,6 +32,7 @@ export { export type { Dashboard, DashboardLayout, + DashboardSection, DataGraph, DerivedMetric, EventDeclaration, @@ -43,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 8b1becc9c..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,123 @@ 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. + 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..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"; }, @@ -117,6 +123,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 +196,29 @@ 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("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..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,5 +1,6 @@ import type { DashboardLayout, + DashboardSection, DataGraph, WidgetVisualization, } from "../schema.js"; @@ -23,13 +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; 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, + }; } /** @@ -42,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>; } @@ -78,12 +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, + series: percent ? asPercentage(series) : series, + format: percent ? "percent" : undefined, }; }); @@ -91,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/datagraph/run.ts b/module/services/frontend/code/packages/saas-sdk/src/datagraph/run.ts index 57ac7f178..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 @@ -2,6 +2,7 @@ import type { DataGraph, DerivedMetric, Metric, + MetricAggregation, MetricBucket, MetricGroupBy, SourceMetric, @@ -40,6 +41,17 @@ 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(); + +// 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[], @@ -57,21 +69,127 @@ 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, }; } +// 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 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, + through && !Number.isNaN(through.getTime()) ? through.getTime() : 0, + ); + + 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]; + if (seen.has(date)) return []; + const zero = { key: `${date}${first.rest}`, value: 0 }; + noEvents.add(zero); + return [zero]; + }); +} + /** Resolve a single source metric against the audit RPC. */ export async function runMetric( client: AuditAggregateClient, @@ -108,21 +226,43 @@ export async function runMetric( partial = true; points.push({ key: bucket.key, value }); } - return toSeries( + const zeroFilled = + metric.groupBy === "time" && + ZERO_OVER_NO_EVENTS.includes(metric.aggregation); + const series = toSeries( metric.id, - points, + zeroFilled + ? fillEmptyBuckets( + points, + response.buckets.map((bucket) => bucket.key), + metric.bucket ?? "day", + rangeEnd(context), + ) + : points, metric.groupBy, metric.bucket, 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 // 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[], @@ -162,14 +302,42 @@ function combineDerived( } } } - const valueAt = (input: MetricSeries, key: string): number | undefined => - input.points.find((point) => point.key === key)?.value; + // 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 values = inputs.map((input) => valueAt(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), + ); + 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[]; @@ -183,7 +351,7 @@ function combineDerived( break; case "ratio": if (present[1] === 0) { - partial = true; + if (!empty) partial = true; continue; } value = present[0] / present[1]; @@ -197,10 +365,12 @@ function combineDerived( partial = true; continue; } - points.push({ key, value }); + const point = { key, value }; + if (empty) noEvents.add(point); + points.push(point); } - return toSeries( + const series = toSeries( metric.id, points, dimension.groupBy, @@ -208,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/src/index.ts b/module/services/frontend/code/packages/saas-sdk/src/index.ts index 7c2a9268c..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, @@ -72,6 +73,7 @@ export { WebhookService } from "../generated/typescript/src/gen/saas/accounts/v1 export type { Dashboard, DashboardLayout, + DashboardSection, DataGraph, DerivedMetric, EventDeclaration, @@ -83,6 +85,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 0a3928aa7..f9227eb69 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,15 @@ 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%". + * `runDashboard` does that scaling, once, on the widgets it resolves (see + * `ResolvedWidget.format`); nothing after it scales again. + */ +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 +127,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 +140,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; @@ -139,6 +152,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 +168,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/packages/saas-sdk/test/dashboard.test.ts b/module/services/frontend/code/packages/saas-sdk/test/dashboard.test.ts index f7b08723b..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" ? [ @@ -104,6 +109,159 @@ 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, + ]); + }); + + // 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("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, + 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( 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..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, @@ -704,3 +708,494 @@ 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"], + 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("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(() => [ + { 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); + }); + + // 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"); + }); +}); + +// 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", + ]); + }); +}); diff --git a/module/services/frontend/code/src/solutions/SolutionDashboard.tsx b/module/services/frontend/code/src/solutions/SolutionDashboard.tsx index 330d45656..42ce473b0 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,13 @@ 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 WidgetHeading, type WidgetReadFailure, } from "@codefly-dev/ui/dashboard"; import { @@ -25,8 +34,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 +63,7 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + Input, Popover, PopoverContent, PopoverTitle, @@ -54,15 +71,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 +114,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 +133,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 +193,33 @@ 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, + heading, + className, +}: { + source: WidgetSource; + widget: MetricWidget; + actions: ReactNode; + heading?: WidgetHeading; + 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 +233,37 @@ 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} + heading={heading} + 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 +383,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 +398,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 +413,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 +597,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 +660,14 @@ function AddTileMenu({ ) : ( addable.map((tile) => ( - onAdd(tile.id)}> - {tile.label} - + onAdd(tile.id)} + /> )) )} @@ -536,8 +675,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 +840,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 +984,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 +1051,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 +1286,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.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 e1376b1f3..62cf209ee 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,934 @@ 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: "avg", + field: "payload:seconds", + }, + { + 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. 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); + 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("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"]); + 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"], + }); + }); + }); + }); +}); 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..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 @@ -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("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("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: ["metric:per_day", "w_returns", "w_items", "metric:items"], + 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(raw, main)).toEqual(["w_items"]); + expect(parseLayout(reordered, main)).toEqual(flat("w_net", "w_items")); + }); + + 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", + "w_items", + ], + seen: ["w_items", "w_net"], + }); + 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,393 @@ 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("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)); + }); + }); + it("reads nothing and writes nothing when storage is missing or blocked", () => { expect(readSavedLayout(blocked, "k")).toBeNull(); expect(readSavedLayout(null, "k")).toBeNull(); @@ -246,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((x) => ({ ...x, section: undefined })), + }; + 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/__tests__/registry.test.ts b/module/services/frontend/code/src/solutions/__tests__/registry.test.ts index 6e2352761..738826766 100644 --- a/module/services/frontend/code/src/solutions/__tests__/registry.test.ts +++ b/module/services/frontend/code/src/solutions/__tests__/registry.test.ts @@ -176,6 +176,74 @@ 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("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. diff --git a/module/services/frontend/code/src/solutions/dashboard-layout.ts b/module/services/frontend/code/src/solutions/dashboard-layout.ts index f94bffd49..4e3053b31 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,116 @@ 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. 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, -): string[] { +export function parseLayout(raw: string | null, dashboard: Dashboard): Layout { const fallback = defaultLayout(dashboard); if (raw === null) return fallback; let saved: unknown; @@ -189,22 +593,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) layout.push({ id: fallback[0].id, tiles: [] }); + + 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; } 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) diff --git a/module/services/frontend/code/ui-inventory.json b/module/services/frontend/code/ui-inventory.json index 50ac6c1cf..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", @@ -374,10 +382,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", @@ -766,6 +778,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",