Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/26703-list-view-row-cap-inspector.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): Record<string, unknown> {
return PageSchema.parse({
name: 'home',
label: 'Home',
type: 'home',
template: 'default',
regions: [{ name: 'main', components: [{ id: 'b1', ...block }] }],
}) as unknown as Record<string, unknown>;
}

function renderInspector(draft: Record<string, unknown>, locale: SupportedLocale = 'en-US'): Mock<MetadataInspectorProps['onPatch']> {
const onPatch: Mock<MetadataInspectorProps['onPatch']> = vi.fn();
render(
<PageBlockInspector
type="page"
name="home"
draft={draft}
selection={{ kind: 'block', id: BLOCK_PATH }}
onPatch={onPatch}
onClearSelection={() => {}}
readOnly={false}
locale={locale}
/>,
);
return onPatch;
}

/** The block as the inspector last wrote it (the patch is shallow: `{ regions }`). */
const committedBlock = (onPatch: Mock<MetadataInspectorProps['onPatch']>): Record<string, unknown> =>
(onPatch.mock.calls.at(-1)![0] as { regions: Array<{ components: Array<Record<string, unknown>> }> }).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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand All @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>> = {
'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);

Expand Down Expand Up @@ -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<string>(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<string>(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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,17 @@ export const BLOCK_CONFIG: Record<string, BlockPropField[]> = {
// `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: [
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/namespaces/studio/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,9 @@ const STUDIO_EN: Record<string, string> = {
'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',
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/namespaces/studio/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,9 @@ const STUDIO_ZH: Record<string, string> = {
'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': '内容',
Expand Down
Loading