diff --git a/.changeset/relatedlist-sort-inheritance-pin.md b/.changeset/relatedlist-sort-inheritance-pin.md new file mode 100644 index 0000000000..b51c6d5f41 --- /dev/null +++ b/.changeset/relatedlist-sort-inheritance-pin.md @@ -0,0 +1,5 @@ +--- +"@objectstack/spec": patch +--- + +Pin the derived related-list sort-inheritance rule as contract text in `relatedList`'s `.describe()` (#13294), the same way `relatedListFilter` was pinned by #8704. The behaviour is already landed and browser-verified (#11345, 2026-08-29 acceptance run): a derived related list (`relatedList: 'primary'`) inherits the child object's DEFAULT list view `sort` — the `isDefault` expanded view, or the first declared list item when none is marked default (resolver `expandViewContainer`, `ui/view.zod.ts`) — and a child object with no list-view sort emits no ordering parameter at all, falling back to record-id order. The wire spelling is stated too: the REST shorthand `sort=` / `sort=-`, never the OData `$orderby` token. Text-only change — the `relatedList` accept-set is byte-identical before and after; a new pin test asserts both the inheritance statement and the negative (no-sort ⇒ no-ordering-parameter) half of the contract text. Generated reference pages regenerated via `pnpm --filter @objectstack/spec check:generated --fix`. diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index fc5369a127..5136a5cd93 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -82,7 +82,7 @@ const result = CurrencyConfigSchema.parse(data); | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | | **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | | **inlineAmountField** | `string` | optional | Numeric child field summed for the inline grid total | -| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). | +| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | | **relatedListTitle** | `string` | optional | Title for the detail-page related list | | **relatedListColumns** | `string[]` | optional | Explicit columns for the detail-page related list, as child field names (e.g. ['name', 'status']); derived from the child object (highlightFields → field walk) when omitted. Strings only — labels, cell types and formatting always derive from the child object's field definitions; column objects are refused at parse. | | **relatedListFilter** | `any` | optional | Declarative default filter for the detail-page related list: AND-composed with the parent-relationship condition `{ [referenceField]: parentId }` — an authored constraint, never a user-editable suggestion. The related-list tab badge count honors the same composed filter, so counts match the visible rows. Canonical Query-DSL FilterCondition (the same dialect as a query `where`), e.g. `{ status: { $ne: 'deleted' } }` to hide soft-deleted children. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 284921c61a..0884c819a1 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -244,7 +244,7 @@ const result = ApiMethod.parse(data); | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | | **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | | **inlineAmountField** | `string` | optional | Numeric child field summed for the inline grid total | -| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). | +| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | | **relatedListTitle** | `string` | optional | Title for the detail-page related list | | **relatedListColumns** | `string[]` | optional | Explicit columns for the detail-page related list, as child field names (e.g. ['name', 'status']); derived from the child object (highlightFields → field walk) when omitted. Strings only — labels, cell types and formatting always derive from the child object's field definitions; column objects are refused at parse. | | **relatedListFilter** | `any` | optional | Declarative default filter for the detail-page related list: AND-composed with the parent-relationship condition `{ [referenceField]: parentId }` — an authored constraint, never a user-editable suggestion. The related-list tab badge count honors the same composed filter, so counts match the visible rows. Canonical Query-DSL FilterCondition (the same dialect as a query `where`), e.g. `{ status: { $ne: 'deleted' } }` to hide soft-deleted children. | @@ -571,7 +571,7 @@ const result = ApiMethod.parse(data); | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | | **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | | **inlineAmountField** | `string` | optional | Numeric child field summed for the inline grid total | -| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). | +| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | | **relatedListTitle** | `string` | optional | Title for the detail-page related list | | **relatedListColumns** | `string[]` | optional | Explicit columns for the detail-page related list, as child field names (e.g. ['name', 'status']); derived from the child object (highlightFields → field walk) when omitted. Strings only — labels, cell types and formatting always derive from the child object's field definitions; column objects are refused at parse. | | **relatedListFilter** | `any` | optional | Declarative default filter for the detail-page related list: AND-composed with the parent-relationship condition `{ [referenceField]: parentId }` — an authored constraint, never a user-editable suggestion. The related-list tab badge count honors the same composed filter, so counts match the visible rows. Canonical Query-DSL FilterCondition (the same dialect as a query `where`), e.g. `{ status: { $ne: 'deleted' } }` to hide soft-deleted children. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index a04fcb87a5..63a45d5ac8 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -80,7 +80,7 @@ Add a new field to an existing object | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | | **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | | **inlineAmountField** | `string` | optional | Numeric child field summed for the inline grid total | -| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). | +| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | | **relatedListTitle** | `string` | optional | Title for the detail-page related list | | **relatedListColumns** | `string[]` | optional | Explicit columns for the detail-page related list, as child field names (e.g. ['name', 'status']); derived from the child object (highlightFields → field walk) when omitted. Strings only — labels, cell types and formatting always derive from the child object's field definitions; column objects are refused at parse. | | **relatedListFilter** | `any` | optional | Declarative default filter for the detail-page related list: AND-composed with the parent-relationship condition `{ [referenceField]: parentId }` — an authored constraint, never a user-editable suggestion. The related-list tab badge count honors the same composed filter, so counts match the visible rows. Canonical Query-DSL FilterCondition (the same dialect as a query `where`), e.g. `{ status: { $ne: 'deleted' } }` to hide soft-deleted children. | @@ -497,7 +497,7 @@ Add a new field to an existing object | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | | **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | | **inlineAmountField** | `string` | optional | Numeric child field summed for the inline grid total | -| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). | +| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | | **relatedListTitle** | `string` | optional | Title for the detail-page related list | | **relatedListColumns** | `string[]` | optional | Explicit columns for the detail-page related list, as child field names (e.g. ['name', 'status']); derived from the child object (highlightFields → field walk) when omitted. Strings only — labels, cell types and formatting always derive from the child object's field definitions; column objects are refused at parse. | | **relatedListFilter** | `any` | optional | Declarative default filter for the detail-page related list: AND-composed with the parent-relationship condition `{ [referenceField]: parentId }` — an authored constraint, never a user-editable suggestion. The related-list tab badge count honors the same composed filter, so counts match the visible rows. Canonical Query-DSL FilterCondition (the same dialect as a query `where`), e.g. `{ status: { $ne: 'deleted' } }` to hide soft-deleted children. | diff --git a/packages/spec/src/data/field.test.ts b/packages/spec/src/data/field.test.ts index 453c23d55e..9fd51ba843 100644 --- a/packages/spec/src/data/field.test.ts +++ b/packages/spec/src/data/field.test.ts @@ -871,6 +871,30 @@ describe('FieldSchema', () => { expect(() => FieldSchema.parse(field)).toThrow(); }); + // [#13294, following #11345's browser-verified acceptance run] the + // derived related list's sort-inheritance rule, pinned as contract text + // the same way #8704 pinned relatedListFilter below. POSITIVE half (the + // inheritance statement + wire spelling) and NEGATIVE half (no + // list-view sort ⇒ no ordering parameter) both live in the same + // `.describe()`, so this pin asserts both — a one-directional assertion + // here would leave the other half free to drift silently. + it('relatedList contract text states DEFAULT-list-view sort inheritance and its wire spelling', () => { + const description = FieldSchema.shape.relatedList.description ?? ''; + expect(description).toContain('DEFAULT list view sort'); + // Wire shape is the REST shorthand `sort=`, never the OData `$orderby` + // token — assert the affirmative spelling directly rather than a bare + // absence check, since the contract text also names `$orderby` (in a + // "never" clause) to steer authors away from it. + expect(description).toContain('sort='); + expect(description).toContain('never $orderby'); + }); + + it('relatedList contract text states the negative half: no list-view sort ⇒ no ordering parameter', () => { + const description = FieldSchema.shape.relatedList.description ?? ''; + expect(description).toContain('no ordering parameter'); + expect(description).toContain('record-id order'); + }); + // [#8704] relatedListFilter — the fourth member of the related-list family. it('should accept relatedListFilter (canonical Query-DSL FilterCondition) and round-trip it', () => { const field: Field = { diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index d6c5b1a6b5..f0e8154daa 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -1131,8 +1131,19 @@ export const FieldSchema = lazySchema(() => { * the DETAIL renderer's interpretation. Being prominence * (not a `relatedLayout` switch) is what admits it to the * object model under ADR-0085's admission test. + * + * SORT INHERITANCE (#13294, following #11345's browser-verified acceptance + * run): a derived related list (`relatedList: 'primary'`) inherits its row + * order from the child object's DEFAULT list view `sort` — the `isDefault` + * expanded view, or the first declared list item when none is marked + * default (resolver `expandViewContainer`, `ui/view.zod.ts`). On the wire + * this reaches the server as the REST shorthand `sort=` / + * `sort=-` (objectui's `serializeOrderBy` lowers + * `QueryParams.$orderby` to this shape) — never the OData `$orderby` token. + * A child object with no list-view sort emits no ordering parameter at all, + * and rows fall back to record-id order. */ - relatedList: z.union([z.boolean(), z.literal('primary')]).optional().describe('Show this child collection as a related list on the parent\'s detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); \'primary\' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085).'), + relatedList: z.union([z.boolean(), z.literal('primary')]).optional().describe('Show this child collection as a related list on the parent\'s detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); \'primary\' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). A derived related list (relatedList: \'primary\') inherits its row order from the child object\'s DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort= / sort=-, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order.'), /** Optional section title for the detail-page related list (defaults to the child object label). */ relatedListTitle: z.string().optional().describe('Title for the detail-page related list'), /**