Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/29944-body-table-empty-keeps-header.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 17 additions & 1 deletion ui/packages/plugin-detail/src/RelatedList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3629,6 +3629,7 @@ export const RelatedList: React.FC<RelatedListProps> = ({
<CardHeader
className={cn('py-3 px-4 sm:py-3 min-h-12 sm:min-h-0', headerClassName)}
onClick={handleHeaderClick}
data-related-list-head=""
>
<CardTitle className={cn('flex items-center justify-between gap-2 text-sm font-semibold', compact && 'flex-wrap')}>
<div className="flex items-center gap-2 min-w-0">
Expand Down Expand Up @@ -3656,7 +3657,9 @@ export const RelatedList: React.FC<RelatedListProps> = ({
{recordCount}
</Badge>
)}
{isEmpty && (
{/* A body table says it under its header instead, where its
table would be (objectstack#29944). */}
{isEmpty && !bodyTable && (
<span className="text-xs text-muted-foreground/70 italic ml-1 truncate">
{t('detail.noRelatedRecords')}
</span>
Expand Down Expand Up @@ -3756,6 +3759,19 @@ export const RelatedList: React.FC<RelatedListProps> = ({
<RelatedListLoadFailed error={loadFailure.error} onRetry={handleRetryRead} />
</CardContent>
)}
{/* 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 && (
<CardContent className="pt-0 pb-4 px-4">
<p
className="rounded-md border border-dashed border-border/60 px-3 py-4 text-center text-xs text-muted-foreground"
data-body-table-empty=""
>
{t('detail.noRelatedRecords')}
</p>
</CardContent>
)}
{!collapsed && !isEmpty && !loadFailure && <CardContent className={cn('pt-0 pb-4 px-4')}>
{/* Filter bar — only when records justify it */}
{showFilterInput && (
Expand Down
5 changes: 4 additions & 1 deletion ui/packages/plugin-detail/src/RelatedListEmptyFold.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -79,7 +80,7 @@ const ACCOUNT_ROWS: Rows = {
],
};

function mount(nodes: object[], rows: Rows) {
function mount(nodes: object[], rows: Rows, bodyTables: ReadonlySet<string> = new Set()) {
const ds = makeDataSource(rows);
const created = vi.fn();
const host: RelatedRecordActionsValue = {
Expand All @@ -89,12 +90,14 @@ function mount(nodes: object[], rows: Rows) {
<SchemaRendererProvider dataSource={ds as unknown as DataSource}>
<RecordContextProvider objectName={PARENT} recordId="a1" dataSource={ds as never}>
<RelatedRecordActionsProvider value={host}>
{/* The stack: siblings in one container, as `page:tabs` draws a tab's children. */}
<div data-testid="stack">
{nodes.map((node, idx) => (
<SchemaRenderer key={idx} schema={node as never} />
))}
</div>
<RecordBodyTablesContext.Provider value={bodyTables}>
{/* The stack: siblings in one container, as `page:tabs` draws a tab's children. */}
<div data-testid="stack">
{nodes.map((node, idx) => (
<SchemaRenderer key={idx} schema={node as never} />
))}
</div>
</RecordBodyTablesContext.Provider>
</RelatedRecordActionsProvider>
</RecordContextProvider>
</SchemaRendererProvider>,
Expand Down Expand Up @@ -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']);
});
});
13 changes: 12 additions & 1 deletion ui/packages/plugin-detail/src/renderers/record-related-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -226,6 +227,8 @@ const RecordRelatedListBody: React.FC<RecordRelatedListRendererProps> = ({
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;

Expand Down Expand Up @@ -612,8 +615,16 @@ const RecordRelatedListBody: React.FC<RecordRelatedListRendererProps> = ({
* 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)));
Expand Down
Loading