From c89bb742924b52946c959de949b50e89b1f9e76d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 01:03:38 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(examples,docs):=20the=20dashboard=20gal?= =?UTF-8?q?lery=20renders=20working=20charts=20again=20=E2=80=94=20filtere?= =?UTF-8?q?d-*=20entries=20leave=20the=20retired=20shape=20(#4600)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements (d1) of #4600's ruling. The 6 `filtered-*` catalog entries taught and rendered the pre-ADR-0021 inline analytics shape, which the renderer retired in framework#3320: 15 of the category's 28 widgets drew the "retired data format" placeholder and 2 more drew DatasetWidget's unsupported-source error. - The 6 entries now bind live surfaces: inline `options.data` (static series / `options.value` metrics) for the five inline examples, and the dataset variant keeps its two `dataset`-bound widgets, which now render through a gallery stub. Every `globalFilters` / `filterBindings` / `targetWidgets` / `dateRange` declaration is unchanged — the filter pedagogy is the point of these entries — and the #4356 pair-form options stay pair-form. - `apps/site` gains `galleryDataSource` (canned `queryDataset` rows) and `registerCatalogBlocks` (the dashboard + chart registrations the gallery page never loaded). Measured: before this change every dashboard tile in the gallery was the registry's "Unknown component type: dashboard" panel. - The 3 teaching locations move off the retired shape and the false prose is corrected ("Working examples", "mix freely", the metadata-check claim). - New render pin: every entry is rendered through the real SchemaRenderer and may show none of the three diagnostics. Red before this change (retired=15 across 5 entries), green after. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- apps/site/app/components/SchemaThumbnail.tsx | 15 +- apps/site/app/components/galleryDataSource.ts | 52 ++++ .../app/components/registerCatalogBlocks.ts | 48 ++++ content/docs/guide/dashboard-filters.md | 101 +++++--- content/docs/plugins/plugin-dashboard.mdx | 15 +- examples/schema-catalog/package.json | 2 + examples/schema-catalog/src/index.ts | 2 +- .../filtered-dashboard-dataset-widgets.json | 13 +- .../filtered-dashboard-date-presets.json | 37 ++- .../filtered-dashboard-dynamic-options.json | 26 +- .../filtered-dashboard-filter-types.json | 34 ++- .../filtered-dashboard-target-widgets.json | 31 ++- .../plugin-dashboard/filtered-dashboard.json | 34 ++- .../plugin-dashboard-gallery-render.test.tsx | 227 ++++++++++++++++++ examples/schema-catalog/tsconfig.test.json | 10 +- packages/plugin-dashboard/README.md | 14 +- pnpm-lock.yaml | 6 + 17 files changed, 573 insertions(+), 94 deletions(-) create mode 100644 apps/site/app/components/galleryDataSource.ts create mode 100644 apps/site/app/components/registerCatalogBlocks.ts create mode 100644 examples/schema-catalog/test/plugin-dashboard-gallery-render.test.tsx diff --git a/apps/site/app/components/SchemaThumbnail.tsx b/apps/site/app/components/SchemaThumbnail.tsx index af8279b189..17ee0e1e84 100644 --- a/apps/site/app/components/SchemaThumbnail.tsx +++ b/apps/site/app/components/SchemaThumbnail.tsx @@ -19,10 +19,17 @@ import React, { import { SchemaRenderer, SchemaRendererContext } from '@object-ui/react'; import { SidebarProvider } from '@object-ui/components'; import type { SchemaNode } from '@object-ui/core'; -// Registers `page-header` & friends — see the module header (objectui#3787). -import './registerLayoutBlocks'; +// Registers `page-header` & friends (objectui#3787) plus the dashboard/chart +// blocks the gallery draws (objectui#4600) — see that module's header. +import './registerCatalogBlocks'; +import { galleryDataSource } from './galleryDataSource'; -const defaultCtx = { dataSource: {} }; +// The gallery's data source. It is handed to `SchemaRenderer` BOTH ways on +// purpose: the context is what nested blocks read, while `DashboardRenderer` +// takes `dataSource` as a React prop — a context-only value never reaches it, +// which is why its dataset-bound widgets rendered "This data source does not +// support dataset queries." while the context already held one (objectui#4600). +const defaultCtx = { dataSource: galleryDataSource }; /** * Tiny class-based error boundary so a single bad schema doesn't take down @@ -135,7 +142,7 @@ export function SchemaThumbnail({
- +
diff --git a/apps/site/app/components/galleryDataSource.ts b/apps/site/app/components/galleryDataSource.ts new file mode 100644 index 0000000000..3686463281 --- /dev/null +++ b/apps/site/app/components/galleryDataSource.ts @@ -0,0 +1,52 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + */ + +/** + * The docs gallery's stand-in data source (objectui#4600). + * + * The catalog is a **presentation corpus**: it ships JSON, not a backend, and + * the gallery renders it with no application behind it. Most entries need + * nothing — their data is inline. Dataset-bound dashboard widgets are the + * exception: `DatasetWidget` routes through `dataSource.queryDataset`, and with + * no such function it renders "This data source does not support dataset + * queries." — which is what `plugin-dashboard/filtered-dashboard-dataset- + * widgets` showed in the gallery until this file existed. + * + * So this is the smallest thing that lets a dataset-bound widget draw: canned + * rows shaped from the query's own `dimensions` / `measures`, so any widget + * gets a two-bucket series (or a single value when it selects no dimension) + * regardless of which dataset it names. It is a DEMO fixture — it does not + * filter, aggregate or honour `runtimeFilter`, and it must never be mistaken + * for a data-source implementation. Real ones live in `@object-ui/data-*`. + * + * It is gallery-only on purpose: `apps/site` is `private`, so nothing here is + * a published package surface. + */ + +/** The subset of a dataset query this fixture reads. */ +interface GalleryDatasetQuery { + dimensions?: string[]; + measures?: string[]; +} + +/** One canned row: dimension values plus measure values. */ +type GalleryRow = Record; + +export const galleryDataSource = { + async queryDataset(dataset: string, query: GalleryDatasetQuery) { + const dimensions = query?.dimensions ?? []; + const measures = query?.measures ?? []; + const measure = measures[0] ?? 'value'; + const rows: GalleryRow[] = dimensions.length + ? [ + { [dimensions[0]]: 'Alpha', [measure]: 42 }, + { [dimensions[0]]: 'Beta', [measure]: 27 }, + ] + : [{ [measure]: 69 }]; + // `object` is what makes a chart drillable; a demo fixture has nothing to + // drill INTO, so it is deliberately omitted along with `dimensionFields`. + return { rows, fields: [] }; + }, +}; diff --git a/apps/site/app/components/registerCatalogBlocks.ts b/apps/site/app/components/registerCatalogBlocks.ts new file mode 100644 index 0000000000..f40c2f2a07 --- /dev/null +++ b/apps/site/app/components/registerCatalogBlocks.ts @@ -0,0 +1,48 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + */ + +/** + * Registers the blocks the **catalog gallery** renders, on top of the layout + * blocks every example host needs (`./registerLayoutBlocks`). + * + * Why this exists (objectui#4600): `ComponentRegistry` only knows a type once + * the package owning it has been loaded, and `/docs/guide/schema-catalog` + * loads no plugin at all — `SchemaCatalogIndex` -> `SchemaThumbnail` imported + * `@object-ui/react`, `@object-ui/components` and the layout blocks, and + * nothing else. `PluginLoader` covers the individual docs pages that embed a + * demo, but the gallery embeds EVERY example and never wraps them in one. + * + * Measured on `origin/main` before this file: all 9 `plugin-dashboard` entries + * rendered the registry's red "Unknown component type: dashboard (OBJUI-001)" + * panel in the gallery — not the dashboard, and not the retired-widget + * placeholder that the entries produce once the plugin IS loaded. This is the + * objectui#3787 defect one layer up: there it was `page-header` resolving to + * nothing, here it is the whole `dashboard` block. + * + * `@object-ui/plugin-charts` comes with it because it owns `chart` — what a + * dashboard's widgets (static, provider-backed and dataset-bound alike) draw + * with, and the root type of the `plugin-charts/*` catalog entries. + * + * EAGER, at module scope, for the same reason `./registerLayoutBlocks` is + * eager: registration has then already happened for the server render, so the + * thumbnails stay in the prerendered HTML instead of appearing on hydration. + * Neither package declares `sideEffects: false`, so a bare side-effect import + * survives bundling (checked); the layout package is the one that needs an + * explicit call, which its own module does. + * + * Deliberately NOT imported by `InteractiveDemo` / `LiveSplitDemo`: those host + * demos on ordinary docs pages, which opt into their plugins through + * `PluginLoader`, and making them eager would pull the chart + dashboard graphs + * into every page carrying any demo. The gallery page's own modal preview is + * unaffected — it renders after `SchemaThumbnail` has already registered these + * on that page. + * + * Guarded by `examples/schema-catalog/test/plugin-dashboard-gallery-render. + * test.tsx`, which mirrors this registration set and fails if this file stops + * loading either package. + */ +import './registerLayoutBlocks'; +import '@object-ui/plugin-dashboard'; +import '@object-ui/plugin-charts'; diff --git a/content/docs/guide/dashboard-filters.md b/content/docs/guide/dashboard-filters.md index abec0fac60..783685f46d 100644 --- a/content/docs/guide/dashboard-filters.md +++ b/content/docs/guide/dashboard-filters.md @@ -16,16 +16,21 @@ that drives **several charts at once**. ObjectUI models this as a Charts stay inline and self-contained; one place owns the filter; each chart edit stays local. -> Working examples: the schema catalog ships a `plugin-dashboard/filtered-dashboard` -> example plus variants for dynamic options, text/number/lookup filter types, -> dataset widgets, the `targetWidgets` allow-list, and date presets with a -> custom range. +> **Working examples**: the schema catalog ships a +> `plugin-dashboard/filtered-dashboard` example plus variants for dynamic +> options, text/number/lookup filter types, dataset widgets, the +> `targetWidgets` allow-list, and date presets with a custom range. They are +> **presentation** examples — the filter declarations are what they teach, so +> their widgets carry inline demo data (the dataset variant additionally binds +> two widgets to a `dataset`), which is what lets the docs gallery draw them +> with no application behind it. Inline static data is never filtered; see +> Known limitations at the end of this page. ## Tutorial: from zero to a filtered dashboard ### Step 1 — a plain dashboard -Start from two charts over **different** objects. Without filters they always +Start from two charts over **different** datasets. Without filters they always show everything: ```json @@ -37,23 +42,44 @@ show everything: "id": "invoices_by_status", "title": "Invoices by Status", "type": "bar", - "object": "invoices", - "categoryField": "status", - "aggregate": "count" + "dataset": "invoices", + "dimensions": ["status"], + "values": ["count"] }, { "id": "accounts_signed", "title": "Accounts Signed", "type": "line", - "object": "accounts", - "categoryField": "signed_at", - "categoryGranularity": "month", - "aggregate": "count" + "dataset": "accounts", + "dimensions": ["signed_month"], + "values": ["count"] } ] } ``` +#### Where a widget's data comes from + +Filters scope a widget's **query**, so which data surface a widget uses decides +whether it can respond at all: + +| Surface | Shape | Filtered? | +| --- | --- | --- | +| Semantic-layer dataset (ADR-0021) | `"dataset": "invoices"` + `dimensions` + `values` | yes — merged into the dataset query as `runtimeFilter` | +| Inline object query | `"options": { "data": { "provider": "object", "object": "invoices", "aggregate": { "function": "count", "groupBy": "status" } } }` | yes — `AND`-merged into that query | +| Inline static data | `"options": { "data": [ … ], "xField": "status", "yField": "count" }` | no — there is no query to scope | + +> **Retired: the top-level inline analytics shape.** `object` + +> `categoryField` / `valueField` / `aggregate` on the widget itself (and the +> pivot `rowField` / `columnField` pair) was **removed** — the renderer no +> longer reads those keys, and a stored widget still carrying them renders a +> visible *"This widget uses a retired data format. Edit it to bind a dataset."* +> prompt instead of a chart. Rebind such a widget to a `dataset` (select its +> `dimensions` and `values` by name), or — for a renderer-internal query with +> no semantic layer behind it — move the query under +> `options.data` with `"provider": "object"`. `@objectstack/spec` refuses the +> retired shape at publish, so this is not a soft deprecation. + ### Step 2 — add the built-in date range Declare `dateRange` at the dashboard level. A preset/custom date-range control @@ -179,25 +205,24 @@ widget stores the concept under a different field — or should ignore a filter { "id": "invoices_by_status", "type": "bar", - "object": "invoices", - "categoryField": "status", - "aggregate": "count" + "dataset": "invoices", + "dimensions": ["status"], + "values": ["count"] }, { "id": "accounts_signed", "type": "line", - "object": "accounts", - "categoryField": "signed_at", - "categoryGranularity": "month", - "aggregate": "count", + "dataset": "accounts", + "dimensions": ["signed_month"], + "values": ["count"], "filterBindings": { "dateRange": "signed_at", "region": "sales_region" } }, { "id": "total_invoices", "title": "Total Invoices (all regions)", "type": "metric", - "object": "invoices", - "aggregate": "count", + "dataset": "invoices", + "values": ["count"], "filterBindings": { "region": false } } ] @@ -265,9 +290,13 @@ is `{ "preset": "last_30_days" }`, a custom range is Widgets bound to a semantic-layer `dataset` participate the same way: the dashboard merges the scoped filter into the widget's `filter`, which the -dataset widget forwards to the dataset query as `runtimeFilter`. Inline -(`object`-based) and dataset-bound widgets can mix freely on one filtered -dashboard. +dataset widget forwards to the dataset query as `runtimeFilter`. Dataset-bound +and inline widgets mix freely on one filtered dashboard — the +`plugin-dashboard/filtered-dashboard-dataset-widgets` catalog entry is exactly +that, two dataset-bound widgets beside an inline one. What differs is only what +each surface can answer: an inline **object query** +(`options.data` with `"provider": "object"`) is scoped like a dataset widget, +while an inline **static array** carries no query and is left untouched. ## Nested variable scopes @@ -281,18 +310,18 @@ stay in sync. ## Known limitations -- **Static-data widgets are not filtered** — a widget with an inline `data` - array has no query to scope, so dashboard filters do not apply to it. Bind - the widget to an `object` (or a `dataset`) if it should respond to filters. -- **Default bindings are metadata-checked for `object` widgets only** — when - a filter's default `field` does not exist on an inline widget's object, the - binding is skipped with a console warning instead of issuing a query that - matches nothing. Dataset-bound widgets can't be checked this way (the - dashboard doesn't know the dataset's base-object fields), so map their - filters explicitly with `filterBindings: { "": "" }` or opt - out with `false`. Explicit string bindings are always honoured as written — - a typo shows up as a visibly empty widget rather than a silently dropped - filter. +- **Static-data widgets are not filtered** — a widget whose `options.data` is + an inline array has no query to scope, so dashboard filters do not apply to + it. Bind the widget to a `dataset` (or give it an `options.data` object + query) if it should respond to filters. +- **A binding is applied as written** — the dashboard does not know a + dataset's fields, so it cannot check a binding target for you. A default + binding whose field the widget's data does not have produces an empty + widget rather than a silent no-op, which is the visible, fixable failure: + map the filter explicitly with `filterBindings: { "": "" }`, or + opt out with `false`. (`buildWidgetScopedFilter` can skip an unknown default + field with a console warning when a host passes it the widget's known field + names; the dashboard renderer does not.) ## i18n diff --git a/content/docs/plugins/plugin-dashboard.mdx b/content/docs/plugins/plugin-dashboard.mdx index 9e88d2680b..fad727847f 100644 --- a/content/docs/plugins/plugin-dashboard.mdx +++ b/content/docs/plugins/plugin-dashboard.mdx @@ -172,19 +172,28 @@ the widget's own `filter`). } ], "widgets": [ - { "id": "w1", "type": "bar", "object": "invoices", "aggregate": "count" }, + { "id": "w1", "type": "bar", "dataset": "invoices", "dimensions": ["status"], "values": ["count"] }, { - "id": "w2", "type": "line", "object": "accounts", "aggregate": "count", + "id": "w2", "type": "line", "dataset": "accounts", "dimensions": ["signed_month"], "values": ["count"], "filterBindings": { "dateRange": "signed_at", "region": "sales_region" } }, { - "id": "w3", "type": "metric", "object": "invoices", "aggregate": "count", + "id": "w3", "type": "metric", "dataset": "invoices", "values": ["count"], "filterBindings": { "region": false } } ] } ``` +The widgets above bind a **dataset** (ADR-0021). The pre-ADR-0021 top-level +`object` + `categoryField` / `valueField` / `aggregate` shape was removed: the +renderer no longer reads those keys and shows a *"This widget uses a retired +data format. Edit it to bind a dataset."* prompt instead of a chart. A widget +that needs a renderer-internal query rather than a semantic-layer one puts it +under `options.data` as `{ "provider": "object", "object": "invoices", +"aggregate": { "function": "count", "groupBy": "status" } }`; an +`options.data` **array** is fixed demo data and is not filtered. + Binding rules, in precedence order: 1. `filterBindings[name]` as a string — apply the filter to that field. diff --git a/examples/schema-catalog/package.json b/examples/schema-catalog/package.json index 5214156f21..8ec2d8c015 100644 --- a/examples/schema-catalog/package.json +++ b/examples/schema-catalog/package.json @@ -33,6 +33,8 @@ "@object-ui/core": "workspace:*", "@object-ui/fields": "workspace:*", "@object-ui/layout": "workspace:*", + "@object-ui/plugin-charts": "workspace:*", + "@object-ui/plugin-dashboard": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/sdui-parser": "workspace:*", "typescript": "^6.0.3" diff --git a/examples/schema-catalog/src/index.ts b/examples/schema-catalog/src/index.ts index a95f8cf9fc..58c96d7e7c 100644 --- a/examples/schema-catalog/src/index.ts +++ b/examples/schema-catalog/src/index.ts @@ -3947,7 +3947,7 @@ const REGISTRY: Record = { id: 'plugin-dashboard/filtered-dashboard-dataset-widgets', meta: { title: "Filtered Dashboard — Dataset + Inline Widgets", - description: "Dashboard filters scoping dataset-bound widgets (via the dataset query's runtimeFilter) alongside inline object widgets", + description: "Dashboard filters scoping dataset-bound widgets (via the dataset query's runtimeFilter) alongside an inline widget", category: 'plugin-dashboard', }, schema: plugin_dashboard_filtered_dashboard_dataset_widgets, diff --git a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-dataset-widgets.json b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-dataset-widgets.json index b4d718eb18..f413614fe2 100644 --- a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-dataset-widgets.json +++ b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-dataset-widgets.json @@ -41,9 +41,16 @@ "id": "invoices_by_status", "title": "Invoices by Status", "type": "bar", - "object": "invoices", - "categoryField": "status", - "aggregate": "count" + "options": { + "xField": "status", + "yField": "count", + "data": [ + { "status": "Draft", "count": 18 }, + { "status": "Sent", "count": 31 }, + { "status": "Paid", "count": 47 }, + { "status": "Void", "count": 4 } + ] + } } ] } diff --git a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-date-presets.json b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-date-presets.json index 16cbc9815e..00ef46538f 100644 --- a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-date-presets.json +++ b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-date-presets.json @@ -13,19 +13,35 @@ "id": "invoices_by_month", "title": "Invoices by Month", "type": "line", - "object": "invoices", - "categoryField": "created_at", - "categoryGranularity": "month", - "aggregate": "count" + "options": { + "xField": "month", + "yField": "count", + "data": [ + { "month": "Jan", "count": 24 }, + { "month": "Feb", "count": 31 }, + { "month": "Mar", "count": 28 }, + { "month": "Apr", "count": 37 }, + { "month": "May", "count": 42 }, + { "month": "Jun", "count": 39 } + ] + } }, { "id": "accounts_signed_by_month", "title": "Accounts Signed by Month", "type": "area", - "object": "accounts", - "categoryField": "signed_at", - "categoryGranularity": "month", - "aggregate": "count", + "options": { + "xField": "month", + "yField": "count", + "data": [ + { "month": "Jan", "count": 6 }, + { "month": "Feb", "count": 9 }, + { "month": "Mar", "count": 14 }, + { "month": "Apr", "count": 11 }, + { "month": "May", "count": 17 }, + { "month": "Jun", "count": 21 } + ] + }, "filterBindings": { "dateRange": "signed_at" } @@ -34,8 +50,9 @@ "id": "all_time_total", "title": "All-time Invoices", "type": "metric", - "object": "invoices", - "aggregate": "count", + "options": { + "value": "3,912" + }, "filterBindings": { "dateRange": false } diff --git a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-dynamic-options.json b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-dynamic-options.json index 0e4e3cb02d..b1cc282051 100644 --- a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-dynamic-options.json +++ b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-dynamic-options.json @@ -21,17 +21,31 @@ "id": "accounts_by_industry", "title": "Accounts by Industry", "type": "bar", - "object": "accounts", - "categoryField": "industry", - "aggregate": "count" + "options": { + "xField": "industry", + "yField": "count", + "data": [ + { "industry": "Software", "count": 34 }, + { "industry": "Manufacturing", "count": 22 }, + { "industry": "Retail", "count": 17 }, + { "industry": "Healthcare", "count": 12 } + ] + } }, { "id": "invoices_by_status", "title": "Invoices by Status", "type": "donut", - "object": "invoices", - "categoryField": "status", - "aggregate": "count", + "options": { + "xField": "status", + "yField": "count", + "data": [ + { "status": "Draft", "count": 18 }, + { "status": "Sent", "count": 31 }, + { "status": "Paid", "count": 47 }, + { "status": "Void", "count": 4 } + ] + }, "filterBindings": { "industry": "account_industry" } diff --git a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-filter-types.json b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-filter-types.json index ce22bc6cab..2264739df7 100644 --- a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-filter-types.json +++ b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-filter-types.json @@ -33,18 +33,33 @@ "id": "invoices_by_status", "title": "Invoices by Status", "type": "bar", - "object": "invoices", - "categoryField": "status", - "aggregate": "count" + "options": { + "xField": "status", + "yField": "count", + "data": [ + { "status": "Draft", "count": 18 }, + { "status": "Sent", "count": 31 }, + { "status": "Paid", "count": 47 }, + { "status": "Void", "count": 4 } + ] + } }, { "id": "invoices_by_month", "title": "Invoices by Month", "type": "line", - "object": "invoices", - "categoryField": "created_at", - "categoryGranularity": "month", - "aggregate": "count", + "options": { + "xField": "month", + "yField": "count", + "data": [ + { "month": "Jan", "count": 24 }, + { "month": "Feb", "count": 31 }, + { "month": "Mar", "count": 28 }, + { "month": "Apr", "count": 37 }, + { "month": "May", "count": 42 }, + { "month": "Jun", "count": 39 } + ] + }, "filterBindings": { "owner": "assigned_to" } @@ -53,8 +68,9 @@ "id": "total_invoices", "title": "Total Invoices (unfiltered)", "type": "metric", - "object": "invoices", - "aggregate": "count", + "options": { + "value": "1,284" + }, "filterBindings": { "customer": false, "amount": false, diff --git a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-target-widgets.json b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-target-widgets.json index e50f39f498..150fad2416 100644 --- a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-target-widgets.json +++ b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-target-widgets.json @@ -23,18 +23,30 @@ "id": "invoices_by_region", "title": "Invoices by Region (allow-listed)", "type": "bar", - "object": "invoices", - "categoryField": "region", - "aggregate": "count" + "options": { + "xField": "region", + "yField": "count", + "data": [ + { "region": "EMEA", "count": 41 }, + { "region": "APAC", "count": 27 }, + { "region": "AMER", "count": 32 } + ] + } }, { "id": "invoices_recent", "title": "Recent Invoices (allow-listed, field override)", "type": "line", - "object": "invoices", - "categoryField": "created_at", - "categoryGranularity": "week", - "aggregate": "count", + "options": { + "xField": "week", + "yField": "count", + "data": [ + { "week": "W1", "count": 7 }, + { "week": "W2", "count": 11 }, + { "week": "W3", "count": 9 }, + { "week": "W4", "count": 14 } + ] + }, "filterBindings": { "status": "state" } @@ -43,8 +55,9 @@ "id": "total_invoices", "title": "Total Invoices (not allow-listed)", "type": "metric", - "object": "invoices", - "aggregate": "count" + "options": { + "value": "1,284" + } } ] } diff --git a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard.json b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard.json index 1ac51e6116..2b8a82dab4 100644 --- a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard.json +++ b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard.json @@ -26,18 +26,33 @@ "id": "invoices_by_status", "title": "Invoices by Status", "type": "bar", - "object": "invoices", - "categoryField": "status", - "aggregate": "count" + "options": { + "xField": "status", + "yField": "count", + "data": [ + { "status": "Draft", "count": 18 }, + { "status": "Sent", "count": 31 }, + { "status": "Paid", "count": 47 }, + { "status": "Void", "count": 4 } + ] + } }, { "id": "accounts_signed", "title": "Accounts Signed", "type": "line", - "object": "accounts", - "categoryField": "signed_at", - "categoryGranularity": "month", - "aggregate": "count", + "options": { + "xField": "month", + "yField": "count", + "data": [ + { "month": "Jan", "count": 6 }, + { "month": "Feb", "count": 9 }, + { "month": "Mar", "count": 14 }, + { "month": "Apr", "count": 11 }, + { "month": "May", "count": 17 }, + { "month": "Jun", "count": 21 } + ] + }, "filterBindings": { "dateRange": "signed_at", "region": "sales_region" @@ -47,8 +62,9 @@ "id": "total_invoices", "title": "Total Invoices (all regions)", "type": "metric", - "object": "invoices", - "aggregate": "count", + "options": { + "value": "1,284" + }, "filterBindings": { "region": false } diff --git a/examples/schema-catalog/test/plugin-dashboard-gallery-render.test.tsx b/examples/schema-catalog/test/plugin-dashboard-gallery-render.test.tsx new file mode 100644 index 0000000000..4e2b5e4347 --- /dev/null +++ b/examples/schema-catalog/test/plugin-dashboard-gallery-render.test.tsx @@ -0,0 +1,227 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#4600 — every `plugin-dashboard` catalog entry is RENDERED, the way + * the docs gallery renders it, and no tile may show a broken-widget diagnostic. + * + * ## The blind spot this closes + * + * `apps/site/app/components/SchemaCatalogIndex.tsx` renders EVERY catalog entry + * through a real `SchemaRenderer` (via `SchemaThumbnail`), so `/docs/guide/ + * schema-catalog` is a published page whose content is this corpus. Until this + * file, nothing in CI rendered these entries: `smoke.test.tsx` contains no + * `render(` call at all — its assertions are structural (every entry is an + * object with a non-empty `type`) — and `plugin-dashboard-global-filters-spec. + * test.ts` (objectui#4356) validates `globalFilters` against the spec without + * rendering anything. + * + * Both are satisfied by an entry that renders a red error box. Measured on + * `origin/main` before this file existed: 15 of the category's 28 widgets + * rendered the retired-format placeholder and 2 more rendered the dataset + * error, i.e. 5 of the 6 `filtered-*` entries were 100% broken tiles — + * including the one `content/docs/guide/dashboard-filters.md` called a + * "Working example". + * + * ## What is asserted + * + * Per entry, after the dashboard has settled: no widget shows the retired + * inline-analytics placeholder, none shows `DatasetWidget`'s + * "does not support dataset queries" error, none shows the registry's + * "Unknown component type" panel, no `role="alert"` survives — and every + * authored widget title is on screen, so an entry cannot pass by rendering + * nothing at all. + * + * The two diagnostic strings are COPIED as literals, deliberately not imported + * from `@object-ui/plugin-dashboard`'s internals: they are user-visible + * contract for this pin, the placeholder constant is private to + * `packages/plugin-dashboard/src/DashboardRenderer.tsx`, and that file is being + * refactored under objectui#4612. A literal here cannot break that seat, and a + * reworded placeholder should turn this pin red for review rather than silently + * following along. + * + * ## Why the module-scope package imports + * + * `ComponentRegistry` only knows a type once the package owning it has loaded, + * so this file registers exactly what the docs site's example hosts register + * (`@object-ui/components`, `@object-ui/layout`, and — since objectui#4600 — + * `@object-ui/plugin-dashboard` + `@object-ui/plugin-charts`). + * + * The chart IMPLEMENTATION behind `React.lazy` is deliberately NOT pre-imported + * (the AGENTS.md §测试纪律 warm-up other chart tests need): nothing here asserts + * a drawn plot, so a widget still showing the chart's own skeleton satisfies + * every assertion below, and no assertion races the lazy import. What is + * asserted is that the widget is a WIDGET — its card and title — rather than + * one of the three diagnostics. + */ +import { describe, it, expect } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import '@object-ui/components'; +import '@object-ui/plugin-dashboard'; +import '@object-ui/plugin-charts'; +import { registerLayout } from '@object-ui/layout'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRenderer, SchemaRendererContext } from '@object-ui/react'; +import fs from 'node:fs'; +import path from 'node:path'; +import { examplesByCategory } from '../src/index.js'; + +registerLayout(); + +/** `DashboardRenderer`'s retired inline-analytics placeholder (framework#3320). */ +const RETIRED_PLACEHOLDER = + 'This widget uses a retired data format. Edit it to bind a dataset.'; +/** `DatasetWidget`'s error when the host data source has no `queryDataset`. */ +const DATASET_UNSUPPORTED = 'This data source does not support dataset queries.'; +/** The registry's panel for a type no loaded package registers (OBJUI-001). */ +const UNKNOWN_COMPONENT = 'Unknown component type'; + +/** + * The docs gallery's data source, in the shape `SchemaThumbnail` supplies it: + * canned rows for `queryDataset` so a dataset-bound widget draws a real chart + * instead of the unsupported-source error. Kept as a local literal rather than + * imported from `apps/site` because `apps/**` is outside every root Vitest + * project (`vitest.config.mts` `sharedExclude`), so an apps-side module is not + * resolvable from here; the last case in this file guards the two from + * drifting apart. + */ +const galleryDataSource = { + queryDataset: async ( + dataset: string, + query: { dimensions?: string[]; measures?: string[] }, + ) => { + const dimensions = query?.dimensions ?? []; + const measures = query?.measures ?? []; + const measure = measures[0] ?? 'value'; + const rows = dimensions.length + ? [ + { [dimensions[0]]: 'Alpha', [measure]: 42 }, + { [dimensions[0]]: 'Beta', [measure]: 27 }, + ] + : [{ [measure]: 69 }]; + return { rows, fields: [] }; + }, +}; + +const entries = examplesByCategory('plugin-dashboard'); + +interface DashboardEntry { + widgets?: Array<{ title?: unknown }>; +} + +/** Render one entry exactly as `SchemaThumbnail` does, then let it settle. */ +async function renderEntry(schema: unknown) { + const { container } = render( + + + , + ); + // A dataset widget starts in `loading` and resolves on a promise. Asserting + // before it settles would pass while the error is still one tick away — the + // vacuous green this pin exists to prevent. + await waitFor(() => + expect(container.querySelector('[data-testid="dataset-loading"]')).toBeNull(), + ); + return container; +} + +const occurrences = (haystack: string, needle: string) => + haystack.split(needle).length - 1; + +describe('plugin-dashboard catalog entries render in the docs gallery (objectui#4600)', () => { + /** + * NON-VACUITY CONTROL. `it.each([])` reports nothing rather than failing, and + * an unregistered `dashboard` type would render one "Unknown component type" + * panel per entry — which contains neither diagnostic string below, so the + * whole sweep would go green over a gallery that draws no dashboard at all. + */ + it('the category is populated and the dashboard block is registered', () => { + expect(entries.length).toBeGreaterThanOrEqual(9); + expect(ComponentRegistry.get('dashboard')).toBeTruthy(); + expect(ComponentRegistry.get('chart')).toBeTruthy(); + }); + + it.each(entries.map((e) => [e.id, e.schema] as const))( + '%s renders every widget without a broken-tile diagnostic', + async (_id, schema) => { + const container = await renderEntry(schema); + const text = container.textContent ?? ''; + + expect(text).not.toContain(RETIRED_PLACEHOLDER); + expect(text).not.toContain(DATASET_UNSUPPORTED); + expect(text).not.toContain(UNKNOWN_COMPONENT); + expect(container.querySelectorAll('[role="alert"]')).toHaveLength(0); + + // Positive control: the tile actually drew its widgets. + const widgets = (schema as DashboardEntry).widgets ?? []; + expect(widgets.length).toBeGreaterThan(0); + for (const widget of widgets) { + if (typeof widget.title === 'string' && widget.title.length > 0) { + expect(text).toContain(widget.title); + } + } + }, + ); + + /** + * The same facts as WIDGET counts, so a regression reports "15 retired + * placeholders across 5 entries" rather than one failing entry at a time. + */ + it('the category produces zero retired-format and zero dataset-error widgets', async () => { + let retired = 0; + let datasetErrors = 0; + const broken: string[] = []; + for (const entry of entries) { + const container = await renderEntry(entry.schema); + const text = container.textContent ?? ''; + const r = occurrences(text, RETIRED_PLACEHOLDER); + const d = occurrences(text, DATASET_UNSUPPORTED); + retired += r; + datasetErrors += d; + if (r || d) broken.push(`${entry.id}: retired=${r} datasetError=${d}`); + } + expect({ retired, datasetErrors, broken }).toEqual({ + retired: 0, + datasetErrors: 0, + broken: [], + }); + }); + + /** + * GALLERY PARITY (source-text guard, deliberately not behavioural). + * + * The cases above mirror the gallery: the same registry and the same + * dataset-capable `dataSource`. They cannot mirror it by construction — + * `apps/**` is excluded from every root Vitest project — so removing the + * gallery's own wiring would leave them green while `/docs/guide/schema- + * catalog` went back to "Unknown component type" (before objectui#4600 that + * was its real state: `SchemaThumbnail` loaded no dashboard package) or to + * the dataset error. This case reads the two host files and pins the three + * things this pin assumes about them. + */ + it('the docs-site gallery host still registers the dashboard packages and passes the dataset stub', () => { + // `process.cwd()` is the repo root by construction: `scripts/vitest- + // invocation-guard.mjs` refuses any run whose Vitest root is not it. + const siteDir = path.join(process.cwd(), 'apps/site/app/components'); + expect(fs.existsSync(siteDir)).toBe(true); + const registrations = fs.readFileSync(path.join(siteDir, 'registerCatalogBlocks.ts'), 'utf8'); + expect(registrations).toContain('@object-ui/plugin-dashboard'); + expect(registrations).toContain('@object-ui/plugin-charts'); + + const dataSourceModule = fs.readFileSync(path.join(siteDir, 'galleryDataSource.ts'), 'utf8'); + expect(dataSourceModule).toContain('queryDataset'); + + // The stub must reach the renderer as a PROP: `SchemaRendererContext` + // alone does not reach `DashboardRenderer`, which takes `dataSource` as a + // React prop (measured on objectui#4600 — a context-only stub still + // rendered the dataset error). + const thumbnail = fs.readFileSync(path.join(siteDir, 'SchemaThumbnail.tsx'), 'utf8'); + expect(thumbnail).toContain('galleryDataSource'); + expect(thumbnail).toMatch(/]*dataSource=\{/); + }); +}); diff --git a/examples/schema-catalog/tsconfig.test.json b/examples/schema-catalog/tsconfig.test.json index c612301eaf..7fadb07d3b 100644 --- a/examples/schema-catalog/tsconfig.test.json +++ b/examples/schema-catalog/tsconfig.test.json @@ -26,7 +26,15 @@ // The tests are `.tsx` (they render catalog examples through the real // `SchemaRenderer`); the build config has no `jsx` because `src/` is JSON // plus plain `.ts`. - "jsx": "react-jsx" + "jsx": "react-jsx", + // `node`, because `plugin-dashboard-gallery-render.test.tsx` reads the + // docs-site gallery host's source with `node:fs` to pin that the gallery + // still wires what that test mirrors (objectui#4600). Same declaration as + // every other package's test project (e.g. `packages/core/tsconfig.test. + // json`). Narrowing to a list is safe here: no catalog test uses a + // jest-dom matcher, and `react`/`react-dom` types arrive through the + // packages that re-export them. + "types": ["node"] }, // `test/`, not `src/`: that is where this package keeps its tests, and it is // the layout the gate could not see. diff --git a/packages/plugin-dashboard/README.md b/packages/plugin-dashboard/README.md index 5c052d17c4..7e2daededb 100644 --- a/packages/plugin-dashboard/README.md +++ b/packages/plugin-dashboard/README.md @@ -231,16 +231,24 @@ into each bound widget's inline query (`AND`-combined with the widget's own } ], "widgets": [ + // Widgets bind a semantic-layer dataset (ADR-0021) and select its + // dimensions/measures by name. The pre-ADR-0021 top-level `object` + + // `categoryField`/`valueField`/`aggregate` shape was REMOVED — a widget + // still carrying it renders "This widget uses a retired data format. + // Edit it to bind a dataset." instead of a chart. A renderer-internal + // query lives under `options.data` as `{ provider: 'object', object, + // aggregate }`; an `options.data` array is fixed demo data. + // // Default binding: the filter's own `field` (dateRange → created_at). - { "id": "w1", "type": "bar", "object": "invoices", "aggregate": "count" }, + { "id": "w1", "type": "bar", "dataset": "invoices", "dimensions": ["status"], "values": ["count"] }, // Explicit binding: map each filter to THIS widget's own field. { - "id": "w2", "type": "line", "object": "accounts", "aggregate": "count", + "id": "w2", "type": "line", "dataset": "accounts", "dimensions": ["signed_month"], "values": ["count"], "filterBindings": { "dateRange": "signed_at", "region": "sales_region" } }, // Opt out of a filter with `false`. { - "id": "w3", "type": "metric", "object": "invoices", "aggregate": "count", + "id": "w3", "type": "metric", "dataset": "invoices", "values": ["count"], "filterBindings": { "region": false } } ] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 839354c74f..9ad42a11b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -657,6 +657,12 @@ importers: '@object-ui/layout': specifier: workspace:* version: link:../../packages/layout + '@object-ui/plugin-charts': + specifier: workspace:* + version: link:../../packages/plugin-charts + '@object-ui/plugin-dashboard': + specifier: workspace:* + version: link:../../packages/plugin-dashboard '@object-ui/react': specifier: workspace:* version: link:../../packages/react From b5693c6340171c7e65c0e1edbe3811803cec53fb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 01:17:24 +0000 Subject: [PATCH 2/2] fix(site): SchemaThumbnail names the layout registrar directly again (#4600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #3904 guard (scripts/__tests__/site-playground-layout-registration-3904. test.ts) discovers every apps/site SchemaRenderer host and reads its imports for `registerLayoutBlocks` by name. Routing that import through the new `registerCatalogBlocks` module satisfied it in the module graph but not in the guard's source-text view, so the host read as one that registers nothing — CI red, correctly. Each host now names both registrars, one per responsibility: layout blocks (#3787) and the gallery's dashboard/chart blocks (#4600). registerCatalogBlocks no longer re-exports the layout one, so the guard's discovery property stays literally true for any host added later. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- apps/site/app/components/SchemaThumbnail.tsx | 10 ++++++++-- apps/site/app/components/registerCatalogBlocks.ts | 10 +++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/site/app/components/SchemaThumbnail.tsx b/apps/site/app/components/SchemaThumbnail.tsx index 17ee0e1e84..3a84e2d3bd 100644 --- a/apps/site/app/components/SchemaThumbnail.tsx +++ b/apps/site/app/components/SchemaThumbnail.tsx @@ -19,8 +19,14 @@ import React, { import { SchemaRenderer, SchemaRendererContext } from '@object-ui/react'; import { SidebarProvider } from '@object-ui/components'; import type { SchemaNode } from '@object-ui/core'; -// Registers `page-header` & friends (objectui#3787) plus the dashboard/chart -// blocks the gallery draws (objectui#4600) — see that module's header. +// Registers `page-header` & friends — see the module header (objectui#3787). +// Named directly, not reached through the module below: `scripts/__tests__/ +// site-playground-layout-registration-3904.test.ts` discovers every +// `SchemaRenderer` host and requires it to import THIS module, and a host that +// pulled it in transitively would read to that guard as a host that registers +// nothing (measured — it went red on exactly that). +import './registerLayoutBlocks'; +// Registers the dashboard + chart blocks the gallery draws (objectui#4600). import './registerCatalogBlocks'; import { galleryDataSource } from './galleryDataSource'; diff --git a/apps/site/app/components/registerCatalogBlocks.ts b/apps/site/app/components/registerCatalogBlocks.ts index f40c2f2a07..7cd388428c 100644 --- a/apps/site/app/components/registerCatalogBlocks.ts +++ b/apps/site/app/components/registerCatalogBlocks.ts @@ -4,8 +4,13 @@ */ /** - * Registers the blocks the **catalog gallery** renders, on top of the layout - * blocks every example host needs (`./registerLayoutBlocks`). + * Registers the plugin blocks the **catalog gallery** renders. A companion to + * `./registerLayoutBlocks`, which every example host imports directly and which + * this module deliberately does NOT re-export: `scripts/__tests__/site- + * playground-layout-registration-3904.test.ts` discovers each `SchemaRenderer` + * host and reads its imports, so a host reaching the layout registrar through + * here would read as a host that registers nothing. Two imports, two + * responsibilities, one visible in each host. * * Why this exists (objectui#4600): `ComponentRegistry` only knows a type once * the package owning it has been loaded, and `/docs/guide/schema-catalog` @@ -43,6 +48,5 @@ * test.tsx`, which mirrors this registration set and fails if this file stops * loading either package. */ -import './registerLayoutBlocks'; import '@object-ui/plugin-dashboard'; import '@object-ui/plugin-charts';