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
22 changes: 22 additions & 0 deletions .changeset/studio-flow-ux-followups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@object-ui/app-shell": patch
---

feat(studio/flow): context-aware Start trigger fields + explicit decision-branch binding

Two flow-builder UX improvements (follow-ups to the decision/screen/simulator fixes in #1927):

- **Start node trigger fields are now context-aware.** The Start node showed `Object`
and `Entry condition` (record-trigger config) even on screen / manual flows where
they don't apply. They're now gated by the chosen `triggerType` — shown for record /
schedule / webhook / event triggers, hidden for manual / unset (screen wizards). A
field that already holds a value is never hidden, so existing flows are unaffected.

- **Decision branches can be bound to edges explicitly.** Selecting a decision out-edge
now shows a **Branch** picker listing the source decision's branches (label · condition,
or "· default"). Picking one writes that branch's expression / label (or marks the
default) onto the edge — so routing stays correct even when edges are connected out of
branch order, instead of relying solely on the implicit by-order auto-wire. A
"— Custom —" option preserves manual editing.

Adds `flow-node-config.test.ts` covering the trigger-field gating.
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 @@ -347,6 +347,7 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.inspector.flowEdge.source': 'From',
'engine.inspector.flowEdge.target': 'To',
'engine.inspector.flowEdge.routing': 'Routing',
'engine.inspector.flowEdge.branch': 'Branch',
'engine.inspector.flowEdge.label': 'Branch label',
'engine.inspector.flowEdge.labelHint': 'e.g. approve / reject',
'engine.inspector.flowEdge.condition': 'Condition',
Expand Down Expand Up @@ -1001,6 +1002,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.inspector.flowEdge.source': '起点',
'engine.inspector.flowEdge.target': '终点',
'engine.inspector.flowEdge.routing': '路由',
'engine.inspector.flowEdge.branch': '分支',
'engine.inspector.flowEdge.label': '分支标签',
'engine.inspector.flowEdge.labelHint': '例如 approve / reject',
'engine.inspector.flowEdge.condition': '条件',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { t } from '../i18n';
import {
InspectorShell,
InspectorTextField,
InspectorSelectField,
InspectorCheckboxField,
InspectorRemoveButton,
InspectorEmptyState,
Expand Down Expand Up @@ -86,6 +87,44 @@ export function FlowEdgeInspector({ selection, draft, onPatch, onClearSelection,

const isDefault = edge.isDefault === true;

// Decision out-edges can bind EXPLICITLY to one of the source decision's
// branches (vs the implicit by-order auto-wire): picking a branch writes its
// expression / label (or marks the default) onto this edge, so routing stays
// correct even when edges are connected out of branch order.
const nodes = Array.isArray((draft as { nodes?: unknown }).nodes)
? ((draft as { nodes: Array<Record<string, unknown>> }).nodes)
: [];
const sourceNode = nodes.find((n) => n.id === edge.source);
const branches =
sourceNode?.type === 'decision' &&
Array.isArray((sourceNode.config as Record<string, unknown> | undefined)?.conditions)
? ((sourceNode.config as { conditions: Array<Record<string, unknown>> }).conditions)
: [];
const branchExpr = (b: Record<string, unknown>) => (typeof b.expression === 'string' ? b.expression.trim() : '');
const branchName = (b: Record<string, unknown>) => (typeof b.label === 'string' ? b.label.trim() : '');
// Which branch this edge currently represents: the default edge maps to the
// `true`/empty branch; otherwise match by condition, then by label. '' = custom.
const selectedBranch = (() => {
if (!branches.length) return '';
if (isDefault) {
const i = branches.findIndex((b) => { const e = branchExpr(b); return e === '' || e === 'true'; });
return i >= 0 ? String(i) : '';
}
const cond = conditionText(edge.condition);
let i = cond ? branches.findIndex((b) => branchExpr(b) === cond) : -1;
if (i < 0 && edge.label) i = branches.findIndex((b) => branchName(b) === edge.label);
return i >= 0 ? String(i) : '';
})();
const applyBranch = (key: string) => {
if (key === '') return; // keep current custom values
const b = branches[Number(key)];
if (!b) return;
const expr = branchExpr(b);
const lbl = branchName(b) || undefined;
if (expr === '' || expr === 'true') patchEdge({ isDefault: true, condition: undefined, label: lbl });
else patchEdge({ isDefault: false, condition: expr, label: lbl });
};

return (
<InspectorShell
kindLabel={t('engine.inspector.flowEdge.kind', locale)}
Expand Down Expand Up @@ -113,6 +152,24 @@ export function FlowEdgeInspector({ selection, draft, onPatch, onClearSelection,
<span className="h-px flex-1 bg-border" aria-hidden />
</div>

{branches.length > 0 && (
<InspectorSelectField
label={t('engine.inspector.flowEdge.branch', locale)}
value={selectedBranch}
options={[
...branches.map((b, i) => {
const expr = branchExpr(b);
const nm = branchName(b) || `Branch ${i + 1}`;
const suffix = expr === '' || expr === 'true' ? ' \u00b7 default' : ` \u00b7 ${expr}`;
return { value: String(i), label: `${nm}${suffix}` };
}),
{ value: '', label: '\u2014 Custom \u2014' },
]}
onCommit={applyBranch}
disabled={readOnly}
/>
)}

<InspectorTextField
label={t('engine.inspector.flowEdge.label', locale)}
value={edge.label ?? ''}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { fieldsForNodeType, isFieldVisible } from './flow-node-config';

describe('start node trigger-field gating (#5)', () => {
const fields = fieldsForNodeType('start');
const objectName = fields.find((f) => f.id === 'objectName')!;
const condition = fields.find((f) => f.id === 'condition')!;

it('hides Object / Entry condition on a screen / manual start (no record trigger)', () => {
const node = { id: 'start', type: 'start' }; // no config.triggerType
expect(isFieldVisible(objectName, node, fields)).toBe(false);
expect(isFieldVisible(condition, node, fields)).toBe(false);
});

it('shows them once a record trigger is picked', () => {
const node = { id: 'start', type: 'start', config: { triggerType: 'record-after-update' } };
expect(isFieldVisible(objectName, node, fields)).toBe(true);
expect(isFieldVisible(condition, node, fields)).toBe(true);
});

it('shows for a schedule trigger too', () => {
const node = { id: 'start', type: 'start', config: { triggerType: 'schedule' } };
expect(isFieldVisible(objectName, node, fields)).toBe(true);
expect(isFieldVisible(condition, node, fields)).toBe(true);
});

it('hides for a manual / autolaunched trigger', () => {
const node = { id: 'start', type: 'start', config: { triggerType: 'manual' } };
expect(isFieldVisible(objectName, node, fields)).toBe(false);
});

it('never hides a field that already holds a value (non-destructive)', () => {
const node = { id: 'start', type: 'start', config: { objectName: 'crm_lead' } }; // no triggerType
expect(isFieldVisible(objectName, node, fields)).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -196,10 +196,12 @@ const FLOW_NODE_CONFIG: Record<string, FlowConfigField[]> = {
ref: { kind: 'object' },
placeholder: 'crm_lead',
help: 'Target object for record / scheduled-scan triggers.',
showWhen: { field: 'triggerType', equals: ['record-after-create', 'record-after-update', 'record-before-update', 'record-after-delete', 'record-change', 'schedule', 'webhook', 'event'] },
}),
cfg('condition', 'Entry condition', 'expression', {
placeholder: 'status == "qualifying" && previous.status != "qualifying"',
help: 'CEL predicate — the flow runs only when this is true. Leave empty to run on every event.',
showWhen: { field: 'triggerType', equals: ['record-after-create', 'record-after-update', 'record-before-update', 'record-after-delete', 'record-change', 'schedule', 'webhook', 'event'] },
}),
cfg('cron', 'Cron schedule', 'text', {
placeholder: '0 7 * * *',
Expand Down
Loading