Skip to content

Commit af53b74

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(studio): no-code condition builder for CEL predicate fields (#1836)
Generalise the Action visible/disabled condition builder to CEL predicate fields across the studio, so admins build conditions visually instead of hand-writing CEL: - ConditionBuilder: accept a pre-fetched field catalog + make the label optional (so it can be embedded in the generic form). - widgets: 'condition' widget wrapping ConditionBuilder (fields from the widget context) + registry entry. - SchemaForm: auto-detect CEL fields by name convention (visible / hidden / disabled / visibleOn / condition / *When) so generic forms get the builder. - PageBlockInspector: page block 'Hidden (CEL)' now uses the builder. Verified live: a page block's Hidden field renders the no-code builder (Add condition / Expression toggle). 274 metadata-admin tests pass. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 83e2e2e commit af53b74

4 files changed

Lines changed: 54 additions & 6 deletions

File tree

packages/app-shell/src/views/metadata-admin/SchemaForm.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,22 @@ function detectColorWidget(name: string, schema: JsonSchema | undefined): string
311311
return undefined;
312312
}
313313

314+
const CONDITION_FIELD_NAMES = new Set(['visible', 'hidden', 'disabled', 'visibleOn', 'condition', 'predicate']);
315+
/**
316+
* Detect a CEL predicate field by NAME CONVENTION (`visible` / `hidden` /
317+
* `disabled` / `visibleOn` / `condition` / `*When`) so it renders the no-code
318+
* condition builder instead of a raw expression text box. String-only, no enum.
319+
*/
320+
function detectConditionWidget(name: string, schema: JsonSchema | undefined): string | undefined {
321+
if (Array.isArray(schema?.enum)) return undefined;
322+
const isString =
323+
schema?.type === 'string' ||
324+
(Array.isArray(schema?.anyOf) && (schema!.anyOf as JsonSchema[]).some((b) => b?.type === 'string'));
325+
if (!isString) return undefined;
326+
if (CONDITION_FIELD_NAMES.has(name) || /When$/.test(name)) return 'condition';
327+
return undefined;
328+
}
329+
314330
/* -------------------------------------------------------------------------- */
315331
/* FormView spec (subset) */
316332
/* -------------------------------------------------------------------------- */
@@ -777,6 +793,10 @@ function FieldRow({
777793
else {
778794
const colorWidget = detectColorWidget(name, schema);
779795
if (colorWidget) widget = colorWidget;
796+
else {
797+
const condWidget = detectConditionWidget(name, schema);
798+
if (condWidget) widget = condWidget;
799+
}
780800
}
781801
}
782802
}

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

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -107,14 +107,18 @@ function initFrom(value: string): { rows: Row[]; join: '&&' | '||'; raw: boolean
107107
return { rows: [], join: '&&', raw: !!value };
108108
}
109109

