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
26 changes: 26 additions & 0 deletions .changeset/related-list-primary-derived-columns.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions examples/app-showcase/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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],
Expand Down
6 changes: 6 additions & 0 deletions examples/app-showcase/src/objects/account.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
6 changes: 6 additions & 0 deletions examples/app-showcase/src/objects/invoice.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
11 changes: 7 additions & 4 deletions examples/app-showcase/src/objects/project.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
}),
Expand Down
106 changes: 0 additions & 106 deletions examples/app-showcase/src/pages/account-detail.page.ts

This file was deleted.

1 change: 0 additions & 1 deletion examples/app-showcase/src/pages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
25 changes: 25 additions & 0 deletions packages/spec/src/data/field.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
17 changes: 16 additions & 1 deletion packages/spec/src/data/field.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
6 changes: 6 additions & 0 deletions packages/spec/src/ui/component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/src/ui/component.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down