Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/relatedlist-sort-inheritance-pin.md
Original file line number Diff line number Diff line change
@@ -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=<field>` / `sort=-<field>`, 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`.
2 changes: 1 addition & 1 deletion content/docs/references/data/field.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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=`<field>` / sort=-`<field>`, 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. |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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=`<field>` / sort=-`<field>`, 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. |
Expand Down Expand Up @@ -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=`<field>` / sort=-`<field>`, 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. |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/system/migration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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=`<field>` / sort=-`<field>`, 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. |
Expand Down Expand Up @@ -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=`<field>` / sort=-`<field>`, 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. |
Expand Down
24 changes: 24 additions & 0 deletions packages/spec/src/data/field.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<field>');
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 = {
Expand Down
13 changes: 12 additions & 1 deletion packages/spec/src/data/field.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<field>` /
* `sort=-<field>` (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=<field> / sort=-<field>, 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'),
/**
Expand Down
Loading