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
20 changes: 20 additions & 0 deletions .changeset/29213-dashboard-global-filter-reserved-date-range.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/spec': minor
'@object-ui/types': minor
---

fix(spec)!: a dashboard global filter keyed `dateRange` beside a declared `dateRange` is refused at every door that parses it

Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: none — `skills/objectstack-ui/rules/dashboards.md` teaches `dateRange` as the reserved name of the built-in date range and no file under `skills/**` keys a `globalFilters` entry `dateRange`; every taught use stays true. `content/docs/ui/dashboards.mdx` gains one sentence naming the refusal.

**BREAKING** for a dashboard that declares `dateRange` and also keys a `globalFilters` entry `dateRange`.

- **Before.** The built-in date range is a filter the dashboard declares under the reserved key `dateRange`. A `globalFilters` entry keyed `dateRange` (its `name`, or its `field` when it has no `name`) beside it parsed, and the dashboard save door (`PUT /api/v1/meta/dashboard/X`) answered 200 in draft and in publish. The console builds the date range first and lets the later definition win, so the board drew the entry's control in place of the declared date range and every widget binding `dateRange` was filtered by the entry's field. The only trace was a browser console warning.
- **Now.** `DashboardSchema` refuses the entry, so every door that parses a dashboard refuses it: `defineStack`, `os validate`, `os build` and the dashboard save door (422 `INVALID_METADATA`). One issue per such entry, at its `name` (or its `field`, when it has no `name`): ``globalFilters[i] is keyed 'dateRange', the reserved name of the built-in date range this dashboard declares in `dateRange`. …``. The rule reads the sibling `dateRange`, so it sits on the dashboard object and is exported as `checkDashboardReservedDateRangeGlobalFilters`: a mirror that rebuilds the dashboard from `DashboardSchema.shape` drops it unless it chains the export, as `@object-ui/types`' `DashboardComponentSchema` now does, so the console's authoring door refuses the same dashboard. On `DashboardSchema` itself, zod 4 now throws on `.omit()` / `.pick()` / `.partial()` (an object carrying a refinement); no caller in this repository derives it that way.
- **Not changed.** On a dashboard that does not declare `dateRange`, an entry keyed `dateRange` is an ordinary filter and stays legal. A named entry whose `field` is `dateRange` is keyed by its name and stays legal.
- **The remedy.** Give the entry another `name` (and rename every widget `filterBindings.dateRange` key that means it), or delete it and keep the built-in `dateRange`.
- **Stored rows.** A stored dashboard carrying the pair still loads, with `_diagnostics` reading `valid: false` and carrying the refusal, and its next save (draft or publish) is refused until it is fixed. Nothing rewrites it: whether the entry was meant as the date range or as a filter of its own only the author knows. The upgrade guide entry is `ui-dashboard-global-filter-reserved-date-range-refused`.

