From 677232f20ac09c4db2b0f85958cabe8a9fdb9dfa Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:33:47 +0800 Subject: [PATCH] feat(studio): a page button created in Studio can be given an action (#2997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit element:button renders inert without an `action`, and Studio had no way to add one. The curated BLOCK_CONFIG entry listed label/variant/size/icon and no `action`, and the generic "Advanced" section is not a fallback for that: 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 — plus an `action` field on element:button whose placeholder shows the expected shape. An empty JSON textarea is otherwise the entire affordance, so `placeholder` is 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#4135) and the inspector cannot render a nested schema as fields yet. A JSON box the author can use beats a curated form modelling a fraction of the shape. The existing field-kind allowlist test caught the new kind on first run, which is the ratchet working; `json` is added there too. app-shell metadata-admin: 968 tests / 111 files green; tsc clean with deps built. Refs #2997 Co-Authored-By: Claude Opus 5 --- .changeset/element-button-action-inspector.md | 26 +++++++++++++++++++ .../inspectors/PageBlockInspector.tsx | 15 ++++++++++- .../previews/__tests__/block-config.test.ts | 25 +++++++++++++++++- .../metadata-admin/previews/block-config.ts | 20 ++++++++++++++ 4 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 .changeset/element-button-action-inspector.md diff --git a/.changeset/element-button-action-inspector.md b/.changeset/element-button-action-inspector.md new file mode 100644 index 0000000000..0779d96a2d --- /dev/null +++ b/.changeset/element-button-action-inspector.md @@ -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 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 752123318a..f0c087c617 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.tsx @@ -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); @@ -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" @@ -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 ( + write(f.name, v)} disabled={readOnly} /> + ); case 'string-list': { const arr = Array.isArray(read(f.name)) ? (read(f.name) as unknown[]) : []; return ( 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 index e46a768aa3..762893d718 100644 --- 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 @@ -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) => { @@ -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`, 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 index 3ce779664d..a5af9934c5 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/block-config.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/block-config.ts @@ -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: @@ -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 } @@ -238,6 +247,17 @@ export const BLOCK_CONFIG: Record = { ], }, { 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 ─────────────────────────────────────────────────────