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
26 changes: 26 additions & 0 deletions .changeset/element-button-action-inspector.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@object-ui/app-shell": minor
---

feat(studio): a page button created in Studio can be given an action

`element:button` renders inert without an `action`, and Studio had no way to add
one. The inspector's curated `BLOCK_CONFIG` entry listed `label`, `variant`,
`size`, `icon` — no `action` — and the generic "Advanced" section is not a
fallback for that, because it enumerates the keys the block **already has**
(`Object.keys(blockProps)`). So it could edit an `action` authored in source, and
never add one to a button dragged from the palette.

Adds a `json` field kind — the same `InspectorJsonField` editor Advanced uses,
reachable for a property that does not exist yet — and an `action` field on
`element:button` carrying `{ "type": "url", "target": "/environments" }` as its
placeholder. An empty JSON textarea is otherwise the whole affordance, so
`placeholder` is now threaded through to the textarea and asserted for every
`json` field.

Raw JSON rather than typed sub-fields deliberately: the spec declares the prop as
`InlineActionSchema` (objectstack-ai/objectstack#4135), and the inspector cannot
render a nested schema as fields yet. A JSON box the author can actually use
beats a curated form that models a fraction of the shape.

Refs objectstack-ai/objectui#2997
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,11 @@ function safeStringify(value: unknown): string {

/** Editable JSON field for object/array properties — commits on blur so a
* half-typed value never trips the parser. Empty clears the property. */
function InspectorJsonField({ label, value, onCommit, disabled }: {
function InspectorJsonField({ label, value, onCommit, disabled, placeholder }: {
label: string; value: unknown; onCommit: (v: unknown) => void; disabled?: boolean;
/** Shown while the property is unset — the expected shape, since an empty
* JSON textarea tells an author nothing about what to type. */
placeholder?: string;
}) {
const initial = React.useMemo(() => safeStringify(value), [value]);
const [text, setText] = React.useState(initial);
Expand All @@ -150,6 +153,7 @@ function InspectorJsonField({ label, value, onCommit, disabled }: {
onChange={(e) => setText(e.target.value)}
onBlur={commit}
disabled={disabled}
placeholder={placeholder}
spellCheck={false}
rows={Math.min(12, Math.max(2, text.split('\n').length))}
className="w-full rounded border border-input bg-background px-2 py-1.5 text-xs font-mono outline-none focus:ring-1 focus:ring-primary resize-y disabled:opacity-60"
Expand Down Expand Up @@ -404,6 +408,15 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection
value={read(f.name) != null ? String(read(f.name)) : undefined}
options={f.options} onCommit={(v) => write(f.name, v)} disabled={readOnly} />
);
case 'json':
// Same editor the "Advanced" section uses, but reachable for a prop the
// block does not have yet — Advanced enumerates existing keys only, so
// without this a curated JSON prop could be edited and never added.
return (
<InspectorJsonField key={k} label={f.label} value={read(f.name)}
placeholder={f.placeholder}
onCommit={(v) => write(f.name, v)} disabled={readOnly} />
);
case 'string-list': {
const arr = Array.isArray(read(f.name)) ? (read(f.name) as unknown[]) : [];
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe('block-config', () => {

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',
'text', 'number', 'boolean', 'select', 'string-list', 'array', 'json',
'object-picker', 'field-picker', 'field-list', 'color',
]);
const check = (f: any, path: string) => {
Expand Down Expand Up @@ -104,6 +104,29 @@ describe('page palette ↔ spec PageComponentType coverage', () => {
expect(PALETTE_EXCLUSIONS['ai:chat_window']).toBeTruthy();
});

it('element:button offers an action editor — without it the button is inert', () => {
// The generic "Advanced" section enumerates keys the block ALREADY has
// (`Object.keys(blockProps)`), so it can edit an existing `action` but can
// never add one. A button created in Studio therefore had no path to
// becoming interactive at all. The spec declares the prop as
// `InlineActionSchema` (objectstack#4135).
const action = BLOCK_CONFIG['element:button'].find((f) => f.name === 'action');
expect(action, 'element:button must expose an `action` field').toBeDefined();
expect(action!.kind).toBe('json');
});

it('every json field carries a placeholder showing the expected shape', () => {
// An empty JSON textarea tells an author nothing. The placeholder is the
// only affordance a raw-JSON editor has, so a json field without one is a
// blank box.
const missing = Object.entries(BLOCK_CONFIG).flatMap(([type, fields]) =>
fields
.filter((f) => f.kind === 'json' && !(f as { placeholder?: string }).placeholder)
.map((f) => `${type}.${f.name}`),
);
expect(missing).toEqual([]);
});

it('a block with a config panel is a block the palette offers', () => {
// A panel for an unauthorable block is how the ai:chat_window
// contradiction stayed invisible. Non-spec objectui blocks (`object-grid`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@
* 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)
* json — a nested object edited as raw JSON, for
* props whose shape the inspector cannot
* yet render as fields (e.g. an inline
* action). Curating it matters even
* though "Advanced" also renders JSON:
* Advanced only lists keys the block
* ALREADY has, so it can edit such a prop
* but never add one.
*/

/** Where a field/field-list picker resolves its object from:
Expand All @@ -26,6 +34,7 @@ export type BlockPropField =
| { 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 }
| { name: string; label: string; kind: 'json'; placeholder?: string }
| { name: string; label: string; kind: 'color'; options?: Array<{ value: string; label: string }> }
// Schema-driven pickers — dropdowns populated from the live metadata.
| { name: string; label: string; kind: 'object-picker'; placeholder?: string }
Expand Down Expand Up @@ -238,6 +247,17 @@ export const BLOCK_CONFIG: Record<string, BlockPropField[]> = {
],
},
{ name: 'icon', label: 'Icon', kind: 'text', placeholder: 'lucide icon name' },
// Without `action` a button renders inert, and the generic "Advanced"
// section can only edit properties the block ALREADY has — so a button
// created in Studio had no way to become interactive at all. The spec
// declares the prop as `InlineActionSchema` (objectstack#4135); a JSON
// editor is the honest fit until the inspector can render a nested schema.
{
name: 'action',
label: 'Action',
kind: 'json',
placeholder: '{ "type": "url", "target": "/environments" }',
},
],

// ── Layout containers ─────────────────────────────────────────────────────
Expand Down
Loading