diff --git a/.changeset/page-block-config-panels.md b/.changeset/page-block-config-panels.md new file mode 100644 index 000000000..1f99f9afe --- /dev/null +++ b/.changeset/page-block-config-panels.md @@ -0,0 +1,5 @@ +--- +"@object-ui/app-shell": minor +--- + +Configurable property panels for page-editor blocks (SDUI). The Studio page editor's block inspector now renders typed, protocol-aligned property fields (editing the block's `properties`) for the minimal SDUI-essential content blocks — `element:text`, `element:image`, `page:header`, `page:card`, `record:related_list` — instead of only the generic `type`/`id`/`className`/`hidden` fields. Previously these properties were editable only via raw JSON. diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 9cbe5433e..82de15620 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -372,6 +372,7 @@ const ENGINE_STRINGS_EN: Record = { 'engine.inspector.pageBlock.id': 'ID', 'engine.inspector.pageBlock.className': 'Class names', 'engine.inspector.pageBlock.hidden': 'Hidden (CEL)', + 'engine.inspector.pageBlock.properties': 'Properties', 'engine.inspector.pageBlock.remove': 'Remove block', 'engine.inspector.pageBlock.outlineLabel': 'Blocks', // Report column inspector @@ -962,6 +963,7 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.inspector.pageBlock.id': 'ID', 'engine.inspector.pageBlock.className': '类名', 'engine.inspector.pageBlock.hidden': '隐藏条件(CEL)', + 'engine.inspector.pageBlock.properties': '属性', 'engine.inspector.pageBlock.remove': '删除区块', 'engine.inspector.pageBlock.outlineLabel': '区块', // Report column inspector diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.tsx index bd8674b9c..5998a7fcc 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.tsx @@ -18,11 +18,15 @@ import { InspectorShell, InspectorReorderButtons, InspectorTextField, + InspectorNumberField, + InspectorSelectField, + InspectorCheckboxField, InspectorRemoveButton, InspectorEmptyState, spliceArray, moveArray, } from './_shared'; +import { BLOCK_CONFIG, blockHasConfig, type BlockPropField } from '../previews/block-config'; interface Block { type?: string; @@ -148,6 +152,62 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection } const patch = (updates: Partial) => onPatch(writeAt(draft, hops, { ...block, ...updates })); + + // Per-block configurable properties (spec `properties`). The renderer hoists + // `properties.*` to the top level, so we read from either and always write + // back to `properties` (the canonical shape). + const blockProps = (block.properties as Record) || {}; + const readProp = (name: string): unknown => blockProps[name] ?? (block as any)[name]; + const patchProp = (name: string, value: unknown) => + patch({ properties: { ...blockProps, [name]: value } } as Partial); + + const renderPropField = (f: BlockPropField) => { + switch (f.kind) { + case 'number': + return ( + patchProp(f.name, v)} + disabled={readOnly} + /> + ); + case 'boolean': + return ( + patchProp(f.name, v)} + disabled={readOnly} + /> + ); + case 'select': + return ( + patchProp(f.name, v)} + disabled={readOnly} + /> + ); + default: + return ( + patchProp(f.name, v)} + disabled={readOnly} + /> + ); + } + }; const remove = () => { onPatch(writeAt(draft, hops, null)); onClearSelection(); }; const move = (to: number) => { if (!sibInfo) return; @@ -181,6 +241,15 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection patch({ id: v })} disabled={readOnly} mono /> patch({ className: v })} disabled={readOnly} mono /> patch({ hidden: v })} disabled={readOnly} mono /> + + {blockHasConfig(block.type) && ( +
+
+ {t('engine.inspector.pageBlock.properties', locale)} +
+ {BLOCK_CONFIG[block.type as string].map(renderPropField)} +
+ )} ); } diff --git a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts new file mode 100644 index 000000000..36f137655 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { BLOCK_CONFIG, blockHasConfig } from '../block-config'; + +describe('block-config', () => { + it('exposes a configurable panel for the minimal SDUI block set', () => { + for (const type of ['element:text', 'element:image', 'page:header', 'page:card', 'record:related_list']) { + expect(blockHasConfig(type)).toBe(true); + expect(BLOCK_CONFIG[type].length).toBeGreaterThan(0); + } + }); + + it('returns false for blocks without a config schema (and for undefined)', () => { + expect(blockHasConfig('page:section')).toBe(false); + expect(blockHasConfig('nav:menu')).toBe(false); + expect(blockHasConfig(undefined)).toBe(false); + }); + + it('every field has a name, label and a valid kind', () => { + const kinds = new Set(['text', 'number', 'boolean', 'select']); + for (const [type, fields] of Object.entries(BLOCK_CONFIG)) { + for (const f of fields) { + expect(f.name, `${type}.name`).toBeTruthy(); + expect(f.label, `${type}.label`).toBeTruthy(); + expect(kinds.has(f.kind), `${type}.${f.name} kind=${f.kind}`).toBe(true); + if (f.kind === 'select') { + expect(Array.isArray(f.options) && f.options.length > 0).toBe(true); + } + } + } + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/previews/block-config.ts b/packages/app-shell/src/views/metadata-admin/previews/block-config.ts new file mode 100644 index 000000000..555fad81f --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/previews/block-config.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * block-config — per-block configurable property schemas for the page editor. + * + * The page block inspector renders these as typed fields that edit the block's + * `properties` (the spec convention; the renderer hoists `properties.*` to the + * top level). This is the minimal, SDUI-essential set of content blocks so each + * is configurable in the UI instead of only via raw JSON. Add more block types + * here as they are needed — keep the field `name`s aligned with the property + * names the corresponding renderer reads. + */ + +export type BlockPropField = + | { name: string; label: string; kind: 'text'; placeholder?: string } + | { name: string; label: string; kind: 'number'; placeholder?: string } + | { name: string; label: string; kind: 'boolean' } + | { name: string; label: string; kind: 'select'; options: Array<{ value: string; label: string }> }; + +export const BLOCK_CONFIG: Record = { + // ── Content elements ────────────────────────────────────────────────────── + 'element:text': [ + { name: 'content', label: 'Content', kind: 'text', placeholder: 'Text…' }, + { + name: 'variant', + label: 'Variant', + kind: 'select', + options: [ + { value: 'heading', label: 'Heading' }, + { value: 'subheading', label: 'Subheading' }, + { value: 'body', label: 'Body' }, + { value: 'caption', label: 'Caption' }, + ], + }, + { + name: 'align', + label: 'Align', + kind: 'select', + options: [ + { value: 'left', label: 'Left' }, + { value: 'center', label: 'Center' }, + { value: 'right', label: 'Right' }, + ], + }, + ], + 'element:image': [ + { name: 'src', label: 'Source URL', kind: 'text', placeholder: 'https://…' }, + { name: 'alt', label: 'Alt text', kind: 'text' }, + { + name: 'fit', + label: 'Fit', + kind: 'select', + options: [ + { value: 'cover', label: 'Cover' }, + { value: 'contain', label: 'Contain' }, + { value: 'fill', label: 'Fill' }, + ], + }, + ], + + // ── Layout containers ───────────────────────────────────────────────────── + 'page:header': [ + { name: 'title', label: 'Title', kind: 'text' }, + { name: 'subtitle', label: 'Subtitle', kind: 'text' }, + { name: 'icon', label: 'Icon', kind: 'text', placeholder: 'lucide icon name' }, + { name: 'breadcrumb', label: 'Show breadcrumb', kind: 'boolean' }, + ], + 'page:card': [ + { name: 'title', label: 'Title', kind: 'text' }, + { name: 'bordered', label: 'Bordered', kind: 'boolean' }, + ], + + // ── Record context ──────────────────────────────────────────────────────── + 'record:related_list': [ + { name: 'objectName', label: 'Object', kind: 'text', placeholder: 'snake_case object' }, + { name: 'relationshipField', label: 'Relationship field', kind: 'text' }, + { name: 'title', label: 'Title', kind: 'text' }, + { name: 'limit', label: 'Limit', kind: 'number', placeholder: '10' }, + ], +}; + +/** Block types that expose a configurable property panel. */ +export function blockHasConfig(type: string | undefined): boolean { + return !!type && Array.isArray(BLOCK_CONFIG[type]) && BLOCK_CONFIG[type].length > 0; +}