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
40 changes: 40 additions & 0 deletions .changeset/5795-related-list-inherit-list-view-sort.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
---
'@object-ui/app-shell': minor
'@object-ui/plugin-detail': minor
---

An auto-derived related list now orders its rows by the CHILD object's default list view
`sort`, instead of falling to the server's primary-key order (objectui#5795). A task
version's "check items" tab whose child object declares `sort: [{ field: 'seq_no' }]`
renders 10/20/30/40; before this it rendered whatever order the ids happened to give —
20/30/10/40 in the reported case — while the child object's own list page obeyed the
declaration.

**Declared as user-visible, deliberately, even though no key was added.** The contract
question ("where does a derived related list's sort declaration live?") was ruled on
objectstack#11345 (maintainer, 2026-08-23) as **direction 1**: inherit the child's list
view sort, and add **no** new spec key — the field-level `relatedListSort` the issue also
proposed was explicitly not approved. So there is nothing new to author, and
`record:related_list.sort` was already declared, parsed and consumed; this fills it. What a
host observes is nonetheless new: a derived related-list descriptor gains a populated
`sort` where it had none, and the query it issues gains an `$orderby`. An app whose child
objects declare a default list order will see those tabs re-order on upgrade — which is the
point of the change, and is why this is not a patch.

Nothing is inherited where nothing was declared: a child object with no default list-view
sort produces the same descriptor, the same node and the same `$orderby`-free query as
before.

