Skip to content

Commit f68b47c

Browse files
fix(plugin-detail): lead the derived related table with the record name (#30090)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBMmajhVvgaZ1CHUdWTUXtVx)_ Fixes #30033 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么:** 关联列表在关系上没有声明列时,列取自子对象的 `highlightFields`,而它按设计从不含记录名称。现在用 `@object-ui/core` 现成的 `leadWithNameField`(ADR-0079:`nameField`,否则平台推导的名称字段)把名称放到第一列,高亮字段跟在后面;高亮里本来就有名称时只出现一次。没有 `highlightFields` 时的字段遍历同样让声明的名称领头。 - **为什么改:** 客户页的「商机」页签只看到金额、日期、概率,五行金额分不清是哪一单;唯一的链接是金额。「联系人」只剩邮箱,「活动」只剩类型。 - **风险与回滚:** 只改 `RelatedList.tsx` 的派生列;作者在关系上声明的列原样不动,`highlightFields` 含义、高亮条和概览紧凑面板都没碰。名称列照样过 FLS、脱敏、外键剔除与全空剔除。`@object-ui/plugin-detail` patch。回滚 = revert 本 PR。 - **先看:** `ui/packages/plugin-detail/src/RelatedList.tsx` 中 `effectiveColumns` 的 `declaredHighlights` 分支与字段遍历排序后的 `leadWithNameField` 三行;测试 `RelatedList.enlargedListGrid-28865.test.tsx` 末尾的 `objectstack#30033` describe。 - **两分钟验证:** `pnpm --dir ui exec vitest run packages/plugin-detail/src/__tests__/RelatedList.enlargedListGrid-28865.test.tsx`,预期 `18 passed`;或在 example CRM 打开 Acme Corp 的「商机」页签,第一列是「商机名称」且可点开记录。 - **需要你做的:** 无,席位落地。 ## What changes `ui/packages/plugin-detail/src/RelatedList.tsx`, `effectiveColumns`, the two paths taken when the relation declares no columns: - **`highlightFields` path.** The declared highlights now go through `leadWithNameField(objectSchema, declaredHighlights)` before the existing normalize, redaction, parent-key, FLS and empty gates. That helper is the one `@object-ui/core` already exports for exactly this (a synthesized list led by the record's name; the console's object list tabs and `InterfaceListPage` call it): the declared `nameField` (ADR-0079), else the type-aware derivation, moved to the front and listed once. It adds nothing when the name has no field definition, is `hidden`, or the derivation lands on a system-managed field. Because it runs before the gates, a name the reader may not read, a redacted one, or one that is the key back to the parent record is still dropped. - **Field walk** (child declares no `highlightFields`). After the existing priority sort, the declared or derived name is moved to the front only if it is already among the surviving entries, so a field the walk filtered out never comes back. The name-ish ranking stays as the fallback order. - **Authored columns** (`columns` on the relation) take the first branch, which is untouched: drawn exactly as declared. - **Who reads this set.** The enlarged tab hands `object-grid` these keys, and the grid auto-links its first column to the record (pinned by `the auto-linked primary field gets the same anchor` in `plugin-grid`'s `ObjectGrid.linkCellAnchor.test.tsx`), so the name is now the row's link. The non-enlarged table (`data-table`, through `sortableColumns` / `tableColumns`) reads the same `effectiveColumns`, so it gets the same lead. Two more readers of `effectiveColumns[0]` and the key list move with it: the phone card gallery's `titleField` (now the name) and the CSV export's field list (now includes the name). The compact panels on 概览 return before any of these and are unchanged. - **`titleFormat`.** `leadWithNameField` answers in field space: a legacy `titleFormat` is a render-only template with no column behind it, so a `titleFormat`-only object leads with the field the platform derives (pinned below), not the formatted title. - **No spec key.** `Metadata-protocol: none`, `Clause-②: no`: no export, key or accepted value changes. Changeset: `@object-ui/plugin-detail` patch. ## Tests New top-level `describe` in `RelatedList.enlargedListGrid-28865.test.tsx` (the enlarged face's shared harness), 7 cases: - `highlightFields` that omit the name (`['account', 'amount', 'close_date', 'probability']`, as `crm_opportunity` declares): the grid's columns are `['name', 'amount', 'close_date', 'probability']` (the parent key `account` is dropped as before). - A declared `nameField` that is not name-ish (`deal_title`) leads the same way. - The name listed in `highlightFields` too: shown once, first. - CONTROL: columns declared on the relation are drawn exactly as declared, no name added. - The non-enlarged table reads the same set: `data-table` columns lead with `name`. - No `highlightFields`: a declared name leads the field walk. - `titleFormat` with no `nameField`: the column is the derived field (`code`), not the formatted title. <details><summary>Runs</summary> - New file, fix in place: `Test Files 1 passed (1)`, `Tests 18 passed (18)`. - Ablation 1, `scripts/ablation-replace.mjs` replacing `leadWithNameField(objectSchema, declaredHighlights).map(normalizeColumn)` with `declaredHighlights.map(normalizeColumn)` (anchor 1 to 0, blob `edff8db6` to `eed0f11d`): 5 failed, 13 passed. Red: the highlight case (`expected [ 'amount', 'close_date', …(1) ]`), the non-name-ish `nameField`, the name-once case, the non-enlarged table, the `titleFormat` case. Restored: blob equals HEAD, `git diff HEAD` empty. - Ablation 2, deleting the walk's `entries.unshift(...entries.splice(leadAt, 1))` line: 1 failed (`no highlightFields: a declared name leads the field walk too`), 17 passed. Restored the same way. - The tests import `../RelatedList` from source, so no dist rebuild sits between the mutation and the run. - Whole `@object-ui/plugin-detail` suite under the lock, before the `main` merge: `Test Files 369 passed | 1 skipped (370)`, `Tests 3260 passed | 18 skipped (3278)`. </details> ## Acceptance notes - **Browser check**, `examples/app-crm` dev boot (`pnpm dev:crm -- --fresh`), Console from `ui/` source through vite, zh-CN, dev admin, Acme Corp. Before = `RelatedList.tsx` at `origin/main` `c23501b2b`, after = this branch; vite restarted between the legs and the served module checked for the change each time. Headers read off the page: | Tab | Before | After | Row link after | |:--|:--|:--|:--| | 商机 on Acme Corp | #, 金额, 预计成交日期, 赢单概率 (%), 操作 | #, 商机名称, 金额, 预计成交日期, 赢单概率 (%), 操作 | `Acme — Year-Ago Renewal` → the opportunity record | | 联系人 on Acme Corp | #, 邮箱, 操作 | #, 姓名, 邮箱, 操作 | `Ada Lovelace` → the contact record | | 活动 on an opportunity | #, 活动类型, 截止日期, 联系人, 客户, 操作 | #, 主题, 活动类型, 截止日期, 联系人, 客户, 操作 | `Product Demo — Globex New CRM` → the activity record | Before, the row link text was `75,000.00`, `ada@acme.example` and `会议`. 联系人 resolves through its declared `nameField: 'full_name'` (a formula); 商机 and 活动 declare none and lead with the derived `name` / `subject`. Screenshots (`before-*.png`, `after-*.png`, plus `*-readings.json`) are in the project's `ui-orders/30033-shots/` folder. - **The non-enlarged face.** Measured in code: it reads the same `effectiveColumns`, so it is fixed in the same place and pinned above. Measured in the browser: on the example CRM's account and opportunity record pages the 概览 tab draws only the compact panels (zero tables on either page), so there is no body table there to photograph. - **`maxColumns`.** The derived set is still capped at `maxColumns` (default 6), and the name now takes one of those slots, so a child with six or more highlights shows one highlight fewer. ## Verification All on head `8408edf6` (this branch merged with `origin/main` `d79eecfe5`; the merge brought no change under `ui/packages/plugin-detail/` or core's `record-title.ts`), after a full `turbo run build` of the merged tree (119/119), under `scripts/pm/os-verify-lock.sh`: - `pnpm --dir ui --filter @object-ui/plugin-detail run type-check` (both programs; the test file is in `tsconfig.test.json`'s file list): exit 0. - `npx eslint src/RelatedList.tsx src/__tests__/RelatedList.enlargedListGrid-28865.test.tsx` from the package: 0 errors. - `node scripts/check-sdui-manifest.mjs`: exit 0, manifest intact (106 components). - `node scripts/pm/dispatch-gates.mjs --commands` derived 35 gates over the 3 changed paths; all 35 ran, all `exit 0`; `--ran` reconciles `35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN`. - The `@object-ui/plugin-detail` vitest suite ran before the merge (above); the merge touched nothing it reads from source. The other families are this PR's CI check-runs. ## Human Minimum - [x] 1. No `packages/spec` change. - [x] 2. No new names. - [x] 3. No formatter ran; eslint reports 0 errors on the touched files. - [x] 4. One issue, one branch, one pull request. - [x] 5. Changeset `@object-ui/plugin-detail` patch, both declaration lines in it and here. - [x] 6. n/a, `packages/spec` untouched. - [x] 7. No hand edits to generated or release-owned docs. - [x] 8. Title, body and comments are in English, except the Chinese maintainer brief above, which the dispatch contract asks for. --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzewdXHDkMAKTyWjGh5)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f6f663a commit f68b47c

3 files changed

Lines changed: 169 additions & 1 deletion

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/plugin-detail': patch
3+
---
4+
5+
fix(plugin-detail): the related table leads with the record's name when the relation declares no columns
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A related list with no columns declared took its columns from the child object's `highlightFields`, which by design never list the record's title, so the related tab on a record page showed amounts and dates with no deal or contact name, and the only link in a row was its first value. The record's name (`nameField`, else the field the platform derives, ADR-0079) now leads that set, listed once even when a highlight names it, and the grid links that first column to the record. The field walk used when the child declares no `highlightFields` leads with the declared name the same way. The enlarged tab and the table in the record body read the same set. Columns an author declares on the relation are drawn exactly as declared. A `titleFormat`-only object leads with the field the platform derives, not the formatted title, which has no column behind it.

‎ui/packages/plugin-detail/src/RelatedList.tsx‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ import {
7272
resolveNameField,
7373
composeParentScopeFilter,
7474
isMultiValueRelationship,
75+
leadWithNameField,
7576
mergeFilterNodes,
7677
readObjectSortability,
7778
filterRefusalSubject,
@@ -2377,8 +2378,16 @@ export const RelatedList: React.FC<RelatedListProps> = ({
23772378
)
23782379
: [];
23792380
if (declaredHighlights.length > 0) {
2381+
// `highlightFields` is what the record page features BESIDE its title, so
2382+
// it never lists the title. A list has no title above it, so the record's
2383+
// name (ADR-0079, `leadWithNameField`) leads the set, listed once even
2384+
// when a highlight names it, and the grid links that first column to the
2385+
// record. It passes the same gates as the rest: an unreadable or redacted
2386+
// name, or a name that is the key back to this record, is still dropped.
23802387
const hf = pruneEmpty(
2381-
filterFLS(filterFK(filterRedacted(declaredHighlights.map(normalizeColumn)))),
2388+
filterFLS(
2389+
filterFK(filterRedacted(leadWithNameField(objectSchema, declaredHighlights).map(normalizeColumn))),
2390+
),
23822391
);
23832392
if (hf.length > 0) return hf.slice(0, Math.max(1, maxColumns));
23842393
}
@@ -2461,6 +2470,13 @@ export const RelatedList: React.FC<RelatedListProps> = ({
24612470
if (aIsStatus !== bIsStatus) return aIsStatus ? -1 : 1;
24622471
return 0;
24632472
});
2473+
// The record's name (ADR-0079) leads the walk too, when it survived the
2474+
// filters above: the name-ish ranking is a guess, the declared name is not.
2475+
// Only moves an entry that is already there, so a field the filters dropped
2476+
// never comes back.
2477+
const lead = leadWithNameField(objectSchema, entries.map(([key]) => key))[0];
2478+
const leadAt = entries.findIndex(([key]) => key === lead);
2479+
if (leadAt > 0) entries.unshift(...entries.splice(leadAt, 1));
24642480

24652481
const generated = entries.map(([key, def]: [string, any]) => {
24662482
const col: any = {

‎ui/packages/plugin-detail/src/__tests__/RelatedList.enlargedListGrid-28865.test.tsx‎

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,3 +238,145 @@ describe('the enlarged related tab on a desktop is the child object list face (o
238238
expect(h.props).toEqual({});
239239
});
240240
});
241+
242+
/**
243+
* objectstack#30033 — with no columns declared for the relation, the columns
244+
* come from the child's `highlightFields`, which by design never list the
245+
* record's title. The record's name (ADR-0079, `leadWithNameField` in
246+
* `@object-ui/core`) leads them, listed once; the grid auto-links its first
247+
* column to the record (`the auto-linked primary field gets the same anchor`
248+
* in `plugin-grid`'s `ObjectGrid.linkCellAnchor.test.tsx`). Authored columns
249+
* are left as authored.
250+
*/
251+
describe('the related table leads with the record name when no columns are declared (objectstack#30033)', () => {
252+
const NAMED_FIELDS = {
253+
name: { type: 'text', label: 'Name' },
254+
account: { type: 'lookup', reference: 'account', label: 'Account' },
255+
amount: { type: 'currency', label: 'Amount' },
256+
close_date: { type: 'date', label: 'Close date' },
257+
probability: { type: 'percent', label: 'Probability' },
258+
};
259+
const NAMED_ROWS = [
260+
{ id: 'o1', name: 'Acme Renewal', account: 'A-1', amount: 120000, close_date: '2026-12-01', probability: 60 },
261+
{ id: 'o2', name: 'Acme Expansion', account: 'A-1', amount: 75000, close_date: '2026-12-15', probability: 40 },
262+
];
263+
264+
function renderDerived({
265+
objectSchema,
266+
rows = NAMED_ROWS,
267+
columns,
268+
enlarged = true,
269+
}: {
270+
objectSchema: Record<string, unknown>;
271+
rows?: Record<string, unknown>[];
272+
columns?: any[];
273+
enlarged?: boolean;
274+
}) {
275+
const dataSource = {
276+
find: vi.fn(async () => ({ data: rows, total: rows.length })),
277+
getObjectSchema: vi.fn(async () => ({ name: 'opportunity', ...objectSchema })),
278+
};
279+
render(
280+
<FilterScopeProvider currentUserId="u-1">
281+
<RelatedListEnlargedContext.Provider value={enlarged}>
282+
<RelatedList
283+
title="Opportunities"
284+
type="table"
285+
api="opportunity"
286+
objectName="opportunity"
287+
referenceField="account"
288+
parentId="A-1"
289+
pageSize={25}
290+
{...(columns ? { columns } : {})}
291+
dataSource={dataSource as never}
292+
/>
293+
</RelatedListEnlargedContext.Provider>
294+
</FilterScopeProvider>,
295+
);
296+
}
297+
298+
const drawnKeys = (cols: any[]) => cols.map((c: any) => (typeof c === 'string' ? c : c.accessorKey));
299+
300+
it('highlightFields that omit the name: the enlarged grid’s first column is the name, the highlights follow', async () => {
301+
renderDerived({
302+
objectSchema: { fields: NAMED_FIELDS, highlightFields: ['account', 'amount', 'close_date', 'probability'] },
303+
});
304+
await waitFor(() => expect(h.schema?.type).toBe('object-grid'));
305+
await waitFor(() => expect(h.schema.data.items).toHaveLength(2));
306+
// `account` is the key back to the parent record, so it is dropped as before.
307+
expect(h.schema.columns).toEqual(['name', 'amount', 'close_date', 'probability']);
308+
});
309+
310+
it('a declared nameField that is not name-ish leads the same way', async () => {
311+
renderDerived({
312+
objectSchema: {
313+
nameField: 'deal_title',
314+
fields: { amount: NAMED_FIELDS.amount, deal_title: { type: 'text', label: 'Deal' } },
315+
highlightFields: ['amount'],
316+
},
317+
rows: [{ id: 'o1', deal_title: 'Acme Renewal', amount: 120000 }],
318+
});
319+
await waitFor(() => expect(h.schema?.data?.items).toHaveLength(1));
320+
expect(h.schema.columns).toEqual(['deal_title', 'amount']);
321+
});
322+
323+
it('the name listed in highlightFields too shows once, first', async () => {
324+
renderDerived({
325+
objectSchema: { fields: NAMED_FIELDS, highlightFields: ['amount', 'close_date', 'name'] },
326+
});
327+
await waitFor(() => expect(h.schema?.data?.items).toHaveLength(2));
328+
expect(h.schema.columns).toEqual(['name', 'amount', 'close_date']);
329+
});
330+
331+
it('CONTROL: columns declared on the relation are drawn exactly as declared, with no name added', async () => {
332+
renderDerived({
333+
objectSchema: { fields: NAMED_FIELDS, highlightFields: ['amount', 'close_date'] },
334+
columns: [{ field: 'amount', label: 'Amount' }, { field: 'probability', label: 'Probability' }],
335+
});
336+
await waitFor(() => expect(h.schema?.data?.items).toHaveLength(2));
337+
expect(h.schema.columns).toEqual(['amount', 'probability']);
338+
});
339+
340+
it('the related table that is not enlarged reads the same column set: the name leads it too', async () => {
341+
renderDerived({
342+
objectSchema: { fields: NAMED_FIELDS, highlightFields: ['account', 'amount', 'close_date'] },
343+
enlarged: false,
344+
});
345+
await waitFor(() => expect(h.schema?.type).toBe('data-table'));
346+
await waitFor(() => expect(h.schema.data).toHaveLength(2));
347+
expect(drawnKeys(h.schema.columns)).toEqual(['name', 'amount', 'close_date']);
348+
});
349+
350+
it('no highlightFields: a declared name leads the field walk too', async () => {
351+
renderDerived({
352+
objectSchema: {
353+
nameField: 'deal_title',
354+
fields: {
355+
status: { type: 'select', label: 'Status', options: [{ value: 'open', label: 'Open' }] },
356+
amount: NAMED_FIELDS.amount,
357+
deal_title: { type: 'text', label: 'Deal' },
358+
},
359+
},
360+
rows: [{ id: 'o1', status: 'open', amount: 120000, deal_title: 'Acme Renewal' }],
361+
});
362+
await waitFor(() => expect(h.schema?.data?.items).toHaveLength(1));
363+
expect(h.schema.columns[0]).toBe('deal_title');
364+
expect(h.schema.columns.filter((c: string) => c === 'deal_title')).toHaveLength(1);
365+
});
366+
367+
it('a titleFormat object with no nameField: the column is the field the platform derives, not the formatted title', async () => {
368+
// `leadWithNameField` answers in field space: `titleFormat` is a
369+
// render-only template with no column behind it (ADR-0079), so the lead is
370+
// the type-aware derivation, here the first title-eligible field.
371+
renderDerived({
372+
objectSchema: {
373+
titleFormat: '{code} · {amount}',
374+
fields: { code: { type: 'text', label: 'Code' }, amount: NAMED_FIELDS.amount },
375+
highlightFields: ['amount'],
376+
},
377+
rows: [{ id: 'o1', code: 'Q-7', amount: 120000 }],
378+
});
379+
await waitFor(() => expect(h.schema?.data?.items).toHaveLength(1));
380+
expect(h.schema.columns).toEqual(['code', 'amount']);
381+
});
382+
});

0 commit comments

Comments
 (0)