Skip to content

Commit c268b72

Browse files
fix(spec)!: refuse a dashboard globalFilters entry keyed dateRange beside a declared dateRange (#29370)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9XkoBAMEDmazTVe2qJYMJPG)_ Fixes #29213 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. ## 维护者速读 - 改了什么:仪表盘声明了内置日期范围 `dateRange` 时,`globalFilters` 里再写一个键为 `dateRange` 的筛选器(按 `name`,无 `name` 时按 `field`)现在会被拒收,报错落在该条目上并点名保留字。 - 为什么改:以前这样写能保存,控制台和 lint 都让后写的定义覆盖内置日期范围,看板上日期选择器消失,所有绑定 `dateRange` 的组件被另一个字段过滤,只有浏览器控制台一条警告。 - 风险与回滚:收紧契约,原本能通过的这类仪表盘会被拒;仓库内实测 0 个仪表盘这样写。回滚即还原本 PR 的两个提交。 - 先看:`packages/spec/src/ui/duplicate-global-filter-names.ts`(新谓词与报错文案)、`packages/spec/src/ui/dashboard.zod.ts`(挂在仪表盘对象上的 refine)、`packages/spec/dropped-refinements.baseline.json`(+5 个站点)。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/ui/dashboard-global-filter-reserved-date-range.test.ts`,预期 11 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes - `packages/spec/src/ui/duplicate-global-filter-names.ts` gains `reservedDateRangeGlobalFilters(dateRange, globalFilters)` beside `duplicateGlobalFilterNames`, sharing its `filterKeyOf` key derivation. I extended the module rather than adding a sibling because it is the same defect class: the built-in date range is a filter declared under the key `dateRange`, so an entry keyed `dateRange` is a second definition behind one key, exactly what the module's existing predicate refuses inside the array. The header no longer says `dateRange` "is not judged here". - `packages/spec/src/ui/dashboard.zod.ts` attaches `refuseReservedDateRangeGlobalFilters` to the dashboard OBJECT (`strictObject(...).superRefine(...)`), not to the array, because the verdict reads the sibling `dateRange`. One issue per entry, at `globalFilters[i].name` (or `.field` when unnamed), naming the reserved word and the built-in date range. The `GlobalFilterSchema.name` describe, the `globalFilters` describe and the `filterBindings` TSDoc now say the entry is refused, not only reserved. - The refusal fires only when `dateRange` is declared (any non-null object, the test both resolvers use). On a dashboard with no `dateRange` the key is free and an entry keyed `dateRange` stays legal (see Acceptance notes). - `packages/spec/dropped-refinements.baseline.json`: the build named five new sites (`ui/Dashboard` root and `manifest.dashboards.element` on the four installed-package projections); `measured.droppedRefinementSites` 988 → 993. - ADR-0087 semantic entry `ui-dashboard-global-filter-reserved-date-range-refused` (`packages/spec/src/migrations/entries/semantic/`), changeset `.changeset/29213-dashboard-global-filter-reserved-date-range.md` (`minor`, `registered`), regenerated `content/docs/references/ui/dashboard.mdx`, one sentence in `content/docs/ui/dashboards.mdx`. - Forced test edits: none. No existing test or fixture changed. ## Why Measured on `main` @ `5a2bdf9c` before the change, with `dateRange: { field: 'name' | 'close_date' }` and `globalFilters: [{ name: 'dateRange', field: … }]`: - Parse: `DashboardSchema.safeParse` answered `success: true` for the named entry, the unnamed `{ field: 'dateRange' }` entry and the undeclared case alike. - Save door: `saveMetaItem` (the `PUT /api/v1/meta/dashboard` handler, stub-engine harness of `protocol.draft-publish-verdict.test.ts`) answered `success: true` in `draft` (then `publishMetaItem` succeeded) and in `publish`; the row was stored `active`. - Console board: `resolveDashboardFilterDefs` (`ui/packages/core`) returned ONE def, `{"name":"dateRange","field":"region","type":"text"}`, plus the console warning `duplicate filter name "dateRange" — the later definition wins`. The date-range control was gone and every `filterBindings.dateRange` bound `region`. Lint's `dashboardFilterDefs` keeps the later definition the same way, and `@objectstack/verify`'s window proof still proves the declared window the board no longer draws. <details><summary>After the change (head commit)</summary> - Save door, `draft` and `publish`: `INVALID_METADATA` 422, one issue at `globalFilters.0.name`, nothing stored. - A row already stored with the pair still loads: `_diagnostics.valid: false`, one error at `globalFilters.0.name` carrying the refusal. - The probes were scratch tests, deleted after the run (no permanent file). </details> ## Tests - New `packages/spec/src/ui/dashboard-global-filter-reserved-date-range.test.ts` (11 cases): three controls (declared `dateRange` with another name; undeclared `dateRange` with an entry named `dateRange`; a named entry over a field called `dateRange`), the card reproduction located at `.name` with the message's first sentence, an empty `dateRange: {}`, an unnamed entry located at `.field`, two `dateRange` entries (one reserved issue each beside the duplicate-key issue), the refusal still answering when another key fails, and three predicate cases on stored-body shapes. - Ablation (committed fix first, `scripts/ablation-replace.mjs`, `dashboard.zod.ts` anchor `}).superRefine(refuseReservedDateRangeGlobalFilters));` → `}));`): 5 failed | 6 passed (11), the five refusal cases red and the controls and predicate cases green; restored blob == HEAD, `git diff HEAD` empty. The spec suite imports `./dashboard.zod` from source, so no dist was involved. <details><summary>Package readings at head 6d7e238</summary> - `pnpm --filter @objectstack/spec test`: Test Files 792 passed (792), Tests 23402 passed | 1 todo. - `pnpm --filter @objectstack/spec typecheck`: exit 0. - `pnpm --filter @objectstack/lint test`: Test Files 189 passed (189), Tests 7830 passed. - `pnpm --filter @objectstack/metadata-protocol test`: Test Files 301 passed | 4 skipped (305), Tests 22127 passed | 26 skipped. - `ui/`: `pnpm exec vitest run packages/core/src/utils packages/plugin-dashboard`: Test Files 311 passed (311), Tests 3865 passed | 6 skipped. - Dogfood (full battery, after a `turbo run build --filter=./packages/**` closure build, under the lock): `pnpm --filter @objectstack/dogfood test` VERDICT command-exit 0, Test Files 391 passed | 3 skipped (394), Tests 3223 passed | 28 skipped (3251). </details> ## Acceptance notes - A `globalFilters` entry keyed `dateRange` on a dashboard that does NOT declare `dateRange` stays accepted. It is meaningful there: the console draws its own control under that name, `filterBindings.dateRange` binds it, lint's `dashboard-filter-binding-unknown` accepts the key against it, and verify's window proof skips the dashboard. Refusing it unconditionally would widen the refusal past the card; it is an open question to the seat. - Blast radius: zero authored dashboards key a global filter `dateRange` (`git grep` over `examples`, `apps`, `packages`, `ui` for `name:`/`field:` spelled `dateRange`: 5 hits, none a `globalFilters` entry; 115 files mention `globalFilters`). - Mirror reach: the rule sits on the dashboard object, so a schema that spreads only `DashboardSchema.shape.globalFilters` keeps the duplicate-key rule and not this one. Every in-repo door parses `DashboardSchema` whole (the dropped-refinements build names the four manifest projections that carry it). - The v18 stored-row listing (`packages/spec/src/conversions/refused-shapes.ts`) does not list this refusal; it is the sibling card #29212's surface and was not touched here. ## Verification Head `6d7e2382`. `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 106 families from 8 paths (331 changed lines); each ran after the last commit with its exit code recorded, and `--ran` answered: 106 derived, 106 run, 0 NOT-MEASURED, 0 UNRUN (exit 0). <details><summary>Gate notes</summary> - `node scripts/check-plugin-teardown-shape.mjs --self-test` first answered exit 3 (PREREQUISITE NOT MET: its pinned positive-control commit was absent from the shallow clone); after `git fetch --depth=1 origin 621a487` it answered exit 0, 48 cases. - `pnpm --filter @objectstack/spec check:generated`: all 14 generated artifacts up to date (after `gen:docs` regenerated `content/docs/references/ui/dashboard.mdx`). - `node scripts/check-changeset-no-major.mjs --base 5a2bdf9 --event` (this body, offline): exit 0, level axis `no (narrowing)` / `narrowed`, no `patch` grading. `check-partof-closing-keyword.mjs` on this body: exit 0. - Local-only `node scripts/check-dev-prereqs.mjs`: exit 1, "the workspace is not built", naming 35 `ui/` workspace packages with no `dist/` (only `./packages/**` was built here). It judges this container's dev-server precondition, not the spec dist, so it is reported and not acted on. - CI families (`Lint & Repo Gates`, `TypeScript Type Check`, `Test Core`, `Build Core`, the UI jobs) are read from this PR's check-runs, not re-run here. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [ ] 3. No formatter ran; `pnpm lint` is CI-owned and was not run locally. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmNvZN4TxxGwnpRp9yG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5651647 commit c268b72

13 files changed

Lines changed: 342 additions & 20 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@object-ui/types': minor
4+
---
5+
6+
fix(spec)!: a dashboard global filter keyed `dateRange` beside a declared `dateRange` is refused at every door that parses it
7+
8+
Clause-②: no (narrowing)
9+
Metadata-protocol: narrowed
10+
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.
11+
12+
**BREAKING** for a dashboard that declares `dateRange` and also keys a `globalFilters` entry `dateRange`.
13+
14+
- **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.
15+
- **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.
16+
- **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.
17+
- **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`.
18+
- **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`.
19+
20+
<!-- adr-0087: registered ui-dashboard-global-filter-reserved-date-range-refused -->

‎content/docs/references/ui/dashboard.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const result = DashboardSchema.parse(data);
3838
| **refreshIntervalSeconds** | `number` | optional | Auto-refresh interval in seconds |
3939
| **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. |
4040
| **dateRange** | `{ field?: string; defaultRange?: Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …>; allowCustomRange?: boolean }` | optional | Global dashboard date range filter configuration |
41-
| **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 |
41+
| **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 |
4242
| **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. |
4343
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this dashboard. |
4444
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
@@ -92,7 +92,7 @@ const result = DashboardSchema.parse(data);
9292

9393
| Property | Type | Required | Description |
9494
| :--- | :--- | :--- | :--- |
95-
| **name** | `string` | optional | Stable filter name (variable key); defaults to field |
95+
| **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) |
9696
| **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) |
9797
| **object** | `string` | optional | Object whose `fields.<object>.<field>` translation-bundle entry resolves this filter's field label and option labels |
9898
| **label** | `string \| Record<string, string>` | optional | Display label for the filter |
@@ -317,7 +317,7 @@ Widget configuration — declared query keys + open renderer extras
317317

