From 26cd84c168a4787136cdb9881d8f22f36345f449 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:10:20 +0000 Subject: [PATCH 1/6] fix(spec)!: refuse a globalFilters entry keyed dateRange on a dashboard that declares dateRange The built-in date range is declared under the reserved key `dateRange`; an entry keyed `dateRange` beside it parsed and replaced the date range's control and binding in the console and lint resolvers. Claude-Session: https://claude.ai/code/session_01ADHkmNvZN4TxxGwnpRp9yG Co-authored-by: Claude --- ...-global-filter-reserved-date-range.test.ts | 135 ++++++++++++++++++ packages/spec/src/ui/dashboard.zod.ts | 48 ++++++- .../src/ui/duplicate-global-filter-names.ts | 68 ++++++++- 3 files changed, 241 insertions(+), 10 deletions(-) create mode 100644 packages/spec/src/ui/dashboard-global-filter-reserved-date-range.test.ts diff --git a/packages/spec/src/ui/dashboard-global-filter-reserved-date-range.test.ts b/packages/spec/src/ui/dashboard-global-filter-reserved-date-range.test.ts new file mode 100644 index 00000000000..ae6427cab47 --- /dev/null +++ b/packages/spec/src/ui/dashboard-global-filter-reserved-date-range.test.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #29213 — on a dashboard that declares the built-in `dateRange`, a + * `globalFilters` entry keyed `dateRange` (its `name`, else its `field`) is + * refused. + * + * Measured on `main` @ `5a2bdf9c` before the rule: `DashboardSchema` accepted + * `{ dateRange: { field: 'close_date' }, globalFilters: [{ name: 'dateRange', field: 'region' }] }`, + * and the console's `resolveDashboardFilterDefs` (like lint's + * `dashboardFilterDefs`) built the built-in date range first and let the + * entry replace it, so the board drew a `region` control where the date range + * belonged and `filterBindings.dateRange` bound `region`. The refusal is + * located at the entry's key. + */ +import { describe, expect, it } from 'vitest'; +import { DashboardSchema } from './dashboard.zod'; +import { reservedDateRangeGlobalFilters } from './duplicate-global-filter-names'; + +const board = (extra: Record) => ({ + name: 'qa_probe_drg', + label: 'QA probe', + widgets: [], + ...extra, +}); + +const customIssues = (input: unknown) => { + const result = DashboardSchema.safeParse(input); + if (result.success) return []; + return result.error.issues.filter((i) => i.code === 'custom'); +}; + +describe('#29213 DashboardSchema refuses a globalFilters entry keyed dateRange beside a declared dateRange', () => { + it('CONTROL: a declared dateRange beside a differently named filter parses', () => { + const result = DashboardSchema.safeParse(board({ + dateRange: { field: 'close_date' }, + globalFilters: [{ name: 'region', field: 'region', type: 'text' }], + })); + expect(result.success).toBe(true); + }); + + it('CONTROL: with no dateRange declared, an entry named dateRange is an ordinary filter and parses', () => { + const result = DashboardSchema.safeParse(board({ + globalFilters: [{ name: 'dateRange', field: 'close_date', type: 'date' }], + })); + expect(result.success).toBe(true); + }); + + it('the card reproduction is refused at globalFilters[0].name, naming the reserved word and the built-in date range', () => { + const result = DashboardSchema.safeParse(board({ + dateRange: { field: 'close_date' }, + globalFilters: [{ name: 'dateRange', field: 'region', type: 'text' }], + })); + expect(result.success).toBe(false); + const issues = result.error!.issues; + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ code: 'custom', path: ['globalFilters', 0, 'name'] }); + expect(issues[0]!.message.startsWith( + "globalFilters[0] is keyed 'dateRange', the reserved name of the built-in date range this dashboard declares in `dateRange`.", + )).toBe(true); + expect(issues[0]!.message).toMatch(/Give this filter another `name` .* or delete it and keep the built-in `dateRange`\.$/); + // Runtime strings carry no tracker number (check:doc-authoring). + expect(issues[0]!.message).not.toMatch(/#\d{3,}/); + }); + + it('an empty dateRange object is a declared dateRange (the shape both resolvers test)', () => { + const issues = customIssues(board({ + dateRange: {}, + globalFilters: [{ name: 'dateRange', field: 'region', type: 'text' }], + })); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ path: ['globalFilters', 0, 'name'] }); + }); + + it('an UNNAMED entry whose field is dateRange is keyed by the field, located at .field', () => { + const issues = customIssues(board({ + dateRange: {}, + globalFilters: [{ name: 'region', field: 'region', type: 'text' }, { field: 'dateRange', type: 'text' }], + })); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ path: ['globalFilters', 1, 'field'] }); + expect(issues[0]!.message).toContain('(a filter with no `name` is keyed by its `field`)'); + }); + + it('CONTROL: a named entry over a field called dateRange is keyed by its name and parses', () => { + const result = DashboardSchema.safeParse(board({ + dateRange: {}, + globalFilters: [{ name: 'period', field: 'dateRange', type: 'text' }], + })); + expect(result.success).toBe(true); + }); + + it('two entries keyed dateRange answer one reserved-name issue each, beside the duplicate-key issue', () => { + const issues = customIssues(board({ + dateRange: {}, + globalFilters: [{ name: 'dateRange', field: 'region' }, { name: 'dateRange', field: 'status' }], + })); + const reserved = issues.filter((i) => i.message.includes('reserved name of the built-in date range')); + expect(reserved.map((i) => i.path)).toEqual([['globalFilters', 0, 'name'], ['globalFilters', 1, 'name']]); + expect(issues.filter((i) => i.message.startsWith('This dashboard declares the global filter')).map((i) => i.path)) + .toEqual([['globalFilters', 1, 'name']]); + }); + + it('the refusal still answers when another key of the dashboard fails too', () => { + const result = DashboardSchema.safeParse(board({ + columns: 99, + dateRange: {}, + globalFilters: [{ name: 'dateRange', field: 'region', type: 'text' }], + })); + expect(result.success).toBe(false); + const paths = result.error!.issues.map((i) => i.path.join('.')); + expect(paths).toContain('columns'); + expect(paths).toContain('globalFilters.0.name'); + }); +}); + +describe('#29213 reservedDateRangeGlobalFilters reads a stored body', () => { + it('answers nothing without a declared dateRange object', () => { + const filters = [{ name: 'dateRange', field: 'region' }]; + expect(reservedDateRangeGlobalFilters(undefined, filters)).toEqual([]); + expect(reservedDateRangeGlobalFilters(null, filters)).toEqual([]); + expect(reservedDateRangeGlobalFilters('this_month', filters)).toEqual([]); + }); + + it('a non-array, a non-object entry and an entry with neither key are left to the schema', () => { + expect(reservedDateRangeGlobalFilters({}, undefined)).toEqual([]); + expect(reservedDateRangeGlobalFilters({}, { name: 'dateRange' })).toEqual([]); + expect(reservedDateRangeGlobalFilters({}, [null, 'dateRange', 7, { label: 'dateRange' }])).toEqual([]); + }); + + it('an empty name falls back to the field, as the duplicate-key predicate does', () => { + expect(reservedDateRangeGlobalFilters({}, [{ name: '', field: 'dateRange' }]).map((e) => [e.index, e.key])) + .toEqual([[0, 'field']]); + }); +}); diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index 9d78c64d54f..74ffa70c8b9 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -14,7 +14,7 @@ import { DATE_RANGE_PRESETS } from '../data/date-range-presets'; import { ChartTypeSchema, ChartConfigSchema, type ChartType } from './chart.zod'; import { ActionType } from './action.zod'; import { navigationTargetRefusal } from './navigation-target'; -import { duplicateGlobalFilterNames } from './duplicate-global-filter-names'; +import { duplicateGlobalFilterNames, reservedDateRangeGlobalFilters } from './duplicate-global-filter-names'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; // `AriaPropsSchema` is no longer imported here: `widgets[].aria` was retired // (#5010). The shape itself is NOT removed — it stays live on `page.aria` / @@ -1396,7 +1396,9 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({ * * Every key must name a filter the dashboard declares — a `globalFilters` * entry's `name` (its `field` when unnamed), or `dateRange` when the - * dashboard declares `dateRange`. The runtime reads a binding by the declared + * dashboard declares `dateRange` (a `globalFilters` entry keyed `dateRange` + * beside it is refused, so that key always means the built-in date range + * there). The runtime reads a binding by the declared * filter's name, so any other key is never read; the lint rule * `dashboard-filter-binding-unknown` (severity error) refuses it at * `os validate` / `os build` and at the metadata publish door. @@ -1588,9 +1590,12 @@ export const GlobalFilterSchema = lazySchema(() => strictObject({ * which the filter's value is published (readable in widget expressions as * `page.`) and the key widgets reference in `filterBindings`. * Defaults to `field`. The name `"dateRange"` is reserved for the built-in - * dashboard date range. + * dashboard date range: on a dashboard that declares `dateRange`, an entry + * keyed `dateRange` (by this name, or by `field` when unnamed) is refused, + * because it would replace the date range's control and binding. On a + * dashboard with no `dateRange` the key is free. */ - name: z.string().optional().describe('Stable filter name (variable key); defaults to field').meta({ title: 'Name' }), + name: z.string().optional().describe('Stable filter name (variable key); defaults to field. Refused as dateRange when the dashboard declares dateRange (the built-in date range\'s reserved name)').meta({ title: 'Name' }), /** * Field name to filter on — at the authoring layer it resolves against the @@ -1688,6 +1693,32 @@ function refuseDuplicateGlobalFilterNames(globalFilters: readonly unknown[], ctx } } +/** + * A dashboard that declares the built-in `dateRange` declares a filter under + * the reserved key `dateRange`, so a `globalFilters` entry keyed `dateRange` + * (its `name`, else its `field`) is refused, located at that entry's key. + * Before this, the pair parsed and the dashboard save door stored it: the + * console and lint resolvers let the later definition win, so the board drew + * the entry's control in place of the declared date range and every widget's + * `filterBindings.dateRange` was filtered by the entry. On a dashboard with no + * `dateRange` the key is free and the entry is an ordinary filter. + * + * Attached to the dashboard object, not to the `globalFilters` array, because + * the verdict reads the sibling `dateRange`: a mirror that spreads + * `DashboardSchema.shape.globalFilters` alone keeps the duplicate-key rule + * above and not this one. Predicate and message: + * `duplicate-global-filter-names.ts` ({@link reservedDateRangeGlobalFilters}). + * Not exported: the schema is the door. + */ +function refuseReservedDateRangeGlobalFilters( + dashboard: { dateRange?: unknown; globalFilters?: unknown }, + ctx: z.RefinementCtx, +): void { + for (const entry of reservedDateRangeGlobalFilters(dashboard.dateRange, dashboard.globalFilters)) { + ctx.addIssue({ code: 'custom', path: ['globalFilters', entry.index, entry.key], message: entry.message }); + } +} + /** * Dashboard Schema * Represents a page containing multiple visualizations. @@ -1793,12 +1824,15 @@ export const DashboardSchema = lazySchema(() => strictObject({ * declared once per dashboard: the key is what the filter bar draws a * control under and what a widget's `filterBindings` names, so two entries * under one key are two definitions behind one control. Refused by - * {@link refuseDuplicateGlobalFilterNames} (#29023). + * {@link refuseDuplicateGlobalFilterNames} (#29023). The built-in + * `dateRange` is declared under the key `dateRange`, so an entry keyed + * `dateRange` beside it is refused too, by the dashboard-level + * {@link refuseReservedDateRangeGlobalFilters} (#29213). */ globalFilters: z.array(GlobalFilterSchema) .superRefine(refuseDuplicateGlobalFilterNames) .optional() - .describe('Global filters that apply to all widgets in the dashboard. Each filter name (its field when it has no name) is declared once'), + .describe('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'), /** * [#22611] The audience gate — the platform's one capability key, with the @@ -1830,7 +1864,7 @@ export const DashboardSchema = lazySchema(() => strictObject({ // ADR-0146 D2 — the override declaration (authored; travels with the item). ...OverrideDeclarationFields, -})); +}).superRefine(refuseReservedDateRangeGlobalFilters)); export type Dashboard = z.input; /** Post-parse shape of {@link Dashboard} — defaults applied, transforms run (ADR-0122). */ diff --git a/packages/spec/src/ui/duplicate-global-filter-names.ts b/packages/spec/src/ui/duplicate-global-filter-names.ts index 8ccea35edf1..91b13db063a 100644 --- a/packages/spec/src/ui/duplicate-global-filter-names.ts +++ b/packages/spec/src/ui/duplicate-global-filter-names.ts @@ -18,9 +18,12 @@ * so every door that parses a dashboard refuses the pair: `defineStack`, * `os validate`, `os build` and the dashboard save door. * - * Scope is ONE dashboard's own `globalFilters` array. The reserved name - * `dateRange` (the built-in date range) is a different question and is not - * judged here. + * Scope of {@link duplicateGlobalFilterNames} is ONE dashboard's own + * `globalFilters` array. The built-in date range is the other declaration a + * key can collide with: a dashboard that declares `dateRange` declares a + * filter under the reserved key `dateRange`, ahead of every `globalFilters` + * entry, and {@link reservedDateRangeGlobalFilters} answers which entries + * reuse that key. */ /** One filter key that a dashboard's `globalFilters` array declares more than once. */ @@ -94,3 +97,62 @@ export function duplicateGlobalFilterNames(globalFilters: unknown): DuplicateGlo } return out; } + +/** The key the built-in date range (`dashboard.dateRange`) is declared under. */ +export const RESERVED_DATE_RANGE_FILTER_NAME = 'dateRange'; + +/** A `globalFilters` entry keyed by the built-in date range's reserved name. */ +export interface ReservedDateRangeGlobalFilter { + /** The entry's index in `globalFilters`. */ + readonly index: number; + /** `name` when the entry authors one, `field` when the key falls back to it. The refusal is located there. */ + readonly key: 'name' | 'field'; + /** The refusal's message, naming the reserved word, the built-in date range and the fix. */ + readonly message: string; +} + +/** + * Every `globalFilters` entry keyed `dateRange` (by its `name`, else its + * `field`) on a dashboard that declares the built-in `dateRange`, ascending. + * + * The built-in date range is a filter the board declares under the reserved + * key `dateRange`, so such an entry is a second definition behind that key: + * the console's `resolveDashboardFilterDefs` and lint's `dashboardFilterDefs` + * both build the built-in first and let the later definition win, so the + * filter bar draws the entry's control in place of the date range, and every + * widget's `filterBindings.dateRange` binds the entry. The declared + * `dateRange` is never drawn, while `@objectstack/verify`'s window proof still + * proves it. + * + * On a dashboard that does NOT declare `dateRange` the key is free and the + * entry is an ordinary filter (the board draws it, `filterBindings.dateRange` + * binds it), so nothing is reported. `dateRange` is read as declared when it is + * any non-null object, the shape both resolvers test. Read on a parsed + * dashboard or a stored body alike. + */ +export function reservedDateRangeGlobalFilters( + dateRange: unknown, + globalFilters: unknown, +): ReservedDateRangeGlobalFilter[] { + if (dateRange === null || typeof dateRange !== 'object') return []; + if (!Array.isArray(globalFilters)) return []; + const out: ReservedDateRangeGlobalFilter[] = []; + globalFilters.forEach((filter, index) => { + const resolved = filterKeyOf(filter); + if (!resolved || resolved.name !== RESERVED_DATE_RANGE_FILTER_NAME) return; + out.push({ + index, + key: resolved.key, + message: + `globalFilters[${index}] is keyed '${RESERVED_DATE_RANGE_FILTER_NAME}'` + + (resolved.key === 'field' ? ' (a filter with no `name` is keyed by its `field`)' : '') + + `, the reserved name of the built-in date range this dashboard declares in \`dateRange\`. ` + + 'The filter bar draws one control under that name and widgets bind it as `filterBindings.dateRange`, so ' + + 'this entry would replace the date range: the board would never draw the declared date range, and every ' + + 'widget binding `dateRange` would be filtered by this entry instead. ' + + 'Give this filter another `name` (and rename every widget `filterBindings.dateRange` key that means it), ' + + 'or delete it and keep the built-in `dateRange`.', + }); + }); + return out; +} From 6d7e23820072973d6a94ed50aea519010772cf1d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:18:37 +0000 Subject: [PATCH 2/6] fix(spec): register the reserved-dateRange refusal, regenerate references, baseline +5 sites Semantic ADR-0087 entry ui-dashboard-global-filter-reserved-date-range-refused, the changeset, the dropped-refinements baseline (the dashboard-level refine adds ui/Dashboard root and four installed-package manifest projections) and the regenerated dashboard reference; one docs sentence naming the refusal. Claude-Session: https://claude.ai/code/session_01ADHkmNvZN4TxxGwnpRp9yG Co-authored-by: Claude --- ...board-global-filter-reserved-date-range.md | 19 +++++++++ content/docs/references/ui/dashboard.mdx | 6 +-- content/docs/ui/dashboards.mdx | 3 ++ .../spec/dropped-refinements.baseline.json | 11 +++-- ...obal-filter-reserved-date-range-refused.ts | 41 +++++++++++++++++++ 5 files changed, 74 insertions(+), 6 deletions(-) create mode 100644 .changeset/29213-dashboard-global-filter-reserved-date-range.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-dashboard-global-filter-reserved-date-range-refused.ts diff --git a/.changeset/29213-dashboard-global-filter-reserved-date-range.md b/.changeset/29213-dashboard-global-filter-reserved-date-range.md new file mode 100644 index 00000000000..e35596188e6 --- /dev/null +++ b/.changeset/29213-dashboard-global-filter-reserved-date-range.md @@ -0,0 +1,19 @@ +--- +'@objectstack/spec': 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: a mirror that spreads only `DashboardSchema.shape.globalFilters` does not carry it. +- **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`. + + diff --git a/content/docs/references/ui/dashboard.mdx b/content/docs/references/ui/dashboard.mdx index 0b710f2a861..aea51a4f97d 100644 --- a/content/docs/references/ui/dashboard.mdx +++ b/content/docs/references/ui/dashboard.mdx @@ -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; … }[]` | 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; … }[]` | 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). | @@ -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..` translation-bundle entry resolves this filter's field label and option labels | | **label** | `string \| Record` | optional | Display label for the filter | @@ -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..` translation-bundle entry resolves this filter's field label and option labels | | **label** | `string \| Record` | optional | Display label for the filter | diff --git a/content/docs/ui/dashboards.mdx b/content/docs/ui/dashboards.mdx index 1d40758963b..400e59a5866 100644 --- a/content/docs/ui/dashboards.mdx +++ b/content/docs/ui/dashboards.mdx @@ -415,6 +415,9 @@ under as a dashboard-level variable (readable in widget expressions as to `field`; the name `dateRange` is reserved for the built-in date range. 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`, an entry keyed `dateRange` (by `name`, +or by `field` when unnamed) is refused: it would replace the date range's control +and every widget's `filterBindings.dateRange` binding. ### Filter controls and options diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 8c18e5acf05..03ad2721827 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -3,7 +3,7 @@ "measured": { "zod": "4.4.3", "publishedSchemasWithDroppedRefinements": 248, - "droppedRefinementSites": 988, + "droppedRefinementSites": 993, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -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", @@ -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", @@ -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", @@ -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", @@ -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", @@ -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", @@ -1592,6 +1596,7 @@ }, "ui/Dashboard": { "sites": [ + "", "globalFilters", "globalFilters.element", "header.actions.element", diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-dashboard-global-filter-reserved-date-range-refused.ts b/packages/spec/src/migrations/entries/semantic/18.ui-dashboard-global-filter-reserved-date-range-refused.ts new file mode 100644 index 00000000000..583b719bdf6 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-dashboard-global-filter-reserved-date-range-refused.ts @@ -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`.', +}; From 0aa87c6cf98d65a4fb8eb8634ff30cf03c2e0394 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:36:21 +0000 Subject: [PATCH 3/6] docs(spec): say dateRange is reserved only on a dashboard that declares dateRange Contract-review should-fix: the dashboards page and the GlobalFilterSchema.name TSDoc now say one thing. The name is reserved, and an entry keyed dateRange refused, only when the dashboard declares the built-in date range; otherwise the name is free. Prose only, no behaviour change. Claude-Session: https://claude.ai/code/session_01ADHkmNvZN4TxxGwnpRp9yG Co-authored-by: Claude --- content/docs/ui/dashboards.mdx | 10 ++++++---- packages/spec/src/ui/dashboard.zod.ts | 10 +++++----- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/content/docs/ui/dashboards.mdx b/content/docs/ui/dashboards.mdx index 400e59a5866..d61b7befc68 100644 --- a/content/docs/ui/dashboards.mdx +++ b/content/docs/ui/dashboards.mdx @@ -412,12 +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.`) 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`, an entry keyed `dateRange` (by `name`, -or by `field` when unnamed) is refused: it would replace the date range's control -and every widget's `filterBindings.dateRange` 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 diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index 74ffa70c8b9..964628f62f8 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -1589,11 +1589,11 @@ export const GlobalFilterSchema = lazySchema(() => strictObject({ * Stable filter name (framework#2501) — the dashboard-variable key under * which the filter's value is published (readable in widget expressions as * `page.`) and the key widgets reference in `filterBindings`. - * Defaults to `field`. The name `"dateRange"` is reserved for the built-in - * dashboard date range: on a dashboard that declares `dateRange`, an entry - * keyed `dateRange` (by this name, or by `field` when unnamed) is refused, - * because it would replace the date range's control and binding. On a - * dashboard with no `dateRange` the key is free. + * Defaults to `field`. On a dashboard that declares `dateRange`, the name + * `"dateRange"` is reserved for that built-in date range: an entry keyed + * `dateRange` (by this name, or by `field` when unnamed) is refused, because + * it would replace the date range's control and binding. On a dashboard with + * no `dateRange` the name is free and the entry is an ordinary filter. */ name: z.string().optional().describe('Stable filter name (variable key); defaults to field. Refused as dateRange when the dashboard declares dateRange (the built-in date range\'s reserved name)').meta({ title: 'Name' }), From bb0e318e32a9ed7c29a5165c855f009ee7c0678d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 14:08:26 +0000 Subject: [PATCH 4/6] fix(spec,ui-types): export checkDashboardReservedDateRangeGlobalFilters and chain it on the console's dashboard mirror The census in spec-object-refinements-7715 counts every object-level check on DashboardSchema: rebuilding DashboardComponentSchema from the spec's shape dropped the new reserved-dateRange check, so the console's authoring door accepted a dashboard the spec refuses. Export it in the house check* shape, chain it on DashboardComponentSchema, name it in the census row, and add @object-ui/types to the changeset. Claude-Session: https://claude.ai/code/session_01ADHkmNvZN4TxxGwnpRp9yG Co-authored-by: Claude --- ...ashboard-global-filter-reserved-date-range.md | 3 ++- packages/spec/src/ui/dashboard.zod.ts | 16 +++++++++------- .../spec-object-refinements-7715.test.ts | 8 +++++++- ui/packages/types/src/zod/complex.zod.ts | 8 +++++++- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/.changeset/29213-dashboard-global-filter-reserved-date-range.md b/.changeset/29213-dashboard-global-filter-reserved-date-range.md index e35596188e6..6d629021c64 100644 --- a/.changeset/29213-dashboard-global-filter-reserved-date-range.md +++ b/.changeset/29213-dashboard-global-filter-reserved-date-range.md @@ -1,5 +1,6 @@ --- '@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 @@ -11,7 +12,7 @@ Skills-docs: none — `skills/objectstack-ui/rules/dashboards.md` teaches `dateR **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: a mirror that spreads only `DashboardSchema.shape.globalFilters` does not carry it. +- **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`. diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index 964628f62f8..b9ba820cf60 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -1704,13 +1704,15 @@ function refuseDuplicateGlobalFilterNames(globalFilters: readonly unknown[], ctx * `dateRange` the key is free and the entry is an ordinary filter. * * Attached to the dashboard object, not to the `globalFilters` array, because - * the verdict reads the sibling `dateRange`: a mirror that spreads - * `DashboardSchema.shape.globalFilters` alone keeps the duplicate-key rule - * above and not this one. Predicate and message: + * the verdict reads the sibling `dateRange`. Exported in the house shape of + * {@link checkDashboardWidgetStageOrder}, a named `(value, ctx)` check chained + * on with `.superRefine(…)`: a mirror that rebuilds the dashboard from + * `DashboardSchema.shape` drops every object-level check, and gets this one + * back only by importing it and chaining it (the console's + * `DashboardComponentSchema` does). Predicate and message: * `duplicate-global-filter-names.ts` ({@link reservedDateRangeGlobalFilters}). - * Not exported: the schema is the door. */ -function refuseReservedDateRangeGlobalFilters( +export function checkDashboardReservedDateRangeGlobalFilters( dashboard: { dateRange?: unknown; globalFilters?: unknown }, ctx: z.RefinementCtx, ): void { @@ -1827,7 +1829,7 @@ export const DashboardSchema = lazySchema(() => strictObject({ * {@link refuseDuplicateGlobalFilterNames} (#29023). The built-in * `dateRange` is declared under the key `dateRange`, so an entry keyed * `dateRange` beside it is refused too, by the dashboard-level - * {@link refuseReservedDateRangeGlobalFilters} (#29213). + * {@link checkDashboardReservedDateRangeGlobalFilters} (#29213). */ globalFilters: z.array(GlobalFilterSchema) .superRefine(refuseDuplicateGlobalFilterNames) @@ -1864,7 +1866,7 @@ export const DashboardSchema = lazySchema(() => strictObject({ // ADR-0146 D2 — the override declaration (authored; travels with the item). ...OverrideDeclarationFields, -}).superRefine(refuseReservedDateRangeGlobalFilters)); +}).superRefine(checkDashboardReservedDateRangeGlobalFilters)); export type Dashboard = z.input; /** Post-parse shape of {@link Dashboard} — defaults applied, transforms run (ADR-0122). */ diff --git a/ui/packages/types/src/__tests__/spec-object-refinements-7715.test.ts b/ui/packages/types/src/__tests__/spec-object-refinements-7715.test.ts index 0d465fa4126..b7f2afec679 100644 --- a/ui/packages/types/src/__tests__/spec-object-refinements-7715.test.ts +++ b/ui/packages/types/src/__tests__/spec-object-refinements-7715.test.ts @@ -344,7 +344,13 @@ const SITES = [ // under its new name only, and the mirror attaches it above. notAttachable: [], }, - { site: 'SpecDashboardFields → DashboardComponentSchema (complex.zod.ts)', spec: SpecDashboardSchema, attached: [], notAttachable: [] }, + { + site: 'SpecDashboardFields → DashboardComponentSchema (complex.zod.ts)', + spec: SpecDashboardSchema, + // objectstack#29213: a `globalFilters` entry keyed `dateRange` beside a declared `dateRange`. + attached: ['checkDashboardReservedDateRangeGlobalFilters'], + notAttachable: [], + }, { site: 'SpecPageFields → PageNodeSchema (layout.zod.ts)', spec: SpecPageSchema, diff --git a/ui/packages/types/src/zod/complex.zod.ts b/ui/packages/types/src/zod/complex.zod.ts index f1d3f0de87c..7d54915b839 100644 --- a/ui/packages/types/src/zod/complex.zod.ts +++ b/ui/packages/types/src/zod/complex.zod.ts @@ -20,6 +20,7 @@ import { z } from 'zod'; import { agedOutSpecKeyRefusal, aliasKeyRefusal, durationKeyRenamedGuidance, handlerKeyRefusal, retiredNodeType, retirementTombstone } from './tombstone.zod.js'; import { ChartTypeSchema as SpecChartTypeSchema, + checkDashboardReservedDateRangeGlobalFilters, checkDashboardWidgetChartMeasureArity, checkDashboardWidgetMetricMeasureArity, checkDashboardWidgetStageOrder, @@ -1741,7 +1742,12 @@ export const DashboardComponentSchema = BaseSchema.extend(SpecDashboardFields.sh 'dashboard', 'dashboard.performance', 'performance', '17.0.0', 'No renderer or runtime read it; dashboard performance tuning was never implemented. Delete the key.', ), -}); +}) + // objectstack#29213: the spec's one object-level dashboard check, re-attached because + // rebuilding from `SpecDashboardFields.shape` drops it. On a dashboard that declares + // `dateRange`, a `globalFilters` entry keyed `dateRange` is refused here with the spec's + // own issue, instead of replacing the date range on the board. + .superRefine(checkDashboardReservedDateRangeGlobalFilters); /** * Dashboard Widget Config Schema (for DashboardConfigPanel) From fc71bedb5d1166708373cc458473a7bdb11ba76b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 14:13:31 +0000 Subject: [PATCH 5/6] chore(spec): regenerate api-surface and export-origins for checkDashboardReservedDateRangeGlobalFilters Claude-Session: https://claude.ai/code/session_01ADHkmNvZN4TxxGwnpRp9yG Co-authored-by: Claude --- packages/spec/api-surface/ui.json | 1 + packages/spec/export-origins/ui.json | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 68448dd4ad3..d471f095ff4 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -526,6 +526,7 @@ "chartAggregateResultKeys (function)", "chartAggregateValueKey (function)", "checkAppLandingItems (function)", + "checkDashboardReservedDateRangeGlobalFilters (function)", "checkDashboardWidgetChartMeasureArity (function)", "checkDashboardWidgetMetricMeasureArity (function)", "checkDashboardWidgetStageOrder (function)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index fac837449a4..a0cb3d190f7 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -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)", From 6b7710c9c0e97e31bc478da45b74fa5a3644acfd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 14:17:19 +0000 Subject: [PATCH 6/6] test(ui-types): name checkDashboardReservedDateRangeGlobalFilters in the import-boundary refinement list Claude-Session: https://claude.ai/code/session_01ADHkmNvZN4TxxGwnpRp9yG Co-authored-by: Claude --- .../types/src/__tests__/imported-defaults-8317.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ui/packages/types/src/__tests__/imported-defaults-8317.test.ts b/ui/packages/types/src/__tests__/imported-defaults-8317.test.ts index 77ae155039c..ba4b48c56e4 100644 --- a/ui/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/ui/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -144,6 +144,7 @@ import { checkDashboardWidgetStageOrder, checkDashboardWidgetMetricMeasureArity, checkDashboardWidgetChartMeasureArity, + checkDashboardReservedDateRangeGlobalFilters, checkPageRequiresKind, checkPageRegionPlacement, checkPageDefaultPlacement, @@ -728,6 +729,9 @@ describe('the import boundary strips every imported default (objectui#8317)', () // `PageNodeSchema` and `PageKindNodeSchema`. ['checkDashboardWidgetChartMeasureArity', checkDashboardWidgetChartMeasureArity], ['checkPageRequiresKind', checkPageRequiresKind], + // objectstack#29213: the spec's reserved-`dateRange` dashboard check, re-attached on + // `DashboardComponentSchema` under the same ruling. + ['checkDashboardReservedDateRangeGlobalFilters', checkDashboardReservedDateRangeGlobalFilters], // objectui#12030's page slot-pair check left this list with the `tabs` // slot (objectstack#27053): the spec no longer carries it. // objectui#12370's page view-data check left this list with objectui#12666: objectstack#23784