Skip to content

Commit 887062c

Browse files
os-zhuangclaude
andauthored
feat(dashboard): dashboard-level filters driving multiple charts (framework#2501) (#2576)
Wire the (previously declared-but-inert) DashboardSchema.globalFilters + dateRange into the runtime: filter values live as dashboard-level variables (PageVariablesProvider, readable as page.<name> in widget expressions), a DashboardFilterBar renders above the widgets, and DashboardRenderer broadcasts the active values into every bound widget's inline query — AND-merged with the widget's own filter (dataset widgets via runtimeFilter). Each widget maps a filter to ITS OWN field via the new DashboardWidgetSchema.filterBindings (string override / false opt-out), defaulting to the filter's own field; legacy targetWidgets allow-list kept. globalFilters[].name added as the stable variable key. Both pending paired @objectstack/spec alignment (framework#2501). - core: pure dashboard-filters module (defs resolution, condition build, per-widget binding resolution); mergeFilters lifted from plugin-report. - Date presets emit date-macro tokens resolved at query time. - Example: schema-catalog plugin-dashboard/filtered-dashboard (2 charts over different objects + opt-out metric). Docs: README + plugin-dashboard.mdx. Tests: 16 core unit tests (binding/condition/merge), 5 renderer tests (broadcast, per-widget fields, opt-out, live re-scope, dataset runtimeFilter). Claude-Session: https://claude.ai/code/session_01U25wX5Ja3oS4dMyVHMnRsK Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6c0135c commit 887062c

15 files changed

Lines changed: 1243 additions & 25 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@object-ui/types": minor
3+
"@object-ui/core": minor
4+
"@object-ui/plugin-dashboard": minor
5+
"@object-ui/plugin-report": patch
6+
---
7+
8+
feat(dashboard): dashboard-level filters (date / region) driving multiple charts (framework#2501)
9+
10+
A dashboard's `dateRange` + `globalFilters` declarations are now wired end to
11+
end: the filter values live as dashboard-level variables (the page variables
12+
primitive, so they're also readable as `page.<name>` in widget expressions),
13+
a filter bar renders above the widgets, and at render time the dashboard
14+
broadcasts the active values into every bound widget's inline query —
15+
`AND`-merged with the widget's own `filter`. Charts stay inline and
16+
self-contained; each widget maps a filter to **its own** field.
17+
18+
- **`@object-ui/types`**`globalFilters[].name` (stable filter/variable key,
19+
defaults to `field`) and `DashboardWidgetSchema.filterBindings`
20+
(`Record<string, string | false>`: per-widget field override / `false`
21+
opt-out). Zod mirrors included. **Pending paired `@objectstack/spec`
22+
alignment (framework#2501)** — same precedent as `dataset` /
23+
`categoryGranularity`.
24+
- **`@object-ui/core`** — new pure `dashboard-filters` module
25+
(`resolveDashboardFilterDefs`, `dashboardFilterVariableDefs`,
26+
`buildFilterCondition`, `buildWidgetScopedFilter`); `mergeFilters` lifted
27+
from plugin-report (re-exported there unchanged). Date presets emit
28+
date-macro tokens (`{30_days_ago}` …) so widgets resolve them at query time
29+
like hand-authored filters.
30+
- **`@object-ui/plugin-dashboard`**`DashboardFilterBar` (date presets +
31+
custom range calendar, select with static `options` or `optionsFrom`,
32+
text/number inputs, reset); `DashboardRenderer` mounts a
33+
`PageVariablesProvider` when filters are declared and merges the
34+
widget-scoped condition into inline widgets' `filter` and dataset widgets'
35+
`runtimeFilter`. Dashboards without filters render exactly as before.
36+
37+
Binding precedence: explicit `filterBindings` string/`false` → legacy
38+
`targetWidgets` allow-list → the filter's own `field` (dateRange defaults to
39+
`created_at`). Static-data widgets are not filtered.

content/docs/plugins/plugin-dashboard.mdx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,51 @@ Object.entries(dashboardComponents).forEach(([type, component]) => {
148148
}
149149
```
150150

151+
## Dashboard-level filters
152+
153+
A dashboard can declare top-level filters — a date range plus any number of
154+
select / text filters — whose values drive every bound widget at once. Filter
155+
values live as dashboard-level variables; each widget declares which of **its
156+
own** fields a filter binds to via `filterBindings`, and the dashboard merges
157+
the active values into each bound widget's inline query (`AND`-combined with
158+
the widget's own `filter`).
159+
160+
```json
161+
{
162+
"type": "dashboard",
163+
"dateRange": { "field": "created_at", "defaultRange": "last_30_days", "allowCustomRange": true },
164+
"globalFilters": [
165+
{ "name": "region", "field": "region", "label": "Region", "type": "select", "options": ["EMEA", "APAC", "AMER"] }
166+
],
167+
"widgets": [
168+
{ "id": "w1", "type": "bar", "object": "invoices", "aggregate": "count" },
169+
{
170+
"id": "w2", "type": "line", "object": "accounts", "aggregate": "count",
171+
"filterBindings": { "dateRange": "signed_at", "region": "sales_region" }
172+
},
173+
{
174+
"id": "w3", "type": "metric", "object": "invoices", "aggregate": "count",
175+
"filterBindings": { "region": false }
176+
}
177+
]
178+
}
179+
```
180+
181+
Binding rules, in precedence order:
182+
183+
1. `filterBindings[name]` as a string — apply the filter to that field.
184+
2. `filterBindings[name]: false` — opt this widget out.
185+
3. Legacy `targetWidgets` on the filter — when set, only listed widget ids get
186+
the default binding (an explicit `filterBindings` entry still wins).
187+
4. Otherwise the filter applies to its own `field` (the built-in date range
188+
defaults to `dateRange.field ?? 'created_at'`).
189+
190+
Date presets stay symbolic (date-macro tokens such as `{30_days_ago}`) until
191+
query time. Dataset-bound widgets receive the merged filter through the
192+
dataset query's `runtimeFilter`. Static-data widgets (inline `data` arrays)
193+
have no query to scope and are not filtered. Filter values are also readable
194+
in widget expressions as `page.<name>`.
195+
151196
## TypeScript Support
152197

153198
```plaintext

examples/schema-catalog/src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,7 @@ import plugin_chatbot_chatbot_with_timestamps from './schemas/plugin-chatbot/cha
387387
import plugin_chatbot_customer_support_chat from './schemas/plugin-chatbot/customer-support-chat.json' with { type: 'json' };
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' };
390+
import plugin_dashboard_filtered_dashboard from './schemas/plugin-dashboard/filtered-dashboard.json' with { type: 'json' };
390391
import plugin_dashboard_support_dashboard from './schemas/plugin-dashboard/support-dashboard.json' with { type: 'json' };
391392
import plugin_editor_javascript_editor from './schemas/plugin-editor/javascript-editor.json' with { type: 'json' };
392393
import plugin_editor_python_editor from './schemas/plugin-editor/python-editor.json' with { type: 'json' };
@@ -3928,6 +3929,15 @@ const REGISTRY: Record<string, Example> = {
39283929
},
39293930
schema: plugin_dashboard_support_dashboard,
39303931
},
3932+
'plugin-dashboard/filtered-dashboard': {
3933+
id: 'plugin-dashboard/filtered-dashboard',
3934+
meta: {
3935+
title: "Filtered Dashboard",
3936+
description: "Dashboard-level date + region filters driving multiple charts over different objects",
3937+
category: 'plugin-dashboard',
3938+
},
3939+
schema: plugin_dashboard_filtered_dashboard,
3940+
},
39313941
'plugin-editor/javascript-editor': {
39323942
id: 'plugin-editor/javascript-editor',
39333943
meta: {
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
{
2+
"type": "dashboard",
3+
"title": "Sales Overview",
4+
"columns": 2,
5+
"gap": 4,
6+
"dateRange": {
7+
"field": "created_at",
8+
"defaultRange": "last_30_days",
9+
"allowCustomRange": true
10+
},
11+
"globalFilters": [
12+
{
13+
"name": "region",
14+
"field": "region",
15+
"label": "Region",
16+
"type": "select",
17+
"options": ["EMEA", "APAC", "AMER"]
18+
}
19+
],
20+
"widgets": [
21+
{
22+
"id": "invoices_by_status",
23+
"title": "Invoices by Status",
24+
"type": "bar",
25+
"object": "invoices",
26+
"categoryField": "status",
27+
"aggregate": "count"
28+
},
29+
{
30+
"id": "accounts_signed",
31+
"title": "Accounts Signed",
32+
"type": "line",
33+
"object": "accounts",
34+
"categoryField": "signed_at",
35+
"categoryGranularity": "month",
36+
"aggregate": "count",
37+
"filterBindings": {
38+
"dateRange": "signed_at",
39+
"region": "sales_region"
40+
}
41+
},
42+
{
43+
"id": "total_invoices",
44+
"title": "Total Invoices (all regions)",
45+
"type": "metric",
46+
"object": "invoices",
47+
"aggregate": "count",
48+
"filterBindings": {
49+
"region": false
50+
}
51+
}
52+
]
53+
}

packages/core/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export * from './runtime/capabilities.js';
4141
export { composeStacks } from '@objectstack/spec';
4242
export * from './utils/drill-down.js';
4343
export * from './utils/date-macros.js';
44+
export * from './utils/dashboard-filters.js';
45+
export * from './utils/merge-filters.js';
4446
export * from './utils/compare-to.js';
4547
export * from './utils/chart-series.js';
4648
export * from './utils/dataset-format.js';
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import { describe, it, expect } from 'vitest';
10+
import {
11+
resolveDashboardFilterDefs,
12+
dashboardFilterVariableDefs,
13+
buildFilterCondition,
14+
buildWidgetScopedFilter,
15+
DATE_RANGE_FILTER_NAME,
16+
type DashboardFilterDef,
17+
} from '../dashboard-filters';
18+
import { mergeFilters } from '../merge-filters';
19+
20+
const regionDef: DashboardFilterDef = {
21+
name: 'region',
22+
field: 'region',
23+
type: 'select',
24+
options: ['EMEA', 'APAC', 'AMER'],
25+
};
26+
27+
const dateDef: DashboardFilterDef = {
28+
name: DATE_RANGE_FILTER_NAME,
29+
field: 'created_at',
30+
type: 'dateRange',
31+
};
32+
33+
describe('resolveDashboardFilterDefs', () => {
34+
it('maps dateRange to the reserved name with a created_at field default', () => {
35+
const defs = resolveDashboardFilterDefs({
36+
dateRange: { defaultRange: 'last_30_days' },
37+
});
38+
expect(defs).toHaveLength(1);
39+
expect(defs[0]).toMatchObject({
40+
name: DATE_RANGE_FILTER_NAME,
41+
field: 'created_at',
42+
type: 'dateRange',
43+
defaultValue: { preset: 'last_30_days' },
44+
});
45+
});
46+
47+
it('honors an explicit dateRange.field and skips a custom default preset', () => {
48+
const defs = resolveDashboardFilterDefs({
49+
dateRange: { field: 'closed_at', defaultRange: 'custom' },
50+
});
51+
expect(defs[0].field).toBe('closed_at');
52+
expect(defs[0].defaultValue).toBeUndefined();
53+
});
54+
55+
it('defaults a global filter name to its field and preserves declared names', () => {
56+
const defs = resolveDashboardFilterDefs({
57+
globalFilters: [
58+
{ field: 'region' },
59+
{ name: 'owner', field: 'owner_id', type: 'lookup' },
60+
],
61+
});
62+
expect(defs.map((d) => d.name)).toEqual(['region', 'owner']);
63+
expect(defs[0].type).toBe('text');
64+
expect(defs[1].field).toBe('owner_id');
65+
});
66+
67+
it('skips entries without a field and lets duplicate names win last', () => {
68+
const defs = resolveDashboardFilterDefs({
69+
globalFilters: [
70+
{ field: '' } as any,
71+
{ name: 'region', field: 'region' },
72+
{ name: 'region', field: 'sales_region' },
73+
],
74+
});
75+
expect(defs).toHaveLength(1);
76+
expect(defs[0].field).toBe('sales_region');
77+
});
78+
});
79+
80+
describe('dashboardFilterVariableDefs', () => {
81+
it('produces page-variable definitions keyed by filter name', () => {
82+
const vars = dashboardFilterVariableDefs([dateDef, regionDef]);
83+
expect(vars).toEqual([
84+
{ name: DATE_RANGE_FILTER_NAME, type: 'object', defaultValue: undefined },
85+
{ name: 'region', type: 'string', defaultValue: undefined },
86+
]);
87+
});
88+
});
89+
90+
describe('buildFilterCondition', () => {
91+
it('maps a date preset to symbolic macro-token bounds', () => {
92+
expect(buildFilterCondition(dateDef, { preset: 'last_30_days' })).toEqual({
93+
$gte: '{30_days_ago}',
94+
$lte: '{today}',
95+
});
96+
expect(buildFilterCondition(dateDef, { preset: 'this_month' })).toEqual({
97+
$gte: '{current_month_start}',
98+
$lte: '{current_month_end}',
99+
});
100+
});
101+
102+
it('passes custom ISO bounds through and omits a missing bound', () => {
103+
expect(buildFilterCondition(dateDef, { from: '2026-01-01', to: '2026-06-30' })).toEqual({
104+
$gte: '2026-01-01',
105+
$lte: '2026-06-30',
106+
});
107+
expect(buildFilterCondition(dateDef, { from: '2026-01-01' })).toEqual({
108+
$gte: '2026-01-01',
109+
});
110+
});
111+
112+
it('maps select values to equality and arrays to $in', () => {
113+
expect(buildFilterCondition(regionDef, 'EMEA')).toBe('EMEA');
114+
expect(buildFilterCondition(regionDef, ['EMEA', 'APAC'])).toEqual({ $in: ['EMEA', 'APAC'] });
115+
});
116+
117+
it('maps text to $contains and numbers to equality', () => {
118+
expect(buildFilterCondition({ name: 'q', field: 'name', type: 'text' }, 'acme')).toEqual({
119+
$contains: 'acme',
120+
});
121+
expect(buildFilterCondition({ name: 'n', field: 'amount', type: 'number' }, 42)).toBe(42);
122+
});
123+
124+
it('returns undefined for empty values', () => {
125+
expect(buildFilterCondition(regionDef, undefined)).toBeUndefined();
126+
expect(buildFilterCondition(regionDef, '')).toBeUndefined();
127+
expect(buildFilterCondition(regionDef, [])).toBeUndefined();
128+
expect(buildFilterCondition(dateDef, {})).toBeUndefined();
129+
expect(buildFilterCondition(dateDef, { preset: undefined })).toBeUndefined();
130+
});
131+
});
132+
133+
describe('buildWidgetScopedFilter', () => {
134+
const defs = [dateDef, regionDef];
135+
136+
it('applies the default binding (the filter\'s own field)', () => {
137+
const scoped = buildWidgetScopedFilter({ id: 'w1' }, defs, { region: 'EMEA' });
138+
expect(scoped).toEqual({ region: 'EMEA' });
139+
});
140+
141+
it('lets filterBindings override the target field per widget', () => {
142+
const scoped = buildWidgetScopedFilter(
143+
{ id: 'w1', filterBindings: { dateRange: 'signed_at', region: 'sales_region' } },
144+
defs,
145+
{ dateRange: { preset: 'last_7_days' }, region: 'APAC' },
146+
);
147+
expect(scoped).toEqual({
148+
$and: [
149+
{ signed_at: { $gte: '{7_days_ago}', $lte: '{today}' } },
150+
{ sales_region: 'APAC' },
151+
],
152+
});
153+
});
154+
155+
it('opts a widget out with filterBindings: false', () => {
156+
const scoped = buildWidgetScopedFilter(
157+
{ id: 'w1', filterBindings: { region: false } },
158+
defs,
159+
{ region: 'EMEA' },
160+
);
161+
expect(scoped).toBeUndefined();
162+
});
163+
164+
it('honors the legacy targetWidgets allow-list for default bindings', () => {
165+
const gated: DashboardFilterDef = { ...regionDef, targetWidgets: ['w2'] };
166+
expect(buildWidgetScopedFilter({ id: 'w1' }, [gated], { region: 'EMEA' })).toBeUndefined();
167+
expect(buildWidgetScopedFilter({ id: 'w2' }, [gated], { region: 'EMEA' })).toEqual({
168+
region: 'EMEA',
169+
});
170+
// Explicit binding wins over the allow-list.
171+
expect(
172+
buildWidgetScopedFilter({ id: 'w1', filterBindings: { region: 'area' } }, [gated], {
173+
region: 'EMEA',
174+
}),
175+
).toEqual({ area: 'EMEA' });
176+
});
177+
178+
it('combines several active filters with $and and returns undefined when none apply', () => {
179+
const scoped = buildWidgetScopedFilter({ id: 'w1' }, defs, {
180+
dateRange: { preset: 'today' },
181+
region: 'EMEA',
182+
});
183+
expect(scoped).toEqual({
184+
$and: [
185+
{ created_at: { $gte: '{today}', $lte: '{today}' } },
186+
{ region: 'EMEA' },
187+
],
188+
});
189+
expect(buildWidgetScopedFilter({ id: 'w1' }, defs, {})).toBeUndefined();
190+
});
191+
});
192+
193+
describe('mergeFilters', () => {
194+
it('ANDs two non-empty filters, passes single ones through, drops empties', () => {
195+
expect(mergeFilters({ a: 1 }, { b: 2 })).toEqual({ $and: [{ a: 1 }, { b: 2 }] });
196+
expect(mergeFilters({ a: 1 }, undefined)).toEqual({ a: 1 });
197+
expect(mergeFilters(undefined, { b: 2 })).toEqual({ b: 2 });
198+
expect(mergeFilters({}, undefined)).toBeUndefined();
199+
});
200+
});

0 commit comments

Comments
 (0)