Skip to content

Commit e914e8c

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(studio): page WYSIWYG canvas, nested-block config, and type-aware Action authoring (#1831)
* feat(studio): editable config for nested page blocks (table/form/metric) Selecting a data-bound block (object-grid table, object-form, object-metric, grid) in the page metadata editor opened an inspector with only type/id/className while the full `properties` binding lived in source — the config panel was disconnected from what the author saw. - block-config: curated fields for object-grid/object-form/object-metric/ object-kanban/grid - PageBlockInspector: generic "Advanced" editor renders every remaining property (scalars as typed inputs, objects/arrays as editable JSON), so the panel always matches source for any block type, curated or not - PageBlockCanvas.childGroups: fix page:card (properties.children, not body), add grid, and a generic properties.children fallback so nested blocks are selectable on the canvas - block-types: object-grid/form/metric/kanban + grid become first-class (icons, a Data category, addable from the block picker) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(studio): WYSIWYG page design canvas — render real blocks with selection The Design canvas showed abstract type-badge cards while Preview rendered the real page — they looked completely different. Now each leaf block renders its REAL component (via SchemaRenderer, the same renderer Preview uses) behind a transparent select/drag overlay with a hover/selected type badge, mirroring the dashboard editor's design mode. Container blocks (grid/card/tabs/section) keep a slim title bar; their nested children render as real previews, and a grid lays its children out in its configured column count. - PageBlockCanvas: BlockLivePreview helper; BlockRow leaf vs container split; NestedChildren renders real child previews honoring grid columns - selection/drag/add/nested-edit ergonomics preserved Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(studio): type-aware Action authoring inspector Replace the flat SchemaForm for `action` with a progressively-disclosed, type-first editor (Salesforce Quick Action / ServiceNow UI Action style): - Basics (label/name/object scope hint/icon/variant) - Behavior branches by type: script -> body editor; api -> method + endpoint; url/flow/modal/form -> a single typed target binding - Inputs: param builder (field-backed picks reuse object field metadata) - Placement: location checkboxes + object-vs-global scope hint - Feedback: confirm/success/error/refresh/undo/mode/shortcut - Conditions: visible/disabled CEL - AI exposure: opt-in via flattened aiExposed/aiDescription (matches ActionPreview + served schema), not the nested ai block - Advanced/rare props fall through to a SchemaForm fed the live server schema with curated keys hidden, so nothing is double-edited or lost Registered via registerMetadataDefaultInspector('action', …). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(studio): Action editor — test-run, condition builder, placement preview Three heavier authoring aids for the Action editor (Retool/ServiceNow-style): - ActionPreview: a "Test request" runner for type:api (composes method + endpoint + bodyExtra, sends a real request with the session bearer, renders HTTP status + response; warns for non-GET) and a "Where it appears" placement simulation that mocks the button in record header / list row / toolbar / more-menu / section / command-palette per the action's locations. - ConditionBuilder (new): no-code visible/disabled editor — [subject][op][value] rows over the bound object's fields + context, joined AND/OR, compiled to CEL. Stateful (in-progress rows persist); round-trip guard keeps hand-authored complex CEL in a raw textarea; empty predicate unsets the field (no invalid ''). - ActionDefaultInspector: Conditions section now uses ConditionBuilder. Verified live: api test request (real POST → HTTP 404 shown), placement mocks, and record.status != 'done' compiled from the builder. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 93284b2 commit e914e8c

11 files changed

Lines changed: 1264 additions & 81 deletions

File tree

--full-page

67.3 KB
Binary file not shown.

packages/app-shell/src/views/metadata-admin/i18n.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,7 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
406406
'engine.inspector.pageBlock.className': 'Class names',
407407
'engine.inspector.pageBlock.hidden': 'Hidden (CEL)',
408408
'engine.inspector.pageBlock.properties': 'Properties',
409+
'engine.inspector.pageBlock.advanced': 'Advanced',
409410
'engine.inspector.pageBlock.remove': 'Remove block',
410411
'engine.inspector.pageBlock.outlineLabel': 'Blocks',
411412
// Report default ("home") inspector
@@ -429,6 +430,9 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
429430
'engine.inspector.report.noSchema': 'Spec schema unavailable — basic properties only.',
430431
// Trailing section for fields the live server has but the bundled spec lacks.
431432
'engine.inspector.moreFields': 'More fields',
433+
// Action default (home) inspector
434+
'engine.inspector.action.kind': 'Action',
435+
'engine.inspector.action.close': 'Close action',
432436
// Dashboard default (home) inspector
433437
'engine.inspector.dashboard.kind': 'Dashboard',
434438
'engine.inspector.dashboard.close': 'Close dashboard',
@@ -1053,6 +1057,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
10531057
'engine.inspector.pageBlock.className': '类名',
10541058
'engine.inspector.pageBlock.hidden': '隐藏条件(CEL)',
10551059
'engine.inspector.pageBlock.properties': '属性',
1060+
'engine.inspector.pageBlock.advanced': '高级属性',
10561061
'engine.inspector.pageBlock.remove': '删除区块',
10571062
'engine.inspector.pageBlock.outlineLabel': '区块',
10581063
// Report default ("home") inspector
@@ -1076,6 +1081,9 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
10761081
'engine.inspector.report.noSchema': '规格 schema 不可用 —— 仅显示基础属性。',
10771082
// Trailing section for fields the live server has but the bundled spec lacks.
10781083
'engine.inspector.moreFields': '更多字段',
1084+
// Action default (home) inspector
1085+
'engine.inspector.action.kind': '动作',
1086+
'engine.inspector.action.close': '关闭动作',
10791087
// Dashboard default (home) inspector
10801088
'engine.inspector.dashboard.kind': '仪表盘',
10811089
'engine.inspector.dashboard.close': '关闭仪表盘',

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

Lines changed: 418 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ConditionBuilder — a no-code editor for a CEL predicate (ServiceNow-style),
5+
* compiling rows of [subject][operator][value] joined by AND/OR into a CEL
6+
* string emitted via onCommit (empty ⇒ '' ⇒ caller should unset).
7+
*
8+
* Stateful by design: rows live in local state so an in-progress row (no
9+
* subject yet) stays on screen instead of vanishing the moment it compiles to
10+
* an empty string. The emitted CEL is recomputed from rows on every edit.
11+
*
12+
* Safety: on (re)load the builder only adopts an existing expression when it
13+
* parses AND round-trips byte-for-byte (whitespace-normalised). Anything it
14+
* can't round-trip cleanly opens in a raw expression textarea, so hand-authored
15+
* complex CEL is never silently rewritten.
16+
*/
17+
18+
import * as React from 'react';
19+
import {
20+
Button, Input, Label,
21+
Select, SelectTrigger, SelectContent, SelectItem, SelectValue,
22+
} from '@object-ui/components';
23+
import { Plus, X, Code2, ListFilter } from 'lucide-react';
24+
import { useObjectFields } from '../previews/useObjectFields';
25+
26+
type Op = '==' | '!=' | '>' | '<' | '>=' | '<=' | 'truthy' | 'falsy';
27+
28+
interface Row { subject: string; op: Op; value: string }
29+
30+
const COMPARE_OPS: Array<{ value: Op; label: string }> = [
31+
{ value: '==', label: 'equals' },
32+
{ value: '!=', label: 'not equals' },
33+
{ value: '>', label: 'greater than' },
34+
{ value: '<', label: 'less than' },
35+
{ value: '>=', label: '≥' },
36+
{ value: '<=', label: '≤' },
37+
{ value: 'truthy', label: 'is set / true' },
38+
{ value: 'falsy', label: 'is empty / false' },
39+
];
40+
41+
const CONTEXT_SUBJECTS = [
42+
{ value: 'record.id', label: 'record.id' },
43+
{ value: 'user.id', label: 'user.id' },
44+
{ value: 'user.email', label: 'user.email' },
45+
{ value: 'user.role', label: 'user.role' },
46+
{ value: 'user.isAdmin', label: 'user.isAdmin' },
47+
{ value: 'org.id', label: 'org.id' },
48+
];
49+
50+
const norm = (s: string) => s.replace(/\s+/g, ' ').trim();
51+
52+
/** Quote a raw value for CEL unless it is a number / boolean / null. */
53+
function fmtValue(v: string): string {
54+
const t = v.trim();
55+
if (t === 'true' || t === 'false' || t === 'null') return t;
56+
if (t !== '' && !Number.isNaN(Number(t))) return t;
57+
return `'${t.replace(/'/g, "\\'")}'`;
58+
}
59+
60+
/** Inverse of fmtValue for display in the value input. */
61+
function unfmtValue(raw: string): string {
62+
const t = raw.trim();
63+
const m = /^'(.*)'$/.exec(t);
64+
if (m) return m[1].replace(/\\'/g, "'");
65+
return t;
66+
}
67+
68+
/** Compile rows → CEL. Rows without a subject are skipped (in-progress). */
69+
function compile(rows: Row[], join: '&&' | '||'): string {
70+
return rows
71+
.filter((r) => r.subject)
72+
.map((r) => {
73+
if (r.op === 'truthy') return r.subject;
74+
if (r.op === 'falsy') return `!${r.subject}`;
75+
return `${r.subject} ${r.op} ${fmtValue(r.value)}`;
76+
})
77+
.join(` ${join} `);
78+
}
79+
80+
/** Parse a simple AND/OR predicate. Returns null if it isn't the simple shape. */
81+
function parse(expr: string): { rows: Row[]; join: '&&' | '||' } | null {
82+
const s = norm(expr);
83+
if (!s) return { rows: [], join: '&&' };
84+
const hasAnd = s.includes('&&');
85+
const hasOr = s.includes('||');
86+
if (hasAnd && hasOr) return null; // mixed joins → too complex
87+
const join: '&&' | '||' = hasOr ? '||' : '&&';
88+
const parts = s.split(hasOr ? '||' : '&&').map((p) => p.trim());
89+
const rows: Row[] = [];
90+
for (const p of parts) {
91+
const cmp = /^([a-zA-Z_][\w.]*)\s*(==|!=|>=|<=|>|<)\s*(.+)$/.exec(p);
92+
if (cmp) { rows.push({ subject: cmp[1], op: cmp[2] as Op, value: unfmtValue(cmp[3]) }); continue; }
93+
const neg = /^!\s*([a-zA-Z_][\w.]*)$/.exec(p);
94+
if (neg) { rows.push({ subject: neg[1], op: 'falsy', value: '' }); continue; }
95+
const truthy = /^([a-zA-Z_][\w.]*)$/.exec(p);
96+
if (truthy) { rows.push({ subject: truthy[1], op: 'truthy', value: '' }); continue; }
97+
return null; // unrecognised term
98+
}
99+
return { rows, join };
100+
}
101+
102+
function initFrom(value: string): { rows: Row[]; join: '&&' | '||'; raw: boolean } {
103+
const p = parse(value || '');
104+
if (p && norm(compile(p.rows, p.join)) === norm(value || '')) {
105+
return { rows: p.rows, join: p.join, raw: false };
106+
}
107+
return { rows: [], join: '&&', raw: !!value };
108+
}
109+
110+
export function ConditionBuilder({ label, value, onCommit, objectName, disabled }: {
111+
label: string;
112+
value: string;
113+
onCommit: (cel: string) => void;
114+
objectName?: string;
115+
disabled?: boolean;
116+
}) {
117+
const { fields } = useObjectFields(objectName);
118+
const subjectOptions = React.useMemo(() => {
119+
const fieldOpts = fields
120+
.filter((f) => !f.hidden)
121+
.map((f) => ({ value: `record.${f.name}`, label: `record.${f.name}` }));
122+
return [...fieldOpts, ...CONTEXT_SUBJECTS];
123+
}, [fields]);
124+
125+
const init = React.useMemo(() => initFrom(value), []); // first mount only
126+
const [rows, setRowsState] = React.useState<Row[]>(init.rows);
127+
const [join, setJoin] = React.useState<'&&' | '||'>(init.join);
128+
const [raw, setRaw] = React.useState<boolean>(init.raw);
129+
130+
// Adopt an externally-changed value (e.g. switching records, or a raw edit
131+
// from elsewhere) when it isn't the CEL we just emitted.
132+
const lastEmitted = React.useRef<string>(value || '');
133+
React.useEffect(() => {
134+
const v = value || '';
135+
if (v === lastEmitted.current) return;
136+
lastEmitted.current = v;
137+
const next = initFrom(v);
138+
setRowsState(next.rows);
139+
setJoin(next.join);
140+
setRaw(next.raw);
141+
}, [value]);
142+
143+
const emit = (nextRows: Row[], nextJoin: '&&' | '||') => {
144+
const cel = compile(nextRows, nextJoin);
145+
lastEmitted.current = cel;
146+
onCommit(cel);
147+
};
148+
const update = (nextRows: Row[], nextJoin: '&&' | '||' = join) => {
149+
setRowsState(nextRows);
150+
setJoin(nextJoin);
151+
emit(nextRows, nextJoin);
152+
};
153+
154+
const compiled = compile(rows, join);
155+
156+
if (raw) {
157+
return (
158+
<div className="space-y-1">
159+
<div className="flex items-center justify-between">
160+
<Label className="text-xs text-muted-foreground">{label}</Label>
161+
<button type="button" disabled={disabled}
162+
onClick={() => { const n = initFrom(value); if (!value || !n.raw) { setRowsState(n.rows); setJoin(n.join); setRaw(false); } }}
163+
className="inline-flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-50">
164+
<ListFilter className="h-3 w-3" /> Builder
165+
</button>
166+
</div>
167+
<textarea
168+
value={value}
169+
onChange={(e) => { lastEmitted.current = e.target.value; onCommit(e.target.value); }}
170+
disabled={disabled}
171+
spellCheck={false}
172+
rows={2}
173+
placeholder="CEL expression, e.g. record.status != 'done' && user.isAdmin"
174+
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"
175+
/>
176+
{value && !parse(value) && (
177+
<div className="text-[10px] text-muted-foreground/70">Advanced expression — Builder only supports simple AND/OR conditions.</div>
178+
)}
179+
</div>
180+
);
181+
}
182+
183+
return (
184+
<div className="space-y-1.5">
185+
<div className="flex items-center justify-between">
186+
<Label className="text-xs text-muted-foreground">{label}</Label>
187+
<button type="button" disabled={disabled} onClick={() => setRaw(true)}
188+
className="inline-flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-50">
189+
<Code2 className="h-3 w-3" /> Expression
190+
</button>
191+
</div>
192+
193+
{rows.length === 0 ? (
194+
<p className="rounded-md border border-dashed bg-muted/30 px-3 py-2 text-center text-[11px] text-muted-foreground">Always — no condition.</p>
195+
) : (
196+
<div className="space-y-1.5">
197+
{rows.map((r, i) => (
198+
<div key={i} className="space-y-1 rounded-md border border-border p-1.5">
199+
{i > 0 && (
200+
<div className="flex justify-center">
201+
<Select value={join} onValueChange={(v) => update(rows, v as '&&' | '||')} disabled={disabled}>
202+
<SelectTrigger className="h-6 w-16 text-[10px]"><SelectValue /></SelectTrigger>
203+
<SelectContent>
204+
<SelectItem value="&&">AND</SelectItem>
205+
<SelectItem value="||">OR</SelectItem>
206+
</SelectContent>
207+
</Select>
208+
</div>
209+
)}
210+
<div className="flex items-center gap-1">
211+
<div className="min-w-0 flex-1">
212+
<Select value={r.subject} onValueChange={(v) => update(rows.map((x, j) => j === i ? { ...x, subject: v } : x))} disabled={disabled}>
213+
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="field / context" /></SelectTrigger>
214+
<SelectContent>
215+
{subjectOptions.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
216+
{r.subject && !subjectOptions.some((o) => o.value === r.subject) && (
217+
<SelectItem value={r.subject}>{r.subject}</SelectItem>
218+
)}
219+
</SelectContent>
220+
</Select>
221+
</div>
222+
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0" disabled={disabled} aria-label="Remove condition"
223+
onClick={() => update(rows.filter((_, j) => j !== i))}>
224+
<X className="h-3.5 w-3.5" />
225+
</Button>
226+
</div>
227+
<div className="flex items-center gap-1">
228+
<div className="w-28 shrink-0">
229+
<Select value={r.op} onValueChange={(v) => update(rows.map((x, j) => j === i ? { ...x, op: v as Op } : x))} disabled={disabled}>
230+
<SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
231+
<SelectContent>
232+
{COMPARE_OPS.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
233+
</SelectContent>
234+
</Select>
235+
</div>
236+
{r.op !== 'truthy' && r.op !== 'falsy' && (
237+
<Input className="h-7 flex-1 text-xs" value={r.value} placeholder="value" disabled={disabled}
238+
onChange={(e) => update(rows.map((x, j) => j === i ? { ...x, value: e.target.value } : x))} />
239+
)}
240+
</div>
241+
</div>
242+
))}
243+
</div>
244+
)}
245+
246+
{!disabled && (
247+
<Button type="button" variant="outline" size="sm" onClick={() => update([...rows, { subject: '', op: 'truthy', value: '' }])}>
248+
<Plus className="mr-1 h-3.5 w-3.5" /> Add condition
249+
</Button>
250+
)}
251+
252+
{compiled && (
253+
<div className="rounded bg-muted/40 px-2 py-1 text-[10px] font-mono text-muted-foreground break-all">{compiled}</div>
254+
)}
255+
</div>
256+
);
257+
}

0 commit comments

Comments
 (0)