110-
export function ConditionBuilder({ label, value, onCommit, objectName, disabled }: {
111-
label: string;
110+
export function ConditionBuilder({ label, value, onCommit, objectName, fields: fieldsProp, disabled }: {
111+
label?: string;
112112
value: string;
113113
onCommit: (cel: string) => void;
114114
objectName?: string;
115+
/** Pre-fetched field catalog (e.g. from the generic form's widget context);
116+
* when omitted, fields are loaded from `objectName`. */
117+
fields?: Array<{ name: string; label?: string; hidden?: boolean }>;
115118
disabled?: boolean;
116119
}) {
117-
const { fields } = useObjectFields(objectName);
120+
const { fields: hookFields } = useObjectFields(objectName);
121+
const fields = fieldsProp ?? hookFields;
118122
const subjectOptions = React.useMemo(() => {
119123
const fieldOpts = fields
120124
.filter((f) => !f.hidden)
@@ -157,7 +161,7 @@ export function ConditionBuilder({ label, value, onCommit, objectName, disabled
157161
return (
158162
<div className="space-y-1">
159163
<div className="flex items-center justify-between">
160-
<Label className="text-xs text-muted-foreground">{label}</Label>
164+
{label ? <Label className="text-xs text-muted-foreground">{label}</Label> : <span />}
161165
<button type="button" disabled={disabled}
162166
onClick={() => { const n = initFrom(value); if (!value || !n.raw) { setRowsState(n.rows); setJoin(n.join); setRaw(false); } }}
163167
className="inline-flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-50">
@@ -183,7 +187,7 @@ export function ConditionBuilder({ label, value, onCommit, objectName, disabled
183187
return (
184188
<div className="space-y-1.5">
185189
<div className="flex items-center justify-between">
186-
<Label className="text-xs text-muted-foreground">{label}</Label>
190+
{label ? <Label className="text-xs text-muted-foreground">{label}</Label> : <span />}
187191
<button type="button" disabled={disabled} onClick={() => setRaw(true)}
188192
className="inline-flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-50">
189193
<Code2 className="h-3 w-3" /> Expression

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
} from './_shared';
2828
import { BLOCK_CONFIG, blockHasConfig, type BlockPropField } from '../previews/block-config';
2929
import { ColorVariantPicker } from '../color-variant-field';
30+
import { ConditionBuilder } from './ConditionBuilder';
3031
import { useObjectOptions } from '../previews/useObjectOptions';
3132
import { useObjectFields } from '../previews/useObjectFields';
3233
import {
@@ -536,7 +537,13 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection
536537
<InspectorTextField label={t('engine.inspector.pageBlock.type', locale)} value={block.type ?? ''} onCommit={(v) => patch({ type: v })} disabled={readOnly} mono />
537538
<InspectorTextField label={t('engine.inspector.pageBlock.id', locale)} value={block.id ?? ''} onCommit={(v) => patch({ id: v })} disabled={readOnly} mono />
538539
<InspectorTextField label={t('engine.inspector.pageBlock.className', locale)} value={block.className ?? ''} onCommit={(v) => patch({ className: v })} disabled={readOnly} mono />
539-
<InspectorTextField label={t('engine.inspector.pageBlock.hidden', locale)} value={block.hidden ?? ''} onCommit={(v) => patch({ hidden: v })} disabled={readOnly} mono />
540+
<ConditionBuilder
541+
label={t('engine.inspector.pageBlock.hidden', locale)}
542+
value={block.hidden ?? ''}
543+
onCommit={(v) => patch({ hidden: v || undefined })}
544+
objectName={pageObject}
545+
disabled={readOnly}
546+
/>
540547

541548
{blockHasConfig(block.type) && (
542549
<div className="space-y-3 border-t border-border pt-3">

packages/app-shell/src/views/metadata-admin/widgets.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { ChevronDown, ChevronsUpDown, ChevronUp, Plus, Search, Trash2 } from 'lu
4646
import { iconNames } from 'lucide-react/dynamic.mjs';
4747
import { detectLocale, t, tFormat } from './i18n';
4848
import { ColorVariantPicker } from './color-variant-field';
49+
import { ConditionBuilder } from './inspectors/ConditionBuilder';
4950

5051
export interface WidgetContext {
5152
/** Names of all object metadata records (for `ref:object`). */
@@ -1646,6 +1647,21 @@ function ColorPickerWidget({ value, onChange, readOnly, schema, fieldSpec }: Wid
16461647
);
16471648
}
16481649

1650+
/* -------------------------------------------------------------------------- */
1651+
/* condition — no-code CEL predicate builder (visible / hidden / disabled / …) */
1652+
/* -------------------------------------------------------------------------- */
1653+
1654+
function ConditionWidget({ value, onChange, readOnly, context }: WidgetProps) {
1655+
return (
1656+
<ConditionBuilder
1657+
value={value == null ? '' : String(value)}
1658+
onCommit={(cel) => onChange(cel || undefined)}
1659+
fields={context?.objectFields}
1660+
disabled={readOnly}
1661+
/>
1662+
);
1663+
}
1664+
16491665
export const WIDGETS: Record<string, WidgetRenderer> = {
16501666
'ref:object': RefObjectWidget,
16511667
'filter-mode': FilterModeWidget,
@@ -1658,6 +1674,7 @@ export const WIDGETS: Record<string, WidgetRenderer> = {
16581674
'view-ref': ViewRefWidget,
16591675
'icon': IconPickerWidget,
16601676
'color-picker': ColorPickerWidget,
1677+
'condition': ConditionWidget,
16611678
'master-detail': MasterDetailWidget,
16621679
'string-tags': StringTagsWidget,
16631680
'multiselect': MultiSelectWidget,

0 commit comments

Comments
 (0)