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/26794-list-view-in-add-block-palette.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's add-block picker offers the list view block

Clause-②: no
Metadata-protocol: none

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.

- **Offered under Data**, beside Table, Form, Metric and Kanban, named "List view" (Chinese: 列表视图).
- **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.
- No key is declared anywhere, and nothing an author already wrote changes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack#26794 — the page designer's add-block picker offers `list-view`.
*
* objectstack#26703 gave the `list-view` block an inspector panel (Object, View,
* Row limit), but the picker is built from `BLOCK_TYPE_META` alone and had no
* `list-view` row, so a designer could edit a list block a page already carried
* and never add one. These cases drive the REAL picker in `PageBlockCanvas`:
* the row sits under the Data heading, picking it writes a bare `list-view`
* node and selects it, and that selection, handed to the REAL inspector with
* the draft the canvas wrote, opens the three controls with nothing bound yet.
*/

import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, within } from '@testing-library/react';
import { PageSchema } from '@objectstack/spec/ui';
import { PageBlockCanvas } from './PageBlockCanvas';
import { PageBlockInspector } from '../inspectors/PageBlockInspector';
import { CATEGORY_LABEL_KEY } from './block-types';
import { t } from '../i18n';
import type { MetadataSelection } from '../preview-registry';

vi.mock('./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 new type label and the inspector rows live in `@object-ui/i18n`'s
// `studio` namespace, whose `zh` catalogue is fetched on demand
// (objectstack#25922). Resident before any case, so zh reads synchronously.
import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio';
await loadStudioCatalogue('zh');

afterEach(cleanup);

type Draft = Record<string, unknown>;

const EMPTY_PAGE: Draft = {
name: 'home',
label: 'Home',
type: 'home',
template: 'default',
regions: [{ name: 'main', components: [] }],
};

/** Mounts the canvas over `EMPTY_PAGE` and records the drafts and selections it produces. */
function renderCanvas(locale: string) {
const drafts: Draft[] = [];
const selections: Array<MetadataSelection | null> = [];
function Harness() {
const [draft, setDraft] = React.useState<Draft>(EMPTY_PAGE);
return (
<PageBlockCanvas
draft={draft}
locale={locale}
onPatch={(patch) => {
const next = { ...draft, ...patch };
drafts.push(next);
setDraft(next);
}}
onSelectionChange={(next) => selections.push(next)}
/>
);
}
render(<Harness />);
return { drafts, selections };
}

/** Opens the first region's picker and returns the `list-view` row and the heading of the group it sits in. */
function openPickerAtListView(locale: string): { row: HTMLButtonElement; heading: string } {
const [add] = screen.getAllByRole('button', { name: t('engine.inspector.add.block', locale) });
fireEvent.click(add);
const code = screen.getAllByText('list-view').find((el) => el.tagName === 'CODE');
expect(code, 'the picker offers a `list-view` row').toBeDefined();
const row = code!.closest('button') as HTMLButtonElement;
const group = row.parentElement!;
return { row, heading: group.firstElementChild!.textContent ?? '' };
}

describe('the add-block picker offers list-view (objectstack#26794)', () => {
it('lists it under the Data heading, named in the designer locale', () => {
for (const locale of ['en-US', 'zh-CN']) {
renderCanvas(locale);
const { row, heading } = openPickerAtListView(locale);
expect(heading).toBe(t(CATEGORY_LABEL_KEY.data, locale));
expect(within(row).getByText(t('engine.pageBlockCanvas.type.listView', locale))).toBeTruthy();
cleanup();
}
// Control: the zh row is a translation, not the en name read through.
expect(t('engine.pageBlockCanvas.type.listView', 'zh-CN')).not.toBe(t('engine.pageBlockCanvas.type.listView', 'en-US'));
});

it('adding it writes a bare list-view node the spec accepts, and selects it', () => {
const { drafts, selections } = renderCanvas('en-US');
fireEvent.click(openPickerAtListView('en-US').row);

const written = drafts.at(-1)!;
expect((written.regions as Array<{ components: unknown[] }>)[0].components).toEqual([{ type: 'list-view' }]);
expect(PageSchema.safeParse(written).success).toBe(true);
expect(selections.at(-1)).toMatchObject({ kind: 'block', id: 'regions[0].components[0]' });
});

it('selecting the added block opens the Object, View and Row limit panel, nothing bound yet', () => {
const { drafts, selections } = renderCanvas('en-US');
fireEvent.click(openPickerAtListView('en-US').row);
const draft = drafts.at(-1)!;
const selection = selections.at(-1)!;
cleanup();

const onPatch = vi.fn();
render(
<PageBlockInspector
type="page"
name="home"
draft={draft}
selection={selection}
onPatch={onPatch}
onClearSelection={() => {}}
readOnly={false}
locale="en-US"
/>,
);
const object = screen.getByLabelText('Object') as HTMLInputElement;
const view = screen.getByLabelText('View') as HTMLInputElement;
const limit = screen.getByLabelText('Row limit') as HTMLInputElement;
expect([object.value, view.value, limit.value]).toEqual(['', '', '']);
expect(limit.placeholder).toBe('5');

// The Object control is how the fresh block gets its object: it writes the
// node-level `dataSource` binding the list reads.
fireEvent.change(object, { target: { value: 'contact' } });
const block = (onPatch.mock.calls.at(-1)![0] as { regions: Array<{ components: Array<Record<string, unknown>> }> })
.regions[0].components[0];
expect(block).toEqual({ type: 'list-view', dataSource: { object: 'contact' } });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -257,11 +257,6 @@ 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 @@ -350,28 +345,13 @@ describe('page palette ↔ spec PageComponentType coverage', () => {
// 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.
// No exemption: `list-view` had a panel and no palette row from
// objectstack#26703 until objectstack#26794 offered it.
const offered = new Set<string>(TYPES_BY_CATEGORY.flatMap((g) => g.types));
const orphanPanels = Object.keys(BLOCK_CONFIG).filter((t) => !offered.has(t) && !(t in PANEL_WITHOUT_PALETTE));
const orphanPanels = Object.keys(BLOCK_CONFIG).filter((t) => !offered.has(t));
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 @@ -44,12 +44,14 @@ import {
Gauge,
Columns3,
LayoutGrid,
LayoutList,
type LucideIcon,
} from 'lucide-react';

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

// Page layout
'page:header': { label: 'Header', labelKey: 'engine.pageBlockCanvas.type.pageHeader', category: 'layout', Icon: PanelTop },
Expand Down
4 changes: 4 additions & 0 deletions ui/packages/i18n/src/namespaces/studio/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,10 @@ const STUDIO_EN: Record<string, string> = {
'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',
// The add-block picker's name for the block whose panel the three rows above
// label; its sibling `engine.pageBlockCanvas.type.*` rows are still in the
// designer table, app-shell `views/metadata-admin/i18n.ts` (objectstack#26794).
'engine.pageBlockCanvas.type.listView': 'List view',
'engine.inspector.pageBlock.field.grid.columns': 'Columns',
'engine.inspector.pageBlock.field.grid.gap': 'Gap',
'engine.inspector.pageBlock.field.element:text.content': 'Content',
Expand Down
4 changes: 4 additions & 0 deletions ui/packages/i18n/src/namespaces/studio/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,10 @@ const STUDIO_ZH: Record<string, string> = {
'engine.inspector.pageBlock.field.list-view.object': '对象',
'engine.inspector.pageBlock.field.list-view.view': '视图',
'engine.inspector.pageBlock.field.list-view.limit': '条数上限',
// The add-block picker's name for the block whose panel the three rows above
// label; its sibling `engine.pageBlockCanvas.type.*` rows are still in the
// designer table, app-shell `views/metadata-admin/i18n.ts` (objectstack#26794).
'engine.pageBlockCanvas.type.listView': '列表视图',
'engine.inspector.pageBlock.field.grid.columns': '列数',
'engine.inspector.pageBlock.field.grid.gap': '间距',
'engine.inspector.pageBlock.field.element:text.content': '内容',
Expand Down
Loading