diff --git a/apps/site/app/components/SchemaThumbnail.tsx b/apps/site/app/components/SchemaThumbnail.tsx
index 27483bb42..c297128f9 100644
--- a/apps/site/app/components/SchemaThumbnail.tsx
+++ b/apps/site/app/components/SchemaThumbnail.tsx
@@ -20,9 +20,22 @@ import { SchemaRenderer, SchemaRendererContext, toRenderableSchema } from '@obje
import { SidebarProvider } from '@object-ui/components';
import type { SchemaNode } from '@object-ui/core';
// 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';
-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 +148,10 @@ export function SchemaThumbnail({
-
+
diff --git a/apps/site/app/components/galleryDataSource.ts b/apps/site/app/components/galleryDataSource.ts
new file mode 100644
index 000000000..368646328
--- /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 000000000..7cd388428
--- /dev/null
+++ b/apps/site/app/components/registerCatalogBlocks.ts
@@ -0,0 +1,52 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ */
+
+/**
+ * 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`
+ * 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 '@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 abec0fac6..783685f46 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 9e88d2680..fad727847 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 5214156f2..8ec2d8c01 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 a95f8cf9f..58c96d7e7 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 b4d718eb1..f413614fe 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 16cbc9815..00ef46538 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 0e4e3cb02..b1cc28205 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 ce22bc6ca..2264739df 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 e50f39f49..150fad241 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 1ac51e611..2b8a82dab 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 000000000..4e2b5e434
--- /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 c612301ea..7fadb07d3 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 5c052d17c..7e2daeded 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 839354c74..9ad42a11b 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