<!-- adr-0087: registered ui-dashboard-global-filter-reserved-date-range-refused -->
6 changes: 3 additions & 3 deletions content/docs/references/ui/dashboard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const result = DashboardSchema.parse(data);
| **refreshIntervalSeconds** | `number` | optional | Auto-refresh interval in seconds |
| **refreshInterval** | `never` | optional | [REMOVED] `dashboard.refreshInterval` was renamed to `refreshIntervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `refreshIntervalSeconds`; the value (seconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
| **dateRange** | `{ field?: string; defaultRange?: Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …>; allowCustomRange?: boolean }` | optional | Global dashboard date range filter configuration |
| **globalFilters** | `{ name?: string; field: string; object?: string; label?: string \| Record<string, string>; … }[]` | optional | Global filters that apply to all widgets in the dashboard. Each filter name (its field when it has no name) is declared once |
| **globalFilters** | `{ name?: string; field: string; object?: string; label?: string \| Record<string, string>; … }[]` | optional | Global filters that apply to all widgets in the dashboard. Each filter name (its field when it has no name) is declared once, and is not dateRange when the dashboard declares dateRange |
| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. The server enforces it: a user who does not hold them all is not listed the item and is refused it by name; a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. |
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this dashboard. |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
Expand Down Expand Up @@ -92,7 +92,7 @@ const result = DashboardSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **name** | `string` | optional | Stable filter name (variable key); defaults to field |
| **name** | `string` | optional | Stable filter name (variable key); defaults to field. Refused as dateRange when the dashboard declares dateRange (the built-in date range's reserved name) |
| **field** | `string` | ✅ | Field name to filter on — at the authoring layer it resolves against the object behind each bound widget's dataset (`dataset.object`), not against that dataset's declared `dimensions`; enforced by the lint rule `dashboard-filter-field-unknown` (severity error) |
| **object** | `string` | optional | Object whose `fields.<object>.<field>` translation-bundle entry resolves this filter's field label and option labels |
| **label** | `string \| Record<string, string>` | optional | Display label for the filter |
Expand Down Expand Up @@ -317,7 +317,7 @@ Widget configuration — declared query keys + open renderer extras

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **name** | `string` | optional | Stable filter name (variable key); defaults to field |
| **name** | `string` | optional | Stable filter name (variable key); defaults to field. Refused as dateRange when the dashboard declares dateRange (the built-in date range's reserved name) |
| **field** | `string` | ✅ | Field name to filter on — at the authoring layer it resolves against the object behind each bound widget's dataset (`dataset.object`), not against that dataset's declared `dimensions`; enforced by the lint rule `dashboard-filter-field-unknown` (severity error) |
| **object** | `string` | optional | Object whose `fields.<object>.<field>` translation-bundle entry resolves this filter's field label and option labels |
| **label** | `string \| Record<string, string>` | optional | Display label for the filter |
Expand Down
7 changes: 6 additions & 1 deletion content/docs/ui/dashboards.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -412,9 +412,14 @@ globalFilters: [
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. The
to `field`. The
filter above names itself `region` for exactly that reason — left to default it
would be keyed `account.region`, dots and all, in every expression and binding.
On a dashboard that declares `dateRange`, the name `dateRange` is reserved for
that built-in date range: an entry keyed `dateRange` (by `name`, or by `field`
when unnamed) is refused, because it would replace the date range's control and
every widget's `filterBindings.dateRange` binding. On a dashboard that does not
declare `dateRange`, the name is free and such an entry is an ordinary filter.

### Filter controls and options

Expand Down
1 change: 1 addition & 0 deletions packages/spec/api-surface/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,7 @@
"chartAggregateResultKeys (function)",
"chartAggregateValueKey (function)",
"checkAppLandingItems (function)",
"checkDashboardReservedDateRangeGlobalFilters (function)",
"checkDashboardWidgetChartMeasureArity (function)",
"checkDashboardWidgetMetricMeasureArity (function)",
"checkDashboardWidgetStageOrder (function)",
Expand Down
11 changes: 8 additions & 3 deletions packages/spec/dropped-refinements.baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"measured": {
"zod": "4.4.3",
"publishedSchemasWithDroppedRefinements": 248,
"droppedRefinementSites": 988,
"droppedRefinementSites": 993,
"refinementSitesThatDidProject": 369,
"refinementSitesWithNoJsonFormToCompare": 0
},
Expand Down Expand Up @@ -81,6 +81,7 @@
"manifest.assignmentRules.element.entries.element",
"manifest.assignmentRules.element.entries.element.assignTo",
"manifest.connectors.element.out",
"manifest.dashboards.element",
"manifest.dashboards.element.globalFilters",
"manifest.dashboards.element.globalFilters.element",
"manifest.dashboards.element.header.actions.element",
Expand Down Expand Up @@ -126,8 +127,8 @@
"manifest.reports.element.runtimeFilter.lazy",
"manifest.sharingRules.element.sharedWith",
"manifest.skills.element.triggerConditions.element",
"manifest.translations.element.valueType.flows.valueType.notices.valueType.message",
"manifest.translations.element.valueType.flows.valueType.errorMessage",
"manifest.translations.element.valueType.flows.valueType.notices.valueType.message",
"manifest.translations.element.valueType.flows.valueType.refusals.valueType.message",
"manifest.translations.element.valueType.flows.valueType.screens.valueType.description",
"manifest.translations.element.valueType.flows.valueType.screens.valueType.title",
Expand Down Expand Up @@ -228,6 +229,7 @@
"data.options[1].manifest.assignmentRules.element.entries.element",
"data.options[1].manifest.assignmentRules.element.entries.element.assignTo",
"data.options[1].manifest.connectors.element.out",
"data.options[1].manifest.dashboards.element",
"data.options[1].manifest.dashboards.element.globalFilters",
"data.options[1].manifest.dashboards.element.globalFilters.element",
"data.options[1].manifest.dashboards.element.header.actions.element",
Expand Down Expand Up @@ -367,6 +369,7 @@
"options[1].manifest.assignmentRules.element.entries.element",
"options[1].manifest.assignmentRules.element.entries.element.assignTo",
"options[1].manifest.connectors.element.out",
"options[1].manifest.dashboards.element",
"options[1].manifest.dashboards.element.globalFilters",
"options[1].manifest.dashboards.element.globalFilters.element",
"options[1].manifest.dashboards.element.header.actions.element",
Expand Down Expand Up @@ -409,8 +412,8 @@
"options[1].manifest.reports.element.runtimeFilter.lazy",
"options[1].manifest.sharingRules.element.sharedWith",
"options[1].manifest.skills.element.triggerConditions.element",
"options[1].manifest.translations.element.valueType.flows.valueType.notices.valueType.message",
"options[1].manifest.translations.element.valueType.flows.valueType.errorMessage",
"options[1].manifest.translations.element.valueType.flows.valueType.notices.valueType.message",
"options[1].manifest.translations.element.valueType.flows.valueType.refusals.valueType.message",
"options[1].manifest.translations.element.valueType.flows.valueType.screens.valueType.description",
"options[1].manifest.translations.element.valueType.flows.valueType.screens.valueType.title",
Expand Down Expand Up @@ -446,6 +449,7 @@
"data.packages.element.options[1].manifest.assignmentRules.element.entries.element",
"data.packages.element.options[1].manifest.assignmentRules.element.entries.element.assignTo",
"data.packages.element.options[1].manifest.connectors.element.out",
"data.packages.element.options[1].manifest.dashboards.element",
"data.packages.element.options[1].manifest.dashboards.element.globalFilters",
"data.packages.element.options[1].manifest.dashboards.element.globalFilters.element",
"data.packages.element.options[1].manifest.dashboards.element.header.actions.element",
Expand Down Expand Up @@ -1592,6 +1596,7 @@
},
"ui/Dashboard": {
"sites": [
"",
"globalFilters",
"globalFilters.element",
"header.actions.element",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/export-origins/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,7 @@
"chartAggregateResultKeys": "src/ui/chart-aggregate.ts#chartAggregateResultKeys (function)",
"chartAggregateValueKey": "src/ui/chart-aggregate.ts#chartAggregateValueKey (function)",
"checkAppLandingItems": "src/ui/app.zod.ts#checkAppLandingItems (function)",
"checkDashboardReservedDateRangeGlobalFilters": "src/ui/dashboard.zod.ts#checkDashboardReservedDateRangeGlobalFilters (function)",
"checkDashboardWidgetChartMeasureArity": "src/ui/dashboard.zod.ts#checkDashboardWidgetChartMeasureArity (function)",
"checkDashboardWidgetMetricMeasureArity": "src/ui/dashboard.zod.ts#checkDashboardWidgetMetricMeasureArity (function)",
"checkDashboardWidgetStageOrder": "src/ui/dashboard.zod.ts#checkDashboardWidgetStageOrder (function)",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

// #29213. An accept-set narrowing of the dashboard schema with no conversion:
// on a dashboard that declares the built-in `dateRange`, a globalFilters entry
// keyed `dateRange` (name, else field) is refused by the dashboard parse.
// Whether the entry was meant as the date range or as a filter of its own, and
// which widgets bind it, only the author knows. Same class as
// ui-dashboard-global-filter-duplicate-name-refused.
export const entry: SemanticMigration = {
id: 'ui-dashboard-global-filter-reserved-date-range-refused',
surface: 'dashboard.globalFilters — an entry keyed `dateRange` (its `name`, or its `field` when it has '
+ 'no `name`) on a dashboard that also declares `dateRange`, e.g. dateRange: { field: \'close_date\' }, '
+ 'globalFilters: [{ name: \'dateRange\', field: \'region\' }]',
replacement: 'Give the entry another `name` (and rename every widget `filterBindings.dateRange` key that '
+ 'means it), or delete it and keep the built-in `dateRange`. On a dashboard that does not declare '
+ '`dateRange`, an entry keyed `dateRange` stays legal.',
reason:
'The built-in date range is a filter the dashboard declares under the reserved key `dateRange`: the '
+ 'key the filter bar draws its control under and the key a widget\'s `filterBindings` names. A '
+ '`globalFilters` entry keyed `dateRange` beside it parsed and the dashboard save door answered 200. '
+ 'The console and lint both build the date range first and let the later definition win, so the '
+ 'board drew the entry\'s control in place of the declared date range, every widget binding '
+ '`dateRange` was filtered by the entry\'s field, and the only trace was a browser console warning. '
+ '`DashboardSchema` now refuses the entry at every door that parses a dashboard: `defineStack`, '
+ '`os validate`, `os build` and the dashboard save door (`422 INVALID_METADATA`), with one issue at '
+ 'the entry\'s `name` (or `field`) naming the reserved word and the built-in date range. Not '
+ 'convertible: whether the entry was meant to be the date range or a filter of its own is a fact '
+ 'only the author holds.',
acceptanceCriteria:
'Each dashboard parses without a `custom` issue at `globalFilters[i].name` (or `.field`) of the form '
+ '"globalFilters[i] is keyed \'dateRange\', the reserved name of the built-in date range this '
+ 'dashboard declares in `dateRange`", and saves. After renaming, check that every widget '
+ '`filterBindings` key names the filter it meant, and that `os validate` reports no '
+ '`dashboard-filter-binding-unknown` finding. A stored `sys_metadata` dashboard that still carries '
+ 'the pair still loads, with `_diagnostics` reading `valid: false` and carrying this refusal, and its '
+ 'next save (draft or publish) is refused; nothing rewrites it. Measured before the change: no '
+ 'authored dashboard in this repository\'s examples, apps, packages or ui tree keys a global filter '
+ '`dateRange`.',
};
Loading
Loading