diff --git a/.changeset/related-list-primary-derived-columns.md b/.changeset/related-list-primary-derived-columns.md new file mode 100644 index 0000000000..1efdc940fd --- /dev/null +++ b/.changeset/related-list-primary-derived-columns.md @@ -0,0 +1,26 @@ +--- +"@objectstack/spec": minor +--- + +Detail-page related lists: `relatedList: 'primary'` prominence + optional related-list columns (#2579). + +`Field.relatedList` on a child's `lookup`/`master_detail` FK becomes a tri-state +`boolean | 'primary'`. `'primary'` marks a CORE relationship — a prominence hint +(ADR-0085), not a layout switch — that the detail page promotes to its own tab, +while non-primary children collapse into a single shared "Related" tab. +`false`/`true` keep their meaning (suppress / show in the derived default), so +the change is additive and opt-in per relationship (no primary anywhere → the +detail page is byte-for-byte the legacy stacked default). + +`RecordRelatedListProps.columns` becomes optional: when omitted the related list +derives its columns from the child object's `highlightFields` / default list +columns — a related list is just another surface that lists that object. +Required → optional is back-compat. + +Renderer + derivation changes ship in objectui: `relatedList: 'primary'` → own +tab; one related list per eligible FK (a child that references the parent +through several relationships now surfaces each, previously only the first); +self-referential relationships (hierarchies) surface a "child" list; and the +lookup-picker default columns are unified onto the same `highlightFields` +source so a picker and a related list of the same object agree with zero +per-surface config. diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index b46c606acb..ea22d2528e 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -22,7 +22,7 @@ import { ChartGalleryDashboard, OpsDashboard } from './src/dashboards/index.js'; import { ShowcaseTaskDataset, ShowcaseProjectDataset } from './src/datasets/index.js'; import { allReports } from './src/reports/index.js'; import { allActions } from './src/actions/index.js'; -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'; +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'; import { allFlows } from './src/flows/index.js'; import { allWebhooks } from './src/webhooks/index.js'; import { allHooks } from './src/hooks/index.js'; @@ -154,7 +154,7 @@ export default defineStack({ apps: [ShowcaseApp], portals: allPortals, views: [TaskViews, ProjectViews, InquiryViews, BusinessUnitViews], - 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], + 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], dashboards: [ChartGalleryDashboard, OpsDashboard], books: allBooks, datasets: [ShowcaseTaskDataset, ShowcaseProjectDataset], diff --git a/examples/app-showcase/src/objects/account.object.ts b/examples/app-showcase/src/objects/account.object.ts index d0607d550f..eb5f326096 100644 --- a/examples/app-showcase/src/objects/account.object.ts +++ b/examples/app-showcase/src/objects/account.object.ts @@ -24,6 +24,12 @@ export const Account = ObjectSchema.create({ // to the stored value, plus the text identifiers. searchableFields: ['name', 'industry', 'status', 'billing_email', 'tax_id'], + // ADR-0085 semantic role: the record's most important fields. Drives the + // detail-page highlight strip (formerly the deleted account-detail page's + // `highlights` slot) plus default list columns / cards — one declaration, + // every surface, no per-page config. + highlightFields: ['status', 'industry', 'annual_revenue'], + fields: { name: Field.text({ label: 'Account Name', required: true, searchable: true, maxLength: 200 }), industry: Field.select({ diff --git a/examples/app-showcase/src/objects/invoice.object.ts b/examples/app-showcase/src/objects/invoice.object.ts index 9da1e62895..d8c18e68cb 100644 --- a/examples/app-showcase/src/objects/invoice.object.ts +++ b/examples/app-showcase/src/objects/invoice.object.ts @@ -52,6 +52,12 @@ export const Invoice = ObjectSchema.create({ label: 'Account', required: true, descriptionField: 'industry', + // Read side: a CORE relationship (`relatedList: 'primary'`) → its own + // "Invoices" tab on the Account detail page, derived from this lookup with + // NO hand-built page. Title/columns declared here on the relationship. + relatedList: 'primary', + relatedListTitle: 'Invoices', + relatedListColumns: ['name', 'status', 'total', 'issued_on'], lookupColumns: [ 'name', { field: 'industry', label: 'Industry', type: 'select' }, diff --git a/examples/app-showcase/src/objects/project.object.ts b/examples/app-showcase/src/objects/project.object.ts index 8007c7c4e5..46ee2d4e00 100644 --- a/examples/app-showcase/src/objects/project.object.ts +++ b/examples/app-showcase/src/objects/project.object.ts @@ -18,13 +18,16 @@ export const Project = ObjectSchema.create({ fields: { name: Field.text({ label: 'Project Name', required: true, searchable: true, maxLength: 200 }), // `relatedList*` is the read-side mirror of inline editing: the Account's - // record DETAIL page auto-renders a "Projects" related list — derived from - // this lookup relationship, with no page config. Title and columns are - // declared here on the relationship (where AI authors the model), not in a - // hand-built page. + // record DETAIL page auto-renders a "Projects" tab — derived from this + // lookup relationship, with NO page config. `relatedList: 'primary'` marks + // it a CORE relationship (ADR-0085 prominence) so the detail page promotes + // it to its own tab; non-primary children collapse into a shared "Related" + // tab. Title and columns are declared here on the relationship (where AI + // authors the model), not in a hand-built page. account: Field.lookup('showcase_account', { label: 'Account', required: true, + relatedList: 'primary', relatedListTitle: 'Projects', relatedListColumns: ['name', 'status', 'health', 'budget', 'end_date'], }), diff --git a/examples/app-showcase/src/pages/account-detail.page.ts b/examples/app-showcase/src/pages/account-detail.page.ts deleted file mode 100644 index e8bc6bf0a9..0000000000 --- a/examples/app-showcase/src/pages/account-detail.page.ts +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { definePage } from '@objectstack/spec/ui'; - -/** - * Account 360 — the flagship "object 360" record page every enterprise app has, - * and the first showcase page to exercise the related-data + collaboration - * blocks that nothing else used before: - * • `record:related_list` — the account's Projects and Invoices, each a live - * child list (relationshipField = the `account` - * lookup on the child object). - * • `record:history` — a self-fetching audit tab (sys_activity field_change), - * and the same changes also surface in the discussion feed. - * • the synthesized `discussion` slot — a unified activity + comment feed - * (@mentions, reactions): the human collaboration surface, for free. - * • `record:highlights` + `record:details` — the summary strip + sections. - * - * `kind: 'slotted'` — overrides only `highlights` + `tabs`; the synthesizer - * fills the header and the discussion feed. (Tabs-with-children render under the - * slotted path; the same shape inside a full-page region does not — mirror the - * working Project Detail page.) - */ -export const AccountDetailPage = definePage({ - name: 'showcase_account_detail', - label: 'Account', - type: 'record', - object: 'showcase_account', - kind: 'slotted', - template: 'default', - isDefault: true, - regions: [], - slots: { - highlights: { - type: 'record:highlights', - properties: { fields: ['status', 'industry', 'annual_revenue'] }, - }, - tabs: { - type: 'page:tabs', - properties: { - type: 'line', - items: [ - { - key: 'details', - label: 'Details', - children: [ - { - type: 'record:details', - properties: { - sections: [ - { label: 'Company', columns: 2, fields: ['website', 'hq'] }, - { label: 'Billing', columns: 2, fields: ['tax_id', 'billing_email'] }, - ], - }, - }, - ], - }, - { - key: 'projects', - label: 'Projects', - children: [ - { - type: 'record:related_list', - properties: { - objectName: 'showcase_project', - relationshipField: 'account', - title: 'Projects', - columns: ['name', 'status', 'health', 'budget', 'end_date'], - sort: [{ field: 'budget', order: 'desc' }], - limit: 10, - showViewAll: true, - }, - }, - ], - }, - { - key: 'invoices', - label: 'Invoices', - children: [ - { - type: 'record:related_list', - properties: { - objectName: 'showcase_invoice', - relationshipField: 'account', - title: 'Invoices', - columns: ['name', 'status', 'total', 'issued_on'], - sort: [{ field: 'issued_on', order: 'desc' }], - limit: 10, - showViewAll: true, - }, - }, - ], - }, - { - key: 'history', - label: 'History', - children: [ - // Self-fetches from sys_activity (field_change events) via record - // context — trackHistory on status/industry feeds the entries. - { type: 'record:history', properties: { limit: 50 } }, - ], - }, - ], - }, - }, - }, -}); diff --git a/examples/app-showcase/src/pages/index.ts b/examples/app-showcase/src/pages/index.ts index 69c239fce4..0a4e8663cb 100644 --- a/examples/app-showcase/src/pages/index.ts +++ b/examples/app-showcase/src/pages/index.ts @@ -9,7 +9,6 @@ export { TaskWorkbenchPage } from './task-workbench.page.js'; export { TaskTriagePage } from './task-triage.page.js'; export { ActiveProjectsPage } from './active-projects.page.js'; export { TaskDetailPage } from './task-detail.page.js'; -export { AccountDetailPage } from './account-detail.page.js'; export { ReviewQueuePage } from './review-queue.page.js'; export { NewProjectWizardPage } from './new-project-wizard.page.js'; export { MyWorkPage } from './my-work.page.js'; diff --git a/packages/spec/src/data/field.test.ts b/packages/spec/src/data/field.test.ts index 725efd47d7..5e35a1b4db 100644 --- a/packages/spec/src/data/field.test.ts +++ b/packages/spec/src/data/field.test.ts @@ -297,6 +297,31 @@ describe('FieldSchema', () => { expect(result.deleteBehavior).toBe('set_null'); }); + it('should accept the relatedList prominence tri-state (false | true | primary)', () => { + for (const relatedList of [false, true, 'primary'] as const) { + const field: Field = { + name: 'account', + label: 'Account', + type: 'lookup', + reference: 'crm_account', + relatedList, + }; + const result = FieldSchema.parse(field); + expect(result.relatedList).toBe(relatedList); + } + }); + + it('should reject an unknown relatedList string (only \'primary\' is allowed)', () => { + const field = { + name: 'account', + label: 'Account', + type: 'lookup', + reference: 'crm_account', + relatedList: 'secondary', + }; + expect(() => FieldSchema.parse(field)).toThrow(); + }); + it('should preserve forward record-picker config (display/columns/filters/depends)', () => { const lookupField: Field = { name: 'account', diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 70e1c812de..98707d64fd 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -446,8 +446,23 @@ export const FieldSchema = lazySchema(() => z.object({ * pulls a child INTO the parent's entry form (write side), `relatedList` * controls its appearance on the parent's detail page (read side). The intent * lives here in the data model; the detail page derives the UI. + * + * Tri-state (ADR-0085 semantic-role style — this is a PROMINENCE hint, NOT a + * layout switch): + * - `false` → suppress this child from the parent's detail page. + * - `true` / absent → shown; participates in the derived default layout + * (count-aware: few children → a tab each, many → the + * long tail collapses into a single "Related" tab). + * - `'primary'` → CORE relationship: always surfaced prominently. The + * detail renderer promotes it to its own tab regardless + * of child count. This states business intent (true + * across every surface — detail tab, mobile card, AI + * summary, search facet); "primary → own tab" is only + * 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. */ - 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.'), + 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).'), /** 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'), /** Optional explicit columns for the detail-page related list (derived from the child object when omitted). */ diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index f2b0ebbccf..a2adbde26e 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -146,6 +146,12 @@ describe('RecordRelatedListProps', () => { expect(() => RecordRelatedListProps.parse({})).toThrow(); expect(() => RecordRelatedListProps.parse({ objectName: 'x' })).toThrow(); }); + + it('should accept a related list without columns (columns derive from the child object)', () => { + const props = { objectName: 'contact', relationshipField: 'account_id' }; + expect(() => RecordRelatedListProps.parse(props)).not.toThrow(); + expect(RecordRelatedListProps.parse(props).columns).toBeUndefined(); + }); }); describe('RecordHighlightsProps', () => { diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 2c236e1a9d..b9e20c7d7e 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -71,7 +71,7 @@ export const RecordDetailsProps = z.object({ export const RecordRelatedListProps = z.object({ objectName: z.string().describe('Related object name (e.g., "task", "opportunity")'), relationshipField: z.string().describe('Field on related object that points to this record (e.g., "account_id")'), - columns: z.array(z.string()).describe('Fields to display in the related list'), + columns: z.array(z.string()).optional().describe('Fields to display in the related list. Optional: when omitted, columns derive from the related object\'s highlightFields / default list columns (a related list is just another surface that lists that object). Override chain: child highlightFields → field-level relatedListColumns → this inline list.'), sort: z.union([ z.string(), z.array(z.object({