Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions apps/site/app/components/SchemaThumbnail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -135,7 +148,10 @@ export function SchemaThumbnail({
<SchemaRendererContext.Provider value={ctx}>
<SidebarProvider className="min-h-0 w-full" defaultOpen={false}>
<div className="w-full p-4">
<SchemaRenderer schema={toRenderableSchema(schema)} />
<SchemaRenderer
schema={toRenderableSchema(schema)}
dataSource={galleryDataSource}
/>
</div>
</SidebarProvider>
</SchemaRendererContext.Provider>
Expand Down
52 changes: 52 additions & 0 deletions apps/site/app/components/galleryDataSource.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

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: [] };
},
};
52 changes: 52 additions & 0 deletions apps/site/app/components/registerCatalogBlocks.ts
Original file line number Diff line number Diff line change
@@ -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';
101 changes: 65 additions & 36 deletions content/docs/guide/dashboard-filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 }
}
]
Expand Down Expand Up @@ -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

Expand All @@ -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: { "<name>": "<field>" }` 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: { "<name>": "<field>" }`, 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

Expand Down
15 changes: 12 additions & 3 deletions content/docs/plugins/plugin-dashboard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions examples/schema-catalog/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion examples/schema-catalog/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3947,7 +3947,7 @@ const REGISTRY: Record<string, Example> = {
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
]
}
}
]
}
Loading
Loading