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
28 changes: 28 additions & 0 deletions .changeset/related-list-primary-tabs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@object-ui/app-shell': minor
'@object-ui/plugin-detail': minor
'@object-ui/fields': minor
---

Detail-page related lists: `relatedList: 'primary'` → own tab, multi-FK & self-referential related lists, unified picker columns (framework #2579).

- **plugin-detail** (`buildDefaultTabs`): the default related-list layout is now
the ADR-0085 prominence rule — lists whose FK declares `relatedList: 'primary'`
each get their OWN tab; every other related list collapses into a single
"Related" tab. With no primary lists this is byte-for-byte the previous stacked
default, so it is opt-in per relationship. `relatedLayout: 'tabs' | 'stack'`
remain app-level overrides (force all-own-tabs / all-stacked).
- **app-shell** (`deriveRelatedLists`): emits one related list per eligible FK —
a child referencing the parent through several relationships (e.g.
`primary_account` + `partner_account`) now surfaces each, disambiguated by the
FK label; includes self-referential relationships (hierarchies → a "child"
list); and carries the `isPrimary` prominence flag through. `RecordDetailView`
threads `isPrimary` into the synthesized page.
- **fields** (`deriveLookupColumns`): the lookup-picker default columns now
prefer the object's ADR-0085 `highlightFields` (then legacy `displayFields`,
then the field walk) — the same "how to list this object" source the related
list uses, so a picker and a related list of the same object agree with zero
per-surface config.

Pairs with the `@objectstack/spec` change that makes `relatedList` a tri-state
(`boolean | 'primary'`) and `record:related_list` `columns` optional.
83 changes: 70 additions & 13 deletions packages/app-shell/src/utils/__tests__/deriveRelatedLists.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ describe('deriveRelatedLists', () => {
childLabel: 'Task',
referenceField: 'project',
isOwned: true,
isPrimary: false,
},
]);
});
Expand All @@ -34,7 +35,12 @@ describe('deriveRelatedLists', () => {
fields: { project_id: { type: 'lookup', reference: 'project' } },
};
const out = deriveRelatedLists(project, [project, note]);
expect(out[0]).toMatchObject({ childObject: 'note', referenceField: 'project_id', isOwned: false });
expect(out[0]).toMatchObject({
childObject: 'note',
referenceField: 'project_id',
isOwned: false,
isPrimary: false,
});
});

it('supports reference_to as well as reference', () => {
Expand Down Expand Up @@ -94,20 +100,54 @@ describe('deriveRelatedLists', () => {
});
});

it('dedupes to one related list per child object (first eligible FK wins)', () => {
const assignment = {
name: 'assignment',
it('flags relatedList: "primary" as isPrimary (prominence → own tab)', () => {
const task = {
name: 'task',
label: 'Task',
fields: { project: { type: 'master_detail', reference: 'project', relatedList: 'primary' } },
};
const out = deriveRelatedLists(project, [project, task]);
expect(out[0]).toMatchObject({ childObject: 'task', isPrimary: true, isOwned: true });
});

it('emits one related list per eligible FK when a child references the parent multiple times', () => {
const opportunity = {
name: 'opportunity',
label: 'Opportunity',
fields: {
project: { type: 'master_detail', reference: 'project' },
secondary_project: { type: 'lookup', reference: 'project' },
primary_project: { type: 'lookup', reference: 'project', label: 'Primary Project' },
partner_project: { type: 'lookup', reference: 'project', label: 'Partner Project' },
},
};
const out = deriveRelatedLists(project, [project, assignment]);
expect(out).toHaveLength(1);
expect(out[0].referenceField).toBe('project');
const out = deriveRelatedLists(project, [project, opportunity]);
expect(out).toHaveLength(2);
expect(out.map((r) => r.referenceField).sort()).toEqual(['partner_project', 'primary_project']);
// No explicit relatedListTitle → the FK label disambiguates the two lists.
expect(out.map((r) => r.title).sort()).toEqual([
'Opportunity · Partner Project',
'Opportunity · Primary Project',
]);
});

it('keeps an explicit relatedListTitle over the multi-FK disambiguation suffix', () => {
const opportunity = {
name: 'opportunity',
label: 'Opportunity',
fields: {
primary_project: {
type: 'lookup', reference: 'project', label: 'Primary Project',
relatedListTitle: 'Primary Opps',
},
partner_project: { type: 'lookup', reference: 'project', label: 'Partner Project' },
},
};
const out = deriveRelatedLists(project, [project, opportunity]);
const byField = Object.fromEntries(out.map((r) => [r.referenceField, r.title]));
expect(byField.primary_project).toBe('Primary Opps');
expect(byField.partner_project).toBe('Opportunity · Partner Project');
});

it('skips a suppressed FK but still considers a sibling FK on the same child', () => {
it('skips a suppressed FK but keeps a sibling FK on the same child', () => {
const assignment = {
name: 'assignment',
fields: {
Expand All @@ -120,10 +160,27 @@ describe('deriveRelatedLists', () => {
expect(out[0].referenceField).toBe('project');
});

it('ignores unrelated objects and self-references', () => {
it('includes a self-referential relationship (hierarchy → "child" list)', () => {
const selfRef = {
name: 'project',
label: 'Project',
fields: {
name: { type: 'text' },
parent_project: { type: 'lookup', reference: 'project', label: 'Parent Project' },
},
};
const out = deriveRelatedLists(selfRef, [selfRef]);
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({
childObject: 'project',
referenceField: 'parent_project',
isOwned: false,
});
});

it('ignores unrelated objects', () => {
const other = { name: 'other', fields: { foo: { type: 'lookup', reference: 'somethingelse' } } };
const selfRef = { name: 'project', fields: { parent: { type: 'tree', reference: 'project' } } };
expect(deriveRelatedLists(project, [project, other, selfRef])).toEqual([]);
expect(deriveRelatedLists(project, [project, other])).toEqual([]);
});

it('handles array-shaped fields', () => {
Expand Down
59 changes: 44 additions & 15 deletions packages/app-shell/src/utils/deriveRelatedLists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,29 @@
*
* This helper scans every object for fields whose `reference`/`reference_to`
* points back at the parent object and produces one related-list descriptor per
* child collection. The detail page (`RecordDetailView`) feeds these into the
* eligible FK. The detail page (`RecordDetailView`) feeds these into the
* `record:related_list` renderers (and the legacy `DetailView.related`).
*
* Rules (kept in lockstep with the relationship-level `relatedList` spec flag):
* - Owned children (`master_detail`) and `lookup` children are SHOWN by
* default. Set `relatedList: false` on the FK field to suppress a noisy
* association/audit link.
* association/audit link; set `relatedList: 'primary'` to mark a CORE
* relationship — the detail page promotes it to its own tab (the tab-vs-
* Related split is decided downstream in `buildDefaultTabs`).
* - `relatedListTitle` / `relatedListColumns` on the FK field override the
* derived title / columns.
* derived title / columns (columns default to the child object's own list
* columns when omitted — resolved by the renderer).
* - 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.
* - One related list per child object (deduped by child object name): the
* first non-audit, non-suppressed FK wins.
* - ONE related list per eligible FK. A child may point at the parent through
* MORE THAN ONE relationship (e.g. `opportunity.primary_account` +
* `opportunity.partner_account`); each surfaces as its own list. When a
* child appears more than once and gave no explicit `relatedListTitle`, the
* FK's label is suffixed to disambiguate ("Opportunity · Partner Account").
* - Self-references are allowed (e.g. `account.parent_account` → `account`):
* the parent record lists the records whose self-FK points back at it
* ("Child Accounts"). Suppress with `relatedList: false` if unwanted.
* - Owned (`master_detail`) children are ordered before plain `lookup`
* children, preserving discovery order within each group.
*/
Expand All @@ -43,6 +52,12 @@ export interface DerivedRelatedList {
columns?: any[];
/** True when the child→parent link is a `master_detail` (owned) relationship. */
isOwned: boolean;
/**
* True when the FK declares `relatedList: 'primary'`. A prominence hint
* (ADR-0085): the detail page promotes this relationship to its OWN tab,
* while non-primary lists collapse into a single "Related" tab.
*/
isPrimary: boolean;
}

interface ObjectLike {
Expand Down Expand Up @@ -73,12 +88,15 @@ export function deriveRelatedLists(
): DerivedRelatedList[] {
if (!objectDef?.name || !Array.isArray(objects) || objects.length === 0) return [];
const parentName = objectDef.name;
const owned: DerivedRelatedList[] = [];
const referenced: DerivedRelatedList[] = [];
const seenChild = new Set<string>();

// Working entries carry the FK label so we can disambiguate multi-FK children
// after the full sweep; it is stripped from the returned descriptors.
type Working = DerivedRelatedList & { _fkLabel: string };
const owned: Working[] = [];
const referenced: Working[] = [];

for (const child of objects) {
if (!child?.name || child.name === parentName) continue;
if (!child?.name) continue;
for (const [fieldName, fieldDef] of fieldEntries(child.fields)) {
if (!fieldDef) continue;
const type = fieldDef.type;
Expand All @@ -87,26 +105,37 @@ export function deriveRelatedLists(
if (AUDIT_FK_FIELDS.has(fieldName)) continue;
// Explicit opt-out lives on the relationship.
if (fieldDef.relatedList === false) continue;
// One related list per child object — the first eligible FK wins.
if (seenChild.has(child.name)) continue;
seenChild.add(child.name);

const entry: DerivedRelatedList = {
const entry: Working = {
childObject: child.name,
childLabel: child.label || child.name,
referenceField: fieldName,
isOwned: type === 'master_detail',
isPrimary: fieldDef.relatedList === 'primary',
_fkLabel: (typeof fieldDef.label === 'string' && fieldDef.label) || fieldName,
...(typeof fieldDef.relatedListTitle === 'string' && fieldDef.relatedListTitle
? { title: fieldDef.relatedListTitle }
: {}),
...(Array.isArray(fieldDef.relatedListColumns) && fieldDef.relatedListColumns.length > 0
? { columns: fieldDef.relatedListColumns }
: {}),
};
// NO `break`: a child object may reference this parent through several FKs.
(entry.isOwned ? owned : referenced).push(entry);
break; // move on to the next child object
}
}

return [...owned, ...referenced];
const all = [...owned, ...referenced];
// Multi-FK disambiguation: when a child object points here through more than
// one relationship and gave no explicit title, suffix the FK label so the two
// lists are distinguishable (e.g. "Opportunity · Partner Account").
const counts: Record<string, number> = {};
for (const r of all) counts[r.childObject] = (counts[r.childObject] || 0) + 1;

return all.map(({ _fkLabel, ...rest }) => {
if (!rest.title && counts[rest.childObject] > 1) {
return { ...rest, title: `${rest.childLabel} · ${_fkLabel}` };
}
return rest;
});
}
25 changes: 17 additions & 8 deletions packages/app-shell/src/views/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1714,14 +1714,23 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
const synthRelated = Array.isArray((detailSchema as any).related)
? ((detailSchema as any).related as any[])
.filter((r) => r?.api && r?.referenceField)
.map((r) => ({
title: r.title,
objectName: r.api,
relationshipField: r.referenceField,
...(Array.isArray(r.columns) ? { columns: r.columns } : {}),
...(typeof r.pageSize === 'number' ? { limit: r.pageSize } : {}),
...(r.icon ? { icon: r.icon } : {}),
}))
.map((r) => {
// Carry the `relatedList: 'primary'` prominence flag from the derived
// relationship graph. Matched by (childObject, referenceField) — the
// unique key of a related list — so it is robust to ordering/filtering.
const derived = childRelations.find(
(c) => c.childObject === r.api && c.referenceField === r.referenceField,
);
return {
title: r.title,
objectName: r.api,
relationshipField: r.referenceField,
...(Array.isArray(r.columns) ? { columns: r.columns } : {}),
...(typeof r.pageSize === 'number' ? { limit: r.pageSize } : {}),
...(r.icon ? { icon: r.icon } : {}),
...(derived?.isPrimary ? { isPrimary: true } : {}),
};
})
: undefined;
const synthHistory = (detailSchema as any).history
? {
Expand Down
25 changes: 25 additions & 0 deletions packages/fields/src/widgets/deriveLookupColumns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,29 @@ describe('deriveLookupColumns', () => {
const cols = deriveLookupColumns(schema, { displayField: 'name' });
expect(cols.map((c) => c.field)).toEqual(['name', 'region']);
});

it('prefers ADR-0085 highlightFields over the declaration-order walk', () => {
const schema = {
fields: {
name: { type: 'text', label: 'Name' },
code: { type: 'text', label: 'Code' },
region: { type: 'select', label: 'Region' },
notes: { type: 'text', label: 'Notes' },
},
highlightFields: ['region', 'code'],
};
const cols = deriveLookupColumns(schema, { displayField: 'name', max: 4 });
// Display field leads, then highlightFields in order — not the raw field walk.
expect(cols.map((c) => c.field)).toEqual(['name', 'region', 'code']);
});

it('lets highlightFields win over legacy displayFields when both are present', () => {
const schema = {
fields: { name: { type: 'text' }, code: { type: 'text' }, region: { type: 'select' } },
highlightFields: ['region'],
displayFields: ['code'],
};
const cols = deriveLookupColumns(schema, { displayField: 'name' });
expect(cols.map((c) => c.field)).toEqual(['name', 'region']);
});
});
34 changes: 24 additions & 10 deletions packages/fields/src/widgets/deriveLookupColumns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,14 @@ export interface DeriveColumnsOptions {

interface ObjectSchemaLike {
fields?: Record<string, { type?: string; label?: string; hidden?: boolean }>;
/** Object-level "fields to display in search result cards" (spec metadata). */
/**
* ADR-0085 semantic role: the object's most important fields. The single
* source for "how to list this object" — shared with the detail-page related
* list — so a lookup picker and a related list of the same object agree on
* columns with zero per-surface config.
*/
highlightFields?: unknown;
/** Object-level "fields to display in search result cards" (legacy spec metadata). */
displayFields?: unknown;
}

Expand All @@ -53,9 +60,10 @@ function dedupe(names: string[]): string[] {
* a lookup field declares no explicit `lookup_columns`.
*
* Priority:
* 1. The referenced object's own `displayFields` ("fields to display in
* search result cards") — the author's declared search-card shape.
* 2. Otherwise the display field plus the next few business fields in
* 1. The referenced object's ADR-0085 `highlightFields` — the canonical
* "how to list this object" set, shared with the detail-page related list.
* 2. Else the object's legacy `displayFields` search-card shape.
* 3. Otherwise the display field plus the next few business fields in
* declaration order, skipping system/audit fields and heavy non-tabular
* types.
*
Expand All @@ -79,17 +87,23 @@ export function deriveLookupColumns(

if (Object.keys(fields).length === 0) return [];

// 1. Honour the object's explicit search-card fields when present.
const declared = objectSchema?.displayFields;
if (Array.isArray(declared) && declared.length > 0) {
const names = (declared as unknown[]).filter(
// 1 & 2. Honour a declared column list — `highlightFields` (ADR-0085
// canonical, shared with the related list) first, then the legacy
// `displayFields` search-card list. The display field always leads.
const fromDeclaredList = (list: unknown): LookupColumnDef[] | null => {
if (!Array.isArray(list) || list.length === 0) return null;
const names = (list as unknown[]).filter(
(n): n is string => typeof n === 'string' && n.length > 0,
);
const ordered = dedupe([displayField, ...names.filter((n) => n !== displayField)]);
return ordered.slice(0, max).map(toCol);
}
};
const fromHighlights = fromDeclaredList(objectSchema?.highlightFields);
if (fromHighlights) return fromHighlights;
const fromDisplay = fromDeclaredList(objectSchema?.displayFields);
if (fromDisplay) return fromDisplay;

// 2. Derive from the field set in declaration order.
// 3. Derive from the field set in declaration order.
const candidates = Object.keys(fields).filter((name) => {
if (name === displayField) return false;
if (SYSTEM_FIELDS.has(name)) return false;
Expand Down
Loading
Loading