Skip to content

Commit 8074feb

Browse files
feat(app-shell): the page designer's add-block picker offers the list view block (#26915)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb3jLLKvBmct3LQ4bsBRmBx5)_ Fixes #26794 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:页面设计器的「添加区块」选择器里新增「列表视图」(`list-view`),放在「数据」分类下,和表格、表单、指标、看板并列;中英文名称齐全。 - **为什么改**:上一张卡(PR #26793)给列表区块加了属性面板(对象、视图、条数上限),但选择器里没有这一项,设计器只能编辑页面里已有的列表区块,不能新加。 - **风险与回滚**:低。只加了选择器里的一行和两种语言的名称,删掉测试里为它留的豁免表;不新增元数据键,渲染端不动。回滚即 revert 本 PR。 - **先看**:`ui/packages/app-shell/src/views/metadata-admin/previews/block-types.ts` 新增的 `'list-view'` 一行;`previews/PageBlockCanvas.addListView-26794.test.tsx`;`previews/__tests__/block-config.test.ts` 删掉的 `PANEL_WITHOUT_PALETTE`。 - **两分钟验证**:`cd ui && pnpm exec vitest run packages/app-shell/src/views/metadata-admin/previews/PageBlockCanvas.addListView-26794.test.tsx`,预期 3 个用例全绿;或在 Studio 打开一个页面,点「添加区块」,在「数据」下选「列表视图」,右侧出现对象、视图、条数上限三个空框,画布上的列表提示先选对象。 - **需要你做的**:无,席位落地。 ## What changes - **The picker offers `list-view`.** `BLOCK_TYPE_META` gains a `list-view` row: category `data`, the `LayoutList` icon (the one its registration in plugin-list names), and the type label `engine.pageBlockCanvas.type.listView`, "List view" / "列表视图". `BlockTypeId` gains the id. `TYPES_BY_CATEGORY`, which the picker renders, is derived from that table, so nothing else changes in the picker. - **The label lives in the `studio` namespace** of `@object-ui/i18n` (en + zh), beside the block's three inspector rows, as the claim's file surface names. Its sibling `engine.pageBlockCanvas.type.*` rows are still in app-shell's designer table (`views/metadata-admin/i18n.ts`); the designer's `t()` asks that table first and the namespace second, so the picker reads either home the same way. - **The exemption goes.** `previews/__tests__/block-config.test.ts` loses `PANEL_WITHOUT_PALETTE` and its self-deleting case; "a block with a config panel is a block the palette offers" now reads the palette with no exemption. - **A fresh block carries no binding.** Measured on main: `addBlock` writes `{ type }` and selects it, and `ListViewBlock` with neither `data` nor `dataSource` draws its existing hint ("No object named: set data.object or dataSource.object.") rather than an empty list. So the chosen behaviour is "asks for its object": the block is written bare, and the panel's Object control writes `dataSource.object`. No canvas default was added. ## Tests `previews/PageBlockCanvas.addListView-26794.test.tsx` drives the real picker in `PageBlockCanvas` and the real `PageBlockInspector`: - the `list-view` row sits under the Data heading, named in en and zh (zh read from the catalogue and checked to differ from en); - picking it writes `[{ type: 'list-view' }]` into the region, the draft passes `PageSchema.safeParse`, and the canvas selects `regions[0].components[0]`; - that selection and draft, handed to the inspector, open Object, View and Row limit, all empty (Row limit hints 5), and typing an object writes `dataSource.object`. ## Acceptance notes - The page-block type labels now live in two homes for one family (one row in the namespace, the rest in the designer table) until the #25922 slice that moves `engine.pageBlockCanvas.*` takes them; the open slice S4 (PR #26900) does not move that family. ## Verification All readings at head `1959511d`, on merge base `6090bb73` (main has since moved to `1708115e` without touching any file of this diff). <details><summary>New test, and the ablation that proves it can fail</summary> - `cd ui && pnpm exec vitest run packages/app-shell/src/views/metadata-admin/previews/PageBlockCanvas.addListView-26794.test.tsx`: Test Files 1 passed, Tests 3 passed. - Ablation (one-time, under the verify lock, restored by trap): the `'list-view'` row deleted from `BLOCK_TYPE_META` (anchor count after mutation 0), then the new file plus `__tests__/block-config.test.ts`: Tests 4 failed, 44 passed, the three new cases and "a block with a config panel is a block the palette offers". Restored: `git diff HEAD` empty, `git hash-object` equals the HEAD blob `f6153ab1`. </details> <details><summary>Touched suites and type-checks</summary> - Narrowed suite, declared: every app-shell test naming `block-types`, `PageBlockCanvas`, `BLOCK_CONFIG` or `PageBlockInspector`, every app-shell or i18n test naming the studio namespace, plus all of `packages/i18n`: Test Files 181 passed, Tests 2387 passed, 13 skipped. The rest of app-shell is left to CI's Test UI shards. - `pnpm --filter @object-ui/i18n run type-check` and `pnpm --filter @object-ui/app-shell run type-check`: exit 0 (both cover test files). - `cd ui && pnpm run check:i18n-designer-parity`: exit 0, every en row has a zh row. `check:i18n-dead-keys`: exit 0, the new key is not reported. `check:new-line-citations`: 0 new citations. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands` derived 34 commands; all 34 ran locally with exit 0 (PR-body gates against this body through `GITHUB_EVENT_PATH`), and `--ran` reconciles 34 derived, 34 run, 0 NOT-MEASURED. `check-partof-closing-keyword` with `PR_BODY`: exit 0. CI's lint and type-check farms are left to CI. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [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. - [ ] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (n/a: no `packages/spec` change) - [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_01QsR2jVDGfqqDsAhmZbg6aF --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jVDGfqqDsAhmZbg6aF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2a40f98 commit 8074feb

