diff --git a/.changeset/dashboard-filters-followups.md b/.changeset/dashboard-filters-followups.md new file mode 100644 index 000000000..d00510c11 --- /dev/null +++ b/.changeset/dashboard-filters-followups.md @@ -0,0 +1,20 @@ +--- +"@object-ui/i18n": patch +"@object-ui/types": patch +--- + +Dashboard-level filters follow-ups (#2578, framework#2501): + +- **i18n**: the `DashboardFilterBar` strings now ship as real locale entries — + `dashboard.filters.*` (bar label, "All time", "Custom…", "All", "Reset", + and the 13 date-range preset labels) added to `en` and `zh`. Previously the + bar always rendered the `useSafeTranslate` English fallbacks. +- **types**: `GlobalFilterSchema.name` and `DashboardWidgetSchema.filterBindings` + landed in `@objectstack/spec` (framework#2501), so the local type + annotations flip from "Pending alignment" to "Aligned" — no shape changes. + +Also adds five schema-catalog examples (`plugin-dashboard/filtered-dashboard-*`: +dynamic `optionsFrom` options, text/number/lookup filter types, dataset + +inline widget mix, `targetWidgets` allow-list, date presets + custom range) +and a new "Dashboard-Level Filters" guide page covering the full tutorial, +`page.*` expression usage, and known limitations with workarounds. diff --git a/content/docs/guide/dashboard-filters.md b/content/docs/guide/dashboard-filters.md new file mode 100644 index 000000000..a33e57e80 --- /dev/null +++ b/content/docs/guide/dashboard-filters.md @@ -0,0 +1,252 @@ +--- +title: "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 own `filter`. + +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-dashboard` +> example plus variants for dynamic options, text/number/lookup filter types, +> dataset widgets, the `targetWidgets` allow-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: + +```json +{ + "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: + +```json +{ + "dateRange": { + "field": "created_at", + "defaultRange": "last_30_days", + "allowCustomRange": true + } +} +``` + +- `field` — the **default** field the range applies to on every bound widget + (falls back to `created_at` when 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`, or `custom` (starts empty and lets the user pick). +- `allowCustomRange` — offer a "Custom…" item that opens a from/to calendar + (default `true`). + +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: + +```json +{ + "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 in `filterBindings`. Defaults to + `field`. (`"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`, or `date`. + +| 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 } }` | + +Options can be static (`options`) or fetched from an object at runtime: + +```json +{ + "name": "industry", + "field": "industry", + "label": "Industry", + "type": "select", + "optionsFrom": { + "object": "accounts", + "valueField": "industry", + "labelField": "industry" + } +} +``` + +`optionsFrom` fetches records through the dashboard's data source and +de-duplicates values client-side (top 200 records; server-side distinct is a +planned enhancement). + +### 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: + +```json +{ + "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: + +1. `filterBindings[name]` as a **string** — apply the filter to that field. +2. `filterBindings[name]: false` — opt this widget out of that filter. +3. Legacy `targetWidgets` on the filter — when set, only listed widget ids get + the default binding (an explicit `filterBindings` entry still wins). +4. Otherwise the filter applies to its own `field` (the built-in date range + defaults to `dateRange.field ?? 'created_at'`). + +That's the whole feature: changing any filter live re-scopes every bound +widget, each against **its own** field. + +## 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`: + +```json +{ + "type": "text", + "value": "Region: ${page.region || 'All'}" +} +``` + +```json +{ + "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. + +## Known limitations + +- **Embedding a Page with its own `variables`** — the dashboard hosts its + filter values in its own variables provider. When a dashboard and a + surrounding Page both declare variables, expressions inside the dashboard + resolve `page.*` against the **innermost** provider only: the outer Page's + variables are shadowed inside the dashboard subtree. Workaround: don't rely + on outer-page variables inside a filtered dashboard's widgets (or duplicate + the value into a dashboard filter). Merging nested variable contexts is a + candidate future enhancement. +- **Static-data widgets are not filtered** — a widget with an inline `data` + array has no query to scope, so dashboard filters do not apply to it. Bind + the widget to an `object` (or a `dataset`) if it should respond to filters. +- **Default bindings assume the field exists** — when a filter's default + `field` does not exist on a widget's object, the widget's query returns + empty (or errors, depending on the backend). Map the filter to the right + field with `filterBindings: { "": "" }`, or opt the widget out + with `filterBindings: { "": false }`. Metadata-aware skipping is a + planned enhancement. + +## 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. diff --git a/content/docs/guide/meta.json b/content/docs/guide/meta.json index 81280e19c..a4cde1c1f 100644 --- a/content/docs/guide/meta.json +++ b/content/docs/guide/meta.json @@ -21,6 +21,7 @@ "designing-app-navigation", "public-forms", "record-edit-modes", + "dashboard-filters", "slotted-pages", "console", "console-architecture", diff --git a/content/docs/plugins/plugin-dashboard.mdx b/content/docs/plugins/plugin-dashboard.mdx index 47f08bbbb..516b2958a 100644 --- a/content/docs/plugins/plugin-dashboard.mdx +++ b/content/docs/plugins/plugin-dashboard.mdx @@ -193,6 +193,13 @@ dataset query's `runtimeFilter`. Static-data widgets (inline `data` arrays) have no query to scope and are not filtered. Filter values are also readable in widget expressions as `page.`. +For a step-by-step tutorial — filter types, `optionsFrom` dynamic options, +`page.*` expression usage, and known limitations with workarounds — see the +[Dashboard-Level Filters guide](/docs/guide/dashboard-filters). The schema +catalog ships runnable variants under `plugin-dashboard/filtered-dashboard*` +(dynamic options, filter types, dataset widgets, `targetWidgets`, date +presets). + ## TypeScript Support ```plaintext diff --git a/examples/schema-catalog/src/index.ts b/examples/schema-catalog/src/index.ts index a98c36fbb..a95f8cf9f 100644 --- a/examples/schema-catalog/src/index.ts +++ b/examples/schema-catalog/src/index.ts @@ -388,6 +388,11 @@ import plugin_chatbot_customer_support_chat from './schemas/plugin-chatbot/custo import plugin_dashboard_basic_dashboard from './schemas/plugin-dashboard/basic-dashboard.json' with { type: 'json' }; import plugin_dashboard_e_commerce_dashboard from './schemas/plugin-dashboard/e-commerce-dashboard.json' with { type: 'json' }; import plugin_dashboard_filtered_dashboard from './schemas/plugin-dashboard/filtered-dashboard.json' with { type: 'json' }; +import plugin_dashboard_filtered_dashboard_dataset_widgets from './schemas/plugin-dashboard/filtered-dashboard-dataset-widgets.json' with { type: 'json' }; +import plugin_dashboard_filtered_dashboard_date_presets from './schemas/plugin-dashboard/filtered-dashboard-date-presets.json' with { type: 'json' }; +import plugin_dashboard_filtered_dashboard_dynamic_options from './schemas/plugin-dashboard/filtered-dashboard-dynamic-options.json' with { type: 'json' }; +import plugin_dashboard_filtered_dashboard_filter_types from './schemas/plugin-dashboard/filtered-dashboard-filter-types.json' with { type: 'json' }; +import plugin_dashboard_filtered_dashboard_target_widgets from './schemas/plugin-dashboard/filtered-dashboard-target-widgets.json' with { type: 'json' }; import plugin_dashboard_support_dashboard from './schemas/plugin-dashboard/support-dashboard.json' with { type: 'json' }; import plugin_editor_javascript_editor from './schemas/plugin-editor/javascript-editor.json' with { type: 'json' }; import plugin_editor_python_editor from './schemas/plugin-editor/python-editor.json' with { type: 'json' }; @@ -3938,6 +3943,51 @@ const REGISTRY: Record = { }, schema: plugin_dashboard_filtered_dashboard, }, + 'plugin-dashboard/filtered-dashboard-dataset-widgets': { + id: 'plugin-dashboard/filtered-dashboard-dataset-widgets', + meta: { + title: "Filtered Dashboard — Dataset + Inline Widgets", + description: "Dashboard filters scoping dataset-bound widgets (via the dataset query's runtimeFilter) alongside inline object widgets", + category: 'plugin-dashboard', + }, + schema: plugin_dashboard_filtered_dashboard_dataset_widgets, + }, + 'plugin-dashboard/filtered-dashboard-date-presets': { + id: 'plugin-dashboard/filtered-dashboard-date-presets', + meta: { + title: "Filtered Dashboard — Date Presets + Custom Range", + description: "Built-in dateRange with presets and allowCustomRange; per-widget date-field override and a dateRange: false opt-out", + category: 'plugin-dashboard', + }, + schema: plugin_dashboard_filtered_dashboard_date_presets, + }, + 'plugin-dashboard/filtered-dashboard-dynamic-options': { + id: 'plugin-dashboard/filtered-dashboard-dynamic-options', + meta: { + title: "Filtered Dashboard — Dynamic Options", + description: "Select filter whose options are fetched from object records via optionsFrom (distinct values at runtime)", + category: 'plugin-dashboard', + }, + schema: plugin_dashboard_filtered_dashboard_dynamic_options, + }, + 'plugin-dashboard/filtered-dashboard-filter-types': { + id: 'plugin-dashboard/filtered-dashboard-filter-types', + meta: { + title: "Filtered Dashboard — Text / Number / Lookup Filters", + description: "Text ($contains), number (equality) and lookup (optionsFrom) filter types, plus a fully opted-out metric", + category: 'plugin-dashboard', + }, + schema: plugin_dashboard_filtered_dashboard_filter_types, + }, + 'plugin-dashboard/filtered-dashboard-target-widgets': { + id: 'plugin-dashboard/filtered-dashboard-target-widgets', + meta: { + title: "Filtered Dashboard — Target Widgets Allow-list", + description: "Legacy targetWidgets allow-list: only listed widgets get the default binding; an explicit filterBindings entry still wins", + category: 'plugin-dashboard', + }, + schema: plugin_dashboard_filtered_dashboard_target_widgets, + }, 'plugin-editor/javascript-editor': { id: 'plugin-editor/javascript-editor', meta: { diff --git a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-dataset-widgets.json b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-dataset-widgets.json new file mode 100644 index 000000000..6186ae2a3 --- /dev/null +++ b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-dataset-widgets.json @@ -0,0 +1,45 @@ +{ + "type": "dashboard", + "title": "Sales Overview (dataset + inline)", + "columns": 2, + "gap": 4, + "dateRange": { + "field": "created_at", + "defaultRange": "this_quarter", + "allowCustomRange": true + }, + "globalFilters": [ + { + "name": "region", + "field": "region", + "label": "Region", + "type": "select", + "options": ["EMEA", "APAC", "AMER"] + } + ], + "widgets": [ + { + "id": "revenue_total", + "title": "Revenue", + "type": "metric", + "dataset": "sales", + "values": ["revenue"] + }, + { + "id": "revenue_by_stage", + "title": "Revenue by Stage", + "type": "donut", + "dataset": "sales", + "dimensions": ["stage"], + "values": ["revenue"] + }, + { + "id": "invoices_by_status", + "title": "Invoices by Status", + "type": "bar", + "object": "invoices", + "categoryField": "status", + "aggregate": "count" + } + ] +} diff --git a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-date-presets.json b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-date-presets.json new file mode 100644 index 000000000..16cbc9815 --- /dev/null +++ b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-date-presets.json @@ -0,0 +1,44 @@ +{ + "type": "dashboard", + "title": "Activity Over Time", + "columns": 2, + "gap": 4, + "dateRange": { + "field": "created_at", + "defaultRange": "last_90_days", + "allowCustomRange": true + }, + "widgets": [ + { + "id": "invoices_by_month", + "title": "Invoices by Month", + "type": "line", + "object": "invoices", + "categoryField": "created_at", + "categoryGranularity": "month", + "aggregate": "count" + }, + { + "id": "accounts_signed_by_month", + "title": "Accounts Signed by Month", + "type": "area", + "object": "accounts", + "categoryField": "signed_at", + "categoryGranularity": "month", + "aggregate": "count", + "filterBindings": { + "dateRange": "signed_at" + } + }, + { + "id": "all_time_total", + "title": "All-time Invoices", + "type": "metric", + "object": "invoices", + "aggregate": "count", + "filterBindings": { + "dateRange": false + } + } + ] +} diff --git a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-dynamic-options.json b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-dynamic-options.json new file mode 100644 index 000000000..0e4e3cb02 --- /dev/null +++ b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-dynamic-options.json @@ -0,0 +1,40 @@ +{ + "type": "dashboard", + "title": "Accounts by Industry", + "columns": 2, + "gap": 4, + "globalFilters": [ + { + "name": "industry", + "field": "industry", + "label": "Industry", + "type": "select", + "optionsFrom": { + "object": "accounts", + "valueField": "industry", + "labelField": "industry" + } + } + ], + "widgets": [ + { + "id": "accounts_by_industry", + "title": "Accounts by Industry", + "type": "bar", + "object": "accounts", + "categoryField": "industry", + "aggregate": "count" + }, + { + "id": "invoices_by_status", + "title": "Invoices by Status", + "type": "donut", + "object": "invoices", + "categoryField": "status", + "aggregate": "count", + "filterBindings": { + "industry": "account_industry" + } + } + ] +} diff --git a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-filter-types.json b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-filter-types.json new file mode 100644 index 000000000..ce22bc6ca --- /dev/null +++ b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-filter-types.json @@ -0,0 +1,65 @@ +{ + "type": "dashboard", + "title": "Invoice Explorer", + "columns": 2, + "gap": 4, + "globalFilters": [ + { + "name": "customer", + "field": "customer_name", + "label": "Customer", + "type": "text" + }, + { + "name": "amount", + "field": "amount", + "label": "Amount", + "type": "number" + }, + { + "name": "owner", + "field": "owner", + "label": "Owner", + "type": "lookup", + "optionsFrom": { + "object": "users", + "valueField": "id", + "labelField": "name" + } + } + ], + "widgets": [ + { + "id": "invoices_by_status", + "title": "Invoices by Status", + "type": "bar", + "object": "invoices", + "categoryField": "status", + "aggregate": "count" + }, + { + "id": "invoices_by_month", + "title": "Invoices by Month", + "type": "line", + "object": "invoices", + "categoryField": "created_at", + "categoryGranularity": "month", + "aggregate": "count", + "filterBindings": { + "owner": "assigned_to" + } + }, + { + "id": "total_invoices", + "title": "Total Invoices (unfiltered)", + "type": "metric", + "object": "invoices", + "aggregate": "count", + "filterBindings": { + "customer": false, + "amount": false, + "owner": false + } + } + ] +} diff --git a/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-target-widgets.json b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-target-widgets.json new file mode 100644 index 000000000..0b8becca2 --- /dev/null +++ b/examples/schema-catalog/src/schemas/plugin-dashboard/filtered-dashboard-target-widgets.json @@ -0,0 +1,45 @@ +{ + "type": "dashboard", + "title": "Invoice Pipeline", + "columns": 2, + "gap": 4, + "globalFilters": [ + { + "name": "status", + "field": "status", + "label": "Status", + "type": "select", + "options": ["draft", "sent", "paid", "void"], + "targetWidgets": ["invoices_by_region", "invoices_recent"] + } + ], + "widgets": [ + { + "id": "invoices_by_region", + "title": "Invoices by Region (allow-listed)", + "type": "bar", + "object": "invoices", + "categoryField": "region", + "aggregate": "count" + }, + { + "id": "invoices_recent", + "title": "Recent Invoices (allow-listed, field override)", + "type": "line", + "object": "invoices", + "categoryField": "created_at", + "categoryGranularity": "week", + "aggregate": "count", + "filterBindings": { + "status": "state" + } + }, + { + "id": "total_invoices", + "title": "Total Invoices (not allow-listed)", + "type": "metric", + "object": "invoices", + "aggregate": "count" + } + ] +} diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 4db7ab77d..e6c098f9f 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -915,6 +915,29 @@ const en = { vsYesterday: 'vs yesterday', vsPreviousPeriod: 'vs previous period', }, + filters: { + label: 'Dashboard filters', + dateRange: 'Date range', + allTime: 'All time', + custom: 'Custom…', + all: 'All', + reset: 'Reset', + range: { + today: 'Today', + yesterday: 'Yesterday', + this_week: 'This week', + last_week: 'Last week', + this_month: 'This month', + last_month: 'Last month', + this_quarter: 'This quarter', + last_quarter: 'Last quarter', + this_year: 'This year', + last_year: 'Last year', + last_7_days: 'Last 7 days', + last_30_days: 'Last 30 days', + last_90_days: 'Last 90 days', + }, + }, }, configPanel: { save: 'Save', diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 44f0b9cbb..0eca3fe6e 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -992,6 +992,29 @@ const zh = { vsYesterday: '较昨日', vsPreviousPeriod: '较上期', }, + filters: { + label: '仪表盘过滤器', + dateRange: '日期范围', + allTime: '全部时间', + custom: '自定义…', + all: '全部', + reset: '重置', + range: { + today: '今天', + yesterday: '昨天', + this_week: '本周', + last_week: '上周', + this_month: '本月', + last_month: '上月', + this_quarter: '本季度', + last_quarter: '上季度', + this_year: '今年', + last_year: '去年', + last_7_days: '近 7 天', + last_30_days: '近 30 天', + last_90_days: '近 90 天', + }, + }, }, configPanel: { save: '保存', diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index f3896b3dd..a51fc09e9 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -750,7 +750,7 @@ export interface DashboardWidgetSchema { * - false → opt this widget out of that filter * - absent → default binding: the filter's own `field` * (dateRange: `dateRange.field ?? 'created_at'`) - * Pending alignment with @objectstack/spec DashboardWidgetSchema + * Aligned with @objectstack/spec DashboardWidgetSchema.filterBindings * (framework#2501). */ filterBindings?: Record; @@ -800,7 +800,7 @@ export interface DashboardSchema extends BaseSchema { /** * Stable filter name used as the dashboard-variable key and as the key * widgets reference in `filterBindings`. Defaults to `field`. - * Pending alignment with @objectstack/spec GlobalFilterSchema + * Aligned with @objectstack/spec GlobalFilterSchema.name * (framework#2501). */ name?: string; diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index 3a5ad7c41..ca4c2fd4d 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -290,7 +290,7 @@ export const DashboardWidgetSchema = z.object({ /** * Global Filter Schema — a dashboard-level filter definition. - * Pending alignment with @objectstack/spec GlobalFilterSchema (framework#2501: `name`). + * Aligned with @objectstack/spec GlobalFilterSchema (framework#2501: `name`). */ export const GlobalFilterSchema = z.object({ name: z.string().optional().describe('Stable filter name (variable key); defaults to field'),