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
5 changes: 5 additions & 0 deletions .changeset/page-block-config-panels.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'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
Expand Down Expand Up @@ -962,6 +963,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -148,6 +152,62 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection
}

const patch = (updates: Partial<Block>) => 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<string, unknown>) || {};
const readProp = (name: string): unknown => blockProps[name] ?? (block as any)[name];
const patchProp = (name: string, value: unknown) =>
patch({ properties: { ...blockProps, [name]: value } } as Partial<Block>);

const renderPropField = (f: BlockPropField) => {
switch (f.kind) {
case 'number':
return (
<InspectorNumberField
key={f.name}
label={f.label}
value={typeof readProp(f.name) === 'number' ? (readProp(f.name) as number) : undefined}
placeholder={f.placeholder}
onCommit={(v) => patchProp(f.name, v)}
disabled={readOnly}
/>
);
case 'boolean':
return (
<InspectorCheckboxField
key={f.name}
label={f.label}
value={!!readProp(f.name)}
onCommit={(v) => patchProp(f.name, v)}
disabled={readOnly}
/>
);
case 'select':
return (
<InspectorSelectField
key={f.name}
label={f.label}
value={readProp(f.name) != null ? String(readProp(f.name)) : undefined}
options={f.options}
onCommit={(v) => patchProp(f.name, v)}
disabled={readOnly}
/>
);
default:
return (
<InspectorTextField
key={f.name}
label={f.label}
value={readProp(f.name) != null ? String(readProp(f.name)) : ''}
placeholder={f.placeholder}
onCommit={(v) => patchProp(f.name, v)}
disabled={readOnly}
/>
);
}
};
const remove = () => { onPatch(writeAt(draft, hops, null)); onClearSelection(); };
const move = (to: number) => {
if (!sibInfo) return;
Expand Down Expand Up @@ -181,6 +241,15 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection
<InspectorTextField label={t('engine.inspector.pageBlock.id', locale)} value={block.id ?? ''} onCommit={(v) => patch({ id: v })} disabled={readOnly} mono />
<InspectorTextField label={t('engine.inspector.pageBlock.className', locale)} value={block.className ?? ''} onCommit={(v) => patch({ className: v })} disabled={readOnly} mono />
<InspectorTextField label={t('engine.inspector.pageBlock.hidden', locale)} value={block.hidden ?? ''} onCommit={(v) => patch({ hidden: v })} disabled={readOnly} mono />

{blockHasConfig(block.type) && (
<div className="space-y-3 border-t border-border pt-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('engine.inspector.pageBlock.properties', locale)}
</div>
{BLOCK_CONFIG[block.type as string].map(renderPropField)}
</div>
)}
</InspectorShell>
);
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
});
});
Original file line number Diff line number Diff line change
@@ -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<string, BlockPropField[]> = {
// ── 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;
}
Loading