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..78c1a75d5d5 --- /dev/null +++ b/.changeset/26703-list-view-row-cap-inspector.md @@ -0,0 +1,15 @@ +--- +'@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 + +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, 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 new file mode 100644 index 00000000000..d0b82c56a6c --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.listViewRowCap-26703.test.tsx @@ -0,0 +1,105 @@ +// 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'; +import type { SupportedLocale } from '../i18n'; + +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[]) }), +})); + +// 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]'; + +/** 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: SupportedLocale = '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 59269718d48..ef5bbb99ed0 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 @@ -397,9 +397,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. // @@ -411,10 +412,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 aface69b403..094468a91a4 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 @@ -257,6 +257,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); @@ -344,11 +349,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: [ 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': '内容',