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

feat(studio): select and edit nested container nodes through the schema-driven flow inspector (#2670)

Phase 2 (#2680) expanded a container's regions (`loop.body` /
`parallel.branches[]` / `try_catch.try`/`catch`) inline on the flow designer
canvas, but the nested nodes were read-only — changing one still meant editing
the container's Advanced JSON by hand. A nested node is now a first-class
selection: click it on the expanded canvas and it opens in the SAME
schema-driven inspector as a top-level node, with a `container › region › node`
breadcrumb. Edits (label / type / description / typed config fields / Advanced
JSON) write straight back into `config.<region>.nodes[i]` — the write rebuilds
the container with explicit spreads so the `config.branches` array stays an
array and each region's own `edges` / a branch's `name` are preserved.

Scope resolves correctly for the region's outer context (ADR-0031): a loop
body node sees the loop's `iteratorVariable` in its data picker even though the
container's own outputs are otherwise out of scope at its id.

This phase is edit-only by design. A nested node keeps its id read-only (rename
it in the container's Advanced JSON), has no delete, and — for a nested
decision — drops the virtual Target column, since a region sub-graph's internal
routing is not managed by the inspector yet (nested region-edge editing,
structural add/remove, and drag are follow-ups). A stale nested deep link
(the draft moved on) resolves to a harmless empty-state rather than writing to
the wrong node.

Also fixes an expression/template validation split now that the engine
publishes a loop `configSchema`: a string property can carry an `xExpression:
'expression' | 'template'` marker so the designer renders bare-CEL vs
`interpolate()` `{var}` semantics (mono editor, data-picker brace mode, and
whether the CEL brace-trap applies) instead of guessing from the field name. A
loop / map `collection` (`{leadList}`) is a template, so it no longer
false-positives as a malformed condition inline or on the canvas badge.
26 changes: 25 additions & 1 deletion apps/console/src/preview-samples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,27 @@ export const SAMPLES: Record<string, Record<string, unknown>> = {
nodes: [
{ id: 'start', type: 'start', label: 'Schedule (daily)', config: { triggerType: 'schedule', objectName: 'contract', condition: 'status == "active"', cron: '0 9 * * *' } },
{ id: 'find', type: 'get_record', label: 'Find expiring contracts', config: { objectName: 'contract', filter: { status: 'active' }, outputVariable: 'contracts' } },
// ADR-0031 structured container: a loop whose `config.body` region is a
// nested sub-graph. On the designer canvas it expands inline (#2680) and
// its body nodes are selectable + editable through the same schema-driven
// inspector as top-level nodes (#2670 Phase 3).
{
id: 'each_contract',
type: 'loop',
label: 'For each expiring contract',
config: {
collection: '{contracts}',
iteratorVariable: 'contract',
body: {
nodes: [
{ id: 'charge_fee', type: 'http_request', label: 'Charge renewal fee', config: { method: 'POST', url: 'https://api.example.com/billing/charge', outputVariable: 'charge' } },
],
edges: [],
},
},
},
{ id: 'fan_out', type: 'parallel', label: 'Notify in parallel', config: { branches: [ { name: 'Email the owner', nodes: [ { id: 'email_owner', type: 'script', label: 'Email Owner', config: { actionType: 'email', template: 'renewal_reminder', recipients: ['owner.email'] } } ], edges: [] }, { name: 'Post to Slack', nodes: [ { id: 'slack_post', type: 'script', label: 'Slack Notify', config: { actionType: 'slack' } } ], edges: [] } ] } },
{ id: 'guard', type: 'try_catch', label: 'Push with retry', config: { errorVariable: '$error', try: { nodes: [ { id: 'push', type: 'http_request', label: 'Push to CRM', config: { method: 'POST', url: 'https://api.example.com/v1/tasks' } } ], edges: [] }, catch: { nodes: [ { id: 'record_failure', type: 'update_record', label: 'Flag Sync Failure', config: { objectName: 'contract', fields: { reminded: false } } } ], edges: [] } } },
{ id: 'check', type: 'decision', label: 'Within reminder window?', config: { conditions: [ { label: 'Within window', expression: 'daysToExpiry <= daysBefore' }, { label: 'Else', expression: 'true' } ] } },
{ id: 'email', type: 'connector_action', label: 'Send renewal email', connectorConfig: { connectorId: 'email', actionId: 'send', input: { to: 'owner.email', template: 'renewal_reminder' } } },
{ id: 'wait', type: 'wait', label: 'Wait 3 days', waitEventConfig: { eventType: 'timer', timerDuration: 'P3D', onTimeout: 'continue' } },
Expand All @@ -130,7 +151,10 @@ export const SAMPLES: Record<string, Record<string, unknown>> = {
],
edges: [
{ source: 'start', target: 'find' },
{ source: 'find', target: 'check' },
{ source: 'find', target: 'each_contract' },
{ source: 'each_contract', target: 'fan_out' },
{ source: 'fan_out', target: 'guard' },
{ source: 'guard', target: 'check' },
{ source: 'check', target: 'email', condition: 'daysToExpiry <= daysBefore' },
{ source: 'check', target: 'skip', isDefault: true },
{ source: 'email', target: 'wait' },
Expand Down
2 changes: 2 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.inspector.flowNode.advanced': 'Advanced (JSON)',
'engine.inspector.flowNode.advancedHint': 'Optional custom keys not covered by the form above — most flows don\u2019t need this.',
'engine.inspector.flowNode.noConfig': 'No configuration needed for this node type.',
'engine.inspector.flowNode.nestedIdHint': 'A node inside a container region keeps its id here — rename it in the container’s Advanced JSON.',
'engine.inspector.flowNode.kv.add': 'Add entry',
'engine.inspector.flowNode.kv.key': 'Key',
'engine.inspector.flowNode.kv.value': 'Value',
Expand Down Expand Up @@ -1715,6 +1716,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.inspector.flowNode.advanced': '高级(JSON)',
'engine.inspector.flowNode.advancedHint': '上方表单未覆盖的可选自定义键 —— 大多数流程无需填写。',
'engine.inspector.flowNode.noConfig': '此节点类型无需配置。',
'engine.inspector.flowNode.nestedIdHint': '容器区域内的节点 ID 在此只读 —— 请在容器的高级 JSON 中重命名。',
'engine.inspector.flowNode.kv.add': '添加条目',
'engine.inspector.flowNode.kv.key': '键',
'engine.inspector.flowNode.kv.value': '值',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* FlowNodeConfigField — inline expression/template validation gating (#2670
* Phase 3 B). The malformed-condition brace-trap (ADR-0032) must fire on a real
* CEL predicate but stay silent on a legitimate `interpolate()` `{var}` template
* — an expression field flagged `refMode: 'template'` (e.g. a loop/map
* collection). The scope-aware unknown-reference note still applies to a
* template, using `{…}`-hole semantics rather than CEL predicate semantics.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { FlowNodeConfigField } from './FlowNodeConfigField';
import type { FlowConfigField } from './flow-node-config';
import type { ScopeGroup } from './useFlowScope';

afterEach(cleanup);

/** A minimal scope with the given variable tokens (all in the `variables` group). */
function scope(tokens: string[]): ScopeGroup[] {
return [
{
id: 'variables',
label: 'Flow variables',
refs: tokens.map((token) => ({ token, label: token, group: 'variables' as const })),
},
];
}

const TEMPLATE_COLLECTION: FlowConfigField = {
id: 'collection',
path: ['config', 'collection'],
label: 'Collection',
kind: 'expression',
refMode: 'template',
};

const CEL_CONDITION: FlowConfigField = {
id: 'condition',
path: ['config', 'condition'],
label: 'Condition',
kind: 'expression',
};

describe('FlowNodeConfigField — expression vs template validation gating', () => {
it('does NOT flag a `{leadList}` template on an expression field in template mode', () => {
render(
<FlowNodeConfigField
field={TEMPLATE_COLLECTION}
value="{leadList}"
onCommit={() => {}}
scopeGroups={scope(['leadList'])}
/>,
);
// The pre-fix bug: the CEL brace-trap fired on the legal single-brace hole.
expect(screen.queryByRole('alert')).toBeNull();
// In scope → no unknown-reference note either.
expect(screen.queryByRole('note')).toBeNull();
});

it('surfaces an out-of-scope hole as a template NOTE (not a CEL error)', () => {
render(
<FlowNodeConfigField
field={TEMPLATE_COLLECTION}
value="{leadLst}"
onCommit={() => {}}
scopeGroups={scope(['leadList'])}
/>,
);
// No brace-trap error…
expect(screen.queryByRole('alert')).toBeNull();
// …but a gentle "did you mean" note, scanned as a `{…}` template hole.
const note = screen.getByRole('note');
expect(note.textContent).toMatch(/leadList/);
});

it('still flags a genuine `{record.x}` brace-in-CEL mistake on a predicate field', () => {
render(
<FlowNodeConfigField
field={CEL_CONDITION}
value="{record.amount} > 10"
onCommit={() => {}}
scopeGroups={scope(['record'])}
/>,
);
expect(screen.getByRole('alert')).toBeInTheDocument();
});

it('does not flag a well-formed CEL predicate with an in-scope reference', () => {
render(
<FlowNodeConfigField
field={CEL_CONDITION}
value="amount > 10"
onCommit={() => {}}
scopeGroups={scope(['amount'])}
/>,
);
expect(screen.queryByRole('alert')).toBeNull();
expect(screen.queryByRole('note')).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -182,17 +182,24 @@ export function FlowNodeConfigField({ field, value, onCommit, disabled, locale,

// ADR-0032 — surface a malformed condition (e.g. the `{record.x}` brace-in-CEL
// mistake) inline, with the same corrective message the build/agent emit. Only
// for expression fields (a genuine template uses single-brace `{var}` legally).
// for expression fields in a *predicate* mode — an expression field flagged
// `refMode: 'template'` (e.g. a loop/map collection authored as `{leadList}`)
// is an `interpolate()` single-brace template where `{var}` is legal, so the
// CEL brace-trap must be gated off or it false-positives on every `{…}`.
const isTemplate = refMode === 'template';
const exprIssue =
field.kind === 'expression' ? validateExpressionClient('predicate', value) : null;
field.kind === 'expression' && !isTemplate ? validateExpressionClient('predicate', value) : null;

// #1934 — pair the picker with a gentle, scope-aware "unknown reference"
// warning: CEL for expression fields, `{…}` holes for template fields. Skipped
// for free-form code (refMode 'expression' on a textarea, e.g. a script body)
// and when scope is unknown. The brace error above takes precedence.
// warning: CEL for predicate expression fields, `{…}` holes for template
// fields (including an expression field in template mode). Skipped for
// free-form code (refMode 'expression' on a textarea, e.g. a script body) and
// when scope is unknown. The brace error above takes precedence.
const scopeRole: 'predicate' | 'template' | null =
field.kind === 'expression'
? 'predicate'
? isTemplate
? 'template'
: 'predicate'
: refMode === 'template' && (field.kind === 'text' || field.kind === 'textarea')
? 'template'
: null;
Expand Down
Loading
Loading