The two `sort` surfaces declare the same union and mean different things by its string arm
— a `ListView` string is the legacy space-separated `'seq_no desc'`, while the related
list's own reader takes `'field'` / `'-field'` — so the inherited value is normalized to
the array arm once, at the derivation, through `@object-ui/core`'s
`convertSortToQueryParams` (the repo's single definition of both authored dialects). An
un-normalized inherit would have ordered by a field literally named `seq_no desc`.

Known and unchanged: `$orderby` is only assembled while the related list is in windowed
(server-paged) mode, so a declared *or* inherited sort still disappears while the built-in
client text filter is active. That hole pre-dates this change and affects the authored prop
identically; it is now pinned as a recorded fact in
`plugin-detail/src/__tests__/RelatedList.sortDroppedOutsideWindowed.test.tsx` rather than
fixed here.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
/**
* 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.
*/

/**
* objectui#5795 — a derived related list inherits the CHILD object's default
* list view `sort`.
*
* Ruled on objectstack#11345 (maintainer, 2026-08-23 15:02Z) as **direction
* 1**: inherit the child's list-view sort, and add **no** new spec key — the
* field-level `relatedListSort` the issue also offered was explicitly not
* approved. So there is nothing new to author: the ordering a child object
* already declares for its own list is the ordering its related lists use,
* exactly as `columns` already defaults to that list's columns.
*
* ## The dialect trap this file exists to pin
*
* `ListView.sort` and `record:related_list.sort` declare the SAME union
* (`string | Array<{field, order}>`) and mean DIFFERENT things by the string
* arm:
*
* - ListView's string is the legacy space-separated clause, `'seq_no desc'`
* (`@objectstack/spec` `ui/view.zod.ts`, annotated `Legacy "field desc"`);
* - the related list's own `normalizeSortSpec` (`plugin-detail/RelatedList
* .tsx`) reads the OData-ish `'field'` / `'-field'`.
*
* Inheriting the string verbatim therefore does not produce "a sort in another
* notation" — it produces `$orderby` on a field whose NAME is the seven
* characters `seq_no desc`, which no object has. The route taken (and pinned
* below) is to normalize at this boundary, always to the ARRAY arm, through
* `@object-ui/core`'s `convertSortToQueryParams` — the repo's one definition of
* both authored dialects — so no second parser of the legacy string exists to
* drift from it.
*
* `deriveRelatedLists` is the ONE place that knows it is reading a ListView and
* writing a related list, which is why the translation belongs here and not as
* a tolerant reader on the consuming end (AGENTS.md #0.1).
*/

import { describe, it, expect } from 'vitest';
import { deriveRelatedLists } from '../deriveRelatedLists';

const PARENT = { name: 'task_version', label: 'Task Version', fields: {} };

/**
* Shaped after the issue's downstream case: a "task version" owns "check
* items" that carry an explicit `seq_no` (10/20/30/40), which rendered in
* record-id order because the derivation emitted no `sort` at all.
*/
const childWithList = (list: unknown) => ({
name: 'check_item',
label: 'Check Item',
...(list === undefined ? {} : { list }),
fields: {
seq_no: { type: 'number', label: 'Seq No' },
task_version: { type: 'master_detail', reference_to: 'task_version', label: 'Task Version' },
},
});

const derive = (child: unknown) =>
deriveRelatedLists(PARENT, [PARENT, child as any])[0];

describe('deriveRelatedLists — inherited default list-view sort (objectui#5795)', () => {
it('SUBJECT — inherits the array arm of the child list view sort', () => {
const entry = derive(childWithList({ sort: [{ field: 'seq_no', order: 'asc' }] }));
expect(entry.childObject).toBe('check_item');
expect(entry.sort).toEqual([{ field: 'seq_no', order: 'asc' }]);
});

it('preserves a multi-key authored order, in the order authored', () => {
// Key order matters and survives the map round-trip inside the
// normalizer because every ObjectStack field name matches
// `^[a-z_][a-z0-9_]*$` — none is an integer-like key JS would hoist.
const entry = derive(
childWithList({
sort: [
{ field: 'stage', order: 'desc' },
{ field: 'seq_no', order: 'asc' },
],
}),
);
expect(entry.sort).toEqual([
{ field: 'stage', order: 'desc' },
{ field: 'seq_no', order: 'asc' },
]);
});

it('THE DIALECT PIN — normalizes the legacy space-separated string arm', () => {
const entry = derive(childWithList({ sort: 'seq_no desc' }));
expect(entry.sort).toEqual([{ field: 'seq_no', order: 'desc' }]);
// Stated as its own assertion because it is the whole failure mode: an
// un-normalized inherit yields a FIELD literally named `seq_no desc`.
expect(entry.sort?.[0].field).toBe('seq_no');
expect(entry.sort?.[0].field).not.toBe('seq_no desc');
});

it('reads a bare legacy string as ascending', () => {
expect(derive(childWithList({ sort: 'seq_no' })).sort).toEqual([
{ field: 'seq_no', order: 'asc' },
]);
});

it('is case-insensitive about the legacy direction word', () => {
expect(derive(childWithList({ sort: 'seq_no DESC' })).sort).toEqual([
{ field: 'seq_no', order: 'desc' },
]);
});

it('COUNTER-PROBE — a child with no list-view sort gains no `sort` key at all', () => {
// Not `[]`, not `undefined`-valued: the key is ABSENT, so the synthesized
// node stays byte-identical to what it was before this inheritance
// existed. Were it present-and-empty, "inherited nothing" and "inherited
// an order" would be indistinguishable downstream — and inheritance would
// be satisfiable by inventing an order.
for (const list of [undefined, {}, { sort: undefined }, { sort: '' }, { sort: [] }]) {
const entry = derive(childWithList(list));
expect(entry.childObject).toBe('check_item');
expect('sort' in entry).toBe(false);
}
});

it('COUNTER-PROBE — an unusable `sort` is dropped, never guessed at', () => {
for (const sort of [42, { field: 'seq_no' }, ['seq_no'], [{ order: 'desc' }], null]) {
expect('sort' in derive(childWithList({ sort }))).toBe(false);
}
});

it('applies the same inherited order to EVERY related list of that child', () => {
// A child may point at one parent through several FKs; each surfaces as
// its own list, and all of them list the same object, so all inherit the
// same declared order.
const child = {
name: 'check_item',
label: 'Check Item',
list: { sort: [{ field: 'seq_no', order: 'asc' }] },
fields: {
seq_no: { type: 'number', label: 'Seq No' },
owner_version: { type: 'master_detail', reference_to: 'task_version', label: 'Owner' },
review_version: { type: 'lookup', reference_to: 'task_version', label: 'Reviewer' },
},
};
const entries = deriveRelatedLists(PARENT, [PARENT, child as any]);
expect(entries).toHaveLength(2);
for (const e of entries) {
expect(e.sort).toEqual([{ field: 'seq_no', order: 'asc' }]);
}
});

it('leaves an unrelated child list untouched when only one child declares a sort', () => {
const sorted = childWithList({ sort: [{ field: 'seq_no', order: 'asc' }] });
const unsorted = {
name: 'attachment_note',
label: 'Note',
fields: {
task_version: { type: 'lookup', reference_to: 'task_version', label: 'Task Version' },
},
};
const entries = deriveRelatedLists(PARENT, [PARENT, sorted as any, unsorted as any]);
const byObject = Object.fromEntries(entries.map((e) => [e.childObject, e]));
expect(byObject.check_item.sort).toEqual([{ field: 'seq_no', order: 'asc' }]);
expect('sort' in byObject.attachment_note).toBe(false);
});
});
67 changes: 67 additions & 0 deletions packages/app-shell/src/utils/deriveRelatedLists.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,13 @@
* - `relatedListTitle` / `relatedListColumns` on the FK field override the
* derived title / columns (columns default to the child object's own list
* columns when omitted — resolved by the renderer).
* - ROW ORDER is inherited from the child object's DEFAULT LIST VIEW `sort`
* (objectui#5795). There is deliberately no field-level `relatedListSort`
* to pair with the keys above: the contract question was ruled on
* objectstack#11345 (maintainer, 2026-08-23) as direction 1 — inherit the
* child's list-view sort, and add NO new spec key. A related list is just
* another surface that lists that object, so it orders the way that
* object's own list orders, exactly as `columns` already defaults.
* - Audit FKs (`created_by` / `updated_by` / `owner_id`) are skipped — they
* exist on virtually every object and would balloon the detail page into
* dozens of duplicate cards.
Expand All@@ -42,6 +49,8 @@
* server-side; this closes the UI/DX gap.
*/

import { convertSortToQueryParams } from '@object-ui/core';

/** Audit/ownership FKs that exist on nearly every object — never related lists. */
const AUDIT_FK_FIELDS = new Set(['created_by', 'updated_by', 'owner_id']);

Expand All@@ -64,12 +73,65 @@ export interface DerivedRelatedList {
* while non-primary lists collapse into a single "Related" tab.
*/
isPrimary: boolean;
/**
* Default row order, INHERITED from the child object's default list view
* `sort` (objectui#5795; ruled on objectstack#11345, maintainer 2026-08-23:
* direction 1 — inherit the child list view's sort, NO new spec key).
*
* Always the ARRAY arm of the `record:related_list.sort` union, never the
* string arm. Both surfaces declare `string | Array<{field, order}>`, but
* the two string arms are DIFFERENT dialects: a ListView string is the
* legacy space-separated `'seq_no desc'`, while the related list's own
* `normalizeSortSpec` reads `'field'` / `'-field'`. Passing the ListView
* string through verbatim would order by a field literally named
* `"seq_no desc"`. Translating at this boundary — the one place that knows
* it is reading a ListView and writing a related list — is the whole point;
* a tolerant reader on the consuming end would be the wrong fix (#0.1).
*
* Absent (never `[]`) when the child declares no default list view sort, so
* the related list's query stays byte-identical to what it sent before.
*/
sort?: Array<{ field: string; order: 'asc' | 'desc' }>;
}

interface ObjectLike {
name?: string;
label?: string;
fields?: Record<string, any> | any[];
/**
* The object's DEFAULT list view, as merged onto the object def by
* `MetadataProvider.mergeViewsIntoObjects` (`merged.list = extra.primary`,
* where `primary` is the expanded view item flagged `isDefault`). Its `sort`
* is what a derived related list inherits.
*
* Optional on purpose: metadata of type `view` may arrive AFTER the objects
* do, in which case this is undefined on the first derivation pass and the
* descriptor carries no `sort`. That is not a silent hole — `objects` is a
* fresh array once the views merge, so the memo over this derivation
* recomputes and the sort appears.
*/
list?: { sort?: string | Array<{ field?: string; order?: 'asc' | 'desc' }> };
}

/**
* The child object's inherited default row order, normalized to the ARRAY arm.
*
* Both arms are lowered through `convertSortToQueryParams` — the repo's ONE
* definition of the authored-`sort` dialects (`@object-ui/core`) — so no second
* parser of the legacy `'field desc'` string can drift from it. Its return is a
* field→direction map; re-expanding it preserves the authored key order because
* every ObjectStack field name matches `^[a-z_][a-z0-9_]*$` (spec
* `field.zod.ts`), so none is an integer-like key that JS would hoist.
*
* Returns `undefined` — never `[]` — when nothing orderable was declared.
*/
function inheritedListViewSort(
list: ObjectLike['list'],
): Array<{ field: string; order: 'asc' | 'desc' }> | undefined {
const map = convertSortToQueryParams(list?.sort as any);
if (!map) return undefined;
const entries = Object.entries(map).map(([field, order]) => ({ field, order }));
return entries.length > 0 ? entries : undefined;
}

/** Normalize an object's `fields` (record or array) into `[name, def]` pairs. */
Expand DownExpand Up@@ -121,6 +183,10 @@ export function deriveRelatedLists(
// it requires read access on the child — the FK's mere existence does not
// grant the current user anything (objectui#2359).
if (canRead && !canRead(child.name)) continue;
// objectui#5795: every related list derived from this child inherits the
// child's own default list-view order, so compute it once per child
// rather than once per FK.
const inheritedSort = inheritedListViewSort(child.list);
for (const [fieldName, fieldDef] of fieldEntries(child.fields)) {
if (!fieldDef) continue;
const type = fieldDef.type;
Expand All@@ -136,6 +202,7 @@ export function deriveRelatedLists(
referenceField: fieldName,
isOwned: type === 'master_detail',
isPrimary: fieldDef.relatedList === 'primary',
...(inheritedSort ? { sort: inheritedSort } : {}),
_fkLabel: (typeof fieldDef.label === 'string' && fieldDef.label) || fieldName,
...(typeof fieldDef.relatedListTitle === 'string' && fieldDef.relatedListTitle
? { title: fieldDef.relatedListTitle }
Expand Down
Loading
Loading