diff --git a/.changeset/29944-body-table-empty-keeps-header.md b/.changeset/29944-body-table-empty-keeps-header.md new file mode 100644 index 00000000000..9c2b87087bf --- /dev/null +++ b/.changeset/29944-body-table-empty-keeps-header.md @@ -0,0 +1,12 @@ +--- +'@object-ui/plugin-detail': patch +--- + +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 + +Clause-②: no +Metadata-protocol: none + +**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. + +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. diff --git a/ui/packages/plugin-detail/src/RelatedList.tsx b/ui/packages/plugin-detail/src/RelatedList.tsx index 3b9366b14df..925a9163d77 100644 --- a/ui/packages/plugin-detail/src/RelatedList.tsx +++ b/ui/packages/plugin-detail/src/RelatedList.tsx @@ -3629,6 +3629,7 @@ export const RelatedList: React.FC = ({
@@ -3656,7 +3657,9 @@ export const RelatedList: React.FC = ({ {recordCount} )} - {isEmpty && ( + {/* A body table says it under its header instead, where its + table would be (objectstack#29944). */} + {isEmpty && !bodyTable && ( {t('detail.noRelatedRecords')} @@ -3756,6 +3759,19 @@ export const RelatedList: React.FC = ({ )} + {/* objectstack#29944 — an empty body table keeps the shape of its + siblings: the header row, then the empty sentence where the table + would be. */} + {!collapsed && isEmpty && bodyTable && !loadFailure && ( + +

+ {t('detail.noRelatedRecords')} +

+
+ )} {!collapsed && !isEmpty && !loadFailure && {/* Filter bar — only when records justify it */} {showFilterInput && ( diff --git a/ui/packages/plugin-detail/src/RelatedListEmptyFold.tsx b/ui/packages/plugin-detail/src/RelatedListEmptyFold.tsx index 032434092e8..1f97276cc92 100644 --- a/ui/packages/plugin-detail/src/RelatedListEmptyFold.tsx +++ b/ui/packages/plugin-detail/src/RelatedListEmptyFold.tsx @@ -22,7 +22,10 @@ * synthesizer knows how many rows a list has; each list does, once its read * answers. So the lists coordinate among themselves, through the small store * below, keyed by that container. A list placed alone, or wrapped in its own - * container, is a stack of one and never folds. + * container, is a stack of one and never folds. A body table of the record + * page (`RecordBodyTablesContext`, objectstack#29944) shares the details + * column with its siblings but is a section of the record, not a member of a + * stack: it answers `foldable: false`, so it keeps its own header. * * ## The rule (`decideEmptyFold`) * diff --git a/ui/packages/plugin-detail/src/renderers/__tests__/record-related-list.emptyFold-25895.test.tsx b/ui/packages/plugin-detail/src/renderers/__tests__/record-related-list.emptyFold-25895.test.tsx index c95436af154..1805e873611 100644 --- a/ui/packages/plugin-detail/src/renderers/__tests__/record-related-list.emptyFold-25895.test.tsx +++ b/ui/packages/plugin-detail/src/renderers/__tests__/record-related-list.emptyFold-25895.test.tsx @@ -37,6 +37,7 @@ import { import type { DataSource } from '@object-ui/types'; // Registers `record:related_list`, the block under test. import '../../index'; +import { RecordBodyTablesContext } from '../../relatedListCompact'; beforeAll(() => { Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); @@ -79,7 +80,7 @@ const ACCOUNT_ROWS: Rows = { ], }; -function mount(nodes: object[], rows: Rows) { +function mount(nodes: object[], rows: Rows, bodyTables: ReadonlySet = new Set()) { const ds = makeDataSource(rows); const created = vi.fn(); const host: RelatedRecordActionsValue = { @@ -89,12 +90,14 @@ function mount(nodes: object[], rows: Rows) { - {/* The stack: siblings in one container, as `page:tabs` draws a tab's children. */} -
- {nodes.map((node, idx) => ( - - ))} -
+ + {/* The stack: siblings in one container, as `page:tabs` draws a tab's children. */} +
+ {nodes.map((node, idx) => ( + + ))} +
+
, @@ -200,3 +203,56 @@ describe('record:related_list — empty lists of a stack fold into one line (#25 expect(cardTitled('Team')).toBeVisible(); }); }); + +/** + * objectstack#29944 — the record page's BODY tables (their ids in + * `RecordBodyTablesContext`) are sections of the record, drawn side by side in + * the details column (or the Details tab), so they share a container like a + * Related tab's lists do. They are not a related-list stack: an empty one + * keeps its own header — title, 0, New — with the empty sentence under it, + * where the table of a non-empty one is. Measured on hotclm's contract page: + * the empty 偏离 table folded into 「暂无相关记录 偏离 + 新建」. + */ +describe('record:related_list — a body table of the record page never folds (objectstack#29944)', () => { + const CONTRACT = [ + list('crm_quote', 'Payment plan'), + list('crm_child_account', 'Deviations'), + list('crm_contact', 'Versions'), + ]; + const CONTRACT_ROWS: Rows = { + crm_quote: [{ id: 'q1', name: 'Q-001', account_id: 'a1' }], + crm_contact: [{ id: 'c1', name: 'Ada', account_id: 'a1' }], + }; + const BODY = new Set(['crm_quote.account_id', 'crm_child_account.account_id', 'crm_contact.account_id']); + + it('an empty body table beside tables with records keeps its header, with the empty sentence under it', async () => { + const { ds, created } = mount(CONTRACT, CONTRACT_ROWS, BODY); + await settled(ds, 3); + // Every list has answered once Deviations counts 0; give a fold the time to draw. + await screen.findByLabelText('0 records'); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(fold()).toBeNull(); + const title = cardTitled('Deviations'); + expect(title).toBeVisible(); + + // The header row is the non-empty tables' header: title, count, New. + const head = title.closest('[data-related-list-head]') as HTMLElement; + expect(head).not.toBeNull(); + fireEvent.click(within(head).getByRole('button', { name: /New/ })); + expect(created).toHaveBeenCalledWith('crm_child_account'); + + // The sentence is not in the header row: it is drawn under it. + expect(within(head).queryByText('No related records found')).toBeNull(); + const sentence = screen.getByText('No related records found'); + expect(sentence).toBeVisible(); + expect(head.compareDocumentPosition(sentence) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it('CONTROL: a Related tab stack under the same context folds as before — only the listed ids are body tables', async () => { + const { ds } = mount(ACCOUNT_STACK, ACCOUNT_ROWS, new Set(['crm_opportunity.account_id'])); + await settled(ds, 4); + await waitFor(() => expect(fold()).not.toBeNull()); + const names = within(fold()!).getAllByRole('group').map((g) => g.getAttribute('aria-label')); + expect(names).toEqual(['Child accounts', 'Leads']); + }); +}); diff --git a/ui/packages/plugin-detail/src/renderers/record-related-list.tsx b/ui/packages/plugin-detail/src/renderers/record-related-list.tsx index d72e52d9361..d6823b2f47a 100644 --- a/ui/packages/plugin-detail/src/renderers/record-related-list.tsx +++ b/ui/packages/plugin-detail/src/renderers/record-related-list.tsx @@ -36,7 +36,8 @@ import { } from '@object-ui/core'; import type { RecordRelatedListComponentProps } from '@object-ui/types'; import { RelatedList } from '../RelatedList'; -import { RelatedListCompactContext } from '../relatedListCompact'; +import { RecordBodyTablesContext, RelatedListCompactContext } from '../relatedListCompact'; +import { relatedCardId } from '../synth/relatedArrangement'; import { useRecordAriaProps } from './recordComponentAria'; import { ParentRecordBinding } from './recordBinding'; import { RelatedListStackSlot } from '../RelatedListEmptyFold'; @@ -226,6 +227,8 @@ const RecordRelatedListBody: React.FC = ({ const { language } = useObjectTranslation(); // A panel of the record page's related column (the fold rule below). const panel = React.useContext(RelatedListCompactContext); + // The record page's body tables (the fold rule below). + const bodyTableIds = React.useContext(RecordBodyTablesContext); const objectName = schema.objectName; @@ -612,8 +615,16 @@ const RecordRelatedListBody: React.FC = ({ * toolbar actions or an authoring notice keep the list's own row. A panel * of the related column folds with its `add` and its actions too: the * panel line's 展开 brings it back with them (objectstack#27524, Target 2). + * A BODY table of the record page (objectstack#29944) never folds: it is a + * section of the record, not a member of a related-list stack, so an empty + * one keeps its header where its siblings keep theirs. */ + const bodyTable = + !panel && + typeof schema.relationshipField === 'string' && + bodyTableIds.has(relatedCardId(objectName, schema.relationshipField)); const foldable = + !bodyTable && !showActionRefusals && !showUnservedActions && (panel || (!schema.add && !(toolbarActions && toolbarActions.length > 0)));