|
| 1 | +--- |
| 2 | +title: "Dashboard-Level Filters" |
| 3 | +--- |
| 4 | + |
| 5 | +A dashboard often needs one top-level filter — a date range, a region select — |
| 6 | +that drives **several charts at once**. ObjectUI models this as a |
| 7 | +**dashboard-level parameter**, not a shared dataset: |
| 8 | + |
| 9 | +- The **filter control and its value live on the dashboard** — hosted as |
| 10 | + dashboard-level variables (the page/dashboard variables primitive). |
| 11 | +- Each widget declares which of **its own** fields a filter binds to via |
| 12 | + `filterBindings` — a small mapping, not a copied query. |
| 13 | +- At render time the dashboard **broadcasts** the active values into every |
| 14 | + bound widget's inline query, `AND`-combined with the widget's own `filter`. |
| 15 | + |
| 16 | +Charts stay inline and self-contained; one place owns the filter; each chart |
| 17 | +edit stays local. |
| 18 | + |
| 19 | +> Working examples: the schema catalog ships a `plugin-dashboard/filtered-dashboard` |
| 20 | +> example plus variants for dynamic options, text/number/lookup filter types, |
| 21 | +> dataset widgets, the `targetWidgets` allow-list, and date presets with a |
| 22 | +> custom range. |
| 23 | +
|
| 24 | +## Tutorial: from zero to a filtered dashboard |
| 25 | + |
| 26 | +### Step 1 — a plain dashboard |
| 27 | + |
| 28 | +Start from two charts over **different** objects. Without filters they always |
| 29 | +show everything: |
| 30 | + |
| 31 | +```json |
| 32 | +{ |
| 33 | + "type": "dashboard", |
| 34 | + "columns": 2, |
| 35 | + "widgets": [ |
| 36 | + { |
| 37 | + "id": "invoices_by_status", |
| 38 | + "title": "Invoices by Status", |
| 39 | + "type": "bar", |
| 40 | + "object": "invoices", |
| 41 | + "categoryField": "status", |
| 42 | + "aggregate": "count" |
| 43 | + }, |
| 44 | + { |
| 45 | + "id": "accounts_signed", |
| 46 | + "title": "Accounts Signed", |
| 47 | + "type": "line", |
| 48 | + "object": "accounts", |
| 49 | + "categoryField": "signed_at", |
| 50 | + "categoryGranularity": "month", |
| 51 | + "aggregate": "count" |
| 52 | + } |
| 53 | + ] |
| 54 | +} |
| 55 | +``` |
| 56 | + |
| 57 | +### Step 2 — add the built-in date range |
| 58 | + |
| 59 | +Declare `dateRange` at the dashboard level. A preset/custom date-range control |
| 60 | +appears in the filter bar above the widgets: |
| 61 | + |
| 62 | +```json |
| 63 | +{ |
| 64 | + "dateRange": { |
| 65 | + "field": "created_at", |
| 66 | + "defaultRange": "last_30_days", |
| 67 | + "allowCustomRange": true |
| 68 | + } |
| 69 | +} |
| 70 | +``` |
| 71 | + |
| 72 | +- `field` — the **default** field the range applies to on every bound widget |
| 73 | + (falls back to `created_at` when omitted). |
| 74 | +- `defaultRange` — the initially selected preset: `today`, `yesterday`, |
| 75 | + `this_week`, `last_week`, `this_month`, `last_month`, `this_quarter`, |
| 76 | + `last_quarter`, `this_year`, `last_year`, `last_7_days`, `last_30_days`, |
| 77 | + `last_90_days`, or `custom` (starts empty and lets the user pick). |
| 78 | +- `allowCustomRange` — offer a "Custom…" item that opens a from/to calendar |
| 79 | + (default `true`). |
| 80 | + |
| 81 | +Presets stay **symbolic** until query time: they compile to date-macro tokens |
| 82 | +(`{30_days_ago}`, `{current_month_start}`, …) that each widget resolves |
| 83 | +exactly like hand-authored widget filters — so a dashboard saved today still |
| 84 | +means "last 30 days" tomorrow. |
| 85 | + |
| 86 | +### Step 3 — add a global filter |
| 87 | + |
| 88 | +Add a `globalFilters` entry. Each entry renders one control in the filter bar: |
| 89 | + |
| 90 | +```json |
| 91 | +{ |
| 92 | + "globalFilters": [ |
| 93 | + { |
| 94 | + "name": "region", |
| 95 | + "field": "region", |
| 96 | + "label": "Region", |
| 97 | + "type": "select", |
| 98 | + "options": ["EMEA", "APAC", "AMER"] |
| 99 | + } |
| 100 | + ] |
| 101 | +} |
| 102 | +``` |
| 103 | + |
| 104 | +- `name` — the **stable filter name**: the variable key the value is published |
| 105 | + under, and the key widgets reference in `filterBindings`. Defaults to |
| 106 | + `field`. (`"dateRange"` is reserved for the built-in date range.) |
| 107 | +- `field` — the default field the filter applies to on bound widgets. |
| 108 | +- `type` — the control type: `text`, `number`, `select`, `lookup`, or `date`. |
| 109 | + |
| 110 | +| Type | Control | Generated condition | |
| 111 | +| --- | --- | --- | |
| 112 | +| `text` | input | `{ field: { "$contains": value } }` | |
| 113 | +| `number` | numeric input | `{ field: value }` (equality) | |
| 114 | +| `select` / `lookup` | dropdown | `{ field: value }` (or `$in` for arrays) | |
| 115 | +| `date` | preset/custom range | `{ field: { "$gte": from, "$lte": to } }` | |
| 116 | + |
| 117 | +Options can be static (`options`) or fetched from an object at runtime: |
| 118 | + |
| 119 | +```json |
| 120 | +{ |
| 121 | + "name": "industry", |
| 122 | + "field": "industry", |
| 123 | + "label": "Industry", |
| 124 | + "type": "select", |
| 125 | + "optionsFrom": { |
| 126 | + "object": "accounts", |
| 127 | + "valueField": "industry", |
| 128 | + "labelField": "industry" |
| 129 | + } |
| 130 | +} |
| 131 | +``` |
| 132 | + |
| 133 | +`optionsFrom` fetches records through the dashboard's data source and |
| 134 | +de-duplicates values client-side (top 200 records; server-side distinct is a |
| 135 | +planned enhancement). |
| 136 | + |
| 137 | +### Step 4 — bind each widget's own fields |
| 138 | + |
| 139 | +By default every filter applies to its own `field` on every widget. When a |
| 140 | +widget stores the concept under a different field — or should ignore a filter |
| 141 | +— declare `filterBindings` on the widget: |
| 142 | + |
| 143 | +```json |
| 144 | +{ |
| 145 | + "widgets": [ |
| 146 | + { |
| 147 | + "id": "invoices_by_status", |
| 148 | + "type": "bar", |
| 149 | + "object": "invoices", |
| 150 | + "categoryField": "status", |
| 151 | + "aggregate": "count" |
| 152 | + }, |
| 153 | + { |
| 154 | + "id": "accounts_signed", |
| 155 | + "type": "line", |
| 156 | + "object": "accounts", |
| 157 | + "categoryField": "signed_at", |
| 158 | + "categoryGranularity": "month", |
| 159 | + "aggregate": "count", |
| 160 | + "filterBindings": { "dateRange": "signed_at", "region": "sales_region" } |
| 161 | + }, |
| 162 | + { |
| 163 | + "id": "total_invoices", |
| 164 | + "title": "Total Invoices (all regions)", |
| 165 | + "type": "metric", |
| 166 | + "object": "invoices", |
| 167 | + "aggregate": "count", |
| 168 | + "filterBindings": { "region": false } |
| 169 | + } |
| 170 | + ] |
| 171 | +} |
| 172 | +``` |
| 173 | + |
| 174 | +Binding rules, in precedence order: |
| 175 | + |
| 176 | +1. `filterBindings[name]` as a **string** — apply the filter to that field. |
| 177 | +2. `filterBindings[name]: false` — opt this widget out of that filter. |
| 178 | +3. Legacy `targetWidgets` on the filter — when set, only listed widget ids get |
| 179 | + the default binding (an explicit `filterBindings` entry still wins). |
| 180 | +4. Otherwise the filter applies to its own `field` (the built-in date range |
| 181 | + defaults to `dateRange.field ?? 'created_at'`). |
| 182 | + |
| 183 | +That's the whole feature: changing any filter live re-scopes every bound |
| 184 | +widget, each against **its own** field. |
| 185 | + |
| 186 | +## Reading filter values in expressions |
| 187 | + |
| 188 | +Filter values are hosted as dashboard variables, so any widget expression can |
| 189 | +read them under the `page.` scope, keyed by the filter's `name`: |
| 190 | + |
| 191 | +```json |
| 192 | +{ |
| 193 | + "type": "text", |
| 194 | + "value": "Region: ${page.region || 'All'}" |
| 195 | +} |
| 196 | +``` |
| 197 | + |
| 198 | +```json |
| 199 | +{ |
| 200 | + "id": "emea_playbook", |
| 201 | + "component": { |
| 202 | + "type": "card", |
| 203 | + "title": "EMEA Playbook", |
| 204 | + "hidden": "${page.region !== 'EMEA'}" |
| 205 | + } |
| 206 | +} |
| 207 | +``` |
| 208 | + |
| 209 | +The built-in date range is an object under `page.dateRange` — a preset selection |
| 210 | +is `{ "preset": "last_30_days" }`, a custom range is |
| 211 | +`{ "from": "2026-01-01", "to": "2026-03-31" }` (either bound may be absent). |
| 212 | + |
| 213 | +## Dataset widgets |
| 214 | + |
| 215 | +Widgets bound to a semantic-layer `dataset` participate the same way: the |
| 216 | +dashboard merges the scoped filter into the widget's `filter`, which the |
| 217 | +dataset widget forwards to the dataset query as `runtimeFilter`. Inline |
| 218 | +(`object`-based) and dataset-bound widgets can mix freely on one filtered |
| 219 | +dashboard. |
| 220 | + |
| 221 | +## Known limitations |
| 222 | + |
| 223 | +- **Embedding a Page with its own `variables`** — the dashboard hosts its |
| 224 | + filter values in its own variables provider. When a dashboard and a |
| 225 | + surrounding Page both declare variables, expressions inside the dashboard |
| 226 | + resolve `page.*` against the **innermost** provider only: the outer Page's |
| 227 | + variables are shadowed inside the dashboard subtree. Workaround: don't rely |
| 228 | + on outer-page variables inside a filtered dashboard's widgets (or duplicate |
| 229 | + the value into a dashboard filter). Merging nested variable contexts is a |
| 230 | + candidate future enhancement. |
| 231 | +- **Static-data widgets are not filtered** — a widget with an inline `data` |
| 232 | + array has no query to scope, so dashboard filters do not apply to it. Bind |
| 233 | + the widget to an `object` (or a `dataset`) if it should respond to filters. |
| 234 | +- **Default bindings assume the field exists** — when a filter's default |
| 235 | + `field` does not exist on a widget's object, the widget's query returns |
| 236 | + empty (or errors, depending on the backend). Map the filter to the right |
| 237 | + field with `filterBindings: { "<name>": "<field>" }`, or opt the widget out |
| 238 | + with `filterBindings: { "<name>": false }`. Metadata-aware skipping is a |
| 239 | + planned enhancement. |
| 240 | + |
| 241 | +## i18n |
| 242 | + |
| 243 | +The filter bar's strings resolve from the `dashboard.filters.*` keys |
| 244 | +(`@object-ui/i18n` ships `en` and `zh` entries — control labels come from each |
| 245 | +filter's `label`, so translate those in your schema metadata). |
| 246 | + |
| 247 | +## Spec alignment |
| 248 | + |
| 249 | +`DashboardSchema.dateRange`, `GlobalFilterSchema` (including `name`) and |
| 250 | +`DashboardWidgetSchema.filterBindings` are part of `@objectstack/spec` |
| 251 | +(framework#2501). Author dashboards against the spec shapes; ObjectUI renders |
| 252 | +them. |
0 commit comments