Skip to content

Commit bf1720b

Browse files
os-zhuangclaude
andauthored
feat(spec): dashboard filter pairing — GlobalFilterSchema.name + DashboardWidgetSchema.filterBindings (#2501) (#3035)
* feat(spec): dashboard filter pairing — GlobalFilterSchema.name + DashboardWidgetSchema.filterBindings (#2501) Land the two dashboard-level-filter properties the objectui runtime already ships (objectui#2576, follow-up tracked in objectui#2578): - GlobalFilterSchema.name — stable filter name used as the dashboard-variable key (page.<name> in widget expressions) and the key widgets reference in filterBindings; defaults to field. "dateRange" is reserved for the built-in dashboard date range. - DashboardWidgetSchema.filterBindings — per-widget map from a dashboard filter name to one of THIS widget's fields (string override / false opt-out / absent = default to the filter's own field). Purely additive; the metadata-admin dashboard inspector derives its form from this schema via z.toJSONSchema, so both properties surface there automatically once objectui bumps @objectstack/spec. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HG5LQnPbQbjAAyofghzz3Z * docs(ui): document GlobalFilter.name + widget filterBindings in dashboards guide Pairs the spec addition with its authoring doc: stable filter names as the page.<name> variable key, per-widget binding overrides / opt-out, and the binding precedence order. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HG5LQnPbQbjAAyofghzz3Z --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 23fe280 commit bf1720b

4 files changed

Lines changed: 95 additions & 1 deletion

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
Dashboard-level filters spec pairing (framework#2501, objectui#2578) — land the
6+
two properties the objectui runtime already ships (objectui#2576) so the
7+
protocol and the renderer agree:
8+
9+
- **`GlobalFilterSchema.name`** (optional string) — stable filter name used as
10+
the dashboard-variable key (readable in widget expressions as `page.<name>`)
11+
and as the key widgets reference in `filterBindings`. Defaults to `field`;
12+
`"dateRange"` is reserved for the built-in dashboard date range.
13+
- **`DashboardWidgetSchema.filterBindings`** (optional
14+
`Record<string, string | false>`) — per-widget binding from a dashboard
15+
filter name to one of THIS widget's fields: a string re-targets the filter to
16+
that field, `false` opts the widget out, absent falls back to the filter's
17+
own `field`.
18+
19+
Purely additive — existing dashboards parse unchanged. The metadata-admin
20+
dashboard inspector (objectui `dashboard-schema.ts`) derives its form from this
21+
schema via `z.toJSONSchema`, so both properties surface there automatically
22+
once objectui picks up this spec version.

content/docs/ui/dashboards.mdx

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,11 +302,42 @@ Add interactive filter controls that apply to all widgets:
302302

303303
```typescript
304304
globalFilters: [
305-
{ field: 'region', label: 'Region', type: 'select' },
305+
{ name: 'region', field: 'region', label: 'Region', type: 'select' },
306306
{ field: 'owner', label: 'Sales Rep', type: 'lookup' },
307307
]
308308
```
309309

310+
Each filter's `name` is its stable identity: the key its value is published
311+
under as a dashboard-level variable (readable in widget expressions as
312+
`page.<name>`) and the key widgets reference in `filterBindings`. It defaults
313+
to `field`; the name `dateRange` is reserved for the built-in date range.
314+
315+
### Per-Widget Filter Bindings
316+
317+
By default a filter applies to its own `field` on every widget (the date
318+
range defaults to `dateRange.field ?? 'created_at'`). When a widget stores
319+
the concept under a different field — or should ignore a filter — declare
320+
`filterBindings` on the widget:
321+
322+
```typescript
323+
widgets: [
324+
// Default binding: dateRange → created_at, region → region.
325+
{ id: 'invoices_by_status', /**/ },
326+
// This widget's own fields differ — map each filter explicitly.
327+
{
328+
id: 'accounts_signed',
329+
filterBindings: { dateRange: 'signed_at', region: 'sales_region' },
330+
/**/
331+
},
332+
// Opt out of a filter with `false`.
333+
{ id: 'total_invoices', filterBindings: { region: false }, /**/ },
334+
]
335+
```
336+
337+
Binding precedence: an explicit `filterBindings` entry (string override or
338+
`false` opt-out) → the filter's legacy `targetWidgets` allow-list → the
339+
filter's own `field`.
340+
310341
## Complete Example
311342

312343
First define the dataset the widgets bind to:

packages/spec/src/ui/dashboard.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,4 +143,24 @@ describe('Dashboard presentation sub-schemas', () => {
143143
expect(f.scope).toBe('dashboard');
144144
expect(GlobalFilterOptionsFromSchema.parse({ object: 'user', valueField: 'id', labelField: 'name' }).object).toBe('user');
145145
});
146+
147+
it('GlobalFilterSchema.name — optional stable variable key (framework#2501)', () => {
148+
const named = GlobalFilterSchema.parse({ name: 'region', field: 'sales_region', type: 'select' });
149+
expect(named.name).toBe('region');
150+
// name stays optional — runtime defaults it to `field`.
151+
expect(GlobalFilterSchema.parse({ field: 'region' }).name).toBeUndefined();
152+
});
153+
154+
it('DashboardWidgetSchema.filterBindings — field override / opt-out (framework#2501)', () => {
155+
const w = DashboardWidgetSchema.parse({
156+
id: 'accounts_signed', type: 'line', dataset: 'accounts', values: ['count'],
157+
filterBindings: { dateRange: 'signed_at', region: 'sales_region', status: false },
158+
});
159+
expect(w.filterBindings).toEqual({ dateRange: 'signed_at', region: 'sales_region', status: false });
160+
// Only string (field name) or literal false are valid binding values.
161+
expect(() => DashboardWidgetSchema.parse({
162+
id: 'w_bad', type: 'metric', dataset: 'sales', values: ['revenue'],
163+
filterBindings: { region: true },
164+
})).toThrow();
165+
});
146166
});

packages/spec/src/ui/dashboard.zod.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,18 @@ export const DashboardWidgetSchema = lazySchema(() => z.object({
184184
/** Widget specific options (colors, legend, etc.) */
185185
options: z.unknown().optional().describe('Widget specific configuration'),
186186

187+
/**
188+
* Per-widget bindings from a dashboard-level filter (referenced by its
189+
* `name`, or the reserved name `"dateRange"` for the built-in date range)
190+
* to one of THIS widget's fields (framework#2501):
191+
* - string → apply the filter to that field (e.g. `{ dateRange: 'signed_at' }`)
192+
* - false → opt this widget out of that filter
193+
* - absent → default binding: the filter's own `field`
194+
* (dateRange: `dateRange.field ?? 'created_at'`)
195+
*/
196+
filterBindings: z.record(z.string(), z.union([z.string(), z.literal(false)])).optional()
197+
.describe("Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out"),
198+
187199
/**
188200
* Rule ids of build diagnostics intentionally suppressed on this widget
189201
* (e.g. `'table-count-only'` when a single-row summary table is deliberate).
@@ -223,6 +235,15 @@ export const GlobalFilterOptionsFromSchema = lazySchema(() => z.object({
223235
* Defines a single global filter control for the dashboard filter bar.
224236
*/
225237
export const GlobalFilterSchema = lazySchema(() => z.object({
238+
/**
239+
* Stable filter name (framework#2501) — the dashboard-variable key under
240+
* which the filter's value is published (readable in widget expressions as
241+
* `page.<name>`) and the key widgets reference in `filterBindings`.
242+
* Defaults to `field`. The name `"dateRange"` is reserved for the built-in
243+
* dashboard date range.
244+
*/
245+
name: z.string().optional().describe('Stable filter name (variable key); defaults to field'),
246+
226247
/** Field name to filter on */
227248
field: z.string().describe('Field name to filter on'),
228249

0 commit comments

Comments
 (0)