From 907a91c974ad362e0ea4f37d7f961bb77426b1ac Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 08:03:08 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(spec):=20dashboard=20filter=20pairing?= =?UTF-8?q?=20=E2=80=94=20GlobalFilterSchema.name=20+=20DashboardWidgetSch?= =?UTF-8?q?ema.filterBindings=20(#2501)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. 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 Claude-Session: https://claude.ai/code/session_01HG5LQnPbQbjAAyofghzz3Z --- .changeset/dashboard-filter-spec-pairing.md | 22 +++++++++++++++++++++ packages/spec/src/ui/dashboard.test.ts | 20 +++++++++++++++++++ packages/spec/src/ui/dashboard.zod.ts | 21 ++++++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 .changeset/dashboard-filter-spec-pairing.md diff --git a/.changeset/dashboard-filter-spec-pairing.md b/.changeset/dashboard-filter-spec-pairing.md new file mode 100644 index 0000000000..9ee02009ee --- /dev/null +++ b/.changeset/dashboard-filter-spec-pairing.md @@ -0,0 +1,22 @@ +--- +'@objectstack/spec': minor +--- + +Dashboard-level filters spec pairing (framework#2501, objectui#2578) — land the +two properties the objectui runtime already ships (objectui#2576) so the +protocol and the renderer agree: + +- **`GlobalFilterSchema.name`** (optional string) — stable filter name used as + the dashboard-variable key (readable in widget expressions as `page.`) + and as the key widgets reference in `filterBindings`. Defaults to `field`; + `"dateRange"` is reserved for the built-in dashboard date range. +- **`DashboardWidgetSchema.filterBindings`** (optional + `Record`) — per-widget binding from a dashboard + filter name to one of THIS widget's fields: a string re-targets the filter to + that field, `false` opts the widget out, absent falls back to the filter's + own `field`. + +Purely additive — existing dashboards parse unchanged. The metadata-admin +dashboard inspector (objectui `dashboard-schema.ts`) derives its form from this +schema via `z.toJSONSchema`, so both properties surface there automatically +once objectui picks up this spec version. diff --git a/packages/spec/src/ui/dashboard.test.ts b/packages/spec/src/ui/dashboard.test.ts index 389c296cde..b9d497432b 100644 --- a/packages/spec/src/ui/dashboard.test.ts +++ b/packages/spec/src/ui/dashboard.test.ts @@ -143,4 +143,24 @@ describe('Dashboard presentation sub-schemas', () => { expect(f.scope).toBe('dashboard'); expect(GlobalFilterOptionsFromSchema.parse({ object: 'user', valueField: 'id', labelField: 'name' }).object).toBe('user'); }); + + it('GlobalFilterSchema.name — optional stable variable key (framework#2501)', () => { + const named = GlobalFilterSchema.parse({ name: 'region', field: 'sales_region', type: 'select' }); + expect(named.name).toBe('region'); + // name stays optional — runtime defaults it to `field`. + expect(GlobalFilterSchema.parse({ field: 'region' }).name).toBeUndefined(); + }); + + it('DashboardWidgetSchema.filterBindings — field override / opt-out (framework#2501)', () => { + const w = DashboardWidgetSchema.parse({ + id: 'accounts_signed', type: 'line', dataset: 'accounts', values: ['count'], + filterBindings: { dateRange: 'signed_at', region: 'sales_region', status: false }, + }); + expect(w.filterBindings).toEqual({ dateRange: 'signed_at', region: 'sales_region', status: false }); + // Only string (field name) or literal false are valid binding values. + expect(() => DashboardWidgetSchema.parse({ + id: 'w_bad', type: 'metric', dataset: 'sales', values: ['revenue'], + filterBindings: { region: true }, + })).toThrow(); + }); }); diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index c8e8c95f3b..f728aaccb0 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -184,6 +184,18 @@ export const DashboardWidgetSchema = lazySchema(() => z.object({ /** Widget specific options (colors, legend, etc.) */ options: z.unknown().optional().describe('Widget specific configuration'), + /** + * Per-widget bindings from a dashboard-level filter (referenced by its + * `name`, or the reserved name `"dateRange"` for the built-in date range) + * to one of THIS widget's fields (framework#2501): + * - string → apply the filter to that field (e.g. `{ dateRange: 'signed_at' }`) + * - false → opt this widget out of that filter + * - absent → default binding: the filter's own `field` + * (dateRange: `dateRange.field ?? 'created_at'`) + */ + filterBindings: z.record(z.string(), z.union([z.string(), z.literal(false)])).optional() + .describe("Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out"), + /** * Rule ids of build diagnostics intentionally suppressed on this widget * (e.g. `'table-count-only'` when a single-row summary table is deliberate). @@ -223,6 +235,15 @@ export const GlobalFilterOptionsFromSchema = lazySchema(() => z.object({ * Defines a single global filter control for the dashboard filter bar. */ export const GlobalFilterSchema = lazySchema(() => z.object({ + /** + * Stable filter name (framework#2501) — the dashboard-variable key under + * which the filter's value is published (readable in widget expressions as + * `page.`) and the key widgets reference in `filterBindings`. + * Defaults to `field`. The name `"dateRange"` is reserved for the built-in + * dashboard date range. + */ + name: z.string().optional().describe('Stable filter name (variable key); defaults to field'), + /** Field name to filter on */ field: z.string().describe('Field name to filter on'), From ca2f1df7b01991bfeb8c1bf5976cbec476f2a704 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 08:11:55 +0000 Subject: [PATCH 2/2] docs(ui): document GlobalFilter.name + widget filterBindings in dashboards guide Pairs the spec addition with its authoring doc: stable filter names as the page. variable key, per-widget binding overrides / opt-out, and the binding precedence order. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HG5LQnPbQbjAAyofghzz3Z --- content/docs/ui/dashboards.mdx | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/content/docs/ui/dashboards.mdx b/content/docs/ui/dashboards.mdx index d2347cdace..e5dd275746 100644 --- a/content/docs/ui/dashboards.mdx +++ b/content/docs/ui/dashboards.mdx @@ -302,11 +302,42 @@ Add interactive filter controls that apply to all widgets: ```typescript globalFilters: [ - { field: 'region', label: 'Region', type: 'select' }, + { name: 'region', field: 'region', label: 'Region', type: 'select' }, { field: 'owner', label: 'Sales Rep', type: 'lookup' }, ] ``` +Each filter's `name` is its stable identity: the key its value is published +under as a dashboard-level variable (readable in widget expressions as +`page.`) and the key widgets reference in `filterBindings`. It defaults +to `field`; the name `dateRange` is reserved for the built-in date range. + +### Per-Widget Filter Bindings + +By default a filter applies to its own `field` on every widget (the date +range defaults to `dateRange.field ?? 'created_at'`). When a widget stores +the concept under a different field — or should ignore a filter — declare +`filterBindings` on the widget: + +```typescript +widgets: [ + // Default binding: dateRange → created_at, region → region. + { id: 'invoices_by_status', /* … */ }, + // This widget's own fields differ — map each filter explicitly. + { + id: 'accounts_signed', + filterBindings: { dateRange: 'signed_at', region: 'sales_region' }, + /* … */ + }, + // Opt out of a filter with `false`. + { id: 'total_invoices', filterBindings: { region: false }, /* … */ }, +] +``` + +Binding precedence: an explicit `filterBindings` entry (string override or +`false` opt-out) → the filter's legacy `targetWidgets` allow-list → the +filter's own `field`. + ## Complete Example First define the dataset the widgets bind to: