From 42add54179564419997b4e420a1577dc2b0113a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 14:52:42 +0000 Subject: [PATCH 1/3] feat(app-shell): the page designer can set a list block's object, view and row cap A list-view block had no BLOCK_CONFIG entry, so its inspector showed no typed fields and its row cap (dataSource.limit) could be set only in JSON. Add Object, View and Row limit controls that write the node-level dataSource binding, with en and zh labels. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jTqu1ZbUqjnAJufZ8m --- .../26703-list-view-row-cap-inspector.md | 14 +++ .../src/views/metadata-admin/i18n.ts | 6 ++ ...ockInspector.listViewRowCap-26703.test.tsx | 98 +++++++++++++++++++ .../__tests__/block-config-i18n.test.ts | 11 ++- .../previews/__tests__/block-config.test.ts | 25 ++++- .../metadata-admin/previews/block-config.ts | 11 +++ 6 files changed, 160 insertions(+), 5 deletions(-) create mode 100644 .changeset/26703-list-view-row-cap-inspector.md create mode 100644 ui/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.listViewRowCap-26703.test.tsx diff --git a/.changeset/26703-list-view-row-cap-inspector.md b/.changeset/26703-list-view-row-cap-inspector.md new file mode 100644 index 00000000000..71de17d5948 --- /dev/null +++ b/.changeset/26703-list-view-row-cap-inspector.md @@ -0,0 +1,14 @@ +--- +'@object-ui/app-shell': patch +--- + +feat(app-shell): the page designer can set a list block's object, view and row cap + +Clause-②: no +Metadata-protocol: none + +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. + +- **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. +- **Labels in both designer locales**, English and Chinese. +- 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. diff --git a/ui/packages/app-shell/src/views/metadata-admin/i18n.ts b/ui/packages/app-shell/src/views/metadata-admin/i18n.ts index ca2c38c0292..fce4f5bf8a3 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -896,6 +896,9 @@ const ENGINE_STRINGS_EN: Record = { 'engine.inspector.pageBlock.field.object-kanban.titleField': 'Title field', 'engine.inspector.pageBlock.field.object-kanban.cardFields': 'Card fields', 'engine.inspector.pageBlock.field.object-kanban.limit': 'Limit', + 'engine.inspector.pageBlock.field.list-view.object': 'Object', + 'engine.inspector.pageBlock.field.list-view.view': 'View', + 'engine.inspector.pageBlock.field.list-view.limit': 'Row limit', 'engine.inspector.pageBlock.field.grid.columns': 'Columns', 'engine.inspector.pageBlock.field.grid.gap': 'Gap', 'engine.inspector.pageBlock.field.element:text.content': 'Content', @@ -4182,6 +4185,9 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.inspector.pageBlock.field.object-kanban.titleField': '标题字段', 'engine.inspector.pageBlock.field.object-kanban.cardFields': '卡片字段', 'engine.inspector.pageBlock.field.object-kanban.limit': '条数上限', + 'engine.inspector.pageBlock.field.list-view.object': '对象', + 'engine.inspector.pageBlock.field.list-view.view': '视图', + 'engine.inspector.pageBlock.field.list-view.limit': '条数上限', 'engine.inspector.pageBlock.field.grid.columns': '列数', 'engine.inspector.pageBlock.field.grid.gap': '间距', 'engine.inspector.pageBlock.field.element:text.content': '内容', diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.listViewRowCap-26703.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.listViewRowCap-26703.test.tsx new file mode 100644 index 00000000000..7b7451fd075 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.listViewRowCap-26703.test.tsx @@ -0,0 +1,98 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26703 — the page designer can set a `list-view` block's row cap. + * + * A list block in a wide page region draws its first `dataSource.limit` rows, + * 5 when none is stated (objectstack#26613). `BLOCK_CONFIG` had no `list-view` + * entry, so selecting one opened an inspector with no typed fields, and the + * "Advanced" section lists only keys the block already has: the cap could be + * set only in JSON. These rows drive the REAL inspector and assert the patch + * it hands the editor: the Object, View and Row limit controls write the + * node-level `dataSource` binding `ListViewBlock` reads, never `properties`. + */ + +import { describe, it, expect, vi, afterEach, type Mock } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { PageSchema } from '@objectstack/spec/ui'; +import { PageBlockInspector } from './PageBlockInspector'; +import type { MetadataInspectorProps } from '../inspector-registry'; + +vi.mock('../previews/useObjectFields', () => ({ + useObjectFields: () => ({ fields: [], loading: false, error: null }), +})); +// No object catalog: the Object picker falls back to its labelled text box. +vi.mock('../useMetadata', () => ({ + useMetadataClient: () => ({ get: vi.fn(async () => undefined), list: vi.fn(async () => [] as unknown[]) }), +})); + +afterEach(cleanup); + +const BLOCK_PATH = 'regions[0].components[0]'; + +/** Author a page the way a user does, parse it with the spec, and hand the RESULT to the inspector. */ +function pageDraft(block: Record): Record { + return PageSchema.parse({ + name: 'home', + label: 'Home', + type: 'home', + template: 'default', + regions: [{ name: 'main', components: [{ id: 'b1', ...block }] }], + }) as unknown as Record; +} + +function renderInspector(draft: Record, locale = 'en-US'): Mock { + const onPatch: Mock = vi.fn(); + render( + {}} + readOnly={false} + locale={locale} + />, + ); + return onPatch; +} + +/** The block as the inspector last wrote it (the patch is shallow: `{ regions }`). */ +const committedBlock = (onPatch: Mock): Record => + (onPatch.mock.calls.at(-1)![0] as { regions: Array<{ components: Array> }> }).regions[0] + .components[0]; + +describe('the designer sets a list-view block’s row cap (objectstack#26703)', () => { + it('shows an editable Row limit box, hinting the capped list’s own 5 when empty', () => { + renderInspector(pageDraft({ type: 'list-view', dataSource: { object: 'contact' } })); + const limit = screen.getByLabelText('Row limit') as HTMLInputElement; + expect(limit.type).toBe('number'); + expect(limit.value).toBe(''); + expect(limit.placeholder).toBe('5'); + expect(limit.disabled).toBe(false); + }); + + it('editing the box writes `dataSource.limit` beside the bound object, not `properties.limit`', () => { + const onPatch = renderInspector(pageDraft({ type: 'list-view', dataSource: { object: 'contact', view: 'mine' }, properties: {} })); + fireEvent.change(screen.getByLabelText('Row limit'), { target: { value: '12' } }); + const block = committedBlock(onPatch); + expect(block.dataSource).toEqual({ object: 'contact', view: 'mine', limit: 12 }); + expect(block.properties).toEqual({}); + }); + + it('the box shows a stored cap, and the Object and View controls show and write the binding', () => { + const onPatch = renderInspector(pageDraft({ type: 'list-view', dataSource: { object: 'contact', view: 'mine', limit: 8 } })); + expect((screen.getByLabelText('Row limit') as HTMLInputElement).value).toBe('8'); + expect((screen.getByLabelText('Object') as HTMLInputElement).value).toBe('contact'); + expect((screen.getByLabelText('View') as HTMLInputElement).value).toBe('mine'); + fireEvent.change(screen.getByLabelText('View'), { target: { value: 'all_open' } }); + expect(committedBlock(onPatch).dataSource).toEqual({ object: 'contact', view: 'all_open', limit: 8 }); + }); + + it('labels the controls in the designer locale', () => { + renderInspector(pageDraft({ type: 'list-view', dataSource: { object: 'contact' } }), 'zh-CN'); + expect(screen.getByLabelText('条数上限')).toBeTruthy(); + expect(screen.getByLabelText('视图')).toBeTruthy(); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-i18n.test.ts b/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-i18n.test.ts index d6a789cca37..aeee571dd02 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-i18n.test.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-i18n.test.ts @@ -392,9 +392,10 @@ describe('placeholders that must NOT be translated (#3979)', () => { because: "a row limit — DEFAULT_KANBAN_FETCH_BATCH_SIZE, the board's fetch batch when the box is empty", }, 'record:details.sections.columns': { literal: '2', because: 'a column count' }, + 'list-view.limit': { literal: '5', because: "a row limit — the capped list's row count when the box is empty" }, }; - it('collects all 17 placeholders — 6 keyed, 11 literal', () => { + it('collects all 18 placeholders — 6 keyed, 12 literal', () => { // Guards the walk: if placeholder collection silently found nothing, every // assertion here would pass over an empty list. // @@ -406,10 +407,12 @@ describe('placeholders that must NOT be translated (#3979)', () => { // `DEFAULT_KANBAN_FETCH_BATCH_SIZE`), so it lands on the literal side and the keyed // half is what stays untouched this time. Then 18 / 7 / 11 until // objectui#8280 removed the `ai:input` panel, whose `agentName` box was a - // KEYED placeholder — the palette never offered that block. - expect(PLACEHOLDERS.length).toBe(17); + // KEYED placeholder — the palette never offered that block. Then 17 / 6 / 11 + // until objectstack#26703 gave `list-view` its row-cap box, a VALUE + // placeholder (`5`, the capped list's own count). + expect(PLACEHOLDERS.length).toBe(18); expect(PLACEHOLDERS.filter((p) => p.spec.key !== undefined).length).toBe(6); - expect(PLACEHOLDERS.filter((p) => p.spec.literal !== undefined).length).toBe(11); + expect(PLACEHOLDERS.filter((p) => p.spec.literal !== undefined).length).toBe(12); }); it('every placeholder declares exactly one of key / literal', () => { diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts b/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts index 19db07e6f87..4a3cbe309fd 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts @@ -252,6 +252,11 @@ describe('record:details sections ↔ spec section-entry coverage (#3819)', () = * from the enum instead, so every value must be an explicit decision — * offered, or excluded with a documented reason. */ +/** Blocks with a config panel and no palette entry, each with why it is still authorable. */ +const PANEL_WITHOUT_PALETTE: Readonly> = { + 'list-view': 'a public block that published pages place; the panel edits the node a page already carries (objectstack#26703)', +}; + describe('page palette ↔ spec PageComponentType coverage', () => { const specNames: string[] = enumOptions(PageComponentType); @@ -339,11 +344,29 @@ describe('page palette ↔ spec PageComponentType coverage', () => { // curated panel and no way to author it (objectui#8280). Palette membership // is the question this case asks, so the palette is the only thing it // reads — `TYPES_BY_CATEGORY`, the list the page canvas's picker renders. + // + // `PANEL_WITHOUT_PALETTE` names the blocks that are authorable WITHOUT the + // palette, each with the stored documents that carry it, so a panel for one + // is an edit of a real node rather than an advert for an unauthorable one. + // `list-view` (objectstack#26703): a public block (`core/src/registry/ + // public-blocks.ts`) that published pages place, such as + // `packages/objectql/src/__fixtures__/hotcrm-4.0.1-published-pages.artifact.json`, + // and whose row cap the designer could otherwise set only in JSON. const offered = new Set(TYPES_BY_CATEGORY.flatMap((g) => g.types)); - const orphanPanels = Object.keys(BLOCK_CONFIG).filter((t) => !offered.has(t)); + const orphanPanels = Object.keys(BLOCK_CONFIG).filter((t) => !offered.has(t) && !(t in PANEL_WITHOUT_PALETTE)); expect(orphanPanels, 'these expose a config panel but cannot be authored').toEqual([]); }); + it('every palette-less panel is still palette-less and still has a panel', () => { + // Self-deleting, like the schema-parity EXEMPT rows: the day the palette + // offers the block, or its panel goes, the row must go too. + const offered = new Set(TYPES_BY_CATEGORY.flatMap((g) => g.types)); + for (const type of Object.keys(PANEL_WITHOUT_PALETTE)) { + expect(offered.has(type), `'${type}' is offered now — delete its row`).toBe(false); + expect(blockHasConfig(type), `'${type}' has no panel — delete its row`).toBe(true); + } + }); + describe("record:quick_actions `location` — the designer's action-location dropdown", () => { const locationField = () => BLOCK_CONFIG['record:quick_actions'].find((f) => f.name === 'location') as diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/block-config.ts b/ui/packages/app-shell/src/views/metadata-admin/previews/block-config.ts index 228dc2a1ea5..115a5aceee9 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/block-config.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/block-config.ts @@ -257,6 +257,17 @@ export const BLOCK_CONFIG: Record = { // `DEFAULT_KANBAN_FETCH_BATCH_SIZE`, the value that applies when the box is empty. { name: 'limit', label: 'engine.inspector.pageBlock.field.object-kanban.limit', kind: 'number', placeholder: { literal: '100' } }, ], + // The page `list-view` block reads its query from the node-level `dataSource` + // binding (`ListViewBlock`, plugin-list): `object` lands on `objectName`, + // `view` names the saved view, and `limit` is its row cap, which a list in a + // wide page region draws as its first rows (objectstack#26613). So all three + // controls write `dataSource.*`, the way `element:repeater`'s do below. + // `{ literal: '5' }` is the capped list's own count when no limit is stated. + 'list-view': [ + { name: 'object', label: 'engine.inspector.pageBlock.field.list-view.object', kind: 'object-picker', at: 'dataSource' }, + { name: 'view', label: 'engine.inspector.pageBlock.field.list-view.view', kind: 'text', at: 'dataSource' }, + { name: 'limit', label: 'engine.inspector.pageBlock.field.list-view.limit', kind: 'number', placeholder: { literal: '5' }, at: 'dataSource' }, + ], // ── Layout grid ─────────────────────────────────────────────────────────── grid: [ From 069c0195e50f30f59c2415ff001aa568bdf61302 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 15:09:13 +0000 Subject: [PATCH 2/3] test(app-shell): type the list-view inspector test's locale Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jTqu1ZbUqjnAJufZ8m --- .../PageBlockInspector.listViewRowCap-26703.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.listViewRowCap-26703.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.listViewRowCap-26703.test.tsx index 7b7451fd075..469414a2cf5 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.listViewRowCap-26703.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.listViewRowCap-26703.test.tsx @@ -17,6 +17,7 @@ import { render, screen, fireEvent, cleanup } from '@testing-library/react'; import { PageSchema } from '@objectstack/spec/ui'; import { PageBlockInspector } from './PageBlockInspector'; import type { MetadataInspectorProps } from '../inspector-registry'; +import type { SupportedLocale } from '../i18n'; vi.mock('../previews/useObjectFields', () => ({ useObjectFields: () => ({ fields: [], loading: false, error: null }), @@ -41,7 +42,7 @@ function pageDraft(block: Record): Record { }) as unknown as Record; } -function renderInspector(draft: Record, locale = 'en-US'): Mock { +function renderInspector(draft: Record, locale: SupportedLocale = 'en-US'): Mock { const onPatch: Mock = vi.fn(); render( Date: Tue, 27 Oct 2026 16:28:39 +0000 Subject: [PATCH 3/3] fix(app-shell): the list-view inspector labels live in the studio i18n namespace Main moved the engine.inspector.pageBlock rows into @object-ui/i18n's lazy studio namespace. Move the three list-view rows there, and load the zh catalogue before the inspector test reads a zh label. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jTqu1ZbUqjnAJufZ8m --- .changeset/26703-list-view-row-cap-inspector.md | 3 ++- .../PageBlockInspector.listViewRowCap-26703.test.tsx | 6 ++++++ ui/packages/i18n/src/namespaces/studio/en.ts | 3 +++ ui/packages/i18n/src/namespaces/studio/zh.ts | 3 +++ 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.changeset/26703-list-view-row-cap-inspector.md b/.changeset/26703-list-view-row-cap-inspector.md index 71de17d5948..78c1a75d5d5 100644 --- a/.changeset/26703-list-view-row-cap-inspector.md +++ b/.changeset/26703-list-view-row-cap-inspector.md @@ -1,5 +1,6 @@ --- '@object-ui/app-shell': patch +'@object-ui/i18n': patch --- feat(app-shell): the page designer can set a list block's object, view and row cap @@ -10,5 +11,5 @@ Metadata-protocol: none 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. - **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. -- **Labels in both designer locales**, English and Chinese. +- **Labels in both designer locales**, English and Chinese, in the `studio` namespace of `@object-ui/i18n` where the page inspector's rows live. - 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. diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.listViewRowCap-26703.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.listViewRowCap-26703.test.tsx index 469414a2cf5..d0b82c56a6c 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.listViewRowCap-26703.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.listViewRowCap-26703.test.tsx @@ -27,6 +27,12 @@ vi.mock('../useMetadata', () => ({ useMetadataClient: () => ({ get: vi.fn(async () => undefined), list: vi.fn(async () => [] as unknown[]) }), })); +// The inspector's `engine.inspector.*` rows live in `@object-ui/i18n`'s `studio` +// namespace, whose `zh` catalogue is fetched on demand (objectstack#25922). +// Resident before any case, so the zh rows below read synchronously. +import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio'; +await loadStudioCatalogue('zh'); + afterEach(cleanup); const BLOCK_PATH = 'regions[0].components[0]'; diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index 39bc00d0abc..babdd8aca27 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -727,6 +727,9 @@ const STUDIO_EN: Record = { 'engine.inspector.pageBlock.field.object-kanban.titleField': 'Title field', 'engine.inspector.pageBlock.field.object-kanban.cardFields': 'Card fields', 'engine.inspector.pageBlock.field.object-kanban.limit': 'Limit', + 'engine.inspector.pageBlock.field.list-view.object': 'Object', + 'engine.inspector.pageBlock.field.list-view.view': 'View', + 'engine.inspector.pageBlock.field.list-view.limit': 'Row limit', 'engine.inspector.pageBlock.field.grid.columns': 'Columns', 'engine.inspector.pageBlock.field.grid.gap': 'Gap', 'engine.inspector.pageBlock.field.element:text.content': 'Content', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index d2a2894511b..70b0f080ec5 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -621,6 +621,9 @@ const STUDIO_ZH: Record = { 'engine.inspector.pageBlock.field.object-kanban.titleField': '标题字段', 'engine.inspector.pageBlock.field.object-kanban.cardFields': '卡片字段', 'engine.inspector.pageBlock.field.object-kanban.limit': '条数上限', + 'engine.inspector.pageBlock.field.list-view.object': '对象', + 'engine.inspector.pageBlock.field.list-view.view': '视图', + 'engine.inspector.pageBlock.field.list-view.limit': '条数上限', 'engine.inspector.pageBlock.field.grid.columns': '列数', 'engine.inspector.pageBlock.field.grid.gap': '间距', 'engine.inspector.pageBlock.field.element:text.content': '内容',