Dashboard-Level Filters
A dashboard often needs one top-level filter — a date range, a region select — that drives several charts at once. ObjectUI models this as a dashboard-level parameter, not a shared dataset:
- The filter control and its value live on the dashboard — hosted as dashboard-level variables (the page/dashboard variables primitive).
- Each widget declares which of its own fields a filter binds to via
filterBindings— a small mapping, not a copied query. - At render time the dashboard broadcasts the active values into every
bound widget's inline query,
AND-combined with the widget's ownfilter.
Charts stay inline and self-contained; one place owns the filter; each chart edit stays local.
Working examples: the schema catalog ships a
plugin-dashboard/filtered-dashboardexample plus variants for dynamic options, text/number/lookup filter types, dataset widgets, thetargetWidgetsallow-list, and date presets with a custom range.
Tutorial: from zero to a filtered dashboard
Step 1 — a plain dashboard
Start from two charts over different objects. Without filters they always show everything:
{
"type": "dashboard",
"columns": 2,
"widgets": [
{
"id": "invoices_by_status",
"title": "Invoices by Status",
"type": "bar",
"object": "invoices",
"categoryField": "status",
"aggregate": "count"
},
{
"id": "accounts_signed",
"title": "Accounts Signed",
"type": "line",
"object": "accounts",
"categoryField": "signed_at",
"categoryGranularity": "month",
"aggregate": "count"
}
]
}Step 2 — add the built-in date range
Declare dateRange at the dashboard level. A preset/custom date-range control
appears in the filter bar above the widgets:
{
"dateRange": {
"field": "created_at",
"defaultRange": "last_30_days",
"allowCustomRange": true
}
}field— the default field the range applies to on every bound widget (falls back tocreated_atwhen omitted).defaultRange— the initially selected preset:today,yesterday,this_week,last_week,this_month,last_month,this_quarter,last_quarter,this_year,last_year,last_7_days,last_30_days,last_90_days, orcustom(starts empty and lets the user pick).allowCustomRange— offer a "Custom…" item that opens a from/to calendar (defaulttrue).
Presets stay symbolic until query time: they compile to date-macro tokens
({30_days_ago}, {current_month_start}, …) that each widget resolves
exactly like hand-authored widget filters — so a dashboard saved today still
means "last 30 days" tomorrow.
Step 3 — add a global filter
Add a globalFilters entry. Each entry renders one control in the filter bar:
{
"globalFilters": [
{
"name": "region",
"field": "region",
"label": "Region",
"type": "select",
"options": ["EMEA", "APAC", "AMER"]
}
]
}name— the stable filter name: the variable key the value is published under, and the key widgets reference infilterBindings. Defaults tofield. ("dateRange"is reserved for the built-in date range.)field— the default field the filter applies to on bound widgets.type— the control type:text,number,select,lookup, ordate.
| Type | Control | Generated condition |
|---|---|---|
text | input | { field: { "$contains": value } } |
number | numeric input | { field: value } (equality) |
select / lookup | dropdown | { field: value } (or $in for arrays) |
date | preset/custom range | { field: { "$gte": from, "$lte": to } } |
Static options accept the @objectstack/spec object form
({ "value": "amer", "label": "AMER" } — canonical, and what the spec
validates) or a bare-string shorthand (["EMEA", "APAC"]); the runtime
normalizes both to value/label pairs. Options can also be fetched from an
object at runtime:
{
"name": "industry",
"field": "industry",
"label": "Industry",
"type": "select",
"optionsFrom": {
"object": "accounts",
"valueField": "industry",
"labelField": "industry"
}
}With a dataset-capable data source, optionsFrom resolves distinct values
server-side (a GROUP BY over the source object), so the option list is
complete regardless of row count. Data sources without dataset queries fall
back to a best-effort client-side dedupe over the first 200 records.
Step 4 — bind each widget's own fields
By default every filter applies to its own field on every widget. When a
widget stores the concept under a different field — or should ignore a filter
— declare filterBindings on the widget:
{
"widgets": [
{
"id": "invoices_by_status",
"type": "bar",
"object": "invoices",
"categoryField": "status",
"aggregate": "count"
},
{
"id": "accounts_signed",
"type": "line",
"object": "accounts",
"categoryField": "signed_at",
"categoryGranularity": "month",
"aggregate": "count",
"filterBindings": { "dateRange": "signed_at", "region": "sales_region" }
},
{
"id": "total_invoices",
"title": "Total Invoices (all regions)",
"type": "metric",
"object": "invoices",
"aggregate": "count",
"filterBindings": { "region": false }
}
]
}Binding rules, in precedence order:
filterBindings[name]as a string — apply the filter to that field.filterBindings[name]: false— opt this widget out of that filter.- Legacy
targetWidgetson the filter — when set, only listed widget ids get the default binding (an explicitfilterBindingsentry still wins). - Otherwise the filter applies to its own
field(the built-in date range defaults todateRange.field ?? 'created_at').
That's the whole feature: changing any filter live re-scopes every bound
widget, each against its own field. Here it is running in the showcase
app's Revenue Pulse dashboard — the date range's default field is the
invoice issued_on, account widgets re-map it to signed_on, and the
"Accounts (all time)" KPI opts out of both filters:

