Skip to content

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

Closed

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'、relatedListTitle、relatedListColumns ❌
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 →Related→Activity/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_account 和 partner_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.ts:relatedList 从 z.boolean() 升为 z.union([z.boolean(), z.literal('primary')])。false=隐藏;true/缺省=进 Related;'primary'=核心,独立 tab。这是显著度意图(跨表面成立),不是布局开关——「primary→独立 tab」只是详情页渲染器的解读,这也是它能通过 ADR-0085 准入测试的原因(对比被否决的 relatedLayout)。加性、向后兼容 → minor(string|false 已有先例 stageField)。

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

packages/spec/src/ui/component.zod.ts:RecordRelatedListProps.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_list 加 view 引用 —— 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.account、showcase_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 定。

Activity

  1. changed the title [-]Detail-page related lists: cut reliance on custom pages (relatedList: 'primary' + derived columns + count-aware tab default)[/-] [+]详情页相关子表:降低对自定义 page 的依赖(relatedList 三态 + 列派生 + 确定性排序)[/+] on Jul 4, 2026
  2. os-zhuang commented on Jul 5, 2026

    @os-zhuang
    ContributorAuthor

    Follow-up filed: #2606 — 条件 tab(给 page:tabs 的 item 加 CEL visibility)。本 issue 里明确 defer 的那块 Tier-2 页面能力。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions