Skip to content

Commit aeb9cf0

Browse files
feat(app-shell): the page designer can set a list block's object, view and row cap (#26793)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbP1hty4ZjhhkV5DXp69CBfb)_ Fixes #26703 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:页面设计器里选中「列表视图」(`list-view`)区块时,右侧属性面板新增三个输入框:对象、视图、条数上限。三者都写到区块的 `dataSource` 绑定上(`dataSource.object` / `.view` / `.limit`),中英文标签齐全。 - **为什么改**:宽区域里的列表区块只显示前 `dataSource.limit` 行(没填时 5 行),但设计器以前没有任何地方能改这个数,只能手改页面 JSON。 - **风险与回滚**:低。只加了设计器面板配置和两种语言的标签,不新增任何元数据键,渲染端不动。回滚即 revert 本 PR。 - **先看**:`ui/packages/app-shell/src/views/metadata-admin/previews/block-config.ts` 新增的 `'list-view'` 一条;`inspectors/PageBlockInspector.listViewRowCap-26703.test.tsx`;`previews/__tests__/block-config.test.ts` 新增的 `PANEL_WITHOUT_PALETTE` 豁免表。 - **两分钟验证**:`cd ui && pnpm exec vitest run packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.listViewRowCap-26703.test.tsx`,预期 4 个用例全绿;或在 Studio 打开一个带列表区块的页面,选中它,「条数上限」框里填 12,页面 JSON 出现 `dataSource.limit: 12`。 - **需要你做的**:无,席位落地。 ## What changes `BLOCK_CONFIG` (the page designer's per-block inspector table) had no `list-view` entry, so selecting a list block showed no typed fields, and "Advanced" lists only keys a block already has. The row cap that PR #26700 made a list block in a wide page region honour (`dataSource.limit`, 5 when unset) could therefore be set only in JSON. The new entry has three controls, all `at: 'dataSource'`, the binding `ListViewBlock` reads its query from: | control | writes | kind | why it belongs | |:--|:--|:--|:--| | Object / 对象 | `dataSource.object` | object picker | the binding lands it on `objectName` (`ListViewBlock.tsx`, `namesRecordSource`) | | View / 视图 | `dataSource.view` | text | the saved view the block binds (`compactListDestination`, `element-data-source.ts`) | | Row limit / 条数上限 | `dataSource.limit` | number, placeholder `5` | the capped list's row count; `5` is its own count when the box is empty | All three are members of the spec's `ElementDataSourceSchema`, which the schema-parity test (`block-config-schema-parity-8216.test.ts`) judges binding controls against. No new key anywhere, and no `packages/spec/**`, server or plugin-list change. ## Why A designer who wants a list block on a page to show 10 rows instead of 5 had no control for it. The cap is a key the block already declares and the renderer already honours, so the gap was only the inspector's. ## Tests - New `PageBlockInspector.listViewRowCap-26703.test.tsx` drives the real inspector on a spec-parsed page: the Row limit box is an editable number input hinting `5`; editing it writes `dataSource.limit` beside the bound object and view, never `properties.limit`; a stored cap, object and view show in their boxes and the View box writes `dataSource.view`; the zh labels render under `zh-CN`. - `block-config-i18n.test.ts`: the placeholder inventory moves 17 / 6 / 11 → 18 / 6 / 12, and `list-view.limit` joins the literal-value list with its reason. - `block-config.test.ts`: see the first acceptance note. - Ablation (one-off, not kept): with the entry committed, `node scripts/ablation-replace.mjs` renamed the `'list-view': [` key (anchor 1 → 0, blob `115a5ace` → `20037ca9`) and the new test file ran red, Tests 4 failed (4). Restored: blob equals HEAD, `git diff HEAD` empty. ## Acceptance notes - **The palette does not offer `list-view`.** `block-config.test.ts` pins "a block with a config panel is a block the palette offers", so the new panel needed a decision. Offering the block in the palette is a product change beyond this card, so the case gains `PANEL_WITHOUT_PALETTE`, a one-row exemption with its reason: `list-view` is a public block (`core/src/registry/public-blocks.ts`) that published pages place (`packages/objectql/src/__fixtures__/hotcrm-4.0.1-published-pages.artifact.json`), so the panel edits a node a page already carries. A second case keeps the row self-deleting: it goes red when the palette offers the block or the panel goes away. Whether the palette should offer `list-view` is left as an open question in the card report. - **View is a text box.** The inspector has no saved-view picker kind. A name the object does not have still fails loudly at render (the binding gate's configuration error), as it does for a hand-written one. - A block that names its object through `data: { provider: 'object', object }` instead of the binding keeps doing so; the Object box shows only `dataSource.object`. ## Verification All readings at head `069c0195` (base `74db43ef`), under `scripts/pm/os-verify-lock.sh`. <details><summary>Touched package: @object-ui/app-shell</summary> - Dependency closure: `pnpm exec turbo run build --concurrency=2 --filter='@object-ui/app-shell^...'`, 63 tasks, VERDICT command-exit 0. - `pnpm --filter @object-ui/app-shell type-check` at `42add541`: `tsc --noEmit` clean, then `tsc -p tsconfig.test.json` one error in the new test (a `string` locale); fixed in `069c0195`, and `tsc -p tsconfig.test.json --noEmit` re-ran clean, VERDICT command-exit 0. - The four touched test files: Test Files 4 passed (4), Tests 87 passed (87). - Narrowed app-shell run: every test file naming `PageBlockInspector`, `block-config`, `BLOCK_CONFIG` or the metadata-admin `i18n` (225 files): Test Files 225 passed (225), Tests 2306 passed (2306). The rest of app-shell is left to CI's Test UI shards. </details> <details><summary>Derived gates: 34 of 34 run</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 34 commands for the 6 changed paths; `--ran` reconciled: "34 derived famil(ies) accounted for — 34 run, 0 NOT-MEASURED". - 33 exit 0, among them `check:nul-bytes`, `check:test-source-alias`, `check:cross-package-test-inputs`, `check:type-check-coverage`, `check:type-check-debt`, `check-sdui-manifest`, `check-empty-changeset`, `check-changeset-no-major`. - `pnpm --dir ui exec vitest run scripts/` ran while the gate loop shared the box: Tests 3 failed, 5062 passed, 2 skipped, all three `Test timed out in 15000ms` in tree-scan cases of `check-hand-rolled-comment-mask.test.ts` and `check-vi-mock-inherit.test.ts`. Re-run alone on a quiet box: Test Files 2 passed (2), Tests 105 passed (105). Neither scan reads a file this diff touches. - Families CI runs over the whole tree (type-check lanes, lint, artifact rosters) are CI's; the PR's check-runs are the reading. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (lint is CI's) - [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: its generated artifacts are regenerated and committed. (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. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jTqu1ZbUqjnAJufZ8m --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jTqu1ZbUqjnAJufZ8m)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 288cb53 commit aeb9cf0

