Skip to content

详情页相关子表:降低对自定义 page 的依赖(relatedList 三态 + 列派生 + 确定性排序) #2579

Description

@os-zhuang

目标

如今要让记录详情页把子表显示成独立 tab(如 showcase_account 的 Projects / Invoices),必须手写一整张自定义 record page(examples/app-showcase/src/pages/account-detail.page.ts)。零配置派生出的详情页太弱,不足以作默认:

  • 所有相关列表被堆进一个 "Related" tab;
  • 每个相关列表都得手写列;
  • 没有办法标记某个子表是「核心」;
  • 每个子对象只派生一条相关列表(同一子对象的多个外键会丢关系)。

本 issue 让**「不写 page / 轻量标注」这条路足够好用,使自定义 page 退回为逃生舱(Tier 2)**——只服务真正复杂/定制的布局,而不是默认路径。下面每一项改动,都是把对象从「必须写 page」往下拉到「零配置或一句字段标注」。

延续 ADR-0085(语义角色优先于表面提示块)。按仓库惯例不新开 ADR(这是在执行 ADR-0085 的既定方向);是否为 relatedList: 'primary' 补一条 ADR-0085 addendum 见文末备注。

三层模型

作者要做什么 用 page 吗
0 · 零配置 什么都不做;从关系图自动派生,列取自子对象 highlightFields/默认列,按确定性规则排布
1 · 字段级意图 在子表外键上标注:relatedList: false | true | 'primary'relatedListTitlerelatedListColumns
2 · 自定义 page 扇出(Open/Closed 分裂)、条件 tab、一个 tab 放多个列表、非关系 tab(图表/报表/嵌入)、精确排序

每项改动都在把对象从 Tier 2 往 Tier 0/1 拉。

默认布局规则(修正:删去「数量自适应」)

只有两件事让一个子表获得独立 tab:

  1. 在它的关系字段上标 relatedList: 'primary';或
  2. 你在自定义 page 里显式排了它。

其余所有子表统一进一个聚合的 "Related" tab(纵向堆叠);relatedList: false 则完全不显示。

⚠️ 这修正了本 issue 早期草案里的「数量自适应」(≤3 个各占一 tab、超过则折叠进 Related)。删除它,因为:① 与 primary 冗余——已有显式的核心信号,就不该再靠数量去猜;② 阈值有「悬崖」(加第 4 个子表会突然从 4 个 tab 塌成 1 个 Related);③ 同一 app 内不一致、难解释;④ 对 AI 授权不友好(结果由 AI 无法预判的计数决定)。改后:完全可预测、无悬崖、对齐主流平台、AI 友好。代价:未标注的简单对象会显示成一个 Related tab 而非多个 tab——但标一个词即可,且更一致。

详情页固定框架:顶部 highlights 条 →Details→ 各 primary tab →RelatedActivity/History

相关子表的排序(tab 顺序 与 Related 内堆叠顺序)

排序对是否 tab 都适用:既决定 tab 条里各 primary tab 的先后,也决定 Related tab 内堆叠列表的先后。

决策:排序沿用「确定性默认 + 需要精确顺序就写 page」,不在对象层加排序键。

  • 派生默认顺序(Tier 0/1,确定性、可预测)master_detail(拥有/组合型,如行项)在前 → lookup 在后;组内用稳定次序兜底(对象注册顺序 / 标题)。primary tab 之间、Related 堆叠之间都用这条规则。
  • 精确顺序 = 自定义 page(Tier 2)page:tabs.items[] 数组本身就是那份「有序列表」,而顺序本质是排布(ADR-0085 准入测试判它归 page);主流平台(Salesforce)也把相关列表顺序放在页面布局属性上,不放在字段上。

为什么不在字段上加数字 relatedListOrder?任何精确全序都需要一份「有序列表」;放在分散于各子对象的外键上,只能用数字(有间隙/并列/重排的坑,AI 易错)或父对象上的名单(= 已否决的「page 换皮」)。唯一自然的有序列表就是 page 的 tabs.items[]——所以精确排序天然归 page。若实测证明「仅为重排就得写 page」太重,退路是字段上的可选数字 relatedListOrder(带上述代价),但不作首选。

场景 cookbook(不同业务需求 → 写什么元数据 → 详情页长什么样)

记号:【X】=一个 tab;=独立 tab;▾Related=聚合 tab 内堆叠。

S1 · 零配置(最常见的默认)

account 下挂 contact / opportunity / case,三个普通 lookup,什么都不额外标:

// contact / opportunity / case 各自:
account: Field.lookup('account', { label: 'Account' }),

