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/block-config-expand.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@object-ui/app-shell": minor
---

Expand page-editor block configuration. Adds configurable property panels for more blocks (`element:number`, `element:button`, `record:alert`) and introduces array-valued property editors — a `string-list` editor (e.g. `record:highlights` fields) and an add/remove `array` editor (e.g. `page:tabs` items, `record:details` sections) — so these blocks are configurable in the UI instead of only via raw JSON.
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import {
moveArray,
} from './_shared';
import { BLOCK_CONFIG, blockHasConfig, type BlockPropField } from '../previews/block-config';
import { Button, Input, Label } from '@object-ui/components';
import { Plus, X, Trash2 } from 'lucide-react';

interface Block {
type?: string;
Expand Down Expand Up @@ -161,50 +163,97 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection
const patchProp = (name: string, value: unknown) =>
patch({ properties: { ...blockProps, [name]: value } } as Partial<Block>);

const renderPropField = (f: BlockPropField) => {
// Generic, recursive field renderer. `read`/`write` abstract the value source
// (the block's `properties` at the top level, or an item object inside an
// `array` field), so the same code drives nested array-item editors.
const renderField = (
f: BlockPropField,
read: (name: string) => unknown,
write: (name: string, value: unknown) => void,
keyPrefix = '',
): React.ReactNode => {
const k = `${keyPrefix}${f.name}`;
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}
/>
<InspectorNumberField key={k} label={f.label}
value={typeof read(f.name) === 'number' ? (read(f.name) as number) : undefined}
placeholder={f.placeholder} onCommit={(v) => write(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}
/>
<InspectorCheckboxField key={k} label={f.label} value={!!read(f.name)}
onCommit={(v) => write(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}
/>
<InspectorSelectField key={k} label={f.label}
value={read(f.name) != null ? String(read(f.name)) : undefined}
options={f.options} onCommit={(v) => write(f.name, v)} disabled={readOnly} />
);
case 'string-list': {
const arr = Array.isArray(read(f.name)) ? (read(f.name) as unknown[]) : [];
return (
<div key={k} className="space-y-1.5">
<Label className="text-xs text-muted-foreground">{f.label}</Label>
{arr.map((s, i) => (
<div key={i} className="flex items-center gap-1.5">
<Input className="h-8 text-sm" value={String(s ?? '')} placeholder={f.placeholder} disabled={readOnly}
onChange={(e) => { const next = [...arr]; next[i] = e.target.value; write(f.name, next); }} />
<Button type="button" variant="ghost" size="icon" className="h-8 w-8" disabled={readOnly}
aria-label="Remove" onClick={() => write(f.name, arr.filter((_, j) => j !== i))}>
<X className="h-4 w-4" />
</Button>
</div>
))}
{!readOnly && (
<Button type="button" variant="outline" size="sm" onClick={() => write(f.name, [...arr, ''])}>
<Plus className="mr-1 h-3.5 w-3.5" /> Add
</Button>
)}
</div>
);
}
case 'array': {
const arr = Array.isArray(read(f.name)) ? (read(f.name) as unknown[]) : [];
return (
<div key={k} className="space-y-2">
<Label className="text-xs text-muted-foreground">{f.label}</Label>
{arr.map((item, i) => {
const itemObj = item && typeof item === 'object' ? (item as Record<string, unknown>) : {};
return (
<div key={i} className="space-y-2 rounded-md border border-border p-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">#{i + 1}</span>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7" disabled={readOnly}
aria-label="Remove item" onClick={() => write(f.name, arr.filter((_, j) => j !== i))}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{f.itemFields.map((itf) =>
renderField(
itf,
(n) => itemObj[n],
(n, v) => { const next = [...arr]; next[i] = { ...itemObj, [n]: v }; write(f.name, next); },
`${k}-${i}-`,
),
)}
</div>
);
})}
{!readOnly && (
<Button type="button" variant="outline" size="sm" onClick={() => write(f.name, [...arr, {}])}>
<Plus className="mr-1 h-3.5 w-3.5" /> {f.addLabel || 'Add'}
</Button>
)}
</div>
);
}
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}
/>
<InspectorTextField key={k} label={f.label}
value={read(f.name) != null ? String(read(f.name)) : ''}
placeholder={f.placeholder} onCommit={(v) => write(f.name, v)} disabled={readOnly} />
);
}
};
Expand Down Expand Up @@ -247,7 +296,7 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection
<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)}
{BLOCK_CONFIG[block.type as string].map((f) => renderField(f, readProp, patchProp))}
</div>
)}
</InspectorShell>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,26 @@ describe('block-config', () => {
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);
}
it('also exposes the array-valued blocks', () => {
for (const type of ['page:tabs', 'record:details', 'record:highlights']) {
expect(blockHasConfig(type)).toBe(true);
}
});