7 files changed

Lines changed: 168 additions & 5 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(app-shell): the page designer can set a list block's object, view and row cap
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
Selecting a `list-view` block in the page designer used to open an inspector with no typed fields. Its row cap, the number of rows a list block in a wide page region draws (5 when none is stated), could be set only by editing the page's JSON, because the "Advanced" section lists only keys the block already has.
12+
13+
- **Three controls.** Object, View and Row limit. They write the block's `dataSource` binding (`dataSource.object`, `dataSource.view`, `dataSource.limit`), the keys the list block already reads. The Row limit box hints `5` when empty.
14+
- **Labels in both designer locales**, English and Chinese, in the `studio` namespace of `@object-ui/i18n` where the page inspector's rows live.
15+
- No new key is declared anywhere, and nothing an author already wrote changes. The palette still does not offer `list-view`: the controls edit list blocks a page already carries.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#26703 — the page designer can set a `list-view` block's row cap.
5+
*
6+
* A list block in a wide page region draws its first `dataSource.limit` rows,
7+
* 5 when none is stated (objectstack#26613). `BLOCK_CONFIG` had no `list-view`
8+
* entry, so selecting one opened an inspector with no typed fields, and the
9+
* "Advanced" section lists only keys the block already has: the cap could be
10+
* set only in JSON. These rows drive the REAL inspector and assert the patch
11+
* it hands the editor: the Object, View and Row limit controls write the
12+
* node-level `dataSource` binding `ListViewBlock` reads, never `properties`.
13+
*/
14+
15+
import { describe, it, expect, vi, afterEach, type Mock } from 'vitest';
16+
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
17+
import { PageSchema } from '@objectstack/spec/ui';
18+
import { PageBlockInspector } from './PageBlockInspector';
19+
import type { MetadataInspectorProps } from '../inspector-registry';
20+
import type { SupportedLocale } from '../i18n';
21+
22+
vi.mock('../previews/useObjectFields', () => ({
23+
useObjectFields: () => ({ fields: [], loading: false, error: null }),
24+
}));
25+
// No object catalog: the Object picker falls back to its labelled text box.
26+
vi.mock('../useMetadata', () => ({
27+
useMetadataClient: () => ({ get: vi.fn(async () => undefined), list: vi.fn(async () => [] as unknown[]) }),
28+
}));
29+
30+
// The inspector's `engine.inspector.*` rows live in `@object-ui/i18n`'s `studio`
31+
// namespace, whose `zh` catalogue is fetched on demand (objectstack#25922).
32+
// Resident before any case, so the zh rows below read synchronously.
33+
import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio';
34+
await loadStudioCatalogue('zh');
35+
36+
afterEach(cleanup);
37+
38+
const BLOCK_PATH = 'regions[0].components[0]';
39+
40+
/** Author a page the way a user does, parse it with the spec, and hand the RESULT to the inspector. */
41+
function pageDraft(block: Record<string, unknown>): Record<string, unknown> {
42+
return PageSchema.parse({
43+
name: 'home',
44+
label: 'Home',
45+
type: 'home',
46+
template: 'default',
47+
regions: [{ name: 'main', components: [{ id: 'b1', ...block }] }],
48+
}) as unknown as Record<string, unknown>;
49+
}
50+
51+
function renderInspector(draft: Record<string, unknown>, locale: SupportedLocale = 'en-US'): Mock<MetadataInspectorProps['onPatch']> {
52+
const onPatch: Mock<MetadataInspectorProps['onPatch']> = vi.fn();
53+
render(
54+
<PageBlockInspector
55+
type="page"
56+
name="home"
57+
draft={draft}
58+
selection={{ kind: 'block', id: BLOCK_PATH }}
59+
onPatch={onPatch}
60+
onClearSelection={() => {}}
61+
readOnly={false}
62+
locale={locale}
63+
/>,
64+
);
65+
return onPatch;
66+
}
67+
68+
/** The block as the inspector last wrote it (the patch is shallow: `{ regions }`). */
69+
const committedBlock = (onPatch: Mock<MetadataInspectorProps['onPatch']>): Record<string, unknown> =>
70+
(onPatch.mock.calls.at(-1)![0] as { regions: Array<{ components: Array<Record<string, unknown>> }> }).regions[0]
71+
.components[0];
72+
73+
describe('the designer sets a list-view block’s row cap (objectstack#26703)', () => {
74+
it('shows an editable Row limit box, hinting the capped list’s own 5 when empty', () => {
75+
renderInspector(pageDraft({ type: 'list-view', dataSource: { object: 'contact' } }));
76+
const limit = screen.getByLabelText('Row limit') as HTMLInputElement;
77+
expect(limit.type).toBe('number');
78+
expect(limit.value).toBe('');
79+
expect(limit.placeholder).toBe('5');
80+
expect(limit.disabled).toBe(false);
81+
});
82+
83+
it('editing the box writes `dataSource.limit` beside the bound object, not `properties.limit`', () => {
84+
const onPatch = renderInspector(pageDraft({ type: 'list-view', dataSource: { object: 'contact', view: 'mine' }, properties: {} }));
85+
fireEvent.change(screen.getByLabelText('Row limit'), { target: { value: '12' } });
86+
const block = committedBlock(onPatch);
87+
expect(block.dataSource).toEqual({ object: 'contact', view: 'mine', limit: 12 });
88+
expect(block.properties).toEqual({});
89+
});
90+
91+
it('the box shows a stored cap, and the Object and View controls show and write the binding', () => {
92+
const onPatch = renderInspector(pageDraft({ type: 'list-view', dataSource: { object: 'contact', view: 'mine', limit: 8 } }));
93+
expect((screen.getByLabelText('Row limit') as HTMLInputElement).value).toBe('8');
94+
expect((screen.getByLabelText('Object') as HTMLInputElement).value).toBe('contact');
95+
expect((screen.getByLabelText('View') as HTMLInputElement).value).toBe('mine');
96+
fireEvent.change(screen.getByLabelText('View'), { target: { value: 'all_open' } });
97+
expect(committedBlock(onPatch).dataSource).toEqual({ object: 'contact', view: 'all_open', limit: 8 });
98+
});
99+
100+
it('labels the controls in the designer locale', () => {
101+
renderInspector(pageDraft({ type: 'list-view', dataSource: { object: 'contact' } }), 'zh-CN');
102+
expect(screen.getByLabelText('条数上限')).toBeTruthy();
103+
expect(screen.getByLabelText('视图')).toBeTruthy();
104+
});
105+
});

‎ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-i18n.test.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -397,9 +397,10 @@ describe('placeholders that must NOT be translated (#3979)', () => {
397397
because: "a row limit — DEFAULT_KANBAN_FETCH_BATCH_SIZE, the board's fetch batch when the box is empty",
398398
},
399399
'record:details.sections.columns': { literal: '2', because: 'a column count' },
400+
'list-view.limit': { literal: '5', because: "a row limit — the capped list's row count when the box is empty" },
400401
};
401402

402-
it('collects all 17 placeholders — 6 keyed, 11 literal', () => {
403+
it('collects all 18 placeholders — 6 keyed, 12 literal', () => {
403404
// Guards the walk: if placeholder collection silently found nothing, every
404405
// assertion here would pass over an empty list.
405406
//
@@ -411,10 +412,12 @@ describe('placeholders that must NOT be translated (#3979)', () => {
411412
// `DEFAULT_KANBAN_FETCH_BATCH_SIZE`), so it lands on the literal side and the keyed
412413
// half is what stays untouched this time. Then 18 / 7 / 11 until
413414
// objectui#8280 removed the `ai:input` panel, whose `agentName` box was a
414-
// KEYED placeholder — the palette never offered that block.
415-
expect(PLACEHOLDERS.length).toBe(17);
415+
// KEYED placeholder — the palette never offered that block. Then 17 / 6 / 11
416+
// until objectstack#26703 gave `list-view` its row-cap box, a VALUE
417+
// placeholder (`5`, the capped list's own count).
418+
expect(PLACEHOLDERS.length).toBe(18);
416419
expect(PLACEHOLDERS.filter((p) => p.spec.key !== undefined).length).toBe(6);
417-
expect(PLACEHOLDERS.filter((p) => p.spec.literal !== undefined).length).toBe(11);
420+
expect(PLACEHOLDERS.filter((p) => p.spec.literal !== undefined).length).toBe(12);
418421
});
419422

420423
it('every placeholder declares exactly one of key / literal', () => {

‎ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,11 @@ describe('record:details sections ↔ spec section-entry coverage (#3819)', () =
257257
* from the enum instead, so every value must be an explicit decision —
258258
* offered, or excluded with a documented reason.
259259
*/
260+
/** Blocks with a config panel and no palette entry, each with why it is still authorable. */
261+
const PANEL_WITHOUT_PALETTE: Readonly<Record<string, string>> = {
262+
'list-view': 'a public block that published pages place; the panel edits the node a page already carries (objectstack#26703)',
263+
};
264+
260265
describe('page palette ↔ spec PageComponentType coverage', () => {
261266
const specNames: string[] = enumOptions(PageComponentType);
262267

@@ -344,11 +349,29 @@ describe('page palette ↔ spec PageComponentType coverage', () => {
344349
// curated panel and no way to author it (objectui#8280). Palette membership
345350
// is the question this case asks, so the palette is the only thing it
346351
// reads — `TYPES_BY_CATEGORY`, the list the page canvas's picker renders.
352+
//
353+
// `PANEL_WITHOUT_PALETTE` names the blocks that are authorable WITHOUT the
354+
// palette, each with the stored documents that carry it, so a panel for one
355+
// is an edit of a real node rather than an advert for an unauthorable one.
356+
// `list-view` (objectstack#26703): a public block (`core/src/registry/
357+
// public-blocks.ts`) that published pages place, such as
358+
// `packages/objectql/src/__fixtures__/hotcrm-4.0.1-published-pages.artifact.json`,
359+
// and whose row cap the designer could otherwise set only in JSON.
347360
const offered = new Set<string>(TYPES_BY_CATEGORY.flatMap((g) => g.types));
348-
const orphanPanels = Object.keys(BLOCK_CONFIG).filter((t) => !offered.has(t));
361+
const orphanPanels = Object.keys(BLOCK_CONFIG).filter((t) => !offered.has(t) && !(t in PANEL_WITHOUT_PALETTE));
349362
expect(orphanPanels, 'these expose a config panel but cannot be authored').toEqual([]);
350363
});
351364

365+
it('every palette-less panel is still palette-less and still has a panel', () => {
366+
// Self-deleting, like the schema-parity EXEMPT rows: the day the palette
367+
// offers the block, or its panel goes, the row must go too.
368+
const offered = new Set<string>(TYPES_BY_CATEGORY.flatMap((g) => g.types));
369+
for (const type of Object.keys(PANEL_WITHOUT_PALETTE)) {
370+
expect(offered.has(type), `'${type}' is offered now — delete its row`).toBe(false);
371+
expect(blockHasConfig(type), `'${type}' has no panel — delete its row`).toBe(true);
372+
}
373+
});
374+
352375
describe("record:quick_actions `location` — the designer's action-location dropdown", () => {
353376
const locationField = () =>
354377
BLOCK_CONFIG['record:quick_actions'].find((f) => f.name === 'location') as

‎ui/packages/app-shell/src/views/metadata-admin/previews/block-config.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,17 @@ export const BLOCK_CONFIG: Record<string, BlockPropField[]> = {
257257
// `DEFAULT_KANBAN_FETCH_BATCH_SIZE`, the value that applies when the box is empty.
258258
{ name: 'limit', label: 'engine.inspector.pageBlock.field.object-kanban.limit', kind: 'number', placeholder: { literal: '100' } },
259259
],
260+
// The page `list-view` block reads its query from the node-level `dataSource`
261+
// binding (`ListViewBlock`, plugin-list): `object` lands on `objectName`,
262+
// `view` names the saved view, and `limit` is its row cap, which a list in a
263+
// wide page region draws as its first rows (objectstack#26613). So all three
264+
// controls write `dataSource.*`, the way `element:repeater`'s do below.
265+
// `{ literal: '5' }` is the capped list's own count when no limit is stated.
266+
'list-view': [
267+
{ name: 'object', label: 'engine.inspector.pageBlock.field.list-view.object', kind: 'object-picker', at: 'dataSource' },
268+
{ name: 'view', label: 'engine.inspector.pageBlock.field.list-view.view', kind: 'text', at: 'dataSource' },
269+
{ name: 'limit', label: 'engine.inspector.pageBlock.field.list-view.limit', kind: 'number', placeholder: { literal: '5' }, at: 'dataSource' },
270+
],
260271

261272
// ── Layout grid ───────────────────────────────────────────────────────────
262273
grid: [

‎ui/packages/i18n/src/namespaces/studio/en.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,9 @@ const STUDIO_EN: Record<string, string> = {
727727
'engine.inspector.pageBlock.field.object-kanban.titleField': 'Title field',
728728
'engine.inspector.pageBlock.field.object-kanban.cardFields': 'Card fields',
729729
'engine.inspector.pageBlock.field.object-kanban.limit': 'Limit',
730+
'engine.inspector.pageBlock.field.list-view.object': 'Object',
731+
'engine.inspector.pageBlock.field.list-view.view': 'View',
732+
'engine.inspector.pageBlock.field.list-view.limit': 'Row limit',
730733
'engine.inspector.pageBlock.field.grid.columns': 'Columns',
731734
'engine.inspector.pageBlock.field.grid.gap': 'Gap',
732735
'engine.inspector.pageBlock.field.element:text.content': 'Content',

‎ui/packages/i18n/src/namespaces/studio/zh.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,9 @@ const STUDIO_ZH: Record<string, string> = {
621621
'engine.inspector.pageBlock.field.object-kanban.titleField': '标题字段',
622622
'engine.inspector.pageBlock.field.object-kanban.cardFields': '卡片字段',
623623
'engine.inspector.pageBlock.field.object-kanban.limit': '条数上限',
624+
'engine.inspector.pageBlock.field.list-view.object': '对象',
625+
'engine.inspector.pageBlock.field.list-view.view': '视图',
626+
'engine.inspector.pageBlock.field.list-view.limit': '条数上限',
624627
'engine.inspector.pageBlock.field.grid.columns': '列数',
625628
'engine.inspector.pageBlock.field.grid.gap': '间距',
626629
'engine.inspector.pageBlock.field.element:text.content': '内容',

0 commit comments

Comments
 (0)