【Details】【▾Related(Contacts、Opportunities、Cases 堆叠)】【Activity】(可预测兜底,对齐 Salesforce 默认)。

S2 · 标出核心子表(Tier 1 主力用法)

opportunity、case 是命脉要各自独立 tab;contact 留在 Related:

// opportunity.object.ts
account: Field.lookup('account', { label: 'Account', relatedList: 'primary' }),
// case.object.ts
account: Field.lookup('account', { label: 'Account', relatedList: 'primary' }),
// contact 保持普通 lookup(不标)

【Details】【Opportunities ★】【Cases ★】【▾Related(Contacts)】【Activity】

S3 · 主子表 / 行项(master_detail,写侧 + 读侧)

invoice 下有 invoice_line,级联删除,在发票表单里内联编辑,并在发票详情页给独立 tab:

// invoice_line.object.ts —— 父是 invoice
invoice: Field.masterDetail('invoice', {
  label: 'Invoice',
  deleteBehavior: 'cascade',
  inlineEdit: 'grid',          // 写侧:invoice 表单里内联行项网格
  inlineTitle: 'Line Items',
  relatedList: 'primary',      // 读侧:invoice 详情页给独立 tab
}),

→ 发票编辑表单:可编辑 Line Items 行项网格(父子原子保存)。
→ 发票详情页【Details】【Line Items ★(可编辑行项网格)】【▾Related(…)】【Activity】

S4 · 隐藏噪音子表

// account_sync_log.object.ts
account: Field.lookup('account', { relatedList: false }),

→ 该列表完全不出现(连 Related 里也没有)。(created_by/updated_by/owner 等审计外键本就自动跳过;false 用于业务噪音表。)

S5 · 自定义列 / 标题

account 的发票 tab 标题叫 "Billing",列要 name/total/status/issued_on:

// invoice.object.ts
account: Field.lookup('account', {
  label: 'Account',
  relatedList: 'primary',
  relatedListTitle: 'Billing',
  relatedListColumns: ['name', 'total', 'status', 'issued_on'],
}),

【…【Billing ★(列:name/total/status/issued_on)】…】
不写 relatedListColumns 时,列自动派生自 invoice 的 highlightFields(「columns 变可选」那条改动的效果)。

S6 · 同一子对象多条关系(multi-FK)

opportunity 有 primary_accountpartner_account 两个字段指向 account:

// opportunity.object.ts
primary_account: Field.lookup('account', { label: 'Primary Account',
  relatedList: 'primary', relatedListTitle: 'Primary Opportunities' }),
partner_account: Field.lookup('account', { label: 'Partner Account',
  relatedListTitle: 'Partner Opportunities' }),   // 不标 primary → 进 Related

【…【Primary Opportunities ★】…【▾Related(Partner Opportunities、…)】】
修复前:「第一个外键胜出」,partner 那条整个消失。

S7 · 自引用层级

// account.object.ts
parent_account: Field.lookup('account', { label: 'Parent Account',
  relatedList: 'primary', relatedListTitle: 'Child Accounts' }),

【…【Child Accounts ★(parent_account = 当前账户 的那些账户)】…】
修复前:自引用(child===parent)被整体跳过,没有这个列表。

S8 · 复杂需求 = 自定义 page(Tier 2 逃生舱)

account 的发票拆 Unpaid/Paid 两 tab + 一个 Revenue 图表 tab + 只对 status=customer 显示的 Contracts tab —— 字段标注做不到(扇出/非关系/条件),写 page:

definePage({ type:'record', object:'account', kind:'slotted', slots:{ tabs:{ type:'page:tabs', properties:{ items:[
  { key:'details', label:'Details', children:[{ type:'record:details' }] },
  { key:'unpaid',  label:'Unpaid',  children:[{ type:'record:related_list',
      properties:{ objectName:'invoice', relationshipField:'account',
                   filter:[{ field:'status', operator:'ne', value:'paid' }] } }] },
  { key:'paid',    label:'Paid',    children:[{ type:'record:related_list',
      properties:{ objectName:'invoice', relationshipField:'account',
                   filter:[{ field:'status', operator:'eq', value:'paid' }] } }] },
  { key:'revenue', label:'Revenue', children:[{ type:'record:chart', /* 非关系 tab */ }] },
]}}}})

→ 完全按你排的 tab。条件 tab(Contracts 仅 customer)目前靠组件级 visibility(内容隐藏、tab 头暂留)——tab 头级 visibility 是单列的 follow-up。

改动清单

1. relatedList 三态提示 — protocol:data(已实现)

