Skip to content

Commit 4a74ea6

Browse files
os-zhuangclaude
andauthored
feat(studio): a page button created in Studio can be given an action (#2997) (#3028)
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: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 390c071 commit 4a74ea6

4 files changed

Lines changed: 84 additions & 2 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@object-ui/app-shell": minor
3+
---
4+
5+
feat(studio): a page button created in Studio can be given an action
6+
7+
`element:button` renders inert without an `action`, and Studio had no way to add
8+
one. The inspector's curated `BLOCK_CONFIG` entry listed `label`, `variant`,
9+
`size`, `icon` — no `action` — and the generic "Advanced" section is not a
10+
fallback for that, because it enumerates the keys the block **already has**
11+
(`Object.keys(blockProps)`). So it could edit an `action` authored in source, and
12+
never add one to a button dragged from the palette.
13+
14+
Adds a `json` field kind — the same `InspectorJsonField` editor Advanced uses,
15+
reachable for a property that does not exist yet — and an `action` field on
16+
`element:button` carrying `{ "type": "url", "target": "/environments" }` as its
17+
placeholder. An empty JSON textarea is otherwise the whole affordance, so
18+
`placeholder` is now threaded through to the textarea and asserted for every
19+
`json` field.
20+
21+
Raw JSON rather than typed sub-fields deliberately: the spec declares the prop as
22+
`InlineActionSchema` (objectstack-ai/objectstack#4135), and the inspector cannot
23+
render a nested schema as fields yet. A JSON box the author can actually use
24+
beats a curated form that models a fraction of the shape.
25+
26+
Refs objectstack-ai/objectui#2997

packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,11 @@ function safeStringify(value: unknown): string {
123123

124124
/** Editable JSON field for object/array properties — commits on blur so a
125125
* half-typed value never trips the parser. Empty clears the property. */
126-
function InspectorJsonField({ label, value, onCommit, disabled }: {
126+
function InspectorJsonField({ label, value, onCommit, disabled, placeholder }: {
127127
label: string; value: unknown; onCommit: (v: unknown) => void; disabled?: boolean;
128+
/** Shown while the property is unset — the expected shape, since an empty
129+
* JSON textarea tells an author nothing about what to type. */
130+
placeholder?: string;
128131
}) {
129132
const initial = React.useMemo(() => safeStringify(value), [value]);
130133
const [text, setText] = React.useState(initial);
@@ -150,6 +153,7 @@ function InspectorJsonField({ label, value, onCommit, disabled }: {
150153
onChange={(e) => setText(e.target.value)}
151154
onBlur={commit}
152155
disabled={disabled}
156+
placeholder={placeholder}
153157
spellCheck={false}
154158
rows={Math.min(12, Math.max(2, text.split('\n').length))}
155159
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
404408
value={read(f.name) != null ? String(read(f.name)) : undefined}
405409
options={f.options} onCommit={(v) => write(f.name, v)} disabled={readOnly} />
406410
);
411+
case 'json':
412+
// Same editor the "Advanced" section uses, but reachable for a prop the
413+
// block does not have yet — Advanced enumerates existing keys only, so
414+
// without this a curated JSON prop could be edited and never added.
415+
return (
416+
<InspectorJsonField key={k} label={f.label} value={read(f.name)}
417+
placeholder={f.placeholder}
418+
onCommit={(v) => write(f.name, v)} disabled={readOnly} />
419+
);
407420
case 'string-list': {
408421
const arr = Array.isArray(read(f.name)) ? (read(f.name) as unknown[]) : [];
409422
return (

packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ describe('block-config', () => {
3131

3232
it('every field (incl. nested array items) has a name, label and valid kind', () => {
3333
const kinds = new Set([
34-
'text', 'number', 'boolean', 'select', 'string-list', 'array',
34+
'text', 'number', 'boolean', 'select', 'string-list', 'array', 'json',
3535
'object-picker', 'field-picker', 'field-list', 'color',
3636
]);
3737
const check = (f: any, path: string) => {
@@ -104,6 +104,29 @@ describe('page palette ↔ spec PageComponentType coverage', () => {
104104
expect(PALETTE_EXCLUSIONS['ai:chat_window']).toBeTruthy();
105105
});
106106

107+
it('element:button offers an action editor — without it the button is inert', () => {
108+
// The generic "Advanced" section enumerates keys the block ALREADY has
109+
// (`Object.keys(blockProps)`), so it can edit an existing `action` but can
110+
// never add one. A button created in Studio therefore had no path to
111+
// becoming interactive at all. The spec declares the prop as
112+
// `InlineActionSchema` (objectstack#4135).
113+
const action = BLOCK_CONFIG['element:button'].find((f) => f.name === 'action');
114+
expect(action, 'element:button must expose an `action` field').toBeDefined();
115+
expect(action!.kind).toBe('json');
116+
});
117+
118+
it('every json field carries a placeholder showing the expected shape', () => {
119+
// An empty JSON textarea tells an author nothing. The placeholder is the
120+
// only affordance a raw-JSON editor has, so a json field without one is a
121+
// blank box.
122+
const missing = Object.entries(BLOCK_CONFIG).flatMap(([type, fields]) =>
123+
fields
124+
.filter((f) => f.kind === 'json' && !(f as { placeholder?: string }).placeholder)
125+
.map((f) => `${type}.${f.name}`),
126+
);
127+
expect(missing).toEqual([]);
128+
});
129+
107130
it('a block with a config panel is a block the palette offers', () => {
108131
// A panel for an unauthorable block is how the ai:chat_window
109132
// contradiction stayed invisible. Non-spec objectui blocks (`object-grid`,

packages/app-shell/src/views/metadata-admin/previews/block-config.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@
1212
* text | number | boolean | select — scalar props
1313
* string-list — an array of strings (e.g. field names)
1414
* array (+ itemFields) — an array of objects (e.g. tab items)
15+
* json — a nested object edited as raw JSON, for
16+
* props whose shape the inspector cannot
17+
* yet render as fields (e.g. an inline
18+
* action). Curating it matters even
19+
* though "Advanced" also renders JSON:
20+
* Advanced only lists keys the block
21+
* ALREADY has, so it can edit such a prop
22+
* but never add one.
1523
*/
1624

1725
/** Where a field/field-list picker resolves its object from:
@@ -26,6 +34,7 @@ export type BlockPropField =
2634
| { name: string; label: string; kind: 'select'; options: Array<{ value: string; label: string }> }
2735
| { name: string; label: string; kind: 'string-list'; placeholder?: string }
2836
| { name: string; label: string; kind: 'array'; itemFields: BlockPropField[]; addLabel?: string }
37+
| { name: string; label: string; kind: 'json'; placeholder?: string }
2938
| { name: string; label: string; kind: 'color'; options?: Array<{ value: string; label: string }> }
3039
// Schema-driven pickers — dropdowns populated from the live metadata.
3140
| { name: string; label: string; kind: 'object-picker'; placeholder?: string }
@@ -238,6 +247,17 @@ export const BLOCK_CONFIG: Record<string, BlockPropField[]> = {
238247
],
239248
},
240249
{ name: 'icon', label: 'Icon', kind: 'text', placeholder: 'lucide icon name' },
250+
// Without `action` a button renders inert, and the generic "Advanced"
251+
// section can only edit properties the block ALREADY has — so a button
252+
// created in Studio had no way to become interactive at all. The spec
253+
// declares the prop as `InlineActionSchema` (objectstack#4135); a JSON
254+
// editor is the honest fit until the inspector can render a nested schema.
255+
{
256+
name: 'action',
257+
label: 'Action',
258+
kind: 'json',
259+
placeholder: '{ "type": "url", "target": "/environments" }',
260+
},
241261
],
242262

243263
// ── Layout containers ─────────────────────────────────────────────────────

0 commit comments

Comments
 (0)