Skip to content

Commit 3461420

Browse files
fix(plugin-detail): an empty body table of the record page keeps its header instead of folding into one line that reads the empty sentence before its title (#30378)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgJQrw5nPbbXAWPNQRojgu2W)_ domain:ui#2 · os-bill · session_01Utp6Cb5R6JDdfyvtj3dPR2 Fixes #29944 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:记录页 v3 详情栏里的正文表格(body table)不再参与关联列表的「空列表折叠」;空的正文表格保留自己的表头(标题、0、新建),「暂无相关记录」画在表头下方、表格本该在的位置。 - 为什么改:正文表格和兄弟区块同处详情栏一个容器,#25895 的折叠按「同一 DOM 父元素」认栈,把详情栏当成了关联栈,空表被收成一行「暂无相关记录 偏离 + 新建」,空态文字跑到表名前面(hotclm 合同页实测)。 - 风险与回滚:只动 plugin-detail 三个渲染文件与一个测试文件,不动 spec、服务端或元数据;关联页签的折叠与右栏空面板行不变(只按页面的正文表格 id 集合判定)。回滚即 revert 本 PR。 - 先看:`ui/packages/plugin-detail/src/renderers/record-related-list.tsx`(`bodyTable` 判定与 `foldable`)→ `ui/packages/plugin-detail/src/RelatedList.tsx`(空正文表格的表头与表头下空态)→ 测试 `record-related-list.emptyFold-25895.test.tsx` 末尾新 describe。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-detail/src/renderers/__tests__/record-related-list.emptyFold-25895.test.tsx`,预期 8/8 通过(在 main 源码上新用例失败:折叠行出现)。 - 需要你做的:无,席位落地。 ## What changes On the record page v3 (`RecordPageColumns`, and the tab-strip page whose Details tab carries the body tables), the record's body tables are drawn as siblings of the details sections in one container. The related-list empty fold (`RelatedListEmptyFold.tsx`, #25895) keys a stack by the DOM parent of each `record:related_list` root, so it took the details column for a Related tab stack: once one body table had rows, an empty one left its place and was drawn as one line after the last table, reading the empty sentence first (「暂无相关记录 偏离 + 新建」). - **A body table never folds.** `record-related-list.tsx` reads `RecordBodyTablesContext` (the page's body-table relationship ids, already provided by the record page host) and, outside the related column's panels, a list whose `object.field` id is in the set answers `foldable: false` to its stack. The body tables and the Related tab are a partition of the page's relationships (`buildDefaultPageSchema`: `related.filter(isBody)` vs the rest), so no Related tab list is in the set, and the fold rule itself (`decideEmptyFold`) is untouched. - **An empty body table keeps its siblings' shape.** In `RelatedList`'s card face, a body table no longer writes 「暂无相关记录」 inline in its header row; while expanded and empty it draws the sentence in the card body under the header (a dashed box where the table would be, `data-body-table-empty`). The header keeps title, count 0, ⤢ and New. Lists that are not body tables keep the inline sentence. The card header gains a `data-related-list-head` attribute (the panel face already has `data-related-panel-head`). - `RelatedListEmptyFold.tsx`: the file header names the body-table exception; no code change there. No new authoring key, no spec change, no change to the Related tab or the related column. ## Tests - New top-level describe in `ui/packages/plugin-detail/src/renderers/__tests__/record-related-list.emptyFold-25895.test.tsx` (the fold's shared harness; it now takes an optional body-table set): - an empty body table beside tables with records: no fold line, its card visible, its header carries count 0 and a New that creates its own object, the empty sentence is not in the header and follows it in document order; - CONTROL: the Related tab stack (the existing `ACCOUNT_STACK`) under a body-table context that lists another relationship folds exactly as before. - Red on main's sources (the three source files restored to `13c0c5ba`, test file from this branch): `Tests 1 failed | 7 passed (8)`, the new case failing on `expect(fold()).toBeNull()`: the fold line (a div with two children) was drawn. - Green: the 116 plugin-detail test files that name `RelatedList`, `record:related_list`, `related-list`, `RecordPageColumns` or the empty sentence, plus three app-shell files that assert on the empty sentence or the New button: `Test Files 118 passed | 1 skipped (119)`, `Tests 1016 passed | 8 skipped (1024)`. - Ablation, three legs through `scripts/ablation-replace.mjs`, each landing verified and restored to the HEAD blob (tests import `src`, no build involved): 1. `!bodyTable &&` dropped from `foldable` → new case red (fold line drawn); 2. the inline sentence drawn for body tables again → new case red (sentence inside the header); 3. the under-header block disabled → new case red (no sentence found). A first attempt at leg 1 was refused by the tool before any run (its replacement text already occurred in the file), then re-run with a distinct replacement. - `pnpm exec turbo run type-check --filter=@object-ui/plugin-detail --concurrency=2`: 52/52 tasks. - `pnpm lint` in `ui/packages/plugin-detail`: 0 errors; per-file warning counts on the three source files equal main's (102 / 17 / 1), the test file 0. ## Acceptance notes - The card's Expected says the empty state goes under the header, "the shape the non-empty tables and the pre-18.3 `record:related_list` both draw". Measured on the published `@object-ui/plugin-detail@18.2.0`: the pre-18.3 empty card wrote the sentence inline in its header row (title, 0, sentence, New), not under it. This PR follows the first half of the Expected (under the header, as the non-empty tables place their table) and keeps the inline sentence for every list that is not a body table. The second symptom on the card, 「合同 0 暂无相关记录」, is that inline header line on a body table; it is covered by the same change. ## Verification <details><summary>Derived gates (dispatch-gates --commands, final tree)</summary> Head `4b04fd97` (after merging `origin/main` at `8589756a`). Every command's exit code was captured before any pipe. ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` `dispatch-gates --ran`: `Run reconciliation — 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN` (a derived zero: all 35 recorded an exit code). `check-sdui-manifest`: `sdui.manifest.json is present and intact (… 106 components)`. </details> <details><summary>Standing ui gates</summary> - `pnpm --dir ui check:new-line-citations`: `VERDICT new-cross-file-line-citations: 0 new citation(s), enforcement report-only -> exit 0`. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CXft9FMzbd2ggz2oyp)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 677ac3b commit 3461420

5 files changed

Lines changed: 108 additions & 10 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/plugin-detail': patch
3+
---
4+
5+
fix(plugin-detail): an empty body table of the record page keeps its header (title, 0, New) with the empty sentence under it, instead of folding into one line that read the empty sentence before the table's title
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
**Visible behaviour change on a released page.** The default record page draws a body table (a related list drawn full width in the details column, or the Details tab) beside its sibling sections. Because the tables share that container, the related-list empty fold took them for a Related tab stack: once one table had rows, an empty one left its place and reappeared as a single line after the last table, reading 「暂无相关记录 · 偏离 · + 新建」 (no related records · Deviations · New). A body table now never folds. An empty one keeps the header row its non-empty siblings draw (title, count 0, ⤢, New), and the empty sentence moves out of that row to the table's place under it.
11+
12+
The fold of a record's Related tab, and the empty-panels line of the related column, are unchanged: only a list whose relationship is one of the page's body tables opts out.

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3629,6 +3629,7 @@ export const RelatedList: React.FC<RelatedListProps> = ({
36293629
<CardHeader
36303630
className={cn('py-3 px-4 sm:py-3 min-h-12 sm:min-h-0', headerClassName)}
36313631
onClick={handleHeaderClick}
3632+
data-related-list-head=""
36323633
>
36333634
<CardTitle className={cn('flex items-center justify-between gap-2 text-sm font-semibold', compact && 'flex-wrap')}>
36343635
<div className="flex items-center gap-2 min-w-0">
@@ -3656,7 +3657,9 @@ export const RelatedList: React.FC<RelatedListProps> = ({
36563657
{recordCount}
36573658
</Badge>
36583659
)}
3659-
{isEmpty && (
3660+
{/* A body table says it under its header instead, where its
3661+
table would be (objectstack#29944). */}
3662+
{isEmpty && !bodyTable && (
36603663
<span className="text-xs text-muted-foreground/70 italic ml-1 truncate">
36613664
{t('detail.noRelatedRecords')}
36623665
</span>
@@ -3756,6 +3759,19 @@ export const RelatedList: React.FC<RelatedListProps> = ({
37563759
<RelatedListLoadFailed error={loadFailure.error} onRetry={handleRetryRead} />
37573760
</CardContent>
37583761
)}
3762+
{/* objectstack#29944 — an empty body table keeps the shape of its
3763+
siblings: the header row, then the empty sentence where the table
3764+
would be. */}
3765+
{!collapsed && isEmpty && bodyTable && !loadFailure && (
3766+
<CardContent className="pt-0 pb-4 px-4">
3767+
<p
3768+
className="rounded-md border border-dashed border-border/60 px-3 py-4 text-center text-xs text-muted-foreground"
3769+
data-body-table-empty=""
3770+
>
3771+
{t('detail.noRelatedRecords')}
3772+
</p>
3773+
</CardContent>
3774+
)}
37593775
{!collapsed && !isEmpty && !loadFailure && <CardContent className={cn('pt-0 pb-4 px-4')}>
37603776
{/* Filter bar — only when records justify it */}
37613777
{showFilterInput && (

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@
2222
* synthesizer knows how many rows a list has; each list does, once its read
2323
* answers. So the lists coordinate among themselves, through the small store
2424
* below, keyed by that container. A list placed alone, or wrapped in its own
25-
* container, is a stack of one and never folds.
25+
* container, is a stack of one and never folds. A body table of the record
26+
* page (`RecordBodyTablesContext`, objectstack#29944) shares the details
27+
* column with its siblings but is a section of the record, not a member of a
28+
* stack: it answers `foldable: false`, so it keeps its own header.
2629
*
2730
* ## The rule (`decideEmptyFold`)
2831
*

‎ui/packages/plugin-detail/src/renderers/__tests__/record-related-list.emptyFold-25895.test.tsx‎

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
import type { DataSource } from '@object-ui/types';
3838
// Registers `record:related_list`, the block under test.
3939
import '../../index';
40+
import { RecordBodyTablesContext } from '../../relatedListCompact';
4041

4142
beforeAll(() => {
4243
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 });
@@ -79,7 +80,7 @@ const ACCOUNT_ROWS: Rows = {
7980
],
8081
};
8182

82-
function mount(nodes: object[], rows: Rows) {
83+
function mount(nodes: object[], rows: Rows, bodyTables: ReadonlySet<string> = new Set()) {
8384
const ds = makeDataSource(rows);
8485
const created = vi.fn();
8586
const host: RelatedRecordActionsValue = {
@@ -89,12 +90,14 @@ function mount(nodes: object[], rows: Rows) {
8990
<SchemaRendererProvider dataSource={ds as unknown as DataSource}>
9091
<RecordContextProvider objectName={PARENT} recordId="a1" dataSource={ds as never}>
9192
<RelatedRecordActionsProvider value={host}>
92-
{/* The stack: siblings in one container, as `page:tabs` draws a tab's children. */}
93-
<div data-testid="stack">
94-
{nodes.map((node, idx) => (
95-
<SchemaRenderer key={idx} schema={node as never} />
96-
))}
97-
</div>
93+
<RecordBodyTablesContext.Provider value={bodyTables}>
94+
{/* The stack: siblings in one container, as `page:tabs` draws a tab's children. */}
95+
<div data-testid="stack">
96+
{nodes.map((node, idx) => (
97+
<SchemaRenderer key={idx} schema={node as never} />
98+
))}
99+
</div>
100+
</RecordBodyTablesContext.Provider>
98101
</RelatedRecordActionsProvider>
99102
</RecordContextProvider>
100103
</SchemaRendererProvider>,
@@ -200,3 +203,56 @@ describe('record:related_list — empty lists of a stack fold into one line (#25
200203
expect(cardTitled('Team')).toBeVisible();
201204
});
202205
});
206+
207+
/**
208+
* objectstack#29944 — the record page's BODY tables (their ids in
209+
* `RecordBodyTablesContext`) are sections of the record, drawn side by side in
210+
* the details column (or the Details tab), so they share a container like a
211+
* Related tab's lists do. They are not a related-list stack: an empty one
212+
* keeps its own header — title, 0, New — with the empty sentence under it,
213+
* where the table of a non-empty one is. Measured on hotclm's contract page:
214+
* the empty 偏离 table folded into 「暂无相关记录 偏离 + 新建」.
215+
*/
216+
describe('record:related_list — a body table of the record page never folds (objectstack#29944)', () => {
217+
const CONTRACT = [
218+
list('crm_quote', 'Payment plan'),
219+
list('crm_child_account', 'Deviations'),
220+
list('crm_contact', 'Versions'),
221+
];
222+
const CONTRACT_ROWS: Rows = {
223+
crm_quote: [{ id: 'q1', name: 'Q-001', account_id: 'a1' }],
224+
crm_contact: [{ id: 'c1', name: 'Ada', account_id: 'a1' }],
225+
};
226+
const BODY = new Set(['crm_quote.account_id', 'crm_child_account.account_id', 'crm_contact.account_id']);
227+
228+
it('an empty body table beside tables with records keeps its header, with the empty sentence under it', async () => {
229+
const { ds, created } = mount(CONTRACT, CONTRACT_ROWS, BODY);
230+
await settled(ds, 3);
231+
// Every list has answered once Deviations counts 0; give a fold the time to draw.
232+
await screen.findByLabelText('0 records');
233+
await new Promise((resolve) => setTimeout(resolve, 50));
234+
expect(fold()).toBeNull();
235+
const title = cardTitled('Deviations');
236+
expect(title).toBeVisible();
237+
238+
// The header row is the non-empty tables' header: title, count, New.
239+
const head = title.closest('[data-related-list-head]') as HTMLElement;
240+
expect(head).not.toBeNull();
241+
fireEvent.click(within(head).getByRole('button', { name: /New/ }));
242+
expect(created).toHaveBeenCalledWith('crm_child_account');
243+
244+
// The sentence is not in the header row: it is drawn under it.
245+
expect(within(head).queryByText('No related records found')).toBeNull();
246+
const sentence = screen.getByText('No related records found');
247+
expect(sentence).toBeVisible();
248+
expect(head.compareDocumentPosition(sentence) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
249+
});
250+
251+
it('CONTROL: a Related tab stack under the same context folds as before — only the listed ids are body tables', async () => {
252+
const { ds } = mount(ACCOUNT_STACK, ACCOUNT_ROWS, new Set(['crm_opportunity.account_id']));
253+
await settled(ds, 4);
254+
await waitFor(() => expect(fold()).not.toBeNull());
255+
const names = within(fold()!).getAllByRole('group').map((g) => g.getAttribute('aria-label'));
256+
expect(names).toEqual(['Child accounts', 'Leads']);
257+
});
258+
});

‎ui/packages/plugin-detail/src/renderers/record-related-list.tsx‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ import {
3636
} from '@object-ui/core';
3737
import type { RecordRelatedListComponentProps } from '@object-ui/types';
3838
import { RelatedList } from '../RelatedList';
39-
import { RelatedListCompactContext } from '../relatedListCompact';
39+
import { RecordBodyTablesContext, RelatedListCompactContext } from '../relatedListCompact';
40+
import { relatedCardId } from '../synth/relatedArrangement';
4041
import { useRecordAriaProps } from './recordComponentAria';
4142
import { ParentRecordBinding } from './recordBinding';
4243
import { RelatedListStackSlot } from '../RelatedListEmptyFold';
@@ -226,6 +227,8 @@ const RecordRelatedListBody: React.FC<RecordRelatedListRendererProps> = ({
226227
const { language } = useObjectTranslation();
227228
// A panel of the record page's related column (the fold rule below).
228229
const panel = React.useContext(RelatedListCompactContext);
230+
// The record page's body tables (the fold rule below).
231+
const bodyTableIds = React.useContext(RecordBodyTablesContext);
229232

230233
const objectName = schema.objectName;
231234

@@ -612,8 +615,16 @@ const RecordRelatedListBody: React.FC<RecordRelatedListRendererProps> = ({
612615
* toolbar actions or an authoring notice keep the list's own row. A panel
613616
* of the related column folds with its `add` and its actions too: the
614617
* panel line's 展开 brings it back with them (objectstack#27524, Target 2).
618+
* A BODY table of the record page (objectstack#29944) never folds: it is a
619+
* section of the record, not a member of a related-list stack, so an empty
620+
* one keeps its header where its siblings keep theirs.
615621
*/
622+
const bodyTable =
623+
!panel &&
624+
typeof schema.relationshipField === 'string' &&
625+
bodyTableIds.has(relatedCardId(objectName, schema.relationshipField));
616626
const foldable =
627+
!bodyTable &&
617628
!showActionRefusals &&
618629
!showUnservedActions &&
619630
(panel || (!schema.add && !(toolbarActions && toolbarActions.length > 0)));

0 commit comments

Comments
 (0)