diff --git a/.changeset/26794-list-view-in-add-block-palette.md b/.changeset/26794-list-view-in-add-block-palette.md new file mode 100644 index 00000000000..7b11fd186b6 --- /dev/null +++ b/.changeset/26794-list-view-in-add-block-palette.md @@ -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. diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/PageBlockCanvas.addListView-26794.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/PageBlockCanvas.addListView-26794.test.tsx new file mode 100644 index 00000000000..4a5a3c6cee3 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/PageBlockCanvas.addListView-26794.test.tsx @@ -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; + +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 = []; + function Harness() { + const [draft, setDraft] = React.useState(EMPTY_PAGE); + return ( + { + const next = { ...draft, ...patch }; + drafts.push(next); + setDraft(next); + }} + onSelectionChange={(next) => selections.push(next)} + /> + ); + } + render(); + 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( + {}} + 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> }> }) + .regions[0].components[0]; + expect(block).toEqual({ type: 'list-view', dataSource: { object: 'contact' } }); + }); +}); 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 094468a91a4..b9a21d79c31 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,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> = { - '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); @@ -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(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(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-types.ts b/ui/packages/app-shell/src/views/metadata-admin/previews/block-types.ts index 98dbe3af699..f6153ab17f0 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/block-types.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/block-types.ts @@ -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' @@ -91,6 +93,11 @@ export const BLOCK_TYPE_META: Record> = { '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 }, diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index 58ef59fd94d..2dce8422368 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -730,6 +730,10 @@ const STUDIO_EN: Record = { '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', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index bcccf115b5d..58902924479 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -624,6 +624,10 @@ const STUDIO_ZH: Record = { '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': '内容',