Selecting EMEA re-scopes every bound widget live (invoices via their own
region, accounts via sales_region), while the opted-out KPI holds steady —
and a Reset button appears once any filter deviates from its default:

Bindings can also be edited visually: the Studio dashboard widget inspector shows a Dashboard filter bindings section (one row per declared filter) with an Apply toggle (opt-out) and a field picker for the override — no JSON editing required.

Reading filter values in expressions
Filter values are hosted as dashboard variables, so any widget expression can
read them under the page. scope, keyed by the filter's name:
{
"type": "text",
"value": "Region: ${page.region || 'All'}"
}{
"id": "emea_playbook",
"component": {
"type": "card",
"title": "EMEA Playbook",
"hidden": "${page.region !== 'EMEA'}"
}
}The built-in date range is an object under page.dateRange — a preset selection
is { "preset": "last_30_days" }, a custom range is
{ "from": "2026-01-01", "to": "2026-03-31" } (either bound may be absent).
Dataset widgets
Widgets bound to a semantic-layer dataset participate the same way: the
dashboard merges the scoped filter into the widget's filter, which the
dataset widget forwards to the dataset query as runtimeFilter. Inline
(object-based) and dataset-bound widgets can mix freely on one filtered
dashboard.
Nested variable scopes
When a filtered dashboard is embedded inside a Page that declares its own
variables, the two scopes merge: inside the dashboard subtree, page.*
resolves the outer Page's variables plus the dashboard's filter values, and a
dashboard filter only shadows an outer variable that has the same name.
Writes route to the scope that defines the variable — setting an outer-page
variable from inside the dashboard updates the outer scope, so both subtrees
stay in sync.
Known limitations
- Static-data widgets are not filtered — a widget with an inline
dataarray has no query to scope, so dashboard filters do not apply to it. Bind the widget to anobject(or adataset) if it should respond to filters. - Default bindings are metadata-checked for
objectwidgets only — when a filter's defaultfielddoes not exist on an inline widget's object, the binding is skipped with a console warning instead of issuing a query that matches nothing. Dataset-bound widgets can't be checked this way (the dashboard doesn't know the dataset's base-object fields), so map their filters explicitly withfilterBindings: { "<name>": "<field>" }or opt out withfalse. Explicit string bindings are always honoured as written — a typo shows up as a visibly empty widget rather than a silently dropped filter.
i18n
The filter bar's strings resolve from the dashboard.filters.* keys
(@object-ui/i18n ships en and zh entries — control labels come from each
filter's label, so translate those in your schema metadata).
Spec alignment
DashboardSchema.dateRange, GlobalFilterSchema (including name) and
DashboardWidgetSchema.filterBindings are part of @objectstack/spec
(framework#2501). Author dashboards against the spec shapes; ObjectUI renders
them.