6 files changed

Lines changed: 174 additions & 23 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's add-block picker offers the list view block
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
The page designer could edit a `list-view` block a page already carried (its Object, View and Row limit panel), but its add-block picker had no row for it, so a designer could not add one.
12+
13+
- **Offered under Data**, beside Table, Form, Metric and Kanban, named "List view" (Chinese: 列表视图).
14+
- **A fresh block carries no binding.** Picking it writes `{ type: 'list-view' }` and selects it, which opens the Object, View and Row limit panel. Until the Object control writes `dataSource.object`, the list shows its existing hint asking for an object.
15+
- No key is declared anywhere, and nothing an author already wrote changes.
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#26794 — the page designer's add-block picker offers `list-view`.
5+
*
6+
* objectstack#26703 gave the `list-view` block an inspector panel (Object, View,
7+
* Row limit), but the picker is built from `BLOCK_TYPE_META` alone and had no
8+
* `list-view` row, so a designer could edit a list block a page already carried
9+
* and never add one. These cases drive the REAL picker in `PageBlockCanvas`:
10+
* the row sits under the Data heading, picking it writes a bare `list-view`
11+
* node and selects it, and that selection, handed to the REAL inspector with
12+
* the draft the canvas wrote, opens the three controls with nothing bound yet.
13+
*/
14+
15+
import * as React from 'react';
16+
import { describe, it, expect, vi, afterEach } from 'vitest';
17+
import { render, screen, fireEvent, cleanup, within } from '@testing-library/react';
18+
import { PageSchema } from '@objectstack/spec/ui';
19+
import { PageBlockCanvas } from './PageBlockCanvas';
20+
import { PageBlockInspector } from '../inspectors/PageBlockInspector';
21+
import { CATEGORY_LABEL_KEY } from './block-types';
22+
import { t } from '../i18n';
23+
import type { MetadataSelection } from '../preview-registry';
24+
25+
vi.mock('./useObjectFields', () => ({
26+
useObjectFields: () => ({ fields: [], loading: false, error: null }),
27+
}));
28+
// No object catalog: the Object picker falls back to its labelled text box.
29+
vi.mock('../useMetadata', () => ({
30+
useMetadataClient: () => ({ get: vi.fn(async () => undefined), list: vi.fn(async () => [] as unknown[]) }),
31+
}));
32+
33+
// The new type label and the inspector rows live in `@object-ui/i18n`'s
34+
// `studio` namespace, whose `zh` catalogue is fetched on demand
35+
// (objectstack#25922). Resident before any case, so zh reads synchronously.
36+
import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio';
37+
await loadStudioCatalogue('zh');
38+
39+
afterEach(cleanup);
40+
41+
type Draft = Record<string, unknown>;
42+
43+
const EMPTY_PAGE: Draft = {
44+
name: 'home',
45+
label: 'Home',
46+
type: 'home',
47+
template: 'default',
48+
regions: [{ name: 'main', components: [] }],
49+
};
50+
51+
/** Mounts the canvas over `EMPTY_PAGE` and records the drafts and selections it produces. */
52+
function renderCanvas(locale: string) {
53+
const drafts: Draft[] = [];
54+
const selections: Array<MetadataSelection | null> = [];
55+
function Harness() {
56+
const [draft, setDraft] = React.useState<Draft>(EMPTY_PAGE);
57+
return (
58+
<PageBlockCanvas
59+
draft={draft}
60+
locale={locale}
61+
onPatch={(patch) => {
62+
const next = { ...draft, ...patch };
63+
drafts.push(next);
64+
setDraft(next);
65+
}}
66+
onSelectionChange={(next) => selections.push(next)}
67+
/>
68+
);
69+
}
70+
render(<Harness />);
71+
return { drafts, selections };
72+
}
73+
74+
/** Opens the first region's picker and returns the `list-view` row and the heading of the group it sits in. */
75+
function openPickerAtListView(locale: string): { row: HTMLButtonElement; heading: string } {
76+
const [add] = screen.getAllByRole('button', { name: t('engine.inspector.add.block', locale) });
77+
fireEvent.click(add);
78+
const code = screen.getAllByText('list-view').find((el) => el.tagName === 'CODE');
79+
expect(code, 'the picker offers a `list-view` row').toBeDefined();
80+
const row = code!.closest('button') as HTMLButtonElement;
81+
const group = row.parentElement!;
82+
return { row, heading: group.firstElementChild!.textContent ?? '' };
83+
}
84+
85+
describe('the add-block picker offers list-view (objectstack#26794)', () => {
86+
it('lists it under the Data heading, named in the designer locale', () => {
87+
for (const locale of ['en-US', 'zh-CN']) {
88+
renderCanvas(locale);
89+
const { row, heading } = openPickerAtListView(locale);
90+
expect(heading).toBe(t(CATEGORY_LABEL_KEY.data, locale));
91+
expect(within(row).getByText(t('engine.pageBlockCanvas.type.listView', locale))).toBeTruthy();
92+
cleanup();
93+
}
94+
// Control: the zh row is a translation, not the en name read through.
95+
expect(t('engine.pageBlockCanvas.type.listView', 'zh-CN')).not.toBe(t('engine.pageBlockCanvas.type.listView', 'en-US'));
96+
});
97+
98+
it('adding it writes a bare list-view node the spec accepts, and selects it', () => {
99+
const { drafts, selections } = renderCanvas('en-US');
100+
fireEvent.click(openPickerAtListView('en-US').row);
101+
102+
const written = drafts.at(-1)!;
103+
expect((written.regions as Array<{ components: unknown[] }>)[0].components).toEqual([{ type: 'list-view' }]);
104+
expect(PageSchema.safeParse(written).success).toBe(true);
105+
expect(selections.at(-1)).toMatchObject({ kind: 'block', id: 'regions[0].components[0]' });
106+
});
107+
108+
it('selecting the added block opens the Object, View and Row limit panel, nothing bound yet', () => {
109+
const { drafts, selections } = renderCanvas('en-US');
110+
fireEvent.click(openPickerAtListView('en-US').row);
111+
const draft = drafts.at(-1)!;
112+
const selection = selections.at(-1)!;
113+
cleanup();
114+
115+
const onPatch = vi.fn();
116+
render(
117+
<PageBlockInspector
118+
type="page"
119+
name="home"
120+
draft={draft}
121+
selection={selection}
122+
onPatch={onPatch}
123+
onClearSelection={() => {}}
124+
readOnly={false}
125+
locale="en-US"
126+
/>,
127+
);
128+
const object = screen.getByLabelText('Object') as HTMLInputElement;
129+
const view = screen.getByLabelText('View') as HTMLInputElement;
130+
const limit = screen.getByLabelText('Row limit') as HTMLInputElement;
131+
expect([object.value, view.value, limit.value]).toEqual(['', '', '']);
132+
expect(limit.placeholder).toBe('5');
133+
134+
// The Object control is how the fresh block gets its object: it writes the
135+
// node-level `dataSource` binding the list reads.
136+
fireEvent.change(object, { target: { value: 'contact' } });
137+
const block = (onPatch.mock.calls.at(-1)![0] as { regions: Array<{ components: Array<Record<string, unknown>> }> })
138+
.regions[0].components[0];
139+
expect(block).toEqual({ type: 'list-view', dataSource: { object: 'contact' } });
140+
});
141+
});

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

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -257,11 +257,6 @@ 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-
265260
describe('page palette ↔ spec PageComponentType coverage', () => {
266261
const specNames: string[] = enumOptions(PageComponentType);
267262

@@ -350,28 +345,13 @@ describe('page palette ↔ spec PageComponentType coverage', () => {
350345
// is the question this case asks, so the palette is the only thing it
351346
// reads — `TYPES_BY_CATEGORY`, the list the page canvas's picker renders.
352347
//
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.
348+
// No exemption: `list-view` had a panel and no palette row from
349+
// objectstack#26703 until objectstack#26794 offered it.
360350
const offered = new Set<string>(TYPES_BY_CATEGORY.flatMap((g) => g.types));
361-
const orphanPanels = Object.keys(BLOCK_CONFIG).filter((t) => !offered.has(t) && !(t in PANEL_WITHOUT_PALETTE));
351+
const orphanPanels = Object.keys(BLOCK_CONFIG).filter((t) => !offered.has(t));
362352
expect(orphanPanels, 'these expose a config panel but cannot be authored').toEqual([]);
363353
});
364354

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-
375355
describe("record:quick_actions `location` — the designer's action-location dropdown", () => {
376356
const locationField = () =>
377357
BLOCK_CONFIG['record:quick_actions'].find((f) => f.name === 'location') as

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,14 @@ import {
4444
Gauge,
4545
Columns3,
4646
LayoutGrid,
47+
LayoutList,
4748
type LucideIcon,
4849
} from 'lucide-react';
4950

5051
export type BlockTypeId =
5152
// data — object-bound views & layout grid (the high-traffic app-page blocks)
5253
| 'grid' | 'object-grid' | 'object-form' | 'object-metric' | 'object-kanban'
54+
| 'list-view'
5355
// page:*
5456
| 'page:header' | 'page:footer' | 'page:sidebar' | 'page:tabs'
5557
| 'page:accordion' | 'page:card' | 'page:section'
@@ -91,6 +93,11 @@ export const BLOCK_TYPE_META: Record<BlockTypeId, Omit<BlockTypeMeta, 'id'>> = {
9193
'object-form': { label: 'Form', labelKey: 'engine.pageBlockCanvas.type.objectForm', category: 'data', Icon: FormInput },
9294
'object-metric': { label: 'Metric', labelKey: 'engine.pageBlockCanvas.type.objectMetric', category: 'data', Icon: Gauge },
9395
'object-kanban': { label: 'Kanban', labelKey: 'engine.pageBlockCanvas.type.objectKanban', category: 'data', Icon: Columns3 },
96+
// A public block (`core/src/registry/public-blocks.ts`) whose panel sets its
97+
// object, view and row cap (`BLOCK_CONFIG['list-view']`). A fresh one carries
98+
// no `dataSource`: the list draws a hint asking for its object until the
99+
// panel's Object control writes `dataSource.object` (objectstack#26794).
100+
'list-view': { label: 'List view', labelKey: 'engine.pageBlockCanvas.type.listView', category: 'data', Icon: LayoutList },
94101

95102
// Page layout
96103
'page:header': { label: 'Header', labelKey: 'engine.pageBlockCanvas.type.pageHeader', category: 'layout', Icon: PanelTop },

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,10 @@ const STUDIO_EN: Record<string, string> = {
730730
'engine.inspector.pageBlock.field.list-view.object': 'Object',
731731
'engine.inspector.pageBlock.field.list-view.view': 'View',
732732
'engine.inspector.pageBlock.field.list-view.limit': 'Row limit',
733+
// The add-block picker's name for the block whose panel the three rows above
734+
// label; its sibling `engine.pageBlockCanvas.type.*` rows are still in the
735+
// designer table, app-shell `views/metadata-admin/i18n.ts` (objectstack#26794).
736+
'engine.pageBlockCanvas.type.listView': 'List view',
733737
'engine.inspector.pageBlock.field.grid.columns': 'Columns',
734738
'engine.inspector.pageBlock.field.grid.gap': 'Gap',
735739
'engine.inspector.pageBlock.field.element:text.content': 'Content',

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,10 @@ const STUDIO_ZH: Record<string, string> = {
624624
'engine.inspector.pageBlock.field.list-view.object': '对象',
625625
'engine.inspector.pageBlock.field.list-view.view': '视图',
626626
'engine.inspector.pageBlock.field.list-view.limit': '条数上限',
627+
// The add-block picker's name for the block whose panel the three rows above
628+
// label; its sibling `engine.pageBlockCanvas.type.*` rows are still in the
629+
// designer table, app-shell `views/metadata-admin/i18n.ts` (objectstack#26794).
630+
'engine.pageBlockCanvas.type.listView': '列表视图',
627631
'engine.inspector.pageBlock.field.grid.columns': '列数',
628632
'engine.inspector.pageBlock.field.grid.gap': '间距',
629633
'engine.inspector.pageBlock.field.element:text.content': '内容',

0 commit comments

Comments
 (0)