diff --git a/.changeset/29541-reserved-date-range-stored-detector.md b/.changeset/29541-reserved-date-range-stored-detector.md new file mode 100644 index 00000000000..80ff057cfa9 --- /dev/null +++ b/.changeset/29541-reserved-date-range-stored-detector.md @@ -0,0 +1,12 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): `os migrate meta --stored` lists a stored dashboard whose global filter is keyed `dateRange` beside a declared `dateRange` + +Clause-②: no +Metadata-protocol: none + +- **Before.** `DashboardSchema` refuses a dashboard that declares the built-in `dateRange` and also keys a `globalFilters` entry `dateRange` (its `name`, or its `field` when it has no `name`), and the dashboard save door answers 422 `INVALID_METADATA`. A dashboard already stored in that shape still loads with `_diagnostics` reading `valid: false`, but the v18 stored-row preview had no detector for it, so `os migrate meta --stored` named no such row before the upgrade. +- **Now.** The preview lists each such stored dashboard under the TODO id `ui-dashboard-global-filter-reserved-date-range-refused` (the refusal's upgrade guide entry), at the entry's `name` (or `field`), the site the parse refuses, with the refusal's own message. The detector calls the refinement's own predicate, so a row is listed exactly when the parse refuses it. Nothing rewrites it: whether the entry was meant to be the date range or a filter of its own, only the author knows. The upgrade guide entries of this refusal and of the duplicate-key refusal now say that `os migrate meta --stored` lists such a row. +- **Not changed.** What any door accepts or refuses. On a dashboard that does not declare `dateRange`, an entry keyed `dateRange` is not listed; nor is a named entry whose `field` is `dateRange`. diff --git a/packages/spec/src/conversions/refused-shapes.test.ts b/packages/spec/src/conversions/refused-shapes.test.ts index 267f0791c27..3091146aa77 100644 --- a/packages/spec/src/conversions/refused-shapes.test.ts +++ b/packages/spec/src/conversions/refused-shapes.test.ts @@ -73,6 +73,7 @@ describe('the refused-shape list — registered against the migration chain', () 'visibility-predicate-has-argument-refused', 'object-duplicate-action-name-refused', 'ui-dashboard-global-filter-duplicate-name-refused', + 'ui-dashboard-global-filter-reserved-date-range-refused', 'object-extension-field-patch-key-grammar-refused', ]); }); @@ -855,6 +856,89 @@ describe('#29212 — a stored dashboard that declares one global filter key twic }); }); +// ── #29541 (the #29213 refusal) ─────────────────────────────────────────── + +/** A dashboard that declares the built-in `dateRange`, unless `declared` is false. */ +const dateRangeBoard = (globalFilters: unknown[], declared = true) => ({ + ...dupBoard(globalFilters), + ...(declared ? { dateRange: { field: 'close_date' } } : {}), +}); +/** The dashboard parse's reserved-`dateRange` issues, as paths. */ +const reservedFilterIssuePaths = (row: unknown) => { + const parsed = DashboardSchema.safeParse(row); + return parsed.success + ? [] + : parsed.error.issues + .filter((i) => i.path[0] === 'globalFilters' && /the reserved name of the built-in date range/.test(i.message)) + .map((i) => i.path); +}; + +describe('#29541 — a stored dashboard whose globalFilters entry is keyed dateRange beside a declared dateRange is listed', () => { + it('one TODO at the entry\'s name, naming the reserved word and the built-in date range; the row is not rewritten', () => { + const row = dateRangeBoard([{ name: 'dateRange', field: 'region', type: 'text' }]); + const { item, todos } = storedTodos('dashboard', row); + expect(item).toEqual(row); + expect(todos).toHaveLength(1); + const todo = todos[0]!; + expect(todo.conversionId).toBe('ui-dashboard-global-filter-reserved-date-range-refused'); + expect(todo.path).toBe('dashboards[0].globalFilters[0].name'); + expect(todo.reason).toMatch(/^No lossless rewrite: whether the entry was meant to be the date range/); + expect(todo.reason).toContain( + "globalFilters[0] is keyed 'dateRange', the reserved name of the built-in date range this dashboard " + + 'declares in `dateRange`.', + ); + expect(todo.reason).not.toMatch(/#\d{3,}/); + expect(JSON.parse(todo.from)).toEqual({ name: 'dateRange', index: 0 }); + // The listing and the parse refuse ONE set: the stored row still fails the parse, at the same site. + expect(reservedFilterIssuePaths(item)).toEqual([['globalFilters', 0, 'name']]); + }); + + it('an entry with no `name` keyed by its `field` is listed at that field, as the parse locates it', () => { + const row = dateRangeBoard([ + { name: 'region', field: 'region', type: 'text' }, + { field: 'dateRange', type: 'date' }, + ]); + const { todos } = storedTodos('dashboard', row); + expect(todos).toHaveLength(1); + expect(todos[0]!.path).toBe('dashboards[0].globalFilters[1].field'); + expect(JSON.parse(todos[0]!.from)).toEqual({ name: 'dateRange', index: 1 }); + expect(todos[0]!.reason).toContain('(a filter with no `name` is keyed by its `field`)'); + expect(reservedFilterIssuePaths(row)).toEqual([['globalFilters', 1, 'field']]); + }); + + it('a key both repeated and reserved is listed under each refusal, as the parse refuses it under each', () => { + const row = dateRangeBoard([ + { name: 'dateRange', field: 'region', type: 'text' }, + { name: 'dateRange', field: 'status', type: 'text' }, + ]); + const { todos } = storedTodos('dashboard', row); + expect(todos.map((t) => `${t.conversionId} @ ${t.path}`)).toEqual([ + 'ui-dashboard-global-filter-duplicate-name-refused @ dashboards[0].globalFilters[1].name', + 'ui-dashboard-global-filter-reserved-date-range-refused @ dashboards[0].globalFilters[0].name', + 'ui-dashboard-global-filter-reserved-date-range-refused @ dashboards[0].globalFilters[1].name', + ]); + expect(reservedFilterIssuePaths(row)).toEqual([['globalFilters', 0, 'name'], ['globalFilters', 1, 'name']]); + expect(dupFilterIssue(row)?.path).toEqual(['globalFilters', 1, 'name']); + }); + + it('CONTROL: with no dateRange declared, an entry keyed dateRange is not listed — and the parse accepts it', () => { + for (const filters of [ + [{ name: 'dateRange', field: 'close_date', type: 'date' }], + [{ field: 'dateRange', type: 'date' }], + ]) { + const row = dateRangeBoard(filters, false); + expect(storedTodos('dashboard', row).todos).toEqual([]); + expect(DashboardSchema.safeParse(row).success).toBe(true); + } + }); + + it('CONTROL: beside a declared dateRange, a named entry over the field dateRange is not listed — and parses', () => { + const row = dateRangeBoard([{ name: 'when', field: 'dateRange', type: 'date' }]); + expect(storedTodos('dashboard', row).todos).toEqual([]); + expect(DashboardSchema.safeParse(row).success).toBe(true); + }); +}); + // ── #29554 (the #29366 refusal) ─────────────────────────────────────────── const patchRow = (fieldPatches: Record) => ({ extend: 'crm_account', fieldPatches }); diff --git a/packages/spec/src/conversions/refused-shapes.ts b/packages/spec/src/conversions/refused-shapes.ts index d205cc7d3b7..8bf7ef447c6 100644 --- a/packages/spec/src/conversions/refused-shapes.ts +++ b/packages/spec/src/conversions/refused-shapes.ts @@ -45,7 +45,8 @@ * ({@link externalBindingDatasourceRefusal}), and the one-name-per-action * judge of the object refinement ({@link duplicateActionNames}), and the * one-key-per-global-filter judge of the dashboard refinement - * ({@link duplicateGlobalFilterNames}), and the `fieldPatches` key + * ({@link duplicateGlobalFilterNames}), and its reserved-`dateRange` judge + * ({@link reservedDateRangeGlobalFilters}), and the `fieldPatches` key * grammar of `ObjectExtensionSchema`'s own parse * ({@link refusedFieldPatchKeys}). A site is listed here exactly * when that door refuses it. One door lives outside this package: the @@ -73,7 +74,11 @@ import { duplicateActionNames } from '../data/duplicate-action-names.js'; import { externalBindingDatasourceRefusal } from '../data/external-binding-datasource.js'; import { ObjectExtensionSchema } from '../data/object.zod.js'; import { ComponentPropsMap } from '../ui/component.zod.js'; -import { duplicateGlobalFilterNames } from '../ui/duplicate-global-filter-names.js'; +import { + duplicateGlobalFilterNames, + RESERVED_DATE_RANGE_FILTER_NAME, + reservedDateRangeGlobalFilters, +} from '../ui/duplicate-global-filter-names.js'; import { ViewDataSchema } from '../ui/view.zod.js'; import { refusedHasArguments } from './cel-has-argument.js'; import type { ConversionTodoDetail } from './types.js'; @@ -587,6 +592,32 @@ const dashboardDuplicateGlobalFilterName: RefusedStoredShape = { }, }; +// ── #29541: a dashboard whose globalFilters entry reuses the reserved key `dateRange` (the #29213 refusal) ── + +const dashboardReservedDateRangeGlobalFilter: RefusedStoredShape = { + id: 'ui-dashboard-global-filter-reserved-date-range-refused', + toMajor: 18, + surface: 'dashboard.globalFilters — an entry keyed `dateRange` (its `name`, or its `field` when it has no ' + + '`name`) on a dashboard that also declares the built-in `dateRange`', + detect(stack, report) { + mapCollection(stack, 'dashboards', (dashboard, path) => { + for (const entry of reservedDateRangeGlobalFilters(dashboard.dateRange, dashboard.globalFilters)) { + report({ + // The site the dashboard parse refuses: the entry's `name`, or its `field`. + path: `${path}.globalFilters[${entry.index}].${entry.key}`, + from: JSON.stringify({ name: RESERVED_DATE_RANGE_FILTER_NAME, index: entry.index }), + reason: 'No lossless rewrite: whether the entry was meant to be the date range or a filter of its own, ' + + 'and which widget `filterBindings.dateRange` keys mean it, only the author knows. Left as stored, ' + + 'the dashboard still loads with `_diagnostics` reading `valid: false`, and its next save, draft or ' + + 'publish, is refused with 422 INVALID_METADATA. ' + + entry.message, + }); + } + return dashboard; + }); + }, +}; + // ── #29554: an object extension whose `fieldPatches` key cannot be a field name (the #29366 refusal) ── /** @@ -647,6 +678,7 @@ export const REFUSED_STORED_SHAPES: readonly RefusedStoredShape[] = [ visibilityHasArgument, objectDuplicateActionName, dashboardDuplicateGlobalFilterName, + dashboardReservedDateRangeGlobalFilter, objectExtensionFieldPatchKeyGrammar, ]; diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-dashboard-global-filter-duplicate-name-refused.ts b/packages/spec/src/migrations/entries/semantic/18.ui-dashboard-global-filter-duplicate-name-refused.ts index def130204a5..b33cd2b3554 100644 --- a/packages/spec/src/migrations/entries/semantic/18.ui-dashboard-global-filter-duplicate-name-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.ui-dashboard-global-filter-duplicate-name-refused.ts @@ -34,6 +34,7 @@ export const entry: SemanticMigration = { + 'filter it meant, and that `os validate` reports no `dashboard-filter-binding-unknown` finding. A ' + 'stored `sys_metadata` dashboard that still repeats a key 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, ' - + 'platform objects, docs or skills repeats a filter key.', + + 'rewrites it. `os migrate meta --stored` lists each such stored dashboard under this entry\'s id, at ' + + 'the second declaration\'s `name` (or `field`). Measured before the change: no authored dashboard in ' + + 'this repository\'s examples, platform objects, docs or skills repeats a filter key.', }; 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 index 583b719bdf6..39cad6832d3 100644 --- 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 @@ -35,7 +35,8 @@ export const entry: SemanticMigration = { + '`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`.', + + 'next save (draft or publish) is refused; nothing rewrites it. `os migrate meta --stored` lists each ' + + 'such stored dashboard under this entry\'s id, at the entry\'s `name` (or `field`). Measured before the ' + + 'change: no authored dashboard in this repository\'s examples, apps, packages or ui tree keys a ' + + 'global filter `dateRange`.', };