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
22 changes: 22 additions & 0 deletions .changeset/dashboard-filter-spec-pairing.md
Original file line number Diff line number Diff line change
@@ -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.<name>`)
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<string, string | false>`) — 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.
33 changes: 32 additions & 1 deletion content/docs/ui/dashboards.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>`) 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:
Expand Down
20 changes: 20 additions & 0 deletions packages/spec/src/ui/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
21 changes: 21 additions & 0 deletions packages/spec/src/ui/dashboard.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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.<name>`) 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'),

Expand Down