Skip to content

Commit e628d1f

Browse files
os-zhuangclaude
andauthored
feat(dashboard-filters): #2578 follow-ups — catalog examples, guide tutorial, i18n entries, spec-alignment cleanup (#2581)
Follow-ups to the dashboard-level filters feature (#2576, framework#2501): - schema-catalog: five new plugin-dashboard examples — optionsFrom dynamic options, text/number/lookup filter types, dataset + inline widget mix, legacy targetWidgets allow-list, and date presets + custom range with a dateRange opt-out. - docs: new 'Dashboard-Level Filters' guide page (full tutorial from zero: dateRange + globalFilters + filterBindings, page.* expression usage, filter-type condition table, known limitations with workarounds); cross- linked from plugin-dashboard.mdx. - i18n: dashboard.filters.* entries for en and zh (bar label, All time, Custom…, All, Reset, and the 13 date-range preset labels) — the filter bar previously always rendered the useSafeTranslate English fallbacks. - types: GlobalFilterSchema.name + DashboardWidgetSchema.filterBindings are now landed in @objectstack/spec, so the 'Pending alignment' JSDoc flips to 'Aligned' (no shape changes). Claude-Session: https://claude.ai/code/session_01HG5LQnPbQbjAAyofghzz3Z Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2efa9fd commit e628d1f

14 files changed

Lines changed: 618 additions & 3 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@object-ui/i18n": patch
3+
"@object-ui/types": patch
4+
---
5+
6+
Dashboard-level filters follow-ups (#2578, framework#2501):
7+
8+
- **i18n**: the `DashboardFilterBar` strings now ship as real locale entries —
9+
`dashboard.filters.*` (bar label, "All time", "Custom…", "All", "Reset",
10+
and the 13 date-range preset labels) added to `en` and `zh`. Previously the
11+
bar always rendered the `useSafeTranslate` English fallbacks.
12+
- **types**: `GlobalFilterSchema.name` and `DashboardWidgetSchema.filterBindings`
13+
landed in `@objectstack/spec` (framework#2501), so the local type
14+
annotations flip from "Pending alignment" to "Aligned" — no shape changes.
15+
16+
Also adds five schema-catalog examples (`plugin-dashboard/filtered-dashboard-*`:
17+
dynamic `optionsFrom` options, text/number/lookup filter types, dataset +
18+
inline widget mix, `targetWidgets` allow-list, date presets + custom range)
19+
and a new "Dashboard-Level Filters" guide page covering the full tutorial,
20+
`page.*` expression usage, and known limitations with workarounds.
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
---
2+
title: "Dashboard-Level Filters"
3+
---
4+
5+
A dashboard often needs one top-level filter — a date range, a region select —
6+
that drives **several charts at once**. ObjectUI models this as a
7+
**dashboard-level parameter**, not a shared dataset:
8+
9+
- The **filter control and its value live on the dashboard** — hosted as
10+
dashboard-level variables (the page/dashboard variables primitive).
11+
- Each widget declares which of **its own** fields a filter binds to via
12+
`filterBindings` — a small mapping, not a copied query.
13+
- At render time the dashboard **broadcasts** the active values into every
14+
bound widget's inline query, `AND`-combined with the widget's own `filter`.
15+
16+
Charts stay inline and self-contained; one place owns the filter; each chart
17+
edit stays local.
18+
19+
> Working examples: the schema catalog ships a `plugin-dashboard/filtered-dashboard`
20+
> example plus variants for dynamic options, text/number/lookup filter types,
21+
> dataset widgets, the `targetWidgets` allow-list, and date presets with a
22+
> custom range.
23+
24+
## Tutorial: from zero to a filtered dashboard
25+
26+
### Step 1 — a plain dashboard
27+
28+
Start from two charts over **different** objects. Without filters they always
29+
show everything:
30+
31+
```json
32+
{
33+
"type": "dashboard",
34+
"columns": 2,
35+
"widgets": [
36+
{
37+
"id": "invoices_by_status",
38+
"title": "Invoices by Status",
39+
"type": "bar",
40+
"object": "invoices",
41+
"categoryField": "status",
42+
"aggregate": "count"
43+
},
44+
{
45+
"id": "accounts_signed",
46+
"title": "Accounts Signed",
47+
"type": "line",
48+
"object": "accounts",
49+
"categoryField": "signed_at",
50+
"categoryGranularity": "month",
51+
"aggregate": "count"
52+
}
53+
]
54+
}
55+
```
56+
57+
### Step 2 — add the built-in date range
58+
59+
Declare `dateRange` at the dashboard level. A preset/custom date-range control
60+
appears in the filter bar above the widgets:
61+
62+
```json
63+
{
64+
"dateRange": {
65+
"field": "created_at",
66+
"defaultRange": "last_30_days",
67+
"allowCustomRange": true
68+
}
69+
}
70+
```
71+
72+
- `field` — the **default** field the range applies to on every bound widget
73+
(falls back to `created_at` when omitted).
74+
- `defaultRange` — the initially selected preset: `today`, `yesterday`,
75+
`this_week`, `last_week`, `this_month`, `last_month`, `this_quarter`,
76+
`last_quarter`, `this_year`, `last_year`, `last_7_days`, `last_30_days`,
77+
`last_90_days`, or `custom` (starts empty and lets the user pick).
78+
- `allowCustomRange` — offer a "Custom…" item that opens a from/to calendar
79+
(default `true`).
80+
81+
Presets stay **symbolic** until query time: they compile to date-macro tokens
82+
(`{30_days_ago}`, `{current_month_start}`, …) that each widget resolves
83+
exactly like hand-authored widget filters — so a dashboard saved today still
84+
means "last 30 days" tomorrow.
85+
86+
### Step 3 — add a global filter
87+
88+
Add a `globalFilters` entry. Each entry renders one control in the filter bar:
89+
90+
```json
91+
{
92+
"globalFilters": [
93+
{
94+
"name": "region",
95+
"field": "region",
96+
"label": "Region",
97+
"type": "select",
98+
"options": ["EMEA", "APAC", "AMER"]
99+
}
100+
]
101+
}
102+
```
103+
104+
- `name` — the **stable filter name**: the variable key the value is published
105+
under, and the key widgets reference in `filterBindings`. Defaults to
106+
`field`. (`"dateRange"` is reserved for the built-in date range.)
107+
- `field` — the default field the filter applies to on bound widgets.
108+
- `type` — the control type: `text`, `number`, `select`, `lookup`, or `date`.
109+
110+
| Type | Control | Generated condition |
111+
| --- | --- | --- |
112+
| `text` | input | `{ field: { "$contains": value } }` |
113+
| `number` | numeric input | `{ field: value }` (equality) |
114+
| `select` / `lookup` | dropdown | `{ field: value }` (or `$in` for arrays) |
115+
| `date` | preset/custom range | `{ field: { "$gte": from, "$lte": to } }` |
116+
117+
Options can be static (`options`) or fetched from an object at runtime:
118+
119+
```json
120+
{
121+
"name": "industry",
122+
"field": "industry",
123+
"label": "Industry",
124+
"type": "select",
125+
"optionsFrom": {
126+
"object": "accounts",
127+
"valueField": "industry",
128+
"labelField": "industry"
129+
}
130+
}
131+
```
132+
133+
`optionsFrom` fetches records through the dashboard's data source and
134+
de-duplicates values client-side (top 200 records; server-side distinct is a
135+
planned enhancement).
136+
137+
### Step 4 — bind each widget's own fields
138+
139+
By default every filter applies to its own `field` on every widget. When a
140+
widget stores the concept under a different field — or should ignore a filter
141+
— declare `filterBindings` on the widget:
142+
143+
```json
144+
{
145+
"widgets": [
146+
{
147+
"id": "invoices_by_status",
148+
"type": "bar",
149+
"object": "invoices",
150+
"categoryField": "status",
151+
"aggregate": "count"
152+
},
153+
{
154+
"id": "accounts_signed",
155+
"type": "line",
156+
"object": "accounts",
157+
"categoryField": "signed_at",
158+
"categoryGranularity": "month",
159+
"aggregate": "count",
160+
"filterBindings": { "dateRange": "signed_at", "region": "sales_region" }
161+
},
162+
{
163+
"id": "total_invoices",
164+
"title": "Total Invoices (all regions)",
165+
"type": "metric",
166+
"object": "invoices",
167+
"aggregate": "count",
168+
"filterBindings": { "region": false }
169+
}
170+
]
171+
}
172+
```
173+
174+
Binding rules, in precedence order:
175+
176+
1. `filterBindings[name]` as a **string** — apply the filter to that field.
177+
2. `filterBindings[name]: false` — opt this widget out of that filter.
178+
3. Legacy `targetWidgets` on the filter — when set, only listed widget ids get
179+
the default binding (an explicit `filterBindings` entry still wins).
180+
4. Otherwise the filter applies to its own `field` (the built-in date range
181+
defaults to `dateRange.field ?? 'created_at'`).
182+
183+
That's the whole feature: changing any filter live re-scopes every bound
184+
widget, each against **its own** field.
185+
186+
## Reading filter values in expressions
187+
188+
Filter values are hosted as dashboard variables, so any widget expression can
189+
read them under the `page.` scope, keyed by the filter's `name`:
190+
191+
```json
192+
{
193+
"type": "text",
194+
"value": "Region: ${page.region || 'All'}"
195+
}
196+
```
197+
198+
```json
199+
{
200+
"id": "emea_playbook",
201+
"component": {
202+
"type": "card",
203+
"title": "EMEA Playbook",
204+
"hidden": "${page.region !== 'EMEA'}"
205+
}
206+
}
207+
```
208+
209+
The built-in date range is an object under `page.dateRange` — a preset selection
210+
is `{ "preset": "last_30_days" }`, a custom range is
211+
`{ "from": "2026-01-01", "to": "2026-03-31" }` (either bound may be absent).
212+
213+
## Dataset widgets
214+
215+
Widgets bound to a semantic-layer `dataset` participate the same way: the
216+
dashboard merges the scoped filter into the widget's `filter`, which the
217+
dataset widget forwards to the dataset query as `runtimeFilter`. Inline
218+
(`object`-based) and dataset-bound widgets can mix freely on one filtered
219+
dashboard.
220+
221+
## Known limitations
222+
223+
- **Embedding a Page with its own `variables`** — the dashboard hosts its
224+
filter values in its own variables provider. When a dashboard and a
225+
surrounding Page both declare variables, expressions inside the dashboard
226+
resolve `page.*` against the **innermost** provider only: the outer Page's
227+
variables are shadowed inside the dashboard subtree. Workaround: don't rely
228+
on outer-page variables inside a filtered dashboard's widgets (or duplicate
229+
the value into a dashboard filter). Merging nested variable contexts is a
230+
candidate future enhancement.
231+
- **Static-data widgets are not filtered** — a widget with an inline `data`
232+
array has no query to scope, so dashboard filters do not apply to it. Bind
233+
the widget to an `object` (or a `dataset`) if it should respond to filters.
234+
- **Default bindings assume the field exists** — when a filter's default
235+
`field` does not exist on a widget's object, the widget's query returns
236+
empty (or errors, depending on the backend). Map the filter to the right
237+
field with `filterBindings: { "<name>": "<field>" }`, or opt the widget out
238+
with `filterBindings: { "<name>": false }`. Metadata-aware skipping is a
239+
planned enhancement.
240+
241+
## i18n
242+
243+
The filter bar's strings resolve from the `dashboard.filters.*` keys
244+
(`@object-ui/i18n` ships `en` and `zh` entries — control labels come from each
245+
filter's `label`, so translate those in your schema metadata).
246+
247+
## Spec alignment
248+
249+
`DashboardSchema.dateRange`, `GlobalFilterSchema` (including `name`) and
250+
`DashboardWidgetSchema.filterBindings` are part of `@objectstack/spec`
251+
(framework#2501). Author dashboards against the spec shapes; ObjectUI renders
252+
them.

content/docs/guide/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"designing-app-navigation",
2222
"public-forms",
2323
"record-edit-modes",
24+
"dashboard-filters",
2425
"slotted-pages",
2526
"console",
2627
"console-architecture",

content/docs/plugins/plugin-dashboard.mdx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,13 @@ dataset query's `runtimeFilter`. Static-data widgets (inline `data` arrays)
193193
have no query to scope and are not filtered. Filter values are also readable
194194
in widget expressions as `page.<name>`.
195195

196+
For a step-by-step tutorial — filter types, `optionsFrom` dynamic options,
197+
`page.*` expression usage, and known limitations with workarounds — see the
198+
[Dashboard-Level Filters guide](/docs/guide/dashboard-filters). The schema
199+
catalog ships runnable variants under `plugin-dashboard/filtered-dashboard*`
200+
(dynamic options, filter types, dataset widgets, `targetWidgets`, date
201+
presets).
202+
196203
## TypeScript Support
197204

198205
```plaintext

examples/schema-catalog/src/index.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,11 @@ import plugin_chatbot_customer_support_chat from './schemas/plugin-chatbot/custo
388388
import plugin_dashboard_basic_dashboard from './schemas/plugin-dashboard/basic-dashboard.json' with { type: 'json' };
389389
import plugin_dashboard_e_commerce_dashboard from './schemas/plugin-dashboard/e-commerce-dashboard.json' with { type: 'json' };
390390
import plugin_dashboard_filtered_dashboard from './schemas/plugin-dashboard/filtered-dashboard.json' with { type: 'json' };
391+
import plugin_dashboard_filtered_dashboard_dataset_widgets from './schemas/plugin-dashboard/filtered-dashboard-dataset-widgets.json' with { type: 'json' };
392+
import plugin_dashboard_filtered_dashboard_date_presets from './schemas/plugin-dashboard/filtered-dashboard-date-presets.json' with { type: 'json' };
393+
import plugin_dashboard_filtered_dashboard_dynamic_options from './schemas/plugin-dashboard/filtered-dashboard-dynamic-options.json' with { type: 'json' };
394+
import plugin_dashboard_filtered_dashboard_filter_types from './schemas/plugin-dashboard/filtered-dashboard-filter-types.json' with { type: 'json' };
395+
import plugin_dashboard_filtered_dashboard_target_widgets from './schemas/plugin-dashboard/filtered-dashboard-target-widgets.json' with { type: 'json' };
391396
import plugin_dashboard_support_dashboard from './schemas/plugin-dashboard/support-dashboard.json' with { type: 'json' };
392397
import plugin_editor_javascript_editor from './schemas/plugin-editor/javascript-editor.json' with { type: 'json' };
393398
import plugin_editor_python_editor from './schemas/plugin-editor/python-editor.json' with { type: 'json' };
@@ -3938,6 +3943,51 @@ const REGISTRY: Record<string, Example> = {
39383943
},
39393944
schema: plugin_dashboard_filtered_dashboard,
39403945
},
3946+
'plugin-dashboard/filtered-dashboard-dataset-widgets': {
3947+
id: 'plugin-dashboard/filtered-dashboard-dataset-widgets',
3948+
meta: {
3949+
title: "Filtered Dashboard — Dataset + Inline Widgets",
3950+
description: "Dashboard filters scoping dataset-bound widgets (via the dataset query's runtimeFilter) alongside inline object widgets",
3951+
category: 'plugin-dashboard',
3952+
},
3953+
schema: plugin_dashboard_filtered_dashboard_dataset_widgets,
3954+
},
3955+
'plugin-dashboard/filtered-dashboard-date-presets': {
3956+
id: 'plugin-dashboard/filtered-dashboard-date-presets',
3957+
meta: {
3958+
title: "Filtered Dashboard — Date Presets + Custom Range",
3959+
description: "Built-in dateRange with presets and allowCustomRange; per-widget date-field override and a dateRange: false opt-out",
3960+
category: 'plugin-dashboard',
3961+
},
3962+
schema: plugin_dashboard_filtered_dashboard_date_presets,
3963+
},
3964+
'plugin-dashboard/filtered-dashboard-dynamic-options': {
3965+
id: 'plugin-dashboard/filtered-dashboard-dynamic-options',
3966+
meta: {
3967+
title: "Filtered Dashboard — Dynamic Options",
3968+
description: "Select filter whose options are fetched from object records via optionsFrom (distinct values at runtime)",
3969+
category: 'plugin-dashboard',
3970+
},
3971+
schema: plugin_dashboard_filtered_dashboard_dynamic_options,
3972+
},
3973+
'plugin-dashboard/filtered-dashboard-filter-types': {
3974+
id: 'plugin-dashboard/filtered-dashboard-filter-types',
3975+
meta: {
3976+
title: "Filtered Dashboard — Text / Number / Lookup Filters",
3977+
description: "Text ($contains), number (equality) and lookup (optionsFrom) filter types, plus a fully opted-out metric",
3978+
category: 'plugin-dashboard',
3979+
},
3980+
schema: plugin_dashboard_filtered_dashboard_filter_types,
3981+
},
3982+
'plugin-dashboard/filtered-dashboard-target-widgets': {
3983+
id: 'plugin-dashboard/filtered-dashboard-target-widgets',
3984+
meta: {
3985+
title: "Filtered Dashboard — Target Widgets Allow-list",
3986+
description: "Legacy targetWidgets allow-list: only listed widgets get the default binding; an explicit filterBindings entry still wins",
3987+
category: 'plugin-dashboard',
3988+
},
3989+
schema: plugin_dashboard_filtered_dashboard_target_widgets,
3990+
},
39413991
'plugin-editor/javascript-editor': {
39423992
id: 'plugin-editor/javascript-editor',
39433993
meta: {

0 commit comments

Comments
 (0)