packages/spec/src/data/field.zod.tsrelatedListz.boolean() 升为 z.union([z.boolean(), z.literal('primary')])false=隐藏;true/缺省=进 Related;'primary'=核心,独立 tab。这是显著度意图(跨表面成立),不是布局开关——「primary→独立 tab」只是详情页渲染器的解读,这也是它能通过 ADR-0085 准入测试的原因(对比被否决的 relatedLayout)。加性、向后兼容 → minorstring|false 已有先例 stageField)。

2. record:related_listcolumns 改为可选 + 子对象派生 — protocol:ui(spec 侧已实现,objectui 派生待做)

packages/spec/src/ui/component.zod.tsRecordRelatedListProps.columns 当前必填,改为可选;省略时列取自子对象 highlightFields/默认列。覆盖链(全是本地值,无跨引用):子对象 highlightFields → 字段级 relatedListColumns → 每 tab 内联 columns。放宽(必填→可选)→ 向后兼容。

3. 默认布局与排序 — objectui

plugin-detail/src/synth/buildDefaultPageSchema.ts + app-shell/src/utils/deriveRelatedLists.ts:实现上文「primary/page→tab,其余→Related」规则 + 确定性排序(master_detail 在前,lookup 在后,稳定次序兜底);删去 count-aware 阈值逻辑。列省略时从子对象 highlightFields 派生。可保留一个应用级全局开关(全 tab / 全 Related),但不设对象级开关(ADR-0085)。

4. 多外键派生 — objectui

deriveRelatedLists.ts:当前「第一个外键胜出」(seenChild 去重 + break)→ 同一子对象两外键指向同一父只出一条。改为每个合格外键出一条;标题默认 relatedListTitle,否则 ${childLabel} · ${fkFieldLabel}

5. 自引用相关列表 — objectui

deriveRelatedLists.ts 当前整体跳过 child.name === parentName → 自引用父外键无自动「下级」列表。改为:非审计外键且 relatedList !== false 时允许自引用;按 where <selfFk> == 本记录id 过滤。

6. lookup 弹出框(选择器)的列 — 澄清 + 一处一致性 — protocol:data / objectui

  • 不复用 relatedList*:选择器是反方向(子选父),展示的是目标对象的候选行(不是子记录),列属于不同对象,不能与相关列表共用键。
  • 选择器保留自己的键:displayField / descriptionField / lookupColumns(富列 {field,label,type})/ lookupFilters / lookupPageSize
  • 一致性(小改动):选择器省略 lookupColumns 时的默认列,统一到目标对象的 highlightFields(与相关列表同源)——「如何列出对象 X」只有一处真源(X 的 highlightFields),相关列表与选择器都从它派生;两侧各自的覆盖键(relatedListColumns vs lookupColumns)保持分离,因为描述不同对象/方向。

明确不做 / 已否决

  • record:related_listview 引用 —— AI 易错面(悬空引用、引错对象、与内联 filter 双写打架、build 绿运行期静默空)。列从子对象派生(✨ Set up Copilot instructions #2);相关列表的过滤本就是结构性外键,不是作者写的业务逻辑。AI 授权平台里「能内联就别引用」。
  • 对象层 relatedLists: [...] 数组 —— page 换皮;ADR-0085 否决对象层信息重排。
  • 对象层 relatedLayout 开关 —— ADR-0085 已否决。
  • 字段上数字 relatedListOrder(暂不做) —— 见「排序」一节;精确顺序归 page。若实测嫌重再议。
  • 业务子过滤分裂(Open/Closed tab) —— 留在 Tier 2(page 内联 filter)。设计如此,对象层不设键。

相关 follow-up(单独,不阻塞本 issue)

  • page:tabs 的 item 级 visibility(CEL) 做条件 tab。tab item 形状({label,icon,children})缺 visibility,而 PageComponentSchema 已有。属 Tier 2 页面能力,与本 issue「降低对 page 的依赖」正交,单独跟踪。

验收

  • examples/app-showcase删除 account-detail.page.ts,仅靠在 showcase_project.accountshowcase_invoice.account 上标 relatedList: 'primary' + 派生默认,复现等价的 Projects/Invoices 独立子表 tab。这是「常见情况不再需要自定义 page」的 dogfood 证明。
  • field-zoo / dogfood 回归覆盖:primary 提级、列派生、多外键、自引用、确定性排序。
  • CI 绿:spec api-surface 保持 minor(无删除导出);example-apps typecheck;dogfood gate。

备注

  • framework(spec)+ objectui(渲染/派生),配套 objectui PR。
  • 按仓库惯例(bugfix/follow-up 不新开 ADR):理由写进 changeset + 代码注释。仅当要把「显著度=意图 / 布局=排布」这条线固化时,才补一条 ADR-0085 addendum——由 owner 定。

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions