Skip to content

Commit 2082109

Browse files
os-zhuangclaude
andauthored
feat(spec): detail-page related-list tabs via relatedList: 'primary' (#2579) (#2594)
`Field.relatedList` becomes tri-state `boolean | 'primary'`. 'primary' marks a core relationship the detail page promotes to its own tab (ADR-0085 prominence, not a layout switch); non-primary children collapse into a shared "Related" tab. `RecordRelatedListProps.columns` becomes optional — columns derive from the child object's highlightFields when omitted. Both additive/back-compat → minor. Dogfood acceptance: showcase_account drops its hand-authored detail page and gets equivalent Projects/Invoices tabs purely from `relatedList: 'primary'` on the child FKs (+ highlightFields migrated off the deleted page's highlights slot). Renderer + derivation ship in objectui (companion PR). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0fddd01 commit 2082109

11 files changed

Lines changed: 95 additions & 115 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
Detail-page related lists: `relatedList: 'primary'` prominence + optional related-list columns (#2579).
6+
7+
`Field.relatedList` on a child's `lookup`/`master_detail` FK becomes a tri-state
8+
`boolean | 'primary'`. `'primary'` marks a CORE relationship — a prominence hint
9+
(ADR-0085), not a layout switch — that the detail page promotes to its own tab,
10+
while non-primary children collapse into a single shared "Related" tab.
11+
`false`/`true` keep their meaning (suppress / show in the derived default), so
12+
the change is additive and opt-in per relationship (no primary anywhere → the
13+
detail page is byte-for-byte the legacy stacked default).
14+
15+
`RecordRelatedListProps.columns` becomes optional: when omitted the related list
16+
derives its columns from the child object's `highlightFields` / default list
17+
columns — a related list is just another surface that lists that object.
18+
Required → optional is back-compat.
19+
20+
Renderer + derivation changes ship in objectui: `relatedList: 'primary'` → own
21+
tab; one related list per eligible FK (a child that references the parent
22+
through several relationships now surfaces each, previously only the first);
23+
self-referential relationships (hierarchies) surface a "child" list; and the
24+
lookup-picker default columns are unified onto the same `highlightFields`
25+
source so a picker and a related list of the same object agree with zero
26+
per-surface config.

examples/app-showcase/objectstack.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { ChartGalleryDashboard, OpsDashboard } from './src/dashboards/index.js';
2222
import { ShowcaseTaskDataset, ShowcaseProjectDataset } from './src/datasets/index.js';
2323
import { allReports } from './src/reports/index.js';
2424
import { allActions } from './src/actions/index.js';
25-
import { StartHerePage, ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, AccountDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, CommandCenterJsxPage, CrmWorkbenchPage, AccountCockpitPage, TaskDeskPage, PageVariablesPage, ContactFormPage, RenewalsPipelinePage } from './src/pages/index.js';
25+
import { StartHerePage, ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, CommandCenterJsxPage, CrmWorkbenchPage, AccountCockpitPage, TaskDeskPage, PageVariablesPage, ContactFormPage, RenewalsPipelinePage } from './src/pages/index.js';
2626
import { allFlows } from './src/flows/index.js';
2727
import { allWebhooks } from './src/webhooks/index.js';
2828
import { allHooks } from './src/hooks/index.js';
@@ -154,7 +154,7 @@ export default defineStack({
154154
apps: [ShowcaseApp],
155155
portals: allPortals,
156156
views: [TaskViews, ProjectViews, InquiryViews, BusinessUnitViews],
157-
pages: [StartHerePage, ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, AccountDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, CommandCenterJsxPage, CrmWorkbenchPage, AccountCockpitPage, TaskDeskPage, PageVariablesPage, ContactFormPage, RenewalsPipelinePage],
157+
pages: [StartHerePage, ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, CommandCenterJsxPage, CrmWorkbenchPage, AccountCockpitPage, TaskDeskPage, PageVariablesPage, ContactFormPage, RenewalsPipelinePage],
158158
dashboards: [ChartGalleryDashboard, OpsDashboard],
159159
books: allBooks,
160160
datasets: [ShowcaseTaskDataset, ShowcaseProjectDataset],

examples/app-showcase/src/objects/account.object.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ export const Account = ObjectSchema.create({
2424
// to the stored value, plus the text identifiers.
2525
searchableFields: ['name', 'industry', 'status', 'billing_email', 'tax_id'],
2626

27+
// ADR-0085 semantic role: the record's most important fields. Drives the
28+
// detail-page highlight strip (formerly the deleted account-detail page's
29+
// `highlights` slot) plus default list columns / cards — one declaration,
30+
// every surface, no per-page config.
31+
highlightFields: ['status', 'industry', 'annual_revenue'],
32+
2733
fields: {
2834
name: Field.text({ label: 'Account Name', required: true, searchable: true, maxLength: 200 }),
2935
industry: Field.select({

examples/app-showcase/src/objects/invoice.object.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ export const Invoice = ObjectSchema.create({
5252
label: 'Account',
5353
required: true,
5454
descriptionField: 'industry',
55+
// Read side: a CORE relationship (`relatedList: 'primary'`) → its own
56+
// "Invoices" tab on the Account detail page, derived from this lookup with
57+
// NO hand-built page. Title/columns declared here on the relationship.
58+
relatedList: 'primary',
59+
relatedListTitle: 'Invoices',
60+
relatedListColumns: ['name', 'status', 'total', 'issued_on'],
5561
lookupColumns: [
5662
'name',
5763
{ field: 'industry', label: 'Industry', type: 'select' },

examples/app-showcase/src/objects/project.object.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,16 @@ export const Project = ObjectSchema.create({
1818
fields: {
1919
name: Field.text({ label: 'Project Name', required: true, searchable: true, maxLength: 200 }),
2020
// `relatedList*` is the read-side mirror of inline editing: the Account's
21-
// record DETAIL page auto-renders a "Projects" related list — derived from
22-
// this lookup relationship, with no page config. Title and columns are
23-
// declared here on the relationship (where AI authors the model), not in a
24-
// hand-built page.
21+
// record DETAIL page auto-renders a "Projects" tab — derived from this
22+
// lookup relationship, with NO page config. `relatedList: 'primary'` marks
23+
// it a CORE relationship (ADR-0085 prominence) so the detail page promotes
24+
// it to its own tab; non-primary children collapse into a shared "Related"
25+
// tab. Title and columns are declared here on the relationship (where AI
26+
// authors the model), not in a hand-built page.
2527
account: Field.lookup('showcase_account', {
2628
label: 'Account',
2729
required: true,
30+
relatedList: 'primary',
2831
relatedListTitle: 'Projects',
2932
relatedListColumns: ['name', 'status', 'health', 'budget', 'end_date'],
3033
}),

examples/app-showcase/src/pages/account-detail.page.ts

Lines changed: 0 additions & 106 deletions
This file was deleted.

examples/app-showcase/src/pages/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ export { TaskWorkbenchPage } from './task-workbench.page.js';
99
export { TaskTriagePage } from './task-triage.page.js';
1010
export { ActiveProjectsPage } from './active-projects.page.js';
1111
export { TaskDetailPage } from './task-detail.page.js';
12-
export { AccountDetailPage } from './account-detail.page.js';
1312
export { ReviewQueuePage } from './review-queue.page.js';
1413
export { NewProjectWizardPage } from './new-project-wizard.page.js';
1514
export { MyWorkPage } from './my-work.page.js';

packages/spec/src/data/field.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,31 @@ describe('FieldSchema', () => {
297297
expect(result.deleteBehavior).toBe('set_null');
298298
});
299299

300+
it('should accept the relatedList prominence tri-state (false | true | primary)', () => {
301+
for (const relatedList of [false, true, 'primary'] as const) {
302+
const field: Field = {
303+
name: 'account',
304+
label: 'Account',
305+
type: 'lookup',
306+
reference: 'crm_account',
307+
relatedList,
308+
};
309+
const result = FieldSchema.parse(field);
310+
expect(result.relatedList).toBe(relatedList);
311+
}
312+
});
313+
314+
it('should reject an unknown relatedList string (only \'primary\' is allowed)', () => {
315+
const field = {
316+
name: 'account',
317+
label: 'Account',
318+
type: 'lookup',
319+
reference: 'crm_account',
320+
relatedList: 'secondary',
321+
};
322+
expect(() => FieldSchema.parse(field)).toThrow();
323+
});
324+
300325
it('should preserve forward record-picker config (display/columns/filters/depends)', () => {
301326
const lookupField: Field = {
302327
name: 'account',

packages/spec/src/data/field.zod.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,8 +446,23 @@ export const FieldSchema = lazySchema(() => z.object({
446446
* pulls a child INTO the parent's entry form (write side), `relatedList`
447447
* controls its appearance on the parent's detail page (read side). The intent
448448
* lives here in the data model; the detail page derives the UI.
449+
*
450+
* Tri-state (ADR-0085 semantic-role style — this is a PROMINENCE hint, NOT a
451+
* layout switch):
452+
* - `false` → suppress this child from the parent's detail page.
453+
* - `true` / absent → shown; participates in the derived default layout
454+
* (count-aware: few children → a tab each, many → the
455+
* long tail collapses into a single "Related" tab).
456+
* - `'primary'` → CORE relationship: always surfaced prominently. The
457+
* detail renderer promotes it to its own tab regardless
458+
* of child count. This states business intent (true
459+
* across every surface — detail tab, mobile card, AI
460+
* summary, search facet); "primary → own tab" is only
461+
* the DETAIL renderer's interpretation. Being prominence
462+
* (not a `relatedLayout` switch) is what admits it to the
463+
* object model under ADR-0085's admission test.
449464
*/
450-
relatedList: z.boolean().optional().describe('Show this child collection as a related list on the parent\'s detail page (read-side mirror of inlineEdit). Defaults to shown for master_detail/lookup; set false to suppress.'),
465+
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 in the count-aware derived default; \'primary\' = core relationship, always promoted to its own tab. Prominence intent, not a layout switch (ADR-0085).'),
451466
/** Optional section title for the detail-page related list (defaults to the child object label). */
452467
relatedListTitle: z.string().optional().describe('Title for the detail-page related list'),
453468
/** Optional explicit columns for the detail-page related list (derived from the child object when omitted). */

packages/spec/src/ui/component.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,12 @@ describe('RecordRelatedListProps', () => {
146146
expect(() => RecordRelatedListProps.parse({})).toThrow();
147147
expect(() => RecordRelatedListProps.parse({ objectName: 'x' })).toThrow();
148148
});
149+
150+
it('should accept a related list without columns (columns derive from the child object)', () => {
151+
const props = { objectName: 'contact', relationshipField: 'account_id' };
152+
expect(() => RecordRelatedListProps.parse(props)).not.toThrow();
153+
expect(RecordRelatedListProps.parse(props).columns).toBeUndefined();
154+
});
149155
});
150156

151157
describe('RecordHighlightsProps', () => {

0 commit comments

Comments
 (0)