it('every field (incl. nested array items) has a name, label and valid kind', () => {
const kinds = new Set(['text', 'number', 'boolean', 'select', 'string-list', 'array']);
const check = (f: any, path: string) => {
expect(f.name, `${path}.name`).toBeTruthy();
expect(f.label, `${path}.label`).toBeTruthy();
expect(kinds.has(f.kind), `${path}.${f.name} kind=${f.kind}`).toBe(true);
if (f.kind === 'select') expect(Array.isArray(f.options) && f.options.length > 0).toBe(true);
if (f.kind === 'array') {
expect(Array.isArray(f.itemFields) && f.itemFields.length > 0).toBe(true);
for (const itf of f.itemFields) check(itf, `${path}.${f.name}[]`);
}
};
for (const [type, fields] of Object.entries(BLOCK_CONFIG)) {
for (const f of fields) check(f, type);
}
});
});
131 changes: 116 additions & 15 deletions packages/app-shell/src/views/metadata-admin/previews/block-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,28 @@
*
* 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.
* top level). Keep each field `name` aligned with the property name the
* corresponding renderer reads. Add block types here as they are needed.
*
* Field kinds:
* text | number | boolean | select — scalar props
* string-list — an array of strings (e.g. field names)
* array (+ itemFields) — an array of objects (e.g. tab items)
*/

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 }> };
| { name: string; label: string; kind: 'select'; options: Array<{ value: string; label: string }> }
| { name: string; label: string; kind: 'string-list'; placeholder?: string }
| { name: string; label: string; kind: 'array'; itemFields: BlockPropField[]; addLabel?: string };

const ALIGN_OPTS = [
{ value: 'left', label: 'Left' },
{ value: 'center', label: 'Center' },
{ value: 'right', label: 'Right' },
];

export const BLOCK_CONFIG: Record<string, BlockPropField[]> = {
// ── Content elements ──────────────────────────────────────────────────────
Expand All @@ -32,16 +43,7 @@ export const BLOCK_CONFIG: Record<string, BlockPropField[]> = {
{ value: 'caption', label: 'Caption' },
],
},
{
name: 'align',
label: 'Align',
kind: 'select',
options: [
{ value: 'left', label: 'Left' },
{ value: 'center', label: 'Center' },
{ value: 'right', label: 'Right' },
],
},
{ name: 'align', label: 'Align', kind: 'select', options: ALIGN_OPTS },
],
'element:image': [
{ name: 'src', label: 'Source URL', kind: 'text', placeholder: 'https://…' },
Expand All @@ -57,6 +59,60 @@ export const BLOCK_CONFIG: Record<string, BlockPropField[]> = {
],
},
],
'element:number': [
{ name: 'object', label: 'Object', kind: 'text', placeholder: 'snake_case object' },
{ name: 'field', label: 'Field', kind: 'text' },
{
name: 'aggregate',
label: 'Aggregate',
kind: 'select',
options: [
{ value: 'count', label: 'Count' },
{ value: 'sum', label: 'Sum' },
{ value: 'avg', label: 'Average' },
{ value: 'min', label: 'Min' },
{ value: 'max', label: 'Max' },
],
},
{
name: 'format',
label: 'Format',
kind: 'select',
options: [
{ value: 'number', label: 'Number' },
{ value: 'currency', label: 'Currency' },
{ value: 'percent', label: 'Percent' },
],
},
{ name: 'prefix', label: 'Prefix', kind: 'text' },
{ name: 'suffix', label: 'Suffix', kind: 'text' },
],
'element:button': [
{ name: 'label', label: 'Label', kind: 'text' },
{
name: 'variant',
label: 'Variant',
kind: 'select',
options: [
{ value: 'primary', label: 'Primary' },
{ value: 'secondary', label: 'Secondary' },
{ value: 'danger', label: 'Danger' },
{ value: 'ghost', label: 'Ghost' },
{ value: 'link', label: 'Link' },
],
},
{
name: 'size',
label: 'Size',
kind: 'select',
options: [
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
],
},
{ name: 'icon', label: 'Icon', kind: 'text', placeholder: 'lucide icon name' },
],

// ── Layout containers ─────────────────────────────────────────────────────
'page:header': [
Expand All @@ -69,6 +125,18 @@ export const BLOCK_CONFIG: Record<string, BlockPropField[]> = {
{ name: 'title', label: 'Title', kind: 'text' },
{ name: 'bordered', label: 'Bordered', kind: 'boolean' },
],
'page:tabs': [
{
name: 'items',
label: 'Tabs',
kind: 'array',
addLabel: 'Add tab',
itemFields: [
{ name: 'key', label: 'Key', kind: 'text' },
{ name: 'label', label: 'Label', kind: 'text' },
],
},
],

// ── Record context ────────────────────────────────────────────────────────
'record:related_list': [
Expand All @@ -77,6 +145,39 @@ export const BLOCK_CONFIG: Record<string, BlockPropField[]> = {
{ name: 'title', label: 'Title', kind: 'text' },
{ name: 'limit', label: 'Limit', kind: 'number', placeholder: '10' },
],
'record:highlights': [
{ name: 'fields', label: 'Fields', kind: 'string-list', placeholder: 'field name' },
],
'record:details': [
{
name: 'sections',
label: 'Sections',
kind: 'array',
addLabel: 'Add section',
itemFields: [
{ name: 'label', label: 'Label', kind: 'text' },
{ name: 'columns', label: 'Columns', kind: 'number', placeholder: '2' },
{ name: 'fields', label: 'Fields', kind: 'string-list', placeholder: 'field name' },
],
},
],
'record:alert': [
{
name: 'severity',
label: 'Severity',
kind: 'select',
options: [
{ value: 'info', label: 'Info' },
{ value: 'warning', label: 'Warning' },
{ value: 'error', label: 'Error' },
{ value: 'success', label: 'Success' },
],
},
{ name: 'title', label: 'Title', kind: 'text' },
{ name: 'body', label: 'Body', kind: 'text' },
{ name: 'icon', label: 'Icon', kind: 'text', placeholder: 'lucide icon name' },
{ name: 'dismissible', label: 'Dismissible', kind: 'boolean' },
],
};

/** Block types that expose a configurable property panel. */
Expand Down
Loading