Skip to content

Commit dc9d1ff

Browse files
feat(plugin-list): cap a page list block's rows and badge each tab with its list's count (#26700)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbEGgVmKgUnvqr54xB8Qupu1)_ Fixes #26613 Clause-②: yes Metadata-protocol: none ## 维护者速读 - **改了什么**:页面里半宽及以上区域的列表块(`list-view`)不再画整张对象列表,而是只画前 N 行(块的 `dataSource.limit`,没写就是 5)、视图前四列的精简表格,没有工具栏、`#` 列和分页,末尾一个「查看全部 N 条 →」链接到对象列表(块绑定的视图)。`page:tabs` 里装列表块的页签,标签上显示各自列表的记录数。 - **为什么改**:HotCRM 销售首页的三个页签把整张对象列表(21 行、`#` 列、所有列、横向滚动条)塞在 KPI 下面;页面块应是摘要,完整列表走链接。 - **风险与回滚**:中低。无新元数据键(`dataSource.limit` 早已可写且已被块读取);窄区域的紧凑列表、对象列表页、没有页面包裹的块都不变。新增的发布面:i18n 键 `list.viewAllCount`(十个语言包)、`@object-ui/components` 导出 `usePageTabCountReporter`、`ListView` 两个宿主 prop。装列表块的页签改为预挂载(隐藏),所以首屏会为每个列表页签各发一次 `$top=5` 查询。回滚即 revert 本 PR。 - **先看**:`ui/packages/plugin-list/src/ListView.tsx`(`cappedWindow` / `cappedPosture` / `settledTotal`);`ui/packages/components/src/renderers/layout/containers.tsx`(页签计数通道与 `forceMount`);`ui/packages/plugin-list/src/__tests__/ListViewBlock.cappedPageBlock-26613.test.tsx`。 - **两分钟验证**:`cd ui && pnpm exec vitest run packages/plugin-list/src/__tests__/ListViewBlock.cappedPageBlock-26613.test.tsx packages/components/src/__tests__/page-tabs-list-count-26613.test.tsx`,预期 10 个用例全绿;或打开 HotCRM 的 Sales Home,三个页签各显示计数,每个页签最多 5 行并以「查看全部 N 条 →」结尾。 - **需要你做的**:无,席位落地。 ## What changes A `list-view` block in a page region half the page or wider (the page renderer's measured `'full'` posture, objectui#12703) now takes a **capped posture** instead of drawing the whole object list: - **Rows.** At most the block's `dataSource.limit`, and 5 (`CAPPED_LIST_DEFAULT_LIMIT`) when it states none. `limit` was already authorable on every page element's data binding (`ElementDataSourceSchema.limit`) and already read by this block, which lands it on `pagination.pageSize` (`LIST_VIEW_DATA_SOURCE`), so **no key is added**. The cap is the query's own window: `ListView` sizes `$top` from it, so a capped block never fetches the rest. - **Columns.** The view's first four visible columns (`CAPPED_LIST_MAX_COLUMNS`) in a table fixed to the block's width; the rest are dropped, not scrolled. A view with no declared columns draws the object's `highlightFields` led by its name field (the grid's own first default rule, through the same `leadWithNameField`), else each record's display name. - **No object-list chrome.** No toolbar, view tabs, `#` row-number column, pager or record-count bar. Each row opens its record; the first cell is a button so a keyboard reaches it. - **The link.** 「View all N →」 (`list.viewAllCount`, new in all ten packs) to the object list on the block's bound view, the destination the compact list's "View all" already uses (`listHref` / `openList`, unchanged). **Tabbed list blocks.** `page:tabs` opens a count channel to each panel (`usePageTabCountReporter`, exported from `@object-ui/components`). The `list-view` block reports the total its own query answered (`ListView`'s new `onTotalChange` host prop), and the tab's badge shows the sum, added to the `record:related_list` counts the strip already derives. A tab holding a `list-view` and no authored `count` is mounted while inactive (Radix `forceMount`, hidden by `data-[state=inactive]:hidden`), so its badge exists before the tab is opened. A second, hand-built count query was not used: the badge reads the rows' own query, so the two cannot disagree. Unchanged: a region narrower than half the page keeps the compact list; a list outside any page region (the object list page, a block with no page around it, an unknown width) keeps the full list; a tab with no list block, or with an authored `count`, still mounts on open only. Query cost on a page whose `page:tabs` holds N list tabs: before, one list query on load (the active tab, `$top` 50, the spec's display default) and one more per tab the user opens; now N list queries on load, each `$top` 5 (or the block's `limit`), and none when a tab is opened later. ## Tests - `ui/packages/plugin-list/src/__tests__/ListViewBlock.cappedPageBlock-26613.test.tsx` (new, real page renderer, real `page:tabs`, real `list-view`, a data source that honours `$top`/`$skip` and answers `total`): (a) a full-width block over 21 records draws 5 rows, asks for `$top` 5, draws the first four of five view columns, no toolbar and no grid, and links "View all 21" to the bound view; (b) `dataSource.limit: 3` caps at 3; (c) a three-tab block (21 / 8 / 3 records) badges each tab `21` / `8` / `3`, runs each tab's query capped at 5, and each tab shows 5 / 5 / 3 rows with its own "View all N"; (d) controls: the aside block stays compact, an unknown width keeps the full list. - `ui/packages/components/src/__tests__/page-tabs-list-count-26613.test.tsx` (new): reports summed per tab with the inactive tab mounted; an authored `count` wins and its tab is not pre-mounted; a tab with no list block is not pre-mounted; an unmounted block withdraws its count; no reporter outside a panel. - `ListViewBlock.compactRegion-12703.test.tsx` and `ListViewBlock.compactViewAll-12703.test.tsx`: the "wide region keeps the full list" controls now assert the capped posture, the half this card changes. ## Acceptance notes - The page designer does not list block properties from the schema: `BLOCK_CONFIG` (`ui/packages/app-shell/src/views/metadata-admin/previews/block-config.ts`) is a hand-curated table with no `list-view` entry, so `limit` is not exposed there in this PR. The table already supports `at: 'dataSource'` fields (`element:repeater` has a `dataSource.limit` number box), so a `list-view` entry is a small follow-up. - The acceptance screenshot of Sales Home at 1280 px is not attached: this container has no HotCRM deployment. - The capped table reads a column entry's own `label` before the field's, as the grid header does; cell values go through the shared cell renderers (`getCellRenderer`), as the compact rows do. ## Verification At `c558a2cb` (branch merged with `main`), under `scripts/pm/os-verify-lock.sh`: - `cd ui && pnpm exec vitest run packages/plugin-list packages/i18n` plus the components page-tabs/posture tests, every test under `packages/app-shell/src/views/__tests__/`, `ObjectView.relayRungCensus-7559`, and the console tests that name the list block or `page:tabs` (`public-block-binding-reach`, `registry-inputs-spec-parity`, `ga-honoured-inputs-author-reach`, `public-contract`, `ApprovalsInboxPage.opensServedPage-12484`, `src/services/`): Test Files 351 passed (351), Tests 4111 passed, 13 skipped. - Before the merge, the full plugin-list, components and i18n suites: 691 files passed; the one red (i18n `count-families-11445`: `list.viewAllCount` was a plain count key) is fixed in this branch by making it a count family in every pack. - `pnpm exec turbo run type-check --filter=@object-ui/plugin-list --filter=@object-ui/components --filter=@object-ui/i18n`: 51 tasks successful. - Ablations, each restored to the HEAD blob afterwards: forcing `capped = false` in `ListViewBlock` turns the three capped tests red; dropping the `forceMount` spread in `containers.tsx` turns the tabbed-badge tests red in both new files. - `node scripts/pm/dispatch-gates.mjs --commands` derived 34 commands; each run with its exit code recorded, 34/34 exit 0; `--ran` reconciliation: 34 run, 0 NOT-MEASURED, 0 UNRUN. - ui gates outside the derivation: `check-i18n-call-site-keys`, `check-i18n-en-drift`, `check-new-cross-file-line-citations --base origin/main`, `check-changeset-fixed`: all exit 0. - PR-body gates against this body: see the os-dev report on the issue. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` untouched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (CI's Lint UI is the authority.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jTS15PV1xApgUqB6Qh --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jTS15PV1xApgUqB6Qh)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5cc31c7 commit dc9d1ff

22 files changed

Lines changed: 985 additions & 64 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@object-ui/plugin-list': patch
3+
'@object-ui/components': patch
4+
'@object-ui/i18n': patch
5+
---
6+
7+
feat(plugin-list): a list block on a page caps its rows and ends with a "View all N" link; a tabbed list block caps each tab and badges each tab with its count
8+
9+
Clause-②: yes
10+
Metadata-protocol: none
11+
12+
A `list-view` block in a page region half the page or wider used to draw the whole object list: every row of its query, the toolbar, the `#` column, every column of the view and a horizontal scrollbar. It now draws a capped list:
13+
14+
- **The rows.** At most `dataSource.limit` of them, the key every page element's data binding already declares, and 5 when the block states none. The cap is the query's own window (`$top`), so a capped block never fetches the rest.
15+
- **The columns.** The view's first four visible columns, in a table fixed to the block's width; the rest are dropped, not scrolled. A view that declares no columns draws the object's `highlightFields` led by its name field, else each record's display name.
16+
- **No object-list chrome.** No toolbar, view tabs, row-number column, pager or record-count bar. A click on a row opens the record, as a grid row does.
17+
- **The link.** Under the rows, 「View all N →」 (`list.viewAllCount`, new in all ten language packs) opens the object list on the block's bound view, the same destination the compact list's "View all" link opens.
18+
19+
A region narrower than half the page keeps the compact list, and a list outside any page region (the object list page, a block with no page around it) keeps the full list.
20+
21+
`page:tabs` now badges a tab holding `list-view` blocks with the number of records those blocks' own queries matched, summed with the `record:related_list` counts it already derives. Such a tab is mounted while inactive, hidden, so the badge is there before the tab is opened; a tab with an authored `count` keeps mounting on open only. Nothing an author writes changes. The published surface widens in three places, which is what `Clause-②: yes` declares: the i18n key `list.viewAllCount` in all ten packs; `usePageTabCountReporter`, a new export of `@object-ui/components`, the count channel a `page:tabs` panel opens to the blocks inside it; and `ListView`'s two new optional host props that use it, `capped` and `onTotalChange`.

‎ui/content/docs/guide/react-pages.md‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -368,21 +368,35 @@ by the build* — but with this tier's own primitive: lay out with the blocks'
368368
structured props (`<flex direction gap>`, `<grid columns>`) and add CSS as a JSON
369369
`style` object. See *Styling*, above.
370370
371-
## List blocks in a narrow region
371+
## List blocks in a page region
372372
373373
Region pages (the schema-tree pages above, built from `regions[].components[]`)
374374
size each block to the region it sits in. A `list-view` block in a region
375375
narrower than half the page, such as an `aside` rail beside `main`, renders a
376376
compact list: a header with the title and record count, then two-line rows
377377
built from the view's first two visible columns. It renders no toolbar, no view
378-
tabs and no row-number column. A region half the page or wider renders the full
379-
list.
378+
tabs and no row-number column.
379+
380+
A region half the page or wider renders a capped list: the first rows only, at
381+
most the block's `dataSource.limit` and 5 when it sets none, in a short table of
382+
the view's first four visible columns (the rest are dropped, not scrolled),
383+
ending with a "View all N" link. It renders no toolbar, no row-number column and
384+
no pager either. A page block is a summary; the link opens the whole list.
385+
386+
```json
387+
{ "type": "list-view", "dataSource": { "object": "crm_lead", "view": "my_leads", "limit": 8 } }
388+
```
380389
381390
The posture is measured from the region's rendered width, never authored, and
382391
no page or block key sets it. `kind:'html'` and `kind:'react'` pages have no
383392
regions, so their list blocks always render the full list.
384393
385-
Under its rows the compact list draws a "View all" link. A block bound to a
394+
A `page:tabs` block whose tabs hold list blocks badges each tab with the number
395+
of records its list holds, the count the list's own query answered, before the
396+
tab is opened. A tab item's own `count` still wins.
397+
398+
Under its rows the compact list draws a "View all" link, and the capped list a
399+
"View all N" link to the same place. A block bound to a
386400
saved view (`dataSource.view`) links to that view's page in the console
387401
(`/apps/APP/OBJECT/view/VIEW`); any other block links to the object's list
388402
(`/apps/APP/OBJECT`). The block's own filter, columns and sort are not carried
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*
8+
* objectstack#26613 — a `page:tabs` panel's count channel.
9+
*
10+
* A tab holding `list-view` blocks is badged with the number of records those
11+
* blocks' own queries matched: each block reports through
12+
* `usePageTabCountReporter`, and the badge shows the sum. So the tab can be
13+
* badged before it is opened, such a tab is mounted while inactive (hidden by
14+
* its `data-state`); a tab with an authored `count`, or with no list block,
15+
* keeps mounting on open only.
16+
*
17+
* The `list-view` here is a stand-in that reports the count it is handed, so
18+
* this file pins the strip's half of the channel alone; the real block's half
19+
* is pinned in plugin-list (`ListViewBlock.cappedPageBlock-26613.test.tsx`).
20+
*
21+
* What is pinned:
22+
* (a) each tab's badge is its blocks' reported counts, summed, the inactive
23+
* tabs included, which are mounted to report;
24+
* (b) an authored `count` wins, and its tab is not mounted while inactive;
25+
* (c) a tab with no list block is not mounted while inactive;
26+
* (d) a block that unmounts withdraws its count;
27+
* (e) outside a tab panel the reporter is `undefined`.
28+
*/
29+
30+
import { describe, it, expect, afterEach } from 'vitest';
31+
import { render, cleanup, waitFor, act } from '@testing-library/react';
32+
import React from 'react';
33+
import { SchemaRenderer } from '@object-ui/react';
34+
import { ComponentRegistry } from '@object-ui/core';
35+
import '../renderers';
36+
import { usePageTabCountReporter } from '../index';
37+
38+
let mounted: string[] = [];
39+
let reporterSeen: Array<boolean> = [];
40+
41+
/** Reports the count it is handed, once mounted, and records that it mounted. */
42+
const ReportingList: React.FC<any> = ({ schema }) => {
43+
const report = usePageTabCountReporter();
44+
reporterSeen.push(report !== undefined);
45+
React.useEffect(() => {
46+
mounted.push(schema.id);
47+
if (typeof schema.reportCount === 'number') report?.(schema.reportCount);
48+
// eslint-disable-next-line react-hooks/exhaustive-deps
49+
}, [schema.reportCount]);
50+
return <div data-testid={`list-${schema.id}`} />;
51+
};
52+
ComponentRegistry.register('list-view', ReportingList, { namespace: 'test', label: 'Reporting list', category: 'view' });
53+
54+
const list = (id: string, reportCount: number) => ({ type: 'list-view', id, properties: { reportCount } });
55+
56+
const triggers = (container: HTMLElement) =>
57+
Array.from(container.querySelectorAll('[role="tab"]')).map((t) => t.textContent);
58+
59+
afterEach(() => {
60+
cleanup();
61+
mounted = [];
62+
reporterSeen = [];
63+
});
64+
65+
describe('page:tabs badges a tab with its list blocks\' counts (objectstack#26613)', () => {
66+
it('(a) sums each tab\'s reports, the inactive tabs mounted to report', async () => {
67+
const { container } = render(
68+
<SchemaRenderer
69+
schema={{
70+
type: 'page:tabs',
71+
id: 'home_tabs',
72+
items: [
73+
{ label: 'Leads', value: 'leads', children: [list('leads', 21)] },
74+
{ label: 'Work', value: 'work', children: [list('deals', 8), list('tasks', 3)] },
75+
],
76+
} as never}
77+
/>,
78+
);
79+
await waitFor(() => expect(triggers(container)).toEqual(['Leads21', 'Work11']));
80+
expect(mounted.sort()).toEqual(['deals', 'leads', 'tasks']);
81+
// The inactive panel is in the tree, marked inactive for its `hidden` class.
82+
const work = container.querySelector('[role="tabpanel"][data-state="inactive"]');
83+
expect(work?.className).toContain('data-[state=inactive]:hidden');
84+
});
85+
86+
it('(b) an authored count wins, and its tab mounts on open only', async () => {
87+
const { container } = render(
88+
<SchemaRenderer
89+
schema={{
90+
type: 'page:tabs',
91+
id: 'home_tabs',
92+
items: [
93+
{ label: 'Leads', value: 'leads', children: [list('leads', 21)] },
94+
{ label: 'Deals', value: 'deals', count: 99, children: [list('deals', 8)] },
95+
],
96+
} as never}
97+
/>,
98+
);
99+
await waitFor(() => expect(triggers(container)).toEqual(['Leads21', 'Deals99']));
100+
expect(mounted).toEqual(['leads']);
101+
});
102+
103+
it('(c) a tab with no list block mounts on open only', async () => {
104+
let probes = 0;
105+
const Probe: React.FC = () => {
106+
React.useEffect(() => {
107+
probes += 1;
108+
}, []);
109+
return null;
110+
};
111+
ComponentRegistry.register('test-probe-26613', Probe, { namespace: 'test', label: 'Probe', category: 'view' });
112+
const { container } = render(
113+
<SchemaRenderer
114+
schema={{
115+
type: 'page:tabs',
116+
id: 'home_tabs',
117+
items: [
118+
{ label: 'Leads', value: 'leads', children: [list('leads', 21)] },
119+
{ label: 'Notes', value: 'notes', children: [{ type: 'test-probe-26613', id: 'probe' }] },
120+
],
121+
} as never}
122+
/>,
123+
);
124+
await waitFor(() => expect(triggers(container)).toEqual(['Leads21', 'Notes']));
125+
expect(probes).toBe(0);
126+
});
127+
128+
it('(d) a block that unmounts withdraws its count', async () => {
129+
const schema = (children: unknown[]) => ({
130+
type: 'page:tabs',
131+
id: 'home_tabs',
132+
items: [
133+
{ label: 'Work', value: 'work', children },
134+
{ label: 'Other', value: 'other', children: [] },
135+
],
136+
});
137+
const view = render(<SchemaRenderer schema={schema([list('deals', 8), list('tasks', 3)]) as never} />);
138+
await waitFor(() => expect(triggers(view.container)).toEqual(['Work11', 'Other']));
139+
act(() => {
140+
view.rerender(<SchemaRenderer schema={schema([list('deals', 8)]) as never} />);
141+
});
142+
await waitFor(() => expect(triggers(view.container)).toEqual(['Work8', 'Other']));
143+
});
144+
145+
it('(e) outside a tab panel the reporter is undefined', () => {
146+
render(<SchemaRenderer schema={list('bare', 5) as never} />);
147+
expect(reporterSeen.length).toBeGreaterThan(0);
148+
expect(reporterSeen.every((seen) => seen === false)).toBe(true);
149+
});
150+
});

‎ui/packages/components/src/index.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ export type { PageBlockSpan } from './renderers/layout/page-block-span';
8888
export { usePageRegionPosture } from './renderers/layout/page-region-posture';
8989
export type { PageRegionPosture } from './renderers/layout/page-region-posture';
9090

91+
// The count channel a `page:tabs` panel opens to the blocks inside it
92+
// (objectstack#26613): the `list-view` block in `@object-ui/plugin-list`
93+
// reports the records its query matched, and the tab's badge shows the sum.
94+
export { usePageTabCountReporter } from './renderers/layout/containers';
95+
9196
// `page:header`'s action row: a record's declared `record_header` /
9297
// `record_more` actions and the host's system actions, through the one
9398
// resolver `page:header` draws them with (objectui#12906). Exported for the

0 commit comments

Comments
 (0)