diff --git a/.changeset/object-grid-default-sort-retired.md b/.changeset/object-grid-default-sort-retired.md new file mode 100644 index 0000000000..42a3c93619 --- /dev/null +++ b/.changeset/object-grid-default-sort-retired.md @@ -0,0 +1,48 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): retire `object-grid`'s legacy `defaultSort` fallback (#11805, ADR-0049) + + + +**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep +launch-window convention ships it as `minor`, per the maintainer's #11805 +ruling — 「不需要major」; the migration prescription is registered under +protocol major 18, where `os migrate meta` users will look). + +`ObjectGridPropsSchema.defaultSort` was the legacy second spelling of `sort`: +a single `{ field, order }` pair the renderer read only when `sort` was absent +— measured at the `.objectui-sha` pin (`190fbd01d`), +`plugin-grid/src/ObjectGrid.tsx:1244-1246` (the `$orderby` fetch fallback) and +`:2847`, where the header-arrow path wraps it `[schema.defaultSort]`, the +exact array shape `sort` carries. One intent, two spellings; objectui's mirror +schema is parity-test-only and parses nothing at runtime, so only this +strictObject can refuse the legacy spelling (objectui#5861 retires the +renderer's reads as the consumer half, on its own schedule). + +FROM → TO: + +- `defaultSort: { field, order }` (no `sort` beside it) → + `sort: [{ field, order }]` — the same pair, wrapped in the array shape every + read path honours. +- `defaultSort` beside an authored `sort` → *(removed)*. The renderer's own + precedence made the fallback unread there, so the deletion is lossless. + +One-line fix: rename the key to `sort` and wrap the value in an array; +`os migrate meta --from 17` lists the mechanical edits for existing sources. + +The retirement kit: + +- `retiredKey()` tombstone in `ObjectGridPropsSchema` — authoring the key is a + tsc error (`never`) and a parse error carrying the wrap-and-rename + prescription (the surface baseline line carries `[RETIRED]`) +- ADR-0087 registration: `ui/ObjectGridProps:defaultSort` in + `RETIRED_KEYS_BY_MAJOR[18]`, and the D2 conversion + `object-grid-default-sort-removed` (protocol 18) wired into the step-18 + chain — wrap-and-rename when `sort` is absent, a pure strip when `sort` is + present +- pin tests (`component.test.ts`): a refusal pin asserting the prescription, a + no-materialize pin, and a surviving-surface pin on `sort` +- zero authored occurrences in either repo's corpora (the card's measurement, + re-run at dispatch), so no in-repo source changes ride along diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index cd24bf7ea8..47e21db4d4 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -275,7 +275,7 @@ const result = AIChatWindowProps.parse(data); | **filter** | `any` | optional | Base query filter (ObjectQL filter array/AST) — lowered to the wire `$filter`. THE key #7750 misspelled as plural | | **defaultFilters** | `any` | optional | Legacy base-filter fallback, read only when `filter` is absent. Prefer `filter` | | **sort** | `any` | optional | Initial sort (array of `{ field, order }`) | -| **defaultSort** | `any` | optional | Legacy single-sort fallback (`{ field, order }`), read only when `sort` is absent. Prefer `sort` | +| **defaultSort** | `never` | optional | [REMOVED] `object-grid` property `defaultSort` was removed in @objectstack/spec 17 (#11805, ADR-0049) — it was the legacy second spelling of `sort`: a single `{ field, order }` pair read only when `sort` was absent, so one intent had two spellings and a grid authoring both silently ignored this one. Rename the key to `sort` and wrap the value in an array (`defaultSort: { field, order }` becomes `sort: [{ field, order }]`); the pair itself is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **pagination** | `any` | optional | Pagination config (`{ pageSize, pageSizeOptions, … }`); its presence enables paging | | **pageSize** | `number` | optional | Flat page-size shorthand; `pagination.pageSize` wins when both are set | | **showPagination** | `boolean` | optional | Show the pager (read only when `pagination` is absent) | diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 8335d00c04..6fb11733e4 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -719,7 +719,7 @@ "ui/ObjectGridProps:conditionalFormatting", "ui/ObjectGridProps:data", "ui/ObjectGridProps:defaultFilters", - "ui/ObjectGridProps:defaultSort", + "ui/ObjectGridProps:defaultSort [RETIRED]", "ui/ObjectGridProps:editable", "ui/ObjectGridProps:exportOptions", "ui/ObjectGridProps:fields", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 21edf8944b..c06ecd3a6e 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -7901,6 +7901,198 @@ const pageComponentResponsiveRemoved: MetadataConversion = { }, }; +/** + * `object-grid`'s legacy single-sort fallback leaves the contract (protocol 18, + * #11805, ADR-0049 enforce-or-remove; maintainer ruling 2026-08-25, + * decision-inbox batch 4 — the producer half of objectui#5861, under the + * objectui#4869 「接受所有」 direction). + * + * `defaultSort` was the second spelling of `sort`: a SINGLE `{ field, order }` + * pair the renderer read only when `sort` was absent — measured at the + * `.objectui-sha` pin (`190fbd01d`), `plugin-grid/src/ObjectGrid.tsx:1244-1246` + * (the `$orderby` fetch fallback) and `:2847`, where the header-arrow path + * wraps it `[schema.defaultSort]`, the exact array shape `sort` carries. One + * intent, two spellings, and only the spec's strictObject can refuse the + * legacy one: objectui's mirror schema exists for parity tests and parses + * nothing at runtime, so stopping the reads without this producer-side + * retirement would turn a declared contract into a silent no-op. + * + * NOT a pure lossless delete, unlike {@link pageStructureInertKeysRemoved}: + * when `sort` is absent the fallback WAS the grid's sort, so the conversion + * carries the pair over — renamed to `sort` and wrapped in the array shape the + * renderer itself wraps it into at `:2847`. When `sort` is present the + * fallback was never read (the renderer's own precedence), and the key strips + * as a lossless delete. Zero authored occurrences in either repo's corpora + * (the card's measurement, re-run at dispatch), so this entry exists for + * stored `sys_metadata` rows and for authors outside the repo. + * + * objectui#5861 (retire the renderer's two reads) is the consumer half and + * proceeds on its own schedule after a released spec version reaches the pin. + */ +const objectGridDefaultSortRemoved: MetadataConversion = { + id: 'object-grid-default-sort-removed', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'page.component.object-grid.defaultSort', + summary: + "object-grid component prop 'defaultSort' removed (#11805 — the legacy single-sort second " + + "spelling of 'sort', read only when 'sort' was absent; the pair moves to sort: [{ field, " + + 'order }], the array shape every read path honours)', + apply(stack, emit) { + return mapPageComponents(stack, (component, path) => { + if (component.type !== 'object-grid') return component; + const properties = component.properties; + if (!isDict(properties) || !('defaultSort' in properties)) return component; + if ('sort' in properties) { + // `sort` present: the fallback was never read — a pure lossless delete. + const stripped = stripKeys(properties, ['defaultSort'], emit, `${path}.properties`); + return { ...component, properties: stripped }; + } + // `sort` absent: the fallback WAS the sort. Carry the pair, wrapped in + // the array shape `sort` reads (the renderer's own `[schema.defaultSort]` + // equivalence); an already-array value moves as-is — an array never + // worked in `defaultSort` (the fetch path read `.field` off it), and + // `sort` is where an array is legal. + const { defaultSort, ...rest } = properties; + emit({ from: 'defaultSort', to: 'sort', path: `${path}.properties.sort` }); + return { + ...component, + properties: { ...rest, sort: Array.isArray(defaultSort) ? defaultSort : [defaultSort] }, + }; + }); + }, + fixture: { + before: { + pages: [ + { + name: 'work_queue', + regions: [ + { + name: 'main', + components: [ + // The fallback IS the sort: no `sort` beside it, so the pair + // moves — renamed and wrapped. + { + type: 'object-grid', + id: 'g1', + properties: { objectName: 'crm_task', defaultSort: { field: 'due_date', order: 'asc' } }, + }, + // Both spellings authored: `sort` wins (the renderer's own + // precedence), so the fallback strips as a lossless delete. + { + type: 'object-grid', + id: 'g2', + properties: { + objectName: 'crm_task', + sort: [{ field: 'priority', order: 'desc' }], + defaultSort: { field: 'due_date', order: 'asc' }, + }, + }, + // `defaultSort` on a component that is not an object-grid — + // not this entry's key (the strip is scoped by component type, + // never by key name). + { + type: 'object-kanban', + id: 'k1', + properties: { objectName: 'crm_task', defaultSort: { field: 'due_date', order: 'asc' } }, + }, + // The nested position (#6775's lesson): a grid inside a card's + // `children` is still a component. + { + type: 'page:card', + id: 'c1', + properties: { + children: [ + { + type: 'object-grid', + id: 'g3', + properties: { objectName: 'crm_lead', defaultSort: { field: 'created_at', order: 'desc' } }, + }, + ], + }, + }, + ], + }, + ], + }, + // The named-slot shape (#6776): a grid authored into a slotted page. + { + name: 'work_queue_detail', + kind: 'slotted', + regions: [], + slots: { + details: { + type: 'object-grid', + id: 'g4', + properties: { objectName: 'crm_task', defaultSort: { field: 'updated_at', order: 'desc' } }, + }, + }, + }, + ], + }, + after: { + pages: [ + { + name: 'work_queue', + regions: [ + { + name: 'main', + components: [ + { + type: 'object-grid', + id: 'g1', + properties: { objectName: 'crm_task', sort: [{ field: 'due_date', order: 'asc' }] }, + }, + { + type: 'object-grid', + id: 'g2', + properties: { + objectName: 'crm_task', + sort: [{ field: 'priority', order: 'desc' }], + }, + }, + { + type: 'object-kanban', + id: 'k1', + properties: { objectName: 'crm_task', defaultSort: { field: 'due_date', order: 'asc' } }, + }, + { + type: 'page:card', + id: 'c1', + properties: { + children: [ + { + type: 'object-grid', + id: 'g3', + properties: { objectName: 'crm_lead', sort: [{ field: 'created_at', order: 'desc' }] }, + }, + ], + }, + }, + ], + }, + ], + }, + { + name: 'work_queue_detail', + kind: 'slotted', + regions: [], + slots: { + details: { + type: 'object-grid', + id: 'g4', + properties: { objectName: 'crm_task', sort: [{ field: 'updated_at', order: 'desc' }] }, + }, + }, + }, + ], + }, + // Four notices: three wrap-and-renames (g1, the nested g3, the slotted g4) + // and one strip (g2, where `sort` already won). + expectedNotices: 4, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -7985,6 +8177,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly> // `element-input-target-variable-removed` (a page component IS a stack // collection member, unlike the `kernel/Manifest:loading` family). 'ui/ElementTextInputProps:targetVariable', + // #11805 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-25, + // decision-inbox batch 4: 「#11805 退役 defaultSort,不需要major」; the producer + // half of objectui#5861, under the objectui#4869 「接受所有」 direction). + // `defaultSort` was the legacy second spelling of `object-grid`'s `sort`: a + // single `{ field, order }` pair the renderer read only when `sort` was absent + // (measured at the `.objectui-sha` pin `190fbd01d`, + // `plugin-grid/src/ObjectGrid.tsx:1244-1246` fetch fallback and `:2847`, which + // wraps it `[schema.defaultSort]` — the exact array shape `sort` carries). One + // intent, two spellings; objectui's mirror schema is parity-test-only and + // parses nothing at runtime, so only this repo's strictObject can refuse the + // key. Zero authored occurrences in either repo's corpora (the card's + // measurement, re-run here at dispatch). + // + // Registered under 18, not 17: v17.0.0 was cut before this landed, so the + // removal ships on the 17.x line (launch-window convention: accept-set + // narrowings ride minor releases) and the prescription lives at the major + // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent, + // as `data/Metric:filters` before it). Tombstoned with `retiredKey()` in + // `ObjectGridPropsSchema` (the surface baseline line carries `[RETIRED]`); + // sources are rewritten by the D2 conversion `object-grid-default-sort-removed` + // (wrap-and-rename to `sort: [pair]` when `sort` is absent; a pure lossless + // delete when `sort` is present, since the fallback was never read then). + 'ui/ObjectGridProps:defaultSort', // #11027 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-22, ruled B: // retire + repair the redirect texts in the same change). The LAST carrier of // the `ResponsiveConfig` layout block, and the destination the diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 13f31c7c77..0ad814a7d4 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -2160,6 +2160,36 @@ describe('#7751 — object-* block props schemas', () => { expect(parsed.defaultFilters).toEqual([['status', '=', 'open']]); }); + // #11805 — the grid's legacy single-sort fallback, retired by maintainer + // ruling 2026-08-25 (decision-inbox batch 4; the producer half of + // objectui#5861 under the objectui#4869 「接受所有」 direction). Unlike + // `defaultFilters` above — a read fallback that STAYS — `defaultSort` was + // the second spelling of `sort` (read only when `sort` was absent, and + // wrapped `[schema.defaultSort]` by the renderer's own header-arrow path), + // so the one-intent-two-spellings rule retires it at the producer. + describe('object-grid `defaultSort` is retired (#11805)', () => { + it('rejects the retired `defaultSort` with the wrap-and-rename prescription', () => { + expect(() => ComponentPropsMap['object-grid'].parse({ + objectName: 'showcase_task', + defaultSort: { field: 'due_date', order: 'asc' }, + })).toThrow(/`defaultSort`.*removed.*`sort`/s); + }); + + it('does not materialize the retired `defaultSort` on a clean parse', () => { + expect(ComponentPropsMap['object-grid'].parse({ objectName: 'showcase_task' })) + .not.toHaveProperty('defaultSort'); + }); + + // The live half of the intent: the array spelling every read path honours. + it('keeps `sort`, the canonical spelling', () => { + const parsed = ComponentPropsMap['object-grid'].parse({ + objectName: 'showcase_task', + sort: [{ field: 'due_date', order: 'asc' }], + }); + expect(parsed.sort).toEqual([{ field: 'due_date', order: 'asc' }]); + }); + }); + it('every object-metric node of the showcase corpus parses GREEN (the clean-corpus control)', () => { // Copies of all three my-work.page.ts metrics + the command-center shape // (variant/format) — the exact nodes the lint must NOT start warning on. diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index d8e33d423d..c794a7c312 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -2203,7 +2203,8 @@ const FILTERS_TO_FILTER = { filters: 'filter' } as const; * `filter` (:739, lowered via `toFilterNode` to `$filter`), `defaultFilters` * (:922 — the LEGACY fallback read only when `filter` is absent; it is read, * so it stays declared — only the plural `filters` has zero read points), - * `sort` (:741) / `defaultSort` (:943), `pagination`/`pageSize`/`showPagination` + * `sort` (:741) / `defaultSort` (:943 — RETIRED #11805, tombstoned below; + * objectui#5861 retires the read), `pagination`/`pageSize`/`showPagination` * (:567, :752, :2475-2480), `searchableFields`/`showSearch` (:959, :2484-2486), * `rowHeight` (:549), `grouping`/`aggregations` (:1076, :1136), `rowColor` * (:1052), `conditionalFormatting` (:884, :1061), `selection`/`selectable` @@ -2233,8 +2234,36 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ defaultFilters: z.unknown().optional() .describe('Legacy base-filter fallback, read only when `filter` is absent. Prefer `filter`'), sort: z.unknown().optional().describe('Initial sort (array of { field, order })'), - defaultSort: z.unknown().optional() - .describe('Legacy single-sort fallback ({ field, order }), read only when `sort` is absent. Prefer `sort`'), + /** + * REMOVED (#11805, maintainer ruling 2026-08-25, decision-inbox batch 4: + * 「#11805 退役 defaultSort,不需要major」 — the ADR-0049 enforce-or-remove + * half of the objectui#4869 「接受所有」 direction; objectui#5861 is the + * consumer half). + * + * The legacy second spelling of `sort`: a SINGLE `{ field, order }` pair the + * renderer read only when `sort` was absent — measured at the `.objectui-sha` + * pin (`190fbd01d`), `plugin-grid/src/ObjectGrid.tsx:1244-1246` (fetch path, + * `$orderby` fallback) and `:2847` (header arrows, where it is wrapped + * `[schema.defaultSort]` — the exact array shape `sort` carries). One intent, + * two spellings; only this repo's strictObject can refuse it (objectui's + * mirror schema is parity-test-only and parses nothing at runtime), so the + * retirement lands here and objectui#5861 retires the reads on its own + * schedule, exactly as `page:card.body` left the renderer's `body ??` + * fallback behind. + * + * The live mechanism is `sort` — the same pair, wrapped in an array. The + * protocol-18 conversion `object-grid-default-sort-removed` carries the + * mechanical rewrite (wrap-and-rename when `sort` is absent; a pure lossless + * delete when `sort` is present, since the fallback was never read then). + */ + defaultSort: retiredKey( + '`object-grid` property `defaultSort` was removed in @objectstack/spec 17 (#11805, ADR-0049) — ' + + 'it was the legacy second spelling of `sort`: a single `{ field, order }` pair read only when ' + + '`sort` was absent, so one intent had two spellings and a grid authoring both silently ignored ' + + 'this one. Rename the key to `sort` and wrap the value in an array (`defaultSort: { field, order }` ' + + 'becomes `sort: [{ field, order }]`); the pair itself is unchanged. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ), pagination: z.unknown().optional() .describe('Pagination config ({ pageSize, pageSizeOptions, … }); its presence enables paging'), pageSize: z.number().optional().describe('Flat page-size shorthand; `pagination.pageSize` wins when both are set'),