diff --git a/.changeset/5293-view-sort-order-spelling.md b/.changeset/5293-view-sort-order-spelling.md index cace18af8c..1f1fbf769d 100644 --- a/.changeset/5293-view-sort-order-spelling.md +++ b/.changeset/5293-view-sort-order-spelling.md @@ -48,9 +48,9 @@ described above no longer finds a live `direction` read on this package's publis path. `order` is the spelling every other sort surface already uses (`SortConfig`, -`NamedListView.sort`, `ObjectGridSchema.sort` / `.defaultSort`, and the shared -`QuerySortEntry` sink), so the prop now has one spelling repo-wide and declared equals -enforced. +`NamedListView.sort`, `ObjectGridSchema.sort` (and, at this change, `.defaultSort` — +since retired, objectui#5861), and the shared `QuerySortEntry` sink), so the prop now has +one spelling repo-wide and declared equals enforced. ⛔ Deliberately **not** a tolerant dual-read (`direction ?? order`): that is the tolerance layer objectui#4869 ruled against, and admitting the old key as an alias would rebuild the diff --git a/.changeset/5861-retire-table-defaultsort.md b/.changeset/5861-retire-table-defaultsort.md new file mode 100644 index 0000000000..205f1320bb --- /dev/null +++ b/.changeset/5861-retire-table-defaultsort.md @@ -0,0 +1,46 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-grid': minor +'@object-ui/plugin-view': minor +--- + +Retire `object-grid`'s `defaultSort` and `object-view`'s `table.defaultSort` +(objectui#5861 — ADR-0049 enforce-or-remove, the "C half" of the 2026-08-22 +ruling on objectui#4869). + +**BREAKING for the legacy spelling, deliberately.** `defaultSort` was the +single-entry `{ field, order }` spelling of `sort`. `@objectstack/spec` 17.3.0 +turned `ObjectGridProps.defaultSort` into a retired-key tombstone, so the +protocol already refuses an authored value by name; the renderers were the last +place that still honoured it. Every read goes in this one change, so no path +honours the key while another ignores it: + +- `@object-ui/plugin-grid` — `ObjectGrid` no longer lowers `defaultSort` into + `$orderby` and no longer falls back to it for the header sort arrows. The + registered `sort` input's description no longer advertises it as a fallback. +- `@object-ui/plugin-view` — `ObjectView` no longer forwards `table.defaultSort` + to the grid, no longer lowers it on the non-grid fetch (calendar / kanban / + gallery / timeline / gantt / map), and no longer wraps it into the sort + handed to a host's `renderListView`. +- `@object-ui/types` — the published faces narrow to match: + `ObjectGridSchema.defaultSort` is now `?: never` (a TypeScript author who + writes it gets a compile error), and the `ObjectGridSchema` zod mirror refuses + it by name with a migration message. `ObjectViewSchema.table` inherits the + `never` member. A tombstone rather than a deletion: `BaseSchema`'s index + signature and `.passthrough()` would otherwise absorb the key silently. + +A document that still carries `defaultSort` renders **unsorted** — it is not +re-routed into `sort`. Migrate by renaming the key and wrapping the value: +`defaultSort: { field: 'name', order: 'asc' }` becomes +`sort: [{ field: 'name', order: 'asc' }]`. `sort` already outranked +`defaultSort` wherever both were written, so documents that carry both keep +their current ordering. + +This supersedes the `defaultSort` handling described by five earlier +changesets still pending in the same release, each of which now carries a dated +note naming this card: objectui#4869 (the non-grid lowering, and "both +spellings of the pair keep working"), objectui#6235 (the delegated-slot wrap), +objectui#8973 (the normalized grid arm), objectui#4082 (the grid's legacy +`defaultSort` leg, graded as needing no change) and objectui#5293 (which cites +`ObjectGridSchema.defaultSort` as a live `order` spelling). Those describe how +the legacy key was honoured; from this release it is not read at all. diff --git a/.changeset/6235-mergedsort-wrap.md b/.changeset/6235-mergedsort-wrap.md index 7c90482826..66e89ca95b 100644 --- a/.changeset/6235-mergedsort-wrap.md +++ b/.changeset/6235-mergedsort-wrap.md @@ -2,8 +2,16 @@ '@object-ui/plugin-view': patch --- -`ObjectView` wraps `table.defaultSort` before handing it to a delegated list view, so a -view whose only ordering is the deprecated key actually sorts (objectui#6235). +At this change `ObjectView` wrapped `table.defaultSort` before handing it to a delegated +list view, so a view whose only ordering was the deprecated key actually sorted +(objectui#6235). + +⚠️ **Dated note, 2026-09-25 — this wrap has since been retired — objectui#5861.** Later in +this same release `table.defaultSort` became an ADR-0049 retirement tombstone and +`mergedSort` lost the branch this entry changed: it now ends at `table.sort`, so a +`table.defaultSort` alone hands the delegated slot no sort at all and the view renders +unsorted. The rest of this entry is kept as the reading of this change; the objectui#5861 +entry states what ships and the migration (`sort: [{ field, order }]`). `ObjectGridSchema.defaultSort` is declared a SINGLE `{ field, order }` object — the zod mirror agrees (`z.object({ field, order })`, not a union) — while the `list-view` node's @@ -21,16 +29,16 @@ Studio's `renderStudioGridList`), so the symptom was an unsorted list with no er while the SAME metadata sorted correctly as a grid, because `ObjectGrid` performs this lowering for the same pair. -The wrap is verbatim the one the non-grid fetch path in this same file already applies -(`|| (schema.table?.defaultSort ? [schema.table.defaultSort] : undefined)`), so all three -consumers now agree and no fourth dialect is introduced. The shared sink is deliberately +The wrap was verbatim the one the non-grid fetch path in this same file then applied +(`|| (schema.table?.defaultSort ? [schema.table.defaultSort] : undefined)`), so at this +change all three consumers agreed and no fourth dialect was introduced. The shared sink is deliberately NOT widened to accept a bare `{ field, order }`: that is the widening the maintainer ruling of 2026-08-22 rejected on the merits, because the same slot legitimately carries `$orderby`'s own `Record` map, in which `{ field: 'desc' }` is a legal ordering by a column literally named `field`. -Precedence is unchanged — a named view's sort still outranks `table.sort`, which still -outranks `table.defaultSort`. Only the final branch changes shape. +Precedence was unchanged — a named view's sort still outranked `table.sort`, which still +outranked `table.defaultSort`. Only the final branch changed shape. One behaviour note for hosts writing off-schema metadata: an ARRAY in `table.defaultSort` was previously forwarded verbatim by this path alone and is now lowered like every other diff --git a/.changeset/8973-grid-orderby-normalize.md b/.changeset/8973-grid-orderby-normalize.md index 3f3ed65476..7820dbd198 100644 --- a/.changeset/8973-grid-orderby-normalize.md +++ b/.changeset/8973-grid-orderby-normalize.md @@ -15,8 +15,13 @@ params.$orderby = schemaSort.map((s) => `${s.field} ${s.order}`).join(', '); So an entry missing `field` or `order` reached the wire as the literal text `undefined`. The legacy `defaultSort` arm one `else` down had the identical defect on a -single object, and is fixed with it — leaving it would keep the class open inside the -same `if`/`else` chain. +single object, and was fixed with it at this change — leaving it would have kept the class +open inside the same `if`/`else` chain. + +⚠️ **Dated note, 2026-09-25 — that legacy arm has since been retired — objectui#5861.** +Later in this same release `ObjectGrid` stopped reading `defaultSort` at all (an ADR-0049 +retirement tombstone), so the chain has two arms, header sort and `sort`. Everything else +in this entry, including the table below, concerns `sort` and still holds. This is a wire failure, not a cosmetic one. `normalizeSortNodes` — the one normalizer every `@objectstack` server ingress funnels through — validates the direction token, so diff --git a/.changeset/grid-default-filters-lowering-4082.md b/.changeset/grid-default-filters-lowering-4082.md index 1fa03310a2..6ead784616 100644 --- a/.changeset/grid-default-filters-lowering-4082.md +++ b/.changeset/grid-default-filters-lowering-4082.md @@ -44,5 +44,10 @@ Not in scope, and deliberately not done: retiring `defaultFilters`. This is consumer-side only — the key the schema admits is unchanged, and its precedence behind the canonical `filter` is unchanged. -The sibling legacy `defaultSort` leg was graded and needs no change; see the PR for the -measurements. +At this change the sibling legacy `defaultSort` leg was graded and needed no change; see +the PR for the measurements. + +⚠️ **Dated note, 2026-09-25 — that leg has since been retired — objectui#5861.** Later in +this same release `ObjectGrid` stopped reading `defaultSort` at all (an ADR-0049 retirement +tombstone), so there is no longer a legacy sort leg beside the `defaultFilters` one this +entry lowers. diff --git a/.changeset/objectview-sort-sink-4869.md b/.changeset/objectview-sort-sink-4869.md index 9408f95811..9b0ed258dd 100644 --- a/.changeset/objectview-sort-sink-4869.md +++ b/.changeset/objectview-sort-sink-4869.md @@ -13,9 +13,9 @@ swallows the error, and the view rendered with no records — while the *same* metadata sorted correctly as a grid, because `ObjectGrid` lowers that pair before using it (objectui#4869, maintainer ruling 2026-08-22). -`ObjectView` now performs the same legacy-to-canonical lowering `ObjectGrid` -already does (`sort ?? (defaultSort ? [defaultSort] : undefined)`) and routes -the whole chain — named view sort, `views` prop sort, `table.sort`, +At this change `ObjectView` performed the same legacy-to-canonical lowering +`ObjectGrid` then did (`sort ?? (defaultSort ? [defaultSort] : undefined)`) and +routed the whole chain — named view sort, `views` prop sort, `table.sort`, `table.defaultSort` — through the shared `convertSortToQueryParams` sink. This was the last object-bound read site sending an authored sort to `$orderby` unlowered; every other block (gantt, map, calendar, timeline, @@ -23,11 +23,21 @@ unlowered; every other block (gantt, map, calendar, timeline, now receives one normalized `Record` from all of them instead of two different dialects. -Precedence is unchanged, and both spellings of the pair keep working. The -shared sink was deliberately **not** widened to accept a bare `{ field, order }`: -its input slot also legitimately carries `$orderby`'s own map, in which -`{ field: 'desc' }` is a valid ordering by a column named `field`, so widening -it would make one shared function guess. +Precedence was unchanged, and at this change both spellings of the pair kept +working. The shared sink was deliberately **not** widened to accept a bare +`{ field, order }`: its input slot also legitimately carries `$orderby`'s own +map, in which `{ field: 'desc' }` is a valid ordering by a column named `field`, +so widening it would make one shared function guess. + +⚠️ **Dated note, 2026-09-25 — the legacy half of that pair has since been +retired — objectui#5861.** Later in this same release `table.defaultSort` became +an ADR-0049 retirement tombstone: `ObjectView` no longer lowers or forwards it on +any path and `ObjectGrid` no longer reads it, so a view whose only sort is +`table.defaultSort` now renders **unsorted** (no longer empty, and no longer +sorted). The paragraphs above are kept as the reading of this change. What still +ships from it is the canonical chain — named view sort, `views` prop sort, +`table.sort` — reaching the shared sink; the objectui#5861 entry states the +migration (`sort: [{ field, order }]`). Two visible shape changes on the wire, both semantically identical to before: a string `table.sort` such as `'name desc'` now serializes as `-name` rather than diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index caf261b9f2..cf8a037d90 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -589,8 +589,9 @@ than one node. Build it from the shapes that do render: The `defaultSort` and `defaultSortOrder` keys documented here were `CRUDSchema`'s own — a flat field name plus a separate direction. They are gone with it. -[ObjectGridSchema](#objectgridschema) declares its own, differently shaped -`defaultSort` (an object with `field` and `order`); that key is unaffected. +[ObjectGridSchema](#objectgridschema) used to declare its own, differently shaped +`defaultSort` (an object with `field` and `order`); that key has since been +retired too — see the note under [ObjectGridSchema](#objectgridschema). Authoring `crud` is now refused by name: `validateSchema` from `@object-ui/core` returns a `RETIRED_TYPE` error on `schema.type` naming the migration above, and @@ -745,7 +746,7 @@ A data grid that auto-fetches from an ObjectQL object definition. Includes searc { "field": "phone" }, { "field": "status", "label": "Status", "sortable": true } ], - "defaultSort": { "field": "name", "order": "asc" }, + "sort": [{ "field": "name", "order": "asc" }], "operations": { "create": true, "read": true, @@ -782,6 +783,15 @@ A data grid that auto-fetches from an ObjectQL object definition. Includes searc | `frozenColumns` | `number` | Number of columns frozen on scroll. | | `navigation` | `ViewNavigationConfig` | SPA navigation configuration. | +> **`defaultSort` is retired (objectui#5861).** `ObjectGridSchema` used to accept a +> legacy single-entry `defaultSort: { field, order }` beside `sort`. The installed +> `@objectstack/spec` protocol refuses it by name (a retired-key tombstone), and no +> renderer reads it any more: a grid that still carries it renders **unsorted**, and +> `@object-ui/types` refuses it on both faces (a `?: never` member and a named zod +> refusal). Rename the key to `sort` and wrap the value in an array — +> `"defaultSort": { "field": "name", "order": "asc" }` becomes +> `"sort": [{ "field": "name", "order": "asc" }]`, as in the example above. + **Related:** [ObjectViewSchema](#objectviewschema), [TableSchema](#tableschema) --- diff --git a/content/docs/plugins/plugin-view.mdx b/content/docs/plugins/plugin-view.mdx index f0d92e8efe..6fbbd1d10f 100644 --- a/content/docs/plugins/plugin-view.mdx +++ b/content/docs/plugins/plugin-view.mdx @@ -99,8 +99,8 @@ declared member of `ObjectViewSchema`, and none is read anywhere in `packages/plugin-view/src`. Because `type: 'object-view'` *is* registered, a node built from those keys still resolves to a renderer; it just never receives an `objectName`, and the component's data effects are all guarded on it -(`ObjectView.tsx:403`, `:420`), so the result is a silent empty view rather than -an error. +(`ObjectView.tsx`'s `if (!dataSource || !schema.objectName) return;` guards), so +the result is a silent empty view rather than an error. Three structural facts about this node: @@ -128,41 +128,48 @@ object through. Anything else you put in them is ignored: | Sub-config | Keys `ObjectView` forwards | | --- | --- | -| `table` | `columns`, `fields`, `title`, `description`, `filter`, `defaultFilters`, `sort`, `defaultSort`, `pagination`, `pageSize`, `selection`, `selectable`, `operations`, `className` | +| `table` | `columns`, `fields`, `title`, `description`, `filter`, `defaultFilters`, `sort`, `pagination`, `pageSize`, `selection`, `selectable`, `operations`, `className` | | `form` | `fields`, `customFields`, `sections`, `groups`, `layout`, `columns`, `title`, `description`, `subforms`, `buttons`, `defaults`, `initialValues`, `readOnly`, `showSubmit`, `submitText`, `showCancel`, `cancelText`, `showReset`, `className` | The forwarding is literal, key by key, spread across three sites in -`ObjectView.tsx` — the non-grid data fetch (around `:604-611`), the grid -schema (around `:1041-1078`), and the schema handed to a host-supplied list -renderer (around `:1201-1209`) — so the four keys below behave the same on -every rendering path. +`ObjectView.tsx` — the non-grid data fetch, the `gridSchema` memo, and the +`mergedFilters` / `mergedSort` pair handed to a host-supplied list renderer — +so the keys below behave the same on every rendering path. + +> **`table.defaultSort` is retired (objectui#5861).** It was the legacy +> single-entry spelling of `table.sort`. The installed `@objectstack/spec` +> protocol refuses `object-grid`'s `defaultSort` by name (a retired-key +> tombstone), and `ObjectView` no longer reads it on any of the three +> paths above (nor does `ObjectGrid`): a view that still carries it renders +> **unsorted**. Rename the key to `sort` and wrap the value in an array — +> `defaultSort: { field: 'name', order: 'asc' }` becomes +> `sort: [{ field: 'name', order: 'asc' }]`. #### Canonical keys now take effect (objectui#5102) -Four of the forwarded `table` keys are pairs — a canonical `ObjectGridSchema` -key and the `@deprecated` legacy spelling it replaced. Both now work; write -the canonical one: +Three of the forwarded `table` keys are pairs — a canonical `ObjectGridSchema` +key and the `@deprecated` legacy spelling it replaced. Both work; write the +canonical one. (A fourth pair, `sort` / `defaultSort`, used to be listed here; +its legacy half is retired — see the note above.) | write this (canonical) | not this (legacy alias — still works) | | --- | --- | | `pagination: { pageSize, pageSizeOptions? }` | `pageSize: number` | | `selection: { type: 'single' \| 'multiple' \| 'none' }` | `selectable: boolean \| 'single' \| 'multiple'` | | `filter: [{ field, operator, value }, …]` (same shape as a named view's `filter`) | `defaultFilters: Record` (equality-only) | -| `sort: SortConfig[]` (`[{ field, order }]`) | `defaultSort: { field, order }` (a single entry, not an array) | Before objectui#5102, `pagination` / `selection` / `filter` / `sort` had **no read point at all** in this file: an author who wrote the canonical shape `ObjectGridSchema`'s own JSDoc recommends got a view that compiled, read correctly, and silently did nothing. That is fixed — the legacy spellings on -the right are not going away, they are simply no longer the ones to reach for. +the right are simply no longer the ones to reach for. **When a key is written both ways, the canonical spelling wins** — that is `ObjectGrid`'s own existing resolution (`schema.pagination?.pageSize || schema.pageSize`; `if (schema.selection?.type) … else if (schema.selectable -!== undefined)`; `schemaFilter !== undefined ? … : schema.defaultFilters`; -`schemaSort ?? (schema.defaultSort ? [schema.defaultSort] : undefined)`), and -`ObjectView` defers to it by forwarding both slots rather than re-resolving -the pair itself: +!== undefined)`; `schemaFilter !== undefined ? … : schema.defaultFilters`), and `ObjectView` +defers to it by forwarding both slots rather than re-resolving the pair +itself: ```typescript import type { ObjectViewSchema } from '@object-ui/types'; @@ -183,9 +190,9 @@ predates this change: an **active named view's own** `filter` / `sort` `table` — `table.filter` does **not** universally win over the legacy `table.defaultFilters`, it wins only when no active named view supplies its own filter. In order, highest first: the active named view's `filter`/`sort`, -then `table.filter`/`table.sort`, then -`table.defaultFilters`/`table.defaultSort`. If you never write `listViews`, -that first tier never applies. +then `table.filter`/`table.sort`, then `table.defaultFilters` (filter only — +`sort` has no legacy tier since `table.defaultSort` was retired). If you never +write `listViews`, that first tier never applies. `pagination` and `selection` have no such tier, and no effect outside the grid: `defaultViewType: 'kanban' | 'gallery' | 'calendar' | 'timeline' | @@ -223,8 +230,9 @@ types: Both spellings resolve here: `ComponentRegistry.register` publishes `namespace:type` and, unless the call passes `skipFallback: true`, the bare -`type` as a back-compat fallback (`packages/core/src/registry/Registry.ts:194`, -fallback at `:226`). No call in this package sets `skipFallback`. Note the +`type` as a back-compat fallback (`ComponentRegistry.register` in +`packages/core/src/registry/Registry.ts`, its `!meta?.skipFallback` branch). No +call in this package sets `skipFallback`. Note the namespaces are not uniform — `object-view`, `view` and `view:simple` register under `plugin-view`, the four control components under `view`. diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 5775bbf626..c400e286c5 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -2217,18 +2217,12 @@ export const ObjectGrid: React.FC = ({ params.$orderby = orderBy; } } - } else if (schema.defaultSort) { - // Legacy support — through the SAME normalizer as the array arm - // above, because it had the SAME defect (objectui#8973): a - // `defaultSort` missing `order` was interpolated straight into - // `$orderby: 'name undefined'`, which the server answers - // `400 INVALID_QUERY`. Fixing one arm and not its neighbour would - // leave the class open in the same `if`/`else` chain. - const orderBy = toOrderByClause([schema.defaultSort as QuerySortEntry]); - if (orderBy !== undefined) { - params.$orderby = orderBy; - } } + // objectui#5861 — there is no third arm. The legacy single-entry + // `defaultSort` was retired under ADR-0049: `@objectstack/spec` + // refuses it by name on `object-grid` (since 17.3.0), so this chain + // reads `sort` alone, and the header reader below reads the same key + // the same way. // Search (objectui#3118). The term the toolbar box holds is a question // about the collection, so it goes to the server rather than to a @@ -4536,11 +4530,12 @@ export const ObjectGrid: React.FC = ({ const groupingPartialLabel = groupingIsPartial ? t('grid.grouping.partialBadge') : undefined; // Before anyone clicks, the headers show the sort the view was authored with - // — read with the same `schemaSort` → `defaultSort` precedence the fetch path - // above uses, so the arrow on screen and the `$orderby` on the wire are the - // same sort. Without that a view arriving `created_at desc` would show no - // arrow, and the first click on that column would ask for `asc` on a list - // that was already `desc`. + // — read from the same `schemaSort` the fetch path above lowers, so the + // arrow on screen and the `$orderby` on the wire are the same sort. (The + // retired `defaultSort` fallback is read by neither, objectui#5861.) + // Without that a view arriving `created_at desc` would show no arrow, and + // the first click on that column would ask for `asc` on a list that was + // already `desc`. // // ⭐ That agreement now covers the SPELLING too (objectui#8961). One used to // escape it: since objectui#8767 the fetch path REFUSES a string `sort` and @@ -4557,9 +4552,7 @@ export const ObjectGrid: React.FC = ({ // A plain expression, not a `useMemo`: this sits below the component's early // returns, where a hook would be skipped on some renders and change the hook // order. Parsing at most a handful of sort keys costs nothing worth a hook. - const declaredSort = parseSchemaSort( - schemaSort ?? (schema.defaultSort ? [schema.defaultSort] : undefined), - ); + const declaredSort = parseSchemaSort(schemaSort); /** * [#5729] The RESTORE leg of objectstack#10235's contract, and the guard * that keeps the personalization PUT off an unsortable column. diff --git a/packages/plugin-grid/src/__tests__/gridArrayArmOrderby-8973.test.tsx b/packages/plugin-grid/src/__tests__/gridArrayArmOrderby-8973.test.tsx index 861118ef6b..3420344fba 100644 --- a/packages/plugin-grid/src/__tests__/gridArrayArmOrderby-8973.test.tsx +++ b/packages/plugin-grid/src/__tests__/gridArrayArmOrderby-8973.test.tsx @@ -17,7 +17,8 @@ * Every key was interpolated UNCONDITIONALLY, so an entry missing `field` or * `order` reached the wire as the literal text `undefined`. The legacy * `defaultSort` arm one `else` down had the identical defect on a single - * object. + * object. (That arm is gone since objectui#5861 retired the key; its section + * below now pins it inert.) * * ## Why this is a wire defect and not a cosmetic one — MEASURED, not assumed * @@ -241,19 +242,31 @@ describe('object-grid array `sort` arm — the card’s six-row probe table (obj }); }); -describe('object-grid legacy `defaultSort` arm — the SAME defect, one `else` down (objectui#8973)', () => { - it('CONTROL — a fully-specified `defaultSort` still lowers to the join string', async () => { - const params = await findParamsFor({ ...BASE, defaultSort: { field: 'name', order: 'desc' } }); +/** + * objectui#5861 — the legacy `defaultSort` arm #8973 normalized is GONE. + * + * `@objectstack/spec` 17.3.0 turned `object-grid`'s `defaultSort` into a + * retired-key tombstone the protocol refuses by name, and objectui#5861 + * removed the arm (ADR-0049 enforce-or-remove). These three cells pinned it + * as WORKING; they are flipped rather than deleted, so every shape it used to + * lower — the fully-specified one included — now asserts NO `$orderby`. The + * canonical control is the first cell of the six-row table above (`CONTROL — + * a fully-specified entry still lowers to the join string`), repeated here on + * the same document so the flip cannot pass because lowering broke outright. + */ +describe('object-grid legacy `defaultSort` arm — RETIRED, nothing lowers (objectui#5861)', () => { + it('a fully-specified `defaultSort` carries NO `$orderby` — it used to lower to `"name desc"`', async () => { + expectNoOrderBy(await findParamsFor({ ...BASE, defaultSort: { field: 'name', order: 'desc' } })); + // CONTROL — the canonical spelling of the same sort, same block, same run. + const params = await findParamsFor({ ...BASE, sort: [{ field: 'name', order: 'desc' }] }); expect(params.$orderby).toBe('name desc'); }); - it('`order` omitted lowers to `asc` — it used to go out as `"name undefined"`', async () => { - const params = await findParamsFor({ ...BASE, defaultSort: { field: 'name' } }); - expect(params.$orderby).toBe('name asc'); - expect(params.$orderby).not.toContain('undefined'); + it('`order` omitted carries NO `$orderby` — it used to lower to `"name asc"`', async () => { + expectNoOrderBy(await findParamsFor({ ...BASE, defaultSort: { field: 'name' } })); }); - it('`field` omitted carries NO `$orderby` — it used to send `"undefined desc"`', async () => { + it('`field` omitted carries NO `$orderby` — unchanged, and no longer for #8973\'s reason', async () => { expectNoOrderBy(await findParamsFor({ ...BASE, defaultSort: { order: 'desc' } })); }); }); diff --git a/packages/plugin-grid/src/__tests__/gridDefaultFiltersLowering.test.tsx b/packages/plugin-grid/src/__tests__/gridDefaultFiltersLowering.test.tsx index eb34336f9a..c5c3c91e35 100644 --- a/packages/plugin-grid/src/__tests__/gridDefaultFiltersLowering.test.tsx +++ b/packages/plugin-grid/src/__tests__/gridDefaultFiltersLowering.test.tsx @@ -260,25 +260,27 @@ describe('object-grid — NEGATIVE CONTROL: the declared `filter` path is unchan }); }); -describe('object-grid — the legacy `defaultSort` leg is NOT filter-lowered (objectui#4082)', () => { - it('sends `defaultSort` as the declared `$orderby` clause string, untouched', async () => { - // The sibling leg triage asked to grade with this one. It feeds `$orderby`, - // not `$filter`, and `"field order"` is a DECLARED-accepted `$orderby` - // shape (`packages/types/src/data.ts:63`, `@example 'name asc'`) — so - // there is no un-lowered hazard to close, and `toFilterNode` (which - // returns a FILTER AST) does not belong on it. Pinned so a later pass - // cannot pattern-match the filter fix onto sort. +describe('object-grid — the legacy `defaultSort` leg is NOT filter-lowered (objectui#4082), and is now RETIRED (objectui#5861)', () => { + it('a retired `defaultSort` reaches neither `$orderby` nor `$filter`', async () => { + // The sibling leg triage asked to grade with this one (objectui#4082). It + // used to feed `$orderby` as the declared `"field order"` clause string, + // and was pinned here so a later pass could not pattern-match the filter + // fix onto sort. objectui#5861 then retired the key under ADR-0049 — + // `@objectstack/spec` refuses it by name on `object-grid` — so this cell + // is FLIPPED: the leg is gone, and it must not resurface as a filter + // either. Canonical control: the next cell, same document shape. const params = await findParamsFor({ ...BASE, defaultSort: { field: 'name', order: 'desc' } as unknown as Record, }); - expect(params.$orderby).toBe('name desc'); + expect(params.$orderby).toBeUndefined(); expect(params.$filter).toBeUndefined(); }); - it('keeps the canonical `sort` emitting the SAME clause-string vocabulary', async () => { - // The reason there is no asymmetry to fix: unlike filter — where canonical - // is lowered and legacy was raw — both sort legs emit the identical shape. + it('keeps the canonical `sort` emitting the clause-string vocabulary', async () => { + // CONTROL for the cell above. Unlike filter — where canonical is lowered + // and legacy was raw — the sort legs emitted the identical shape, which is + // why there was never an asymmetry to fix; only the canonical leg is left. const params = await findParamsFor({ ...BASE, sort: [{ field: 'name', order: 'desc' }], diff --git a/packages/plugin-grid/src/__tests__/gridRetiredStringSort-8767.test.tsx b/packages/plugin-grid/src/__tests__/gridRetiredStringSort-8767.test.tsx index 555c67b3b9..833cf22fef 100644 --- a/packages/plugin-grid/src/__tests__/gridRetiredStringSort-8767.test.tsx +++ b/packages/plugin-grid/src/__tests__/gridRetiredStringSort-8767.test.tsx @@ -140,7 +140,9 @@ describe('object-grid — the retired string `sort` clause is REFUSED at this bl it('does not fall through to the legacy `defaultSort` leg when it refuses', async () => { // A refusal that quietly handed the query to the next arm would be a // silent substitution — a different ordering than either the author asked - // for or the refusal announced. + // for or the refusal announced. (Since objectui#5861 retired `defaultSort` + // there is no such arm left to fall to; the cell stays as the guard that + // none is re-added.) const params = await findParamsFor({ ...authoredAtRuntime('name desc'), defaultSort: { field: 'status', order: 'asc' }, diff --git a/packages/plugin-grid/src/__tests__/serverSorting.test.tsx b/packages/plugin-grid/src/__tests__/serverSorting.test.tsx index 0a6a4121ee..131294b122 100644 --- a/packages/plugin-grid/src/__tests__/serverSorting.test.tsx +++ b/packages/plugin-grid/src/__tests__/serverSorting.test.tsx @@ -229,6 +229,28 @@ describe('ObjectGrid — column-header sorting is server-side (#3106)', () => { } }); + it('a retired `defaultSort` lights NO arrow and sends NO $orderby — both readers dropped it (objectui#5861)', async () => { + // Until objectui#5861 the header reader fell back to `defaultSort` after + // `sort` and the fetch path had a third arm for it, so this schema drew a + // `status desc` arrow over a `status desc` query. The key is an ADR-0049 + // tombstone — `@objectstack/spec` refuses it by name on `object-grid` — + // and both readers dropped it in the same change, so they still agree: + // no arrow, and no ordering on the wire. The canonical control is `shows + // the view's declared sort before anyone clicks` above, same harness. + const ds = makeDataSource(); + const { container } = renderGrid(ds, { defaultSort: { field: 'status', order: 'desc' } }); + await waitFor(() => expect(screen.getByText('Row 0')).toBeInTheDocument()); + + const status = headerCell(container, 'Status'); + expect(status.querySelector('[class*="chevron-down"]')).toBeNull(); + expect(status.querySelector('[class*="chevron-up"]')).toBeNull(); + // Non-vacuity: the column still renders its sortable header chrome, so the + // two nulls mean "no ACTIVE sort", not "no icons here at all". + expect(status.querySelector('[class*="chevrons-up-down"]')).not.toBeNull(); + + expect(Object.prototype.hasOwnProperty.call(lastFindParams(ds), '$orderby')).toBe(false); + }); + it('withholds the sort affordance from a relational column (#3096)', async () => { const ds = makeDataSource(); const { container } = renderGrid(ds); @@ -295,8 +317,7 @@ describe('parseSchemaSort — the header reader reads the ONE declared spelling it('CONTROL — a declared entry with no `order` still reads ascending', () => { // Untouched by objectui#8961, and named here so the narrowing is not read // as a second, stricter judgement of the ENTRY: what moved is which - // SPELLING of the key is admitted, not how a declared entry is read. This - // is also the shape `defaultSort` arrives in, wrapped by the read site. + // SPELLING of the key is admitted, not how a declared entry is read. expect(parseSchemaSort([{ field: 'name' }])).toEqual([{ field: 'name', order: 'asc' }]); }); diff --git a/packages/plugin-grid/src/index.tsx b/packages/plugin-grid/src/index.tsx index 15d83a07a8..96241f5955 100644 --- a/packages/plugin-grid/src/index.tsx +++ b/packages/plugin-grid/src/index.tsx @@ -196,18 +196,23 @@ export const ObjectGridRenderer: React.FC<{ schema: any; [key: string]: any }> = * Publishing them is what makes the manifest, the `.d.ts`, the designer and the * renderer finally agree. * - * TEN keys `@objectstack/spec` 17.0.0 GA also declares are deliberately NOT here + * TEN keys `@objectstack/spec` 17.0.0 GA also declared are deliberately NOT here * — this block's own `@deprecated` legacy spellings (`fields`, `staticData`, * `selectable`, `pageSize`, `showSearch`, `showPagination`, `defaultSort`, * `defaultFilters`, `resizableColumns`, `title` — all tagged `@deprecated` in - * `ObjectGridSchema`, `packages/types/src/objectql.ts`). The renderer still reads - * them as back-compat fallbacks, but publishing a deprecated alias as NEW + * `ObjectGridSchema`, `packages/types/src/objectql.ts`). The renderer reads + * the rest of them as back-compat fallbacks, but publishing a deprecated alias as NEW * authoring surface would harden it into a second dialect (AGENTS.md #0.1), so * each gets a cited exemption in the console parity gate instead. Their canonical * spellings — `columns`, `data`, `selection`, `pagination`, `searchableFields`, * `sort`, `filter`, `resizable`, `label` — are all declared here, and each * description below says so, so the exemption teaches rather than merely omits. * + * `defaultSort` is the exception and is no longer a fallback at all: spec 17.3.0 + * turned it into a retired-key tombstone the protocol refuses by name, and + * objectui#5861 removed every renderer read of it (ADR-0049 enforce-or-remove). + * It stays off this list because the contract refuses it, not by exemption. + * * ## `data` declares the CONTRACT's shape, not the shortcut's (objectui#5090) * * The key landed above with `type: 'array'`, labelled "Static Data" and described @@ -236,7 +241,7 @@ const GRID_QUERY_INPUTS: ComponentInput[] = [ // ── identity ────────────────────────────────────────────────────────────── { name: 'label', type: 'string', description: 'Grid label, used as the table caption and as the export file title. The canonical spelling — the deprecated `title` is only read when this is absent.' }, // ── query shaping ───────────────────────────────────────────────────────── - { name: 'sort', type: 'array', description: 'Initial sort order, `[{ field, order }]`. The canonical spelling — the deprecated single-sort `defaultSort` is only read when this is absent.' }, + { name: 'sort', type: 'array', description: 'Initial sort order, `[{ field, order }]`. The only sort spelling this block reads — the retired single-sort `defaultSort` is refused by the protocol and ignored by the renderer.' }, { name: 'pagination', type: 'object', description: 'Pagination config, `{ pageSize, pageSizeOptions, … }`. Presence enables paging with the object\'s settings, and an explicit off wins — the deprecated flat `showPagination: false` turns paging off even beside this object, because this object declares no off switch of its own. Prefer it over the deprecated flat `pageSize` / `showPagination` pair.' }, { name: 'searchableFields', type: 'array', of: 'string', description: 'Fields the toolbar search box queries. A non-empty list is what enables search — prefer it over the deprecated boolean `showSearch`, which cannot say WHICH fields to search.' }, { name: 'data', type: 'object', description: 'Data source configuration — a `ViewData` object discriminated by `provider`: `{ provider: "object", object }` (what an omitted `data` falls back to, using `objectName`), `{ provider: "api", read, write }`, `{ provider: "value", items: [...] }` for inline rows that bypass the object query, or `{ provider: "schema", schemaId }`. The canonical spelling — the deprecated `staticData` is the array-only shortcut for the `value` provider, so inline rows go under `items` here rather than in a bare array.' }, diff --git a/packages/plugin-view/README.md b/packages/plugin-view/README.md index e7f0e491f2..32055fea03 100644 --- a/packages/plugin-view/README.md +++ b/packages/plugin-view/README.md @@ -202,10 +202,18 @@ object through. Anything else you put in them is ignored: | Sub-config | Keys `ObjectView` forwards | | --- | --- | -| `table` | `columns`, `fields`, `title`, `description`, `filter`, `defaultFilters`, `sort`, `defaultSort`, `pagination`, `pageSize`, `selection`, `selectable`, `operations`, `className` | +| `table` | `columns`, `fields`, `title`, `description`, `filter`, `defaultFilters`, `sort`, `pagination`, `pageSize`, `selection`, `selectable`, `operations`, `className` | | `form` | `fields`, `customFields`, `sections`, `groups`, `layout`, `columns`, `title`, `description`, `subforms`, `buttons`, `defaults`, `initialValues`, `readOnly`, `showSubmit`, `submitText`, `showCancel`, `cancelText`, `showReset`, `className` | -Four of the forwarded `table` keys are pairs — a canonical `ObjectGridSchema` +> **`table.defaultSort` is retired (objectui#5861).** It was the legacy +> single-entry spelling of `table.sort`. The installed `@objectstack/spec` +> protocol refuses `object-grid`'s `defaultSort` by name (a retired-key +> tombstone), and neither `ObjectView` nor `ObjectGrid` reads it any more: +> a view that still carries it renders **unsorted**. Rename the key to `sort` +> and wrap the value in an array — `defaultSort: { field: 'name', order: 'asc' }` +> becomes `sort: [{ field: 'name', order: 'asc' }]`. + +Three of the forwarded `table` keys are pairs — a canonical `ObjectGridSchema` key and the `@deprecated` legacy spelling it replaced. As of objectui#5102 the canonical spelling **takes effect** on every rendering path (the grid, and the non-grid `kanban` / `gallery` / `calendar` / `timeline` / `gantt` / `map` @@ -217,16 +225,14 @@ just no longer the one to reach for: | `pagination: { pageSize, pageSizeOptions? }` | `pageSize: number` | | `selection: { type: 'single' \| 'multiple' \| 'none' }` | `selectable: boolean \| 'single' \| 'multiple'` | | `filter: [{ field, operator, value }, …]` (same shape as a named view's `filter`) | `defaultFilters: Record` (equality-only) | -| `sort: SortConfig[]` (`[{ field, order }]`) | `defaultSort: { field, order }` (a single entry, not an array) | **Precedence when a key is written both ways** — `table: { pagination: { pageSize: 10 }, pageSize: 50 }`, say — the canonical spelling wins. That is `ObjectGrid`'s own existing resolution (`schema.pagination?.pageSize || schema.pageSize`; `if (schema.selection?.type) … else if (schema.selectable -!== undefined)`; `schemaFilter !== undefined ? … : schema.defaultFilters`; -`schemaSort ?? (schema.defaultSort ? [schema.defaultSort] : undefined)`), and -`ObjectView` defers to it by forwarding both slots rather than re-resolving -the pair itself: +!== undefined)`; `schemaFilter !== undefined ? … : schema.defaultFilters`), and `ObjectView` +defers to it by forwarding both slots rather than re-resolving the pair +itself: ```typescript import type { ObjectViewSchema } from '@object-ui/types'; @@ -245,9 +251,9 @@ const schema: ObjectViewSchema = { predates this change: an **active named view's own** `filter` / `sort` (`listViews..filter` / `.sort`) always outranks anything written on `table`. In order, highest first: the active named view's `filter`/`sort`, -then `table.filter`/`table.sort`, then `table.defaultFilters`/ -`table.defaultSort`. (If you never write `listViews`, that first tier never -applies.) `pagination` and `selection` have no such tier, and no effect +then `table.filter`/`table.sort`, then `table.defaultFilters` (filter only — +`sort` has no legacy tier since `table.defaultSort` was retired). (If you +never write `listViews`, that first tier never applies.) `pagination` and `selection` have no such tier, and no effect outside the grid — the non-grid renderers don't page or multi-select, so `ObjectView` never forwards either spelling to them. diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index 79ea85e947..f8fba1e43b 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -377,8 +377,8 @@ export interface ObjectViewProps { * If not provided, uses schema.listViews or falls back to default grid view. * * `sort` spells its direction key `order`, like every other sort surface in - * the repo (`SortConfig`, `NamedListView.sort`, `ObjectGridSchema.sort` / - * `.defaultSort`) and like the shared sink `convertSortToQueryParams` reads + * the repo (`SortConfig`, `NamedListView.sort`, `ObjectGridSchema.sort`) + * and like the shared sink `convertSortToQueryParams` reads * it. It used to be declared as `direction` (objectui#5293), which NO * consumer of THIS prop ever read: all three consumers of the resolved * `activeView.sort` read `order`, so a host writing `direction: 'desc'` got a @@ -1054,37 +1054,25 @@ export const ObjectView: React.FC = ({ // objectui#4869: this was the LAST object-bound read site handing an // AUTHORED sort to `$orderby` unlowered — gantt / map / calendar / // timeline / `record:line_items` all lower through the shared sink - // already. Leaving this one raw was not merely a divergence, it was a - // live `400 INVALID_SORT`: `table.defaultSort` is declared a SINGLE - // `{ field, order }` object, so it reached the adapter's - // `serializeOrderBy` as an `$orderby` MAP and - // `Object.entries({ field: 'name', order: 'desc' })` serialized to the - // wire string `field,-order` — two columns that do not exist. The - // server rejects an unreadable sort rather than ignoring it, the catch - // below swallows the 400, and a calendar/kanban/gallery whose only sort - // was `table.defaultSort` rendered EMPTY while the SAME metadata sorted - // correctly as a grid. - // - // The legacy member of the pair is lowered HERE, before the sink, which - // is verbatim the resolution `ObjectGrid` already performs for this - // exact pair (`plugin-grid/src/ObjectGrid.tsx`: `schemaSort ?? - // (schema.defaultSort ? [schema.defaultSort] : undefined)`) and which - // ObjectView's own grid path inherits by forwarding both slots. It is - // not a new tolerance layer: the sink still honours only the two - // spellings the schema declares (`string` and `SortConfig[]`), and - // ⛔ must NOT be widened to accept a bare `{ field, order }` — its input - // slot legitimately also carries `$orderby`'s own + // already, and so does this one now (below, `convertSortToQueryParams`). + // ⛔ The sink must NOT be widened to accept a bare `{ field, order }` — + // its input slot legitimately also carries `$orderby`'s own // `Record` map, in which `{ field: 'desc' }` is a // perfectly legal ordering by a column literally named `field`, so the // sink would have to GUESS. (Maintainer ruling 2026-08-22: Option A; // Option B — widening the shared sink — rejected on the merits.) // - // Precedence is unchanged: the canonical `table.sort` still outranks the - // deprecated `table.defaultSort`, and both still lose to a view's sort — - // the same order the grid path and `mergedSort` express. + // objectui#5861: the chain ends at the canonical `table.sort`. The + // legacy single-entry `table.defaultSort` that used to be lowered here + // as a fourth branch is RETIRED under ADR-0049 — `@objectstack/spec` + // refuses it by name on `object-grid` — and it was retired on all three + // `ObjectView` paths at once (this fetch, the grid forwarding and the + // delegated `mergedSort`) together with `ObjectGrid`'s own read, so no + // path honours the key while another ignores it. A view's sort still + // outranks `table.sort`, the same order the grid path and `mergedSort` + // express. const sort = currentNamedViewConfig?.sort || activeViewQueryInputs?.sort - || schema.table?.sort - || (schema.table?.defaultSort ? [schema.table.defaultSort] : undefined); + || schema.table?.sort; // Auto-inject $expand for lookup/master_detail fields. Reached only // with the schema resolved (the gate above), so a view whose object @@ -1988,41 +1976,38 @@ export const ObjectView: React.FC = ({ // on the floor. An author who wrote the canonical shape the type recommends // got a compile-clean, semantically correct, RUNTIME-INERT view. // - // ObjectGrid already reads both spellings of all four and already resolves - // them canonical-first (`schema.pagination?.pageSize ?? schema.pageSize`; + // ObjectGrid reads both spellings of the first three and resolves them + // canonical-first (`schema.pagination?.pageSize ?? schema.pageSize`; // `if (schema.selection?.type) … else if (schema.selectable !== undefined)`; - // `schemaFilter !== undefined ? … : schema.defaultFilters`; - // `schemaSort ?? (schema.defaultSort ? [schema.defaultSort] : undefined)`). - // So the fix is forwarding, not translation — and the precedence is not a - // free choice here: emitting both slots lets ObjectGrid's existing - // canonical-wins rule decide, which is the only answer that keeps the two - // layers saying the same thing. + // `schemaFilter !== undefined ? … : schema.defaultFilters`), so the fix is + // forwarding, not translation — and the precedence is not a free choice + // here: emitting both slots lets ObjectGrid's existing canonical-wins rule + // decide, which is the only answer that keeps the two layers saying the + // same thing. + // + // objectui#5861: the fourth pair is no longer a pair. `defaultSort` was + // RETIRED under ADR-0049 — `@objectstack/spec` refuses it by name on + // `object-grid` — so it is not forwarded, ObjectGrid no longer reads it, and + // `sort` below is the only sort slot this memo fills. // // objectui#5270: the two segments AHEAD of `table` had a second, separate // problem — an ARITY mismatch, not a spelling one. Both of them carry an // ARRAY of sort keys (`NamedListView.sort` is `Array< { field, order } >`; // the `views` prop declares an array too) and both were being written into - // `defaultSort`, which is declared a SINGLE `{ field, order }`. Neither of - // ObjectGrid's two readers survives that: - // - // header `parseSchemaSort(schemaSort ?? [schema.defaultSort])` becomes - // `parseSchemaSort([[{ field, order }]])`. The outer array is - // iterated and each entry must be a string or an object with a - // string `field`; a nested ARRAY is neither, so the entry is - // dropped and the result is `[]` — no arrow, the view arrives - // looking unsorted. - // fetch `` `${(schema.defaultSort as any).field} ${….order}` `` reads two - // missing keys off an array and sends the literal string - // `"undefined undefined"` as `$orderby`. + // `defaultSort`, which was declared a SINGLE `{ field, order }`. Neither of + // the two `defaultSort` readers ObjectGrid had at the time survived that: + // the header reader re-wrapped the array into a nested one that parsed to + // no arrow, and the fetch reader read `field` / `order` off the array and + // sent the literal string `"undefined undefined"` as `$orderby`. (Both of + // those readers are gone since objectui#5861 retired `defaultSort`.) // // So the view's sort now rides the CANONICAL slot, `ObjectGridSchema.sort`, // which holds the multi-key arity a view carries. That is also the shape the // shared sort sink accepts (`convertSortToQueryParams`, `SortConfig[]` — // objectui#4869, narrowed to the array alone by objectui#8221), so this // converges on the normalized dialect instead of introducing another. - // Precedence is unchanged: ObjectGrid resolves `sort ?? defaultSort`, so a - // view sort still outranks a `table.defaultSort`, and `table.sort` still - // outranks it too — the same order `mergedSort` and the non-grid fetch use. + // Precedence: a view sort outranks `table.sort` — the same order + // `mergedSort` and the non-grid fetch use. const gridSchema: ObjectGridSchema = useMemo(() => { // The two segments ahead of the `table` one, resolved once. // @@ -2030,9 +2015,10 @@ export const ObjectView: React.FC = ({ // `filter`/`defaultFilters` are not interchangeable downstream — // ObjectGrid lowers the canonical slot through `toFilterNode` and // raw-assigns the legacy one — so moving a named-view filter across would - // change the wire shape of a path objectui#5270 does not own. The sort - // pair has no such asymmetry: both slots reach `$orderby` unlowered, and - // only the canonical one can hold more than a single key. + // change the wire shape of a path objectui#5270 does not own. Sort has no + // such asymmetry to preserve: its canonical slot is the only one left + // (the legacy `defaultSort` is retired, objectui#5861), and it is the one + // that can hold more than a single key. const viewFilter = currentNamedViewConfig?.filter || activeView?.filter; const viewSort = currentNamedViewConfig?.sort || activeView?.sort; @@ -2054,11 +2040,6 @@ export const ObjectView: React.FC = ({ create: false, // Create is handled by the view's create button }, defaultFilters: viewFilter || schema.table?.defaultFilters, - // Legacy slot, `table` segment ONLY (objectui#5270). The view segments - // moved to the canonical `sort` below because this one holds a single - // `{ field, order }` and they carry arrays; ObjectGrid resolves - // `sort ?? defaultSort`, so a view sort still outranks this default. - defaultSort: schema.table?.defaultSort, // Canonical `table` keys, at last forwarded. `filter` carries the // `table` segment ONLY: the view segment resolved above already occupies // the legacy slot, and ObjectGrid prefers this slot over that one — so @@ -2216,40 +2197,23 @@ export const ObjectView: React.FC = ({ // branches were dead. They are gone rather than corrected: the delegated // renderer owns the filter/sort UI and does its own combining. // - // The `table` segment of both chains reads the canonical key first and the - // deprecated one as its alias (objectui#5102). Both land on `list-view`'s - // own `filter` / `sort` keys below, so a canonical value arrives in the slot - // that already matches its shape. - // - // objectui#6235: that last sentence used to be FALSE of the sort chain's - // final branch. `list-view`'s `sort` slot is declared `string | SortConfig[]` - // (the spec's own `ListViewSchema.sort`, imported by reference into - // `packages/types/src/zod/objectql.zod.ts`), and every branch above the last - // produces one of those two — but `table.defaultSort` is declared a SINGLE - // `{ field, order }` object, and it was forwarded BARE. There is no - // compile-time witness: `ObjectViewSchema.table` collapses to a bare index - // signature (objectui#5102) and this node is assembled on the host- - // composition surface (objectui#5097), whose `renderListView` slot types - // `schema` as `any`. - // - // Every reader of that slot then drops the sort SILENTLY — no crash, no - // error, just an unsorted list: `ListView.parseSortConfig` and - // `ObjectGrid.parseSchemaSort` both open `typeof sort === 'string' ? [sort] - // : Array.isArray(sort) ? sort : []`, so a bare object yields `[]`, and the - // shared sink `convertSortToQueryParams` returns `undefined` for it. Both - // in-tree hosts feed this slot straight into `ListView` - // (`app-shell/src/views/ObjectView.tsx` `fullSchema`, and - // `studio-design/StudioDesignSurface.tsx` `renderStudioGridList`). + // The `table` segment of the filter chain reads the canonical key first and + // the deprecated one as its alias (objectui#5102). Both chains land on + // `list-view`'s own `filter` / `sort` keys below, so a canonical value + // arrives in the slot that already matches its shape. // - // So the legacy member of the pair is lowered HERE, in the caller, verbatim - // as the non-grid fetch path above already does it and as `ObjectGrid` - // performs it for this exact pair. ⛔ The alternative — teaching the shared - // sink to accept a bare `{ field, order }` — is the widening the maintainer - // ruling of 2026-08-22 REJECTED on the merits (quoted with the non-grid - // fetch above): that slot legitimately also carries `$orderby`'s own - // `Record` map, in which `{ field: 'desc' }` is a legal - // ordering by a column literally named `field`, so the sink would have to - // GUESS. Precedence is untouched — only the last branch changes shape. + // objectui#5861: the sort chain has no alias branch any more. It used to end + // in the legacy single-entry `table.defaultSort`, wrapped into an array here + // (objectui#6235) because `list-view`'s `sort` slot declares `SortConfig[]` + // and every reader of that slot drops a bare object silently. That key is + // now RETIRED under ADR-0049 — `@objectstack/spec` refuses it by name on + // `object-grid` — and it was retired here in the same change as the non-grid + // fetch above, the grid forwarding and `ObjectGrid`'s own read, so no path + // honours it while another ignores it. ⛔ Do not bring the alias back by + // teaching the shared sink a bare `{ field, order }`: that is the widening + // the maintainer ruling of 2026-08-22 REJECTED (quoted with the non-grid + // fetch above), because that slot legitimately also carries `$orderby`'s own + // `Record` map. const mergedFilters = currentNamedViewConfig?.filter || activeView?.filter || schema.table?.filter @@ -2257,8 +2221,7 @@ export const ObjectView: React.FC = ({ const mergedSort = currentNamedViewConfig?.sort || activeView?.sort - || schema.table?.sort - || (schema.table?.defaultSort ? [schema.table.defaultSort] : undefined); + || schema.table?.sort; // --- Content renderer --- const renderContent = () => { diff --git a/packages/plugin-view/src/__tests__/ObjectView.canonicalTableKeys.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.canonicalTableKeys.test.tsx index 7067ded115..36301ee54b 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.canonicalTableKeys.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.canonicalTableKeys.test.tsx @@ -21,6 +21,15 @@ * defaultFilters | filter | NO * defaultSort | sort | NO * + * ⚠️ objectui#5861 RETIRED the fourth pair's legacy half. `defaultSort` is an + * ADR-0049 tombstone — `@objectstack/spec` 17.3.0 refuses it by name on + * `object-grid` — and every reader dropped it together: ObjectView no longer + * forwards it on the grid path, no longer lowers it on the non-grid fetch or + * into the delegated `renderListView` sort, and ObjectGrid no longer reads it. + * The cells below that pinned it as a WORKING alias were flipped, not deleted: + * each now asserts the key is inert on its path, beside a canonical `sort` + * control on the same path. The other three pairs are untouched. + * * So an author writing the shape the type recommends — `table: { pagination: * { pageSize: 25 } }` — got a view that compiled, read correctly, and did * NOTHING. There was no failure signal anywhere: the key is declared on @@ -200,9 +209,16 @@ describe('grid path: the legacy spellings still work', () => { expect(grid.defaultFilters).toEqual({ status: 'active' }); }); - it('still forwards table.defaultSort', () => { + it('no longer forwards a retired table.defaultSort — and does not rescue it into `sort` (objectui#5861)', () => { + // Flipped from `still forwards table.defaultSort`. Neither slot carries it: + // re-emitting the value into the canonical `sort` would be the tolerant + // alias the retirement exists to end, just moved one hop. const grid = forwardedGridSchema({ table: { defaultSort: { field: 'name', order: 'asc' } } as any }); - expect(grid.defaultSort).toEqual({ field: 'name', order: 'asc' }); + expect(grid.defaultSort).toBeUndefined(); + expect(grid.sort).toBeUndefined(); + // CONTROL, same path: the canonical spelling still arrives. + expect(forwardedGridSchema({ table: { sort: [{ field: 'name', order: 'asc' }] } as any }).sort) + .toEqual([{ field: 'name', order: 'asc' }]); }); it('leaves a canonical slot empty when only the legacy key is written', () => { @@ -244,7 +260,9 @@ describe('grid path: both spellings written — each rides its own slot', () => expect(grid.filter).toEqual([['stage', '=', 'won']]); expect(grid.defaultFilters).toEqual({ stage: 'lost' }); expect(grid.sort).toEqual([{ field: 'name', order: 'desc' }]); - expect(grid.defaultSort).toEqual({ field: 'created', order: 'asc' }); + // objectui#5861 — the sort pair has no legacy slot any more: the retired + // `defaultSort` is dropped, not forwarded beside the canonical value. + expect(grid.defaultSort).toBeUndefined(); }); }); @@ -296,16 +314,16 @@ describe('grid path: a named view still outranks the table segment', () => { expect(grid.defaultSort).toBeUndefined(); }); - it('keeps the named view sort in force over a legacy table.defaultSort too', () => { - // The other side of the same precedence: the `table` segment holds the - // legacy slot alone, and ObjectGrid prefers the canonical one, so the view - // still wins. Without this the move could have inverted the pair silently. + it('keeps the named view sort in force, and a retired table.defaultSort beside it is not forwarded (objectui#5861)', () => { + // This used to assert the `table` segment rode the legacy slot beside the + // view's canonical one. The legacy slot is gone: the view sort still wins, + // and the retired key reaches ObjectGrid in neither slot. const grid = forwardedGridSchema({ ...namedView, table: { defaultSort: { field: 'created', order: 'asc' } } as any, } as any); expect(grid.sort).toEqual([{ field: 'name', order: 'desc' }]); - expect(grid.defaultSort).toEqual({ field: 'created', order: 'asc' }); + expect(grid.defaultSort).toBeUndefined(); }); }); @@ -325,11 +343,13 @@ describe('non-grid path: ObjectView resolves the pair itself, canonical first', // normalized shape every other object-bound block already sends. // // What objectui#5102 put here is NOT weakened by that: each expectation is - // still an exact `toEqual` on a fully specified map, so both halves of every - // pair — canonical `table.sort` and legacy `table.defaultSort` — are still - // pinned as WORKING, and the PRECEDENCE between them is still pinned. What - // changed is the shape they arrive in, which is the defect objectui#4869 - // fixed. See `ObjectView.sortSink.test.tsx` for the read-site evidence. + // still an exact `toEqual` on a fully specified map. What changed is the + // shape they arrive in, which is the defect objectui#4869 fixed. See + // `ObjectView.sortSink.test.tsx` for the read-site evidence. + // + // ⚠️ objectui#5861 then RETIRED the legacy half of the sort pair: the two + // cells below that pinned `table.defaultSort` as working now pin it inert — + // no `$orderby` from it alone, and no effect beside a canonical `table.sort`. it('queries with a canonical table.filter', async () => { const params = await queryParams(nonGridFind({ table: { filter: [['stage', '=', 'won']] } as any })); expect(params.$filter).toEqual([['stage', '=', 'won']]); @@ -363,7 +383,7 @@ describe('non-grid path: ObjectView resolves the pair itself, canonical first', expect(params.$orderby).toEqual({ name: 'desc' }); }); - it('prefers table.sort over table.defaultSort', async () => { + it('orders by table.sort alone when a retired table.defaultSort is written beside it', async () => { const params = await queryParams( nonGridFind({ table: { sort: [{ field: 'name', order: 'desc' }], defaultSort: { field: 'created', order: 'asc' } } as any, @@ -372,18 +392,18 @@ describe('non-grid path: ObjectView resolves the pair itself, canonical first', expect(params.$orderby).toEqual({ name: 'desc' }); }); - it('still orders by table.defaultSort alone', async () => { + it('a retired table.defaultSort alone orders NOTHING (objectui#5861)', async () => { + // Flipped from `still orders by table.defaultSort alone`, which asserted + // `{ created: 'asc' }`. The key is an ADR-0049 tombstone the protocol + // refuses by name, so this read site no longer lowers it: the query goes + // out unsorted. The canonical control is `orders by a canonical + // table.sort` above, on this same path. const params = await queryParams(nonGridFind({ table: { defaultSort: { field: 'created', order: 'asc' } } as any })); - // objectui#4869: `{ field: 'created', order: 'asc' }` — the value this line - // used to assert — is what the server answered `400 INVALID_SORT` for. The - // legacy spelling is still HONOURED (that is objectui#5102's half, and it - // is what this exact-value assertion keeps pinned); it is now honoured in - // the shape the query can carry. - expect(params.$orderby).toEqual({ created: 'asc' }); + expect(params.$orderby).toBeUndefined(); }); }); -describe('delegated renderListView: canonical first, alias still working', () => { +describe('delegated renderListView: canonical first, filter alias still working, sort alias retired', () => { it('hands over a canonical table.filter', () => { expect(delegatedSchema({ table: { filter: [['stage', '=', 'won']] } as any }).filter) .toEqual([['stage', '=', 'won']]); @@ -401,7 +421,7 @@ describe('delegated renderListView: canonical first, alias still working', () => .toEqual({ status: 'active' }); }); - it('hands over a canonical table.sort and prefers it over table.defaultSort', () => { + it('hands over a canonical table.sort, and a retired table.defaultSort beside it changes nothing', () => { const SORT = [{ field: 'name', order: 'desc' }]; expect(delegatedSchema({ table: { sort: SORT } as any }).sort).toEqual(SORT); const s = delegatedSchema({ @@ -410,20 +430,14 @@ describe('delegated renderListView: canonical first, alias still working', () => expect(s.sort).toEqual(SORT); }); - it('still hands over table.defaultSort alone — WRAPPED', () => { - // objectui#6235, and the same transition the `$orderby` assertion above - // made for the fetch path: the legacy spelling is still HONOURED when it - // is the only source (that is objectui#5102's half, and it is what this - // exact-value assertion keeps pinned); it is now honoured in the shape the - // delegated slot declares. - // - // This used to read `{ field: 'created', order: 'asc' }` — the bare object, - // handed to `list-view`'s `sort`, declared `string | SortConfig[]`. Every - // reader of that slot dropped it silently: `ListView.parseSortConfig` and - // `ObjectGrid.parseSchemaSort` return `[]` for a non-array, and the shared - // sink returns `undefined`. So this cell was green while the forwarded - // value could not be read by anything downstream. + it('hands over NO sort for a retired table.defaultSort alone (objectui#5861)', () => { + // Flipped from `still hands over table.defaultSort alone — WRAPPED`, which + // asserted `[{ field: 'created', order: 'asc' }]` (objectui#6235's wrap of + // the legacy single entry into the `SortConfig[]` the slot declares). The + // key is retired, so there is nothing to wrap: the delegated node gets no + // `sort`, exactly as if none had been authored. Canonical control: the + // cell above, on this same path. expect(delegatedSchema({ table: { defaultSort: { field: 'created', order: 'asc' } } as any }).sort) - .toEqual([{ field: 'created', order: 'asc' }]); + .toBeUndefined(); }); }); diff --git a/packages/plugin-view/src/__tests__/ObjectView.filterSources.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.filterSources.test.tsx index 978cfd7587..3d32941590 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.filterSources.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.filterSources.test.tsx @@ -181,79 +181,44 @@ describe('ObjectView hands the view filter to the delegated renderer', () => { expect(convertSortToQueryParams(seen[0]?.sort)).toEqual({ name: 'asc' }); }); - // This cell used to be `forwards the sort alongside it`, and it asserted that - // an ARRAY in `table.defaultSort` reached the delegated slot verbatim. Two - // things were wrong with it, both recorded by objectui#6235: + // ── objectui#5861 — `table.defaultSort` is RETIRED, so nothing is wrapped ── // - // 1. It could not fail in either state — a verbatim `toEqual` on a verbatim - // pass-through (objectui#5270's recorded trap). - // 2. Its input is metadata the schema REFUSES. - // `packages/types/src/zod/objectql.zod.ts` declares - // `defaultSort: z.object({ field, order })` — not a union, not an array — - // so no conforming author can produce what it was pinning. - // - // The wrap lowers it verbatim as the non-grid fetch path and `ObjectGrid` do - // for this exact pair, which means invalid input stays invalid instead of - // being rescued. That is the point, not a gap: the shared sink REFUSES it - // rather than guessing, which is the 2026-08-22 ruling's whole basis. - // ⛔ Do not "fix" this with an `Array.isArray` flatten in the caller — that - // is a tolerant second dialect for input the protocol already rejects, and it - // would make this surface disagree with both of its siblings again. - it('does not rescue an ARRAY in defaultSort — the arity the schema refuses', () => { + // These three cells used to pin objectui#6235's wrap: the legacy single + // `{ field, order }` in `table.defaultSort` was lowered into the + // `SortConfig[]` the delegated `list-view` slot declares (and an ARRAY in it + // was re-wrapped to `[[…]]` and refused downstream). The key is now an + // ADR-0049 tombstone — `@objectstack/spec` refuses it by name on + // `object-grid` — and `mergedSort` no longer has a `defaultSort` branch, so + // each cell is FLIPPED to assert the key reaches the slot in no shape. + // ⛔ Not deleted: they are the receipt that the alias was retired rather + // than quietly dropped from one path while another still honours it. + it('forwards no sort for an ARRAY in a retired defaultSort — not even the refused `[[…]]` shape', () => { const seen = renderDelegated({ table: { defaultSort: [{ field: 'name', order: 'asc' }] } as any }); - expect(seen[0]?.sort).toEqual([[{ field: 'name', order: 'asc' }]]); - // Refused, not guessed — the same answer `:862` and ObjectGrid give it. + expect(seen[0]?.sort).toBeUndefined(); expect(convertSortToQueryParams(seen[0]?.sort)).toBeUndefined(); }); - // ── objectui#6235 — the DECLARED arity, which is where the defect lived ──── - // - // Everything above hands `defaultSort` an array. `ObjectGridSchema` declares - // it a SINGLE `{ field, order }` object, and that shape — the only one an - // author following the type can write — was forwarded BARE into a slot - // declared `string | SortConfig[]` (`list-view`'s `sort`, imported by - // reference from the spec's own `ListViewSchema`). No compile-time witness: - // `ObjectViewSchema.table` is a bare index signature (objectui#5102) and the - // `renderListView` slot types `schema` as `any` (objectui#5097). - // - // These two cells are the discriminating ones. Against `origin/main` the - // first reads `{ field: 'created', order: 'asc' }` and the second reads - // `undefined`; both are green only with the wrap at `mergedSort`. - it('WRAPS a bare-object table.defaultSort into the SortConfig[] the slot declares', () => { + it('does not WRAP a bare-object table.defaultSort any more — the slot gets no sort', () => { + // Flipped from `WRAPS a bare-object table.defaultSort into the SortConfig[] + // the slot declares`, which asserted `[{ field: 'created', order: 'asc' }]`. const seen = renderDelegated({ table: { defaultSort: { field: 'created', order: 'asc' } } as any }); - // Before objectui#6235 this was the bare object — the arity the slot does - // not declare, and the one three of the four chain branches never produce. - expect(seen[0]?.sort).toEqual([{ field: 'created', order: 'asc' }]); + expect(seen[0]?.sort).toBeUndefined(); }); - it('hands the delegated slot a sort its READERS can actually parse', () => { - // The symptom, not the shape. A verbatim pass-through assertion cannot - // fail in either state (objectui#5270's recorded trap, and the cell above - // this block is the resident example), so this one runs the forwarded - // value through a real reader of that slot instead. - // - // `convertSortToQueryParams` is the repo's ONE sort sink and is what the - // delegated node's `sort` ultimately reaches on every non-grid view type - // (`ObjectCalendar` / `ObjectMap` / `ObjectTimeline` / `ObjectGantt` each - // call it on `schema.sort`). It refuses a bare `{ field, order }` BY - // DESIGN — the 2026-08-22 maintainer ruling rejected widening it, because - // the same slot legitimately carries `$orderby`'s own - // `Record` map where `{ field: 'desc' }` orders by a - // column named `field`. So the caller must wrap, and until it did, the - // sink returned `undefined`: a silently UNSORTED list, no error anywhere. - // `ListView.parseSortConfig` and `ObjectGrid.parseSchemaSort` — the two - // readers the in-tree hosts reach through — fail the same way, returning - // `[]` from the same `Array.isArray(sort) ? sort : []` opening. - const seen = renderDelegated({ table: { defaultSort: { field: 'created', order: 'asc' } } as any }); - expect(convertSortToQueryParams(seen[0]?.sort)).toEqual({ created: 'asc' }); + it('hands the delegated slot a sort its READERS can parse only from the canonical key', () => { + // The symptom, not the shape: the forwarded value is run through the + // repo's one sort sink, which the delegated node's `sort` reaches on every + // non-grid view type. A retired `defaultSort` gives it nothing to order + // by; the canonical `sort` (CONTROL, same path) still orders. + const retired = renderDelegated({ table: { defaultSort: { field: 'created', order: 'asc' } } as any }); + expect(convertSortToQueryParams(retired[0]?.sort)).toBeUndefined(); + const canonical = renderDelegated({ table: { sort: [{ field: 'created', order: 'asc' }] } as any }); + expect(convertSortToQueryParams(canonical[0]?.sort)).toEqual({ created: 'asc' }); }); - it('keeps the canonical table.sort ahead of the wrapped legacy default', () => { - // CONTROL — green in both states. Named so it is not read as evidence for - // the fix: it guards the wrong shape where the wrap is written so that the - // `defaultSort` branch starts winning (e.g. by wrapping the chain's result - // rather than its final branch, making the always-truthy array outrank - // everything). Precedence is the half of `mergedSort` that must NOT move. + it('keeps the canonical table.sort when a retired defaultSort is written beside it', () => { + // CONTROL — green in both states (before objectui#5861 the canonical key + // outranked the wrapped legacy default; now the legacy key is not read). const seen = renderDelegated({ table: { sort: [{ field: 'name', order: 'desc' }], defaultSort: { field: 'created', order: 'asc' } } as any, }); @@ -261,10 +226,10 @@ describe('ObjectView hands the view filter to the delegated renderer', () => { }); it('forwards nothing when the view declares neither', () => { - // CONTROL — green in both states. Guards the other wrong shape: an - // unconditional `[schema.table.defaultSort]`, which forwards `[undefined]` - // when nothing was authored. That is truthy and one entry long, so the - // sink and both parsers would report a sort that does not exist. + // CONTROL — green in both states. Guards a truthy placeholder (e.g. an + // unconditional one-entry array) reaching the slot when nothing was + // authored, which the sink and both parsers would read as a sort that + // does not exist. const seen = renderDelegated({}); expect(seen[0]?.filter).toBeUndefined(); expect(seen[0]?.sort).toBeUndefined(); diff --git a/packages/plugin-view/src/__tests__/ObjectView.namedViewSortArity.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.namedViewSortArity.test.tsx index d40581b8a7..65627aaf32 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.namedViewSortArity.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.namedViewSortArity.test.tsx @@ -265,10 +265,14 @@ describe("objectui#5270 — a named view's sort reaches the grid", () => { expect(headerCell(container, 'Name').querySelector('[class*="chevron-down"]')).not.toBeNull(); }); - it("still honours a table.defaultSort when no view supplies one", async () => { - // The legacy `table` slot keeps working: it is the one shape `defaultSort` - // was always declared to hold, and this fix narrows what is written into - // it rather than retiring it. + it("a retired table.defaultSort sorts NOTHING on the real grid path — no $orderby, no arrow (objectui#5861)", async () => { + // Flipped from `still honours a table.defaultSort when no view supplies + // one`, which asserted `$orderby: 'name desc'` and a descending arrow. The + // key is an ADR-0049 tombstone — `@objectstack/spec` refuses it by name on + // `object-grid` — and objectui#5861 removed the forwarding here together + // with `ObjectGrid`'s own fetch and header reads, so neither half of the + // grid honours it. Driven through the REAL `ObjectGrid`, as this file's + // header explains, because the forwarded object alone cannot show it. const ds = makeDataSource(); const { container } = renderView( { @@ -280,6 +284,28 @@ describe("objectui#5270 — a named view's sort reaches the grid", () => { ); await waitFor(() => expect(screen.getByText('Alpha')).toBeInTheDocument()); + const params = lastFindParams(ds); + expect(Object.prototype.hasOwnProperty.call(params, '$orderby')).toBe(false); + const name = headerCell(container, 'Name'); + expect(name.querySelector('[class*="chevron-down"]')).toBeNull(); + expect(name.querySelector('[class*="chevron-up"]')).toBeNull(); + }); + + it("CONTROL — the canonical table.sort still sorts on the same real grid path", async () => { + // Same view, the retired key's canonical successor. Green on both sides of + // objectui#5861, which is what makes the cell above a statement about the + // retired key rather than about sorting having stopped. + const ds = makeDataSource(); + const { container } = renderView( + { + type: 'object-view', + objectName: 'task', + table: { columns: ['name', 'status'], sort: [{ field: 'name', order: 'desc' }] }, + } as unknown as ObjectViewSchema, + ds, + ); + await waitFor(() => expect(screen.getByText('Alpha')).toBeInTheDocument()); + await waitFor(() => expect(lastFindParams(ds).$orderby).toBe('name desc')); expect(headerCell(container, 'Name').querySelector('[class*="chevron-down"]')).not.toBeNull(); }); diff --git a/packages/plugin-view/src/__tests__/ObjectView.sortSink.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.sortSink.test.tsx index 6331d57d7a..92f174dfb4 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.sortSink.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.sortSink.test.tsx @@ -52,7 +52,20 @@ * must still refuse the bare object. * * The ADR-0049 enforce-or-remove retirement of `table.defaultSort` (the "C - * half") is deliberately NOT folded in here; it is its own card. + * half") was deliberately NOT folded in here; it was its own card. + * + * ## objectui#5861 — the C half landed, and flipped this file's legacy pins + * + * `@objectstack/spec` 17.3.0 turned `ObjectGridProps.defaultSort` into a + * retired-key tombstone the protocol refuses by name, and objectui#5861 + * removed every renderer read of it at once — including the lowering this + * file pinned. So the cells that asserted the lowered value (`{ created: + * 'asc' }`) now assert the key is INERT: no `$orderby` at all. That is still a + * discriminating assertion against the original defect — the map-shaped + * `field`/`order` payload cannot come back either — and each flipped cell + * sits beside a canonical `table.sort` control on the same read site, so + * "defaultSort no longer sorts" cannot be satisfied by sorting having broken + * outright. The sink-strictness guard is unchanged. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -115,27 +128,28 @@ beforeEach(() => { vi.clearAllMocks(); }); -describe('the legacy table.defaultSort no longer reaches $orderby as a map', () => { - it('sends the field as a COLUMN, not as the two keys `field` and `order`', async () => { +describe('the retired table.defaultSort reaches $orderby in NO shape (objectui#5861)', () => { + it('sends no $orderby at all — neither the map-shaped `field`/`order` payload nor a lowered column', async () => { + // Flipped from `sends the field as a COLUMN`, which asserted `{ created: + // 'asc' }` — objectui#4869's lowering. Before THAT fix this read the two + // non-existent columns `field` and `order`; after objectui#5861 the key is + // not read at all, so the query carries no ordering. const $orderby = await orderbyFor({ table: { defaultSort: { field: 'created', order: 'asc' } } as any, }); + expect($orderby).toBeUndefined(); - // The whole defect in one line. Before the fix this read - // `['field', 'order']` — the two non-existent columns the server answered - // `400 INVALID_SORT` for, leaving the view empty. - expect(Object.keys($orderby)).toEqual(['created']); - expect(Object.keys($orderby)).not.toContain('field'); - expect(Object.keys($orderby)).not.toContain('order'); - - expect($orderby).toEqual({ created: 'asc' }); + // CONTROL, same read site, same run: the canonical spelling still sorts. + expect(await orderbyFor({ table: { sort: [{ field: 'created', order: 'asc' }] } as any })) + .toEqual({ created: 'asc' }); }); - it('carries a descending legacy default through as `desc`', async () => { - // The lowering must not lose the direction on the way to the sink: the - // single object becomes a one-entry `SortConfig[]`, which is a spelling the - // sink already declares. + it('a descending legacy default is inert too — the direction is not what decides it', async () => { + // Flipped from `carries a descending legacy default through as `desc``. expect(await orderbyFor({ table: { defaultSort: { field: 'created', order: 'desc' } } as any })) + .toBeUndefined(); + // CONTROL — canonical `desc` on the same read site. + expect(await orderbyFor({ table: { sort: [{ field: 'created', order: 'desc' }] } as any })) .toEqual({ created: 'desc' }); }); }); @@ -203,8 +217,8 @@ describe('every other member of the chain reaches $orderby normalized too', () = }); }); -describe('precedence is unchanged by the lowering', () => { - it('keeps the canonical table.sort ahead of the legacy table.defaultSort', async () => { +describe('precedence among the sort sources that are still read', () => { + it('a canonical table.sort decides alone — a retired table.defaultSort beside it has no say', async () => { expect(await orderbyFor({ table: { sort: [{ field: 'name', order: 'desc' }], @@ -213,7 +227,7 @@ describe('precedence is unchanged by the lowering', () => { })).toEqual({ name: 'desc' }); }); - it('keeps a named view sort ahead of both table spellings', async () => { + it('keeps a named view sort ahead of the table segment', async () => { expect(await orderbyFor({ listViews: { won: { label: 'Won', type: 'calendar', sort: [{ field: 'name', order: 'desc' }] }, @@ -279,8 +293,10 @@ describe('the census the card asked for: no spelling regressed on the way in', ( expect(convertSortToQueryParams({ field: 'created', order: 'asc' } as any)).toBeUndefined(); expect(convertSortToQueryParams({ field: 'desc' } as any)).toBeUndefined(); - // ...and the read site is what closes the gap, on the very same input. + // ...and the read site no longer closes that gap for the retired key + // (objectui#5861): on the very same input it sends nothing, rather than + // lowering the legacy spelling into the canonical one. expect(await orderbyFor({ table: { defaultSort: { field: 'created', order: 'asc' } } as any })) - .toEqual({ created: 'asc' }); + .toBeUndefined(); }); }); diff --git a/packages/types/src/__tests__/object-grid-default-sort-tombstone-5861.test.ts b/packages/types/src/__tests__/object-grid-default-sort-tombstone-5861.test.ts new file mode 100644 index 0000000000..a28ddd29a2 --- /dev/null +++ b/packages/types/src/__tests__/object-grid-default-sort-tombstone-5861.test.ts @@ -0,0 +1,150 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `ObjectGridSchema.defaultSort` is an ADR-0049 retirement tombstone on BOTH + * published faces (objectui#5861 — the "C half" of the 2026-08-22 ruling on + * objectui#4869). + * + * ## Why a tombstone, and not a deletion + * + * `@objectstack/spec` 17.3.0 converted `ObjectGridProps.defaultSort` to a + * retired-key tombstone, and objectui#5861 removed every renderer read of it. + * Deleting the member from this package would not have refused anything: + * + * - the zod mirror reaches `BaseSchema`, which is `.passthrough()`, so an + * undeclared key is KEPT unexamined — the document would parse green; + * - the TypeScript twin extends `BaseSchema`'s `[key: string]: any`, so an + * undeclared key is absorbed as `any` — the literal would compile. + * + * Either way an author would write a key nothing reads and get an unsorted + * grid with no signal. So the key stays DECLARED and unwritable on both faces: + * `retirementTombstone()` in `zod/objectql.zod.ts` and `?: never` on the + * interface — this package's convention (`ObjectViewSchema.viewTabBar`). + * + * ## Every refusal carries a lit control on the same instrument + * + * A red parse on a `.passthrough()` schema proves nothing about BY-NAME + * refusal on its own; what proves it is an unrecognised key on the same + * document staying green, and the canonical `sort` staying green beside it. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectGridPropsSchema as SpecObjectGridPropsSchema } from '@objectstack/spec/ui'; +import { ObjectGridSchema } from '../zod/objectql.zod.js'; +import type { ObjectGridSchema as ObjectGridSchemaType } from '../objectql.js'; + +/** A control key no surface in this package or the protocol declares. */ +const UNKNOWN_KEY = 'zzzNotAKeyAnySurfaceDeclares5861'; + +/** The minimum green `object-grid` document, shared by every case below. */ +const NODE = { type: 'object-grid' as const, objectName: 'probe' }; + +/** What an author used to write, and what the tombstone sends them to. */ +const AUTHORED_LEGACY = { field: 'name', order: 'desc' as const }; +const AUTHORED_CANONICAL = [{ field: 'name', order: 'desc' as const }]; + +function issueAt(result: ReturnType, key: string) { + return result.success ? undefined : result.error.issues.find((i) => i.path.join('.') === key); +} + +describe('objectql.zod.ts#ObjectGridSchema — `defaultSort` is refused BY NAME (objectui#5861)', () => { + it('refuses an authored `defaultSort`, and the issue path names the key', () => { + const r = ObjectGridSchema.safeParse({ ...NODE, defaultSort: AUTHORED_LEGACY }); + expect(r.success).toBe(false); + const issue = issueAt(r, 'defaultSort'); + expect(issue).toBeDefined(); + // `retirementTombstone()` customises the MESSAGE only; the code stays the + // one a bare `z.never()` reports. + expect(issue?.code).toBe('invalid_type'); + }); + + it('LIT CONTROL — an unrecognised key on the SAME document stays green', () => { + // Without this the refusal above would be consistent with the whole object + // having turned strict — a much larger contract change than the one ruled. + expect(ObjectGridSchema.safeParse({ ...NODE, [UNKNOWN_KEY]: AUTHORED_LEGACY }).success).toBe(true); + }); + + it('the message names the remedy — rename to `sort` and wrap in an array', () => { + const message = String(issueAt(ObjectGridSchema.safeParse({ ...NODE, defaultSort: AUTHORED_LEGACY }), 'defaultSort')?.message); + expect(message).toContain('RETIRED (objectui#5861, ADR-0049)'); + expect(message).toContain('sort: [{ field, order }]'); + }); + + it('writes the SAME string into the metadata channel, so docs cannot drift from the parse message', () => { + const described = (ObjectGridSchema.shape.defaultSort as { description?: string }).description; + const issue = issueAt(ObjectGridSchema.safeParse({ ...NODE, defaultSort: AUTHORED_LEGACY }), 'defaultSort'); + expect(described).toBe(String(issue?.message)); + }); + + it('the tombstone is a MEMBER, not a deletion — that is what makes the refusal loud', () => { + expect('defaultSort' in ObjectGridSchema.shape).toBe(true); + expect(UNKNOWN_KEY in ObjectGridSchema.shape).toBe(false); + }); +}); + +describe('the OTHER direction — the spelling the tombstone names is still writable', () => { + it('canonical `sort` parses green on the same document the legacy key reddens', () => { + expect(ObjectGridSchema.safeParse({ ...NODE, sort: AUTHORED_CANONICAL }).success).toBe(true); + }); + + it('a document that omits the key entirely stays green — a tombstone gates nothing else', () => { + expect(ObjectGridSchema.safeParse(NODE).success).toBe(true); + }); +}); + +describe('the UPSTREAM half — re-derived from the installed pin, not copied into prose', () => { + it('upstream REFUSES `defaultSort` on `object-grid` and ACCEPTS `sort`', () => { + const refused = SpecObjectGridPropsSchema.safeParse({ objectName: 'probe', defaultSort: AUTHORED_LEGACY }); + const accepted = SpecObjectGridPropsSchema.safeParse({ objectName: 'probe', sort: AUTHORED_CANONICAL }); + expect(refused.success).toBe(false); + expect(accepted.success).toBe(true); + }); + + it('upstream refuses it BY NAME, with a rename prescription an arbitrary unknown key does not get', () => { + const named = SpecObjectGridPropsSchema.safeParse({ objectName: 'probe', defaultSort: AUTHORED_LEGACY }); + const control = SpecObjectGridPropsSchema.safeParse({ objectName: 'probe', [UNKNOWN_KEY]: 1 }); + expect(named.success).toBe(false); + expect(control.success).toBe(false); + const namedIssue = !named.success ? named.error.issues.find((i) => i.path.join('.') === 'defaultSort') : undefined; + const controlText = !control.success ? control.error.issues.map((i) => i.message).join('\n') : ''; + // The same code this package's tombstone reports, at the same path. + expect(namedIssue?.code).toBe('invalid_type'); + expect(String(namedIssue?.message)).toContain('`sort`'); + expect(controlText).not.toContain('`sort`'); + }); +}); + +/* ── The TypeScript twin ────────────────────────────────────────────────── */ + +/** + * The `@ts-expect-error` below IS the assertion: `defaultSort?: never` makes + * the legacy literal a compile error. It fails this package's + * `tsc -p tsconfig.test.json` leg as an UNUSED directive if the member is ever + * deleted (the index signature would absorb the key) or re-typed as a live + * `{ field, order }`. + */ +export const authoredDefaultSortRefused: ObjectGridSchemaType = { + type: 'object-grid', + objectName: 'probe', + // @ts-expect-error — `defaultSort` is RETIRED (objectui#5861): declared `?: never`, so no value is authorable. + defaultSort: AUTHORED_LEGACY, +}; + +/** LIT CONTROL: the canonical spelling is a declared, writable member of the twin. */ +export const authoredSortIsDeclared: ObjectGridSchemaType = { + type: 'object-grid', + objectName: 'probe', + sort: AUTHORED_CANONICAL, +}; + +describe('the TypeScript twin', () => { + it('is compiled by this package’s type-check leg, which is where the two bindings above are read', () => { + expect([authoredDefaultSortRefused, authoredSortIsDeclared]).toHaveLength(2); + }); +}); diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 1db02f5e6d..3d1bb46de8 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -846,14 +846,24 @@ export interface ObjectGridSchema extends BaseSchema { showPagination?: boolean; /** - * @deprecated Use sort instead - * Legacy sort configuration + * RETIRED (objectui#5861, ADR-0049) — the legacy single-entry spelling of + * {@link ObjectGridSchema.sort}. `@objectstack/spec` 17.3.0 converted + * `ObjectGridProps.defaultSort` to a retired-key tombstone, so the protocol + * refuses an authored value by name; the renderers no longer read it either + * (`ObjectGrid`'s fetch arm and header reader, and `ObjectView`'s three + * `table` paths all dropped the read together). + * + * `?: never`, not a deletion — this package's tombstone convention (see + * `ObjectViewSchema.viewTabBar`): {@link BaseSchema}'s `[key: string]: any` + * would absorb a deleted member, so the retired spelling would type-check + * green and do nothing. Lockstep with the Zod twin (`zod/objectql.zod.ts`, + * `retirementTombstone()`). + * + * @deprecated RETIRED (objectui#5861) — write `sort: [{ field, order }]` + * instead (rename the key and wrap the value in an array). */ - defaultSort?: { - field: string; - order: 'asc' | 'desc'; - }; - + defaultSort?: never; + /** * @deprecated Use filter instead * Legacy default filters diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 87a3fef216..11d0909d42 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -269,7 +269,18 @@ export const ObjectGridSchema = BaseSchema.extend({ showSearch: z.boolean().optional(), showFilters: z.boolean().optional(), showPagination: z.boolean().optional(), - defaultSort: z.object({ field: z.string(), order: z.enum(['asc', 'desc']) }).optional(), + // objectui#5861 — RETIRED under ADR-0049, in lockstep with the `?: never` + // twin on the interface. `@objectstack/spec` refuses this key BY NAME on + // `object-grid` (a retired-key tombstone since 17.3.0), and no renderer in + // this repo reads it any more, so a declared-but-ignored member here would + // parse green and draw an unsorted grid. A tombstone rather than a deletion: + // `BaseSchema` is `.passthrough()`, so an undeclared key is KEPT unexamined, + // not refused. + defaultSort: retirementTombstone( + 'RETIRED (objectui#5861, ADR-0049) — `defaultSort` was the legacy single-entry spelling of `sort`, ' + + 'and nothing reads it any more; the upstream protocol refuses it by name on `object-grid`. ' + + 'Rename the key to `sort` and wrap the value in an array: `sort: [{ field, order }]`.', + ), defaultFilters: z.record(z.string(), z.any()).optional(), // The legacy caption/export-title fallback — `ObjectGrid.tsx` reads it at // exactly two sites, `viewLabel: schema.label || schema.title` and