318318
| Property | Type | Required | Description |
319319
| :--- | :--- | :--- | :--- |
320-
| **name** | `string` | optional | Stable filter name (variable key); defaults to field |
320+
| **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) |
321321
| **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) |
322322
| **object** | `string` | optional | Object whose `fields.<object>.<field>` translation-bundle entry resolves this filter's field label and option labels |
323323
| **label** | `string \| Record<string, string>` | optional | Display label for the filter |

‎content/docs/ui/dashboards.mdx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,9 +412,14 @@ globalFilters: [
412412
Each filter's `name` is its stable identity: the key its value is published
413413
under as a dashboard-level variable (readable in widget expressions as
414414
`page.<name>`) and the key widgets reference in `filterBindings`. It defaults
415-
to `field`; the name `dateRange` is reserved for the built-in date range. The
415+
to `field`. The
416416
filter above names itself `region` for exactly that reason — left to default it
417417
would be keyed `account.region`, dots and all, in every expression and binding.
418+
On a dashboard that declares `dateRange`, the name `dateRange` is reserved for
419+
that built-in date range: an entry keyed `dateRange` (by `name`, or by `field`
420+
when unnamed) is refused, because it would replace the date range's control and
421+
every widget's `filterBindings.dateRange` binding. On a dashboard that does not
422+
declare `dateRange`, the name is free and such an entry is an ordinary filter.
418423

419424
### Filter controls and options
420425

‎packages/spec/api-surface/ui.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,7 @@
526526
"chartAggregateResultKeys (function)",
527527
"chartAggregateValueKey (function)",
528528
"checkAppLandingItems (function)",
529+
"checkDashboardReservedDateRangeGlobalFilters (function)",
529530
"checkDashboardWidgetChartMeasureArity (function)",
530531
"checkDashboardWidgetMetricMeasureArity (function)",
531532
"checkDashboardWidgetStageOrder (function)",

‎packages/spec/dropped-refinements.baseline.json‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"measured": {
44
"zod": "4.4.3",
55
"publishedSchemasWithDroppedRefinements": 248,
6-
"droppedRefinementSites": 988,
6+
"droppedRefinementSites": 993,
77
"refinementSitesThatDidProject": 369,
88
"refinementSitesWithNoJsonFormToCompare": 0
99
},
@@ -81,6 +81,7 @@
8181
"manifest.assignmentRules.element.entries.element",
8282
"manifest.assignmentRules.element.entries.element.assignTo",
8383
"manifest.connectors.element.out",
84+
"manifest.dashboards.element",
8485
"manifest.dashboards.element.globalFilters",
8586
"manifest.dashboards.element.globalFilters.element",
8687
"manifest.dashboards.element.header.actions.element",
@@ -126,8 +127,8 @@
126127
"manifest.reports.element.runtimeFilter.lazy",
127128
"manifest.sharingRules.element.sharedWith",
128129
"manifest.skills.element.triggerConditions.element",
129-
"manifest.translations.element.valueType.flows.valueType.notices.valueType.message",
130130
"manifest.translations.element.valueType.flows.valueType.errorMessage",
131+
"manifest.translations.element.valueType.flows.valueType.notices.valueType.message",
131132
"manifest.translations.element.valueType.flows.valueType.refusals.valueType.message",
132133
"manifest.translations.element.valueType.flows.valueType.screens.valueType.description",
133134
"manifest.translations.element.valueType.flows.valueType.screens.valueType.title",
@@ -228,6 +229,7 @@
228229
"data.options[1].manifest.assignmentRules.element.entries.element",
229230
"data.options[1].manifest.assignmentRules.element.entries.element.assignTo",
230231
"data.options[1].manifest.connectors.element.out",
232+
"data.options[1].manifest.dashboards.element",
231233
"data.options[1].manifest.dashboards.element.globalFilters",
232234
"data.options[1].manifest.dashboards.element.globalFilters.element",
233235
"data.options[1].manifest.dashboards.element.header.actions.element",
@@ -367,6 +369,7 @@
367369
"options[1].manifest.assignmentRules.element.entries.element",
368370
"options[1].manifest.assignmentRules.element.entries.element.assignTo",
369371
"options[1].manifest.connectors.element.out",
372+
"options[1].manifest.dashboards.element",
370373
"options[1].manifest.dashboards.element.globalFilters",
371374
"options[1].manifest.dashboards.element.globalFilters.element",
372375
"options[1].manifest.dashboards.element.header.actions.element",
@@ -409,8 +412,8 @@
409412
"options[1].manifest.reports.element.runtimeFilter.lazy",
410413
"options[1].manifest.sharingRules.element.sharedWith",
411414
"options[1].manifest.skills.element.triggerConditions.element",
412-
"options[1].manifest.translations.element.valueType.flows.valueType.notices.valueType.message",
413415
"options[1].manifest.translations.element.valueType.flows.valueType.errorMessage",
416+
"options[1].manifest.translations.element.valueType.flows.valueType.notices.valueType.message",
414417
"options[1].manifest.translations.element.valueType.flows.valueType.refusals.valueType.message",
415418
"options[1].manifest.translations.element.valueType.flows.valueType.screens.valueType.description",
416419
"options[1].manifest.translations.element.valueType.flows.valueType.screens.valueType.title",
@@ -446,6 +449,7 @@
446449
"data.packages.element.options[1].manifest.assignmentRules.element.entries.element",
447450
"data.packages.element.options[1].manifest.assignmentRules.element.entries.element.assignTo",
448451
"data.packages.element.options[1].manifest.connectors.element.out",
452+
"data.packages.element.options[1].manifest.dashboards.element",
449453
"data.packages.element.options[1].manifest.dashboards.element.globalFilters",
450454
"data.packages.element.options[1].manifest.dashboards.element.globalFilters.element",
451455
"data.packages.element.options[1].manifest.dashboards.element.header.actions.element",
@@ -1592,6 +1596,7 @@
15921596
},
15931597
"ui/Dashboard": {
15941598
"sites": [
1599+
"",
15951600
"globalFilters",
15961601
"globalFilters.element",
15971602
"header.actions.element",

‎packages/spec/export-origins/ui.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,7 @@
509509
"chartAggregateResultKeys": "src/ui/chart-aggregate.ts#chartAggregateResultKeys (function)",
510510
"chartAggregateValueKey": "src/ui/chart-aggregate.ts#chartAggregateValueKey (function)",
511511
"checkAppLandingItems": "src/ui/app.zod.ts#checkAppLandingItems (function)",
512+
"checkDashboardReservedDateRangeGlobalFilters": "src/ui/dashboard.zod.ts#checkDashboardReservedDateRangeGlobalFilters (function)",
512513
"checkDashboardWidgetChartMeasureArity": "src/ui/dashboard.zod.ts#checkDashboardWidgetChartMeasureArity (function)",
513514
"checkDashboardWidgetMetricMeasureArity": "src/ui/dashboard.zod.ts#checkDashboardWidgetMetricMeasureArity (function)",
514515
"checkDashboardWidgetStageOrder": "src/ui/dashboard.zod.ts#checkDashboardWidgetStageOrder (function)",
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
// #29213. An accept-set narrowing of the dashboard schema with no conversion:
6+
// on a dashboard that declares the built-in `dateRange`, a globalFilters entry
7+
// keyed `dateRange` (name, else field) is refused by the dashboard parse.
8+
// Whether the entry was meant as the date range or as a filter of its own, and
9+
// which widgets bind it, only the author knows. Same class as
10+
// ui-dashboard-global-filter-duplicate-name-refused.
11+
export const entry: SemanticMigration = {
12+
id: 'ui-dashboard-global-filter-reserved-date-range-refused',
13+
surface: 'dashboard.globalFilters — an entry keyed `dateRange` (its `name`, or its `field` when it has '
14+
+ 'no `name`) on a dashboard that also declares `dateRange`, e.g. dateRange: { field: \'close_date\' }, '
15+
+ 'globalFilters: [{ name: \'dateRange\', field: \'region\' }]',
16+
replacement: 'Give the entry another `name` (and rename every widget `filterBindings.dateRange` key that '
17+
+ 'means it), or delete it and keep the built-in `dateRange`. On a dashboard that does not declare '
18+
+ '`dateRange`, an entry keyed `dateRange` stays legal.',
19+
reason:
20+
'The built-in date range is a filter the dashboard declares under the reserved key `dateRange`: the '
21+
+ 'key the filter bar draws its control under and the key a widget\'s `filterBindings` names. A '
22+
+ '`globalFilters` entry keyed `dateRange` beside it parsed and the dashboard save door answered 200. '
23+
+ 'The console and lint both build the date range first and let the later definition win, so the '
24+
+ 'board drew the entry\'s control in place of the declared date range, every widget binding '
25+
+ '`dateRange` was filtered by the entry\'s field, and the only trace was a browser console warning. '
26+
+ '`DashboardSchema` now refuses the entry at every door that parses a dashboard: `defineStack`, '
27+
+ '`os validate`, `os build` and the dashboard save door (`422 INVALID_METADATA`), with one issue at '
28+
+ 'the entry\'s `name` (or `field`) naming the reserved word and the built-in date range. Not '
29+
+ 'convertible: whether the entry was meant to be the date range or a filter of its own is a fact '
30+
+ 'only the author holds.',
31+
acceptanceCriteria:
32+
'Each dashboard parses without a `custom` issue at `globalFilters[i].name` (or `.field`) of the form '
33+
+ '"globalFilters[i] is keyed \'dateRange\', the reserved name of the built-in date range this '
34+
+ 'dashboard declares in `dateRange`", and saves. After renaming, check that every widget '
35+
+ '`filterBindings` key names the filter it meant, and that `os validate` reports no '
36+
+ '`dashboard-filter-binding-unknown` finding. A stored `sys_metadata` dashboard that still carries '
37+
+ 'the pair still loads, with `_diagnostics` reading `valid: false` and carrying this refusal, and its '
38+
+ 'next save (draft or publish) is refused; nothing rewrites it. Measured before the change: no '
39+
+ 'authored dashboard in this repository\'s examples, apps, packages or ui tree keys a global filter '
40+
+ '`dateRange`.',
41+
};

0 commit comments

Comments
 (0)