Merge pull request #310 from mdwt/feature/multi-branch-conditions

This commit is contained in:
Dries Augustyns
2026-03-14 16:21:25 +01:00
committed by GitHub
5 changed files with 882 additions and 216 deletions
+134 -90
View File
@@ -91,6 +91,34 @@ const STEP_TYPE_BG = {
UPDATE_CONTACT: '#e0e7ff',
};
// Multi-branch condition helpers
function getExpectedBranches(config: any): string[] {
if (config?.mode === 'multi') {
return [...(config.branches || []).map((b: any) => b.id), 'default'];
}
return ['yes', 'no'];
}
function getBranchLabel(config: any, branchId: string): string {
if (config?.mode === 'multi') {
if (branchId === 'default') return 'Default';
const branch = config.branches?.find((b: any) => b.id === branchId);
return branch?.name || branchId;
}
return branchId === 'yes' ? 'Yes' : 'No';
}
const BRANCH_COLORS = ['#16a34a', '#dc2626', '#2563eb', '#d97706', '#7c3aed', '#0891b2', '#be185d', '#059669'];
function getBranchColor(config: any, branchId: string): string {
if (config?.mode === 'multi') {
if (branchId === 'default') return '#64748b';
const idx = config.branches?.findIndex((b: any) => b.id === branchId) ?? 0;
return BRANCH_COLORS[idx % BRANCH_COLORS.length]!;
}
return branchId === 'yes' ? '#16a34a' : '#dc2626';
}
// Dagre layout function
function getLayoutedElements(nodes: Node[], edges: Edge[]) {
const dagreGraph = new dagre.graphlib.Graph();
@@ -129,6 +157,7 @@ function getLayoutedElements(nodes: Node[], edges: Edge[]) {
});
// Center "Add Step" nodes directly below their parent nodes
// For condition nodes with multiple branches, let dagre handle horizontal spread
const adjustedNodes = layoutedNodes.map(node => {
if (node.type === 'addStep') {
// Find the parent node (the source of the edge connecting to this add node)
@@ -136,7 +165,17 @@ function getLayoutedElements(nodes: Node[], edges: Edge[]) {
if (parentEdge) {
const parentNode = layoutedNodes.find(n => n.id === parentEdge.source);
if (parentNode) {
// Center the add node below the parent node
// Check how many add-step siblings share this parent
const siblingAddNodes = edges.filter(
e => e.source === parentEdge.source && layoutedNodes.find(n => n.id === e.target && n.type === 'addStep'),
);
if (siblingAddNodes.length > 1) {
// Multiple branches — let dagre's spread positioning stand, only keep y
return node;
}
// Single add node — center below parent
return {
...node,
position: {
@@ -324,9 +363,16 @@ function CustomNode({
: String(data.config.field)}
</span>
</div>
<div className="text-[10px] text-neutral-500 ml-4">
{data.config.operator} &quot;{String(data.config.value)}&quot;
</div>
{data.config.mode === 'multi' ? (
<div className="text-[10px] text-neutral-500 ml-4">
{data.config.branches?.length || 0} branch{(data.config.branches?.length || 0) !== 1 ? 'es' : ''} +
default
</div>
) : (
<div className="text-[10px] text-neutral-500 ml-4">
{data.config.operator} &quot;{String(data.config.value)}&quot;
</div>
)}
</div>
</div>
)}
@@ -393,7 +439,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
const reactFlowInstance = useReactFlow();
const [addStepContext, setAddStepContext] = useState<{
fromStepId: string | null;
branch?: 'yes' | 'no';
branch?: string;
} | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [stepToDelete, setStepToDelete] = useState<string | null>(null);
@@ -455,41 +501,27 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
if (step.type === 'EXIT') return; // Exit steps can't have next steps
if (step.type === 'CONDITION') {
// Check for yes and no branches
const hasYesBranch = step.outgoingTransitions?.some(t => {
const condition = t.condition;
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === 'yes';
});
const hasNoBranch = step.outgoingTransitions?.some(t => {
const condition = t.condition;
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === 'no';
});
const expectedBranches = getExpectedBranches(step.config);
if (!hasYesBranch) {
nodes.push({
id: `${step.id}-add-yes`,
type: 'addStep',
position: {x: 0, y: 0},
draggable: false,
data: {
label: 'Yes',
onClick: () => setAddStepContext({fromStepId: step.id, branch: 'yes'}),
},
expectedBranches.forEach(branchId => {
const hasBranch = step.outgoingTransitions?.some(t => {
const condition = t.condition;
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === branchId;
});
}
if (!hasNoBranch) {
nodes.push({
id: `${step.id}-add-no`,
type: 'addStep',
position: {x: 0, y: 0},
draggable: false,
data: {
label: 'No',
onClick: () => setAddStepContext({fromStepId: step.id, branch: 'no'}),
},
});
}
if (!hasBranch) {
nodes.push({
id: `${step.id}-add-${branchId}`,
type: 'addStep',
position: {x: 0, y: 0},
draggable: false,
data: {
label: getBranchLabel(step.config, branchId),
onClick: () => setAddStepContext({fromStepId: step.id, branch: branchId}),
},
});
}
});
} else {
// For non-condition steps, add + node if no outgoing transitions
if (!step.outgoingTransitions || step.outgoingTransitions.length === 0) {
@@ -520,7 +552,12 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
const condition = transition.condition;
const isConditional = condition && typeof condition === 'object' && 'branch' in condition;
const branch =
condition && typeof condition === 'object' && 'branch' in condition ? condition.branch : undefined;
condition && typeof condition === 'object' && 'branch' in condition
? (condition.branch as string)
: undefined;
const branchColor = branch ? getBranchColor(step.config, branch) : '#94a3b8';
const branchLabel = branch ? getBranchLabel(step.config, branch) : undefined;
edges.push({
id: transition.id,
@@ -528,9 +565,9 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
target: transition.toStepId,
type: 'smoothstep',
animated: step.type === 'DELAY' || step.type === 'WAIT_FOR_EVENT',
label: isConditional ? (branch === 'yes' ? 'Yes' : 'No') : undefined,
label: isConditional ? branchLabel : undefined,
labelStyle: {
fill: branch === 'yes' ? '#16a34a' : branch === 'no' ? '#dc2626' : '#64748b',
fill: isConditional ? branchColor : '#64748b',
fontWeight: 600,
fontSize: 12,
},
@@ -541,12 +578,12 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
labelBgPadding: [8, 4] as [number, number],
labelBgBorderRadius: 4,
style: {
stroke: isConditional ? (branch === 'yes' ? '#16a34a' : '#dc2626') : '#94a3b8',
stroke: isConditional ? branchColor : '#94a3b8',
strokeWidth: 2.5,
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: isConditional ? (branch === 'yes' ? '#16a34a' : '#dc2626') : '#94a3b8',
color: isConditional ? branchColor : '#94a3b8',
width: 22,
height: 22,
},
@@ -558,47 +595,37 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
if (step.type === 'EXIT') return;
if (step.type === 'CONDITION') {
const hasYesBranch = step.outgoingTransitions?.some(
t =>
t.condition && typeof t.condition === 'object' && 'branch' in t.condition && t.condition.branch === 'yes',
);
const hasNoBranch = step.outgoingTransitions?.some(
t => t.condition && typeof t.condition === 'object' && 'branch' in t.condition && t.condition.branch === 'no',
);
const expectedBranches = getExpectedBranches(step.config);
if (!hasYesBranch) {
edges.push({
id: `${step.id}-add-yes-edge`,
source: step.id,
target: `${step.id}-add-yes`,
type: 'straight',
animated: false,
label: 'Yes',
labelStyle: {fill: '#16a34a', fontWeight: 600, fontSize: 12},
labelBgStyle: {fill: '#fff', fillOpacity: 0.95},
labelBgPadding: [8, 4] as [number, number],
labelBgBorderRadius: 4,
style: {stroke: '#16a34a', strokeWidth: 2.5, strokeDasharray: '5,5'},
markerEnd: {type: MarkerType.ArrowClosed, color: '#16a34a', width: 22, height: 22},
});
}
expectedBranches.forEach(branchId => {
const hasBranch = step.outgoingTransitions?.some(
t =>
t.condition &&
typeof t.condition === 'object' &&
'branch' in t.condition &&
t.condition.branch === branchId,
);
if (!hasNoBranch) {
edges.push({
id: `${step.id}-add-no-edge`,
source: step.id,
target: `${step.id}-add-no`,
type: 'straight',
animated: false,
label: 'No',
labelStyle: {fill: '#dc2626', fontWeight: 600, fontSize: 12},
labelBgStyle: {fill: '#fff', fillOpacity: 0.95},
labelBgPadding: [8, 4] as [number, number],
labelBgBorderRadius: 4,
style: {stroke: '#dc2626', strokeWidth: 2.5, strokeDasharray: '5,5'},
markerEnd: {type: MarkerType.ArrowClosed, color: '#dc2626', width: 22, height: 22},
});
}
if (!hasBranch) {
const color = getBranchColor(step.config, branchId);
const label = getBranchLabel(step.config, branchId);
edges.push({
id: `${step.id}-add-${branchId}-edge`,
source: step.id,
target: `${step.id}-add-${branchId}`,
type: 'smoothstep',
animated: false,
label,
labelStyle: {fill: color, fontWeight: 600, fontSize: 12},
labelBgStyle: {fill: '#fff', fillOpacity: 0.95},
labelBgPadding: [8, 4] as [number, number],
labelBgBorderRadius: 4,
style: {stroke: color, strokeWidth: 2.5, strokeDasharray: '5,5'},
markerEnd: {type: MarkerType.ArrowClosed, color, width: 22, height: 22},
});
}
});
} else {
if (!step.outgoingTransitions || step.outgoingTransitions.length === 0) {
edges.push({
@@ -645,15 +672,13 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
const connectedAddNodes = updatedNodes.filter(node => {
if (node.type !== 'addStep') return false;
// Check if this add node is connected to the moved node
return (
node.id === `${movedNode.id}-add` ||
node.id === `${movedNode.id}-add-yes` ||
node.id === `${movedNode.id}-add-no`
);
return node.id === `${movedNode.id}-add` || node.id.startsWith(`${movedNode.id}-add-`);
});
// Update positions of connected "+" nodes to stay centered below parent
connectedAddNodes.forEach(addNode => {
// Update positions of connected "+" nodes to stay below parent
if (connectedAddNodes.length === 1) {
// Single add node — center below parent
const addNode = connectedAddNodes[0]!;
const addNodeIndex = updatedNodes.findIndex(n => n.id === addNode.id);
if (addNodeIndex !== -1 && updatedNodes[addNodeIndex]) {
const existingNode = updatedNodes[addNodeIndex];
@@ -665,7 +690,24 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
},
};
}
});
} else if (connectedAddNodes.length > 1) {
// Multiple branches — move all add nodes by the same delta as the parent
const oldX = movedNode.position.x;
const deltaX = change.position.x - oldX;
connectedAddNodes.forEach(addNode => {
const addNodeIndex = updatedNodes.findIndex(n => n.id === addNode.id);
if (addNodeIndex !== -1 && updatedNodes[addNodeIndex]) {
const existingNode = updatedNodes[addNodeIndex];
updatedNodes[addNodeIndex] = {
...existingNode,
position: {
x: existingNode.position.x + deltaX,
y: existingNode.position.y,
},
};
}
});
}
}
});
@@ -733,7 +775,9 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
// Create the transition with proper condition
const condition = addStepContext.branch ? {branch: addStepContext.branch} : null;
const priority = addStepContext.branch === 'yes' ? 0 : addStepContext.branch === 'no' ? 1 : 0;
const expectedBranches = fromStep.type === 'CONDITION' ? getExpectedBranches(fromStep.config) : [];
const branchIndex = addStepContext.branch ? expectedBranches.indexOf(addStepContext.branch) : -1;
const priority = branchIndex >= 0 ? branchIndex : 0;
await network.fetch<unknown, typeof WorkflowSchemas.createTransition>(
'POST',
+412 -106
View File
@@ -205,23 +205,33 @@ export default function WorkflowEditorPage() {
break;
case 'CONDITION':
// Extract field name from both legacy format (object) and new format (string)
let fieldValue = '';
if (config.field) {
if (typeof config.field === 'object' && config.field !== null && 'field' in config.field) {
fieldValue = String(config.field.field || '');
} else {
fieldValue = String(config.field);
if (config.mode === 'multi') {
// Multi-branch validation
if (!config.field) {
errors.push(`"${step.name}" step is missing condition field`);
}
if (!Array.isArray(config.branches) || config.branches.length === 0) {
errors.push(`"${step.name}" step needs at least one branch`);
}
} else {
// Extract field name from both legacy format (object) and new format (string)
let fieldValue = '';
if (config.field) {
if (typeof config.field === 'object' && config.field !== null && 'field' in config.field) {
fieldValue = String(config.field.field || '');
} else {
fieldValue = String(config.field);
}
}
}
if (!fieldValue || !config.operator) {
errors.push(`"${step.name}" step is missing condition configuration (field or operator)`);
}
// Check if value is required for this operator
const operatorNeedsValue = !['exists', 'notExists'].includes(String(config.operator || ''));
if (operatorNeedsValue && (config.value === undefined || config.value === null || config.value === '')) {
errors.push(`"${step.name}" step is missing a value for the condition`);
if (!fieldValue || !config.operator) {
errors.push(`"${step.name}" step is missing condition configuration (field or operator)`);
}
// Check if value is required for this operator
const operatorNeedsValue = !['exists', 'notExists'].includes(String(config.operator || ''));
if (operatorNeedsValue && (config.value === undefined || config.value === null || config.value === '')) {
errors.push(`"${step.name}" step is missing a value for the condition`);
}
}
break;
@@ -264,20 +274,37 @@ export default function WorkflowEditorPage() {
errors.push('Workflow must have a trigger step');
}
// Check for CONDITION steps that don't have both yes and no branches
// Check for CONDITION steps that don't have all required branches connected
workflow.steps.forEach(step => {
if (step.type === 'CONDITION' && step.outgoingTransitions) {
const hasYesBranch = step.outgoingTransitions.some(t => {
const condition = t.condition;
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === 'yes';
});
const hasNoBranch = step.outgoingTransitions.some(t => {
const condition = t.condition;
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === 'no';
const config = step.config && typeof step.config === 'object' && !Array.isArray(step.config) ? step.config : {};
// Determine expected branches based on mode
let expectedBranches: string[];
if ((config as any).mode === 'multi' && Array.isArray((config as any).branches)) {
expectedBranches = [...(config as any).branches.map((b: any) => b.id), 'default'];
} else {
expectedBranches = ['yes', 'no'];
}
const missingBranches = expectedBranches.filter(branchId => {
return !step.outgoingTransitions.some(t => {
const condition = t.condition;
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === branchId;
});
});
if (!hasYesBranch || !hasNoBranch) {
errors.push(`"${step.name}" condition step must have both YES and NO branches connected`);
if (missingBranches.length > 0) {
if ((config as any).mode === 'multi') {
const branchNames = missingBranches.map(id => {
if (id === 'default') return 'Default';
const branch = (config as any).branches?.find((b: any) => b.id === id);
return branch?.name || id;
});
errors.push(`"${step.name}" condition step is missing connections for: ${branchNames.join(', ')}`);
} else {
errors.push(`"${step.name}" condition step must have both YES and NO branches connected`);
}
}
}
});
@@ -1839,6 +1866,59 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
);
// CONDITION fields - handle both old format (object) and new format (string)
const [conditionMode, setConditionMode] = useState<'binary' | 'multi'>(() => {
return config?.mode === 'multi' ? 'multi' : 'binary';
});
// Helper to check if switching from multi to binary is safe
const hasMultiBranchConnections = () => {
if (step.type !== 'CONDITION') return false;
if (config?.mode !== 'multi') return false;
const transitions = (step as any).outgoingTransitions || [];
// Check if any transition has a branch that's not 'yes' or 'no' (multi-branch specific)
return transitions.some((t: any) => {
const condition = t.condition;
if (condition && typeof condition === 'object' && 'branch' in condition) {
const branch = condition.branch as string;
// Multi-branch specific branches (not the simple if/else branches)
return branch !== 'yes' && branch !== 'no';
}
return false;
});
};
// Helper to check if switching from binary to multi is safe
const hasBinaryConnections = () => {
if (step.type !== 'CONDITION') return false;
if (config?.mode === 'multi') return false;
const transitions = (step as any).outgoingTransitions || [];
// Check if any transition has 'yes' or 'no' branches (binary-specific)
return transitions.some((t: any) => {
const condition = t.condition;
if (condition && typeof condition === 'object' && 'branch' in condition) {
const branch = condition.branch as string;
return branch === 'yes' || branch === 'no';
}
return false;
});
};
// Safe mode change handler
const handleModeChange = (newMode: 'binary' | 'multi') => {
// If switching from multi to binary, check for multi-branch connections
if (conditionMode === 'multi' && newMode === 'binary' && hasMultiBranchConnections()) {
// Don't allow the switch - user needs to disconnect branches first
return;
}
// If switching from binary to multi, check for binary connections
if (conditionMode === 'binary' && newMode === 'multi' && hasBinaryConnections()) {
// Don't allow the switch - user needs to disconnect branches first
return;
}
setConditionMode(newMode);
};
const [conditionField, setConditionField] = useState(() => {
if (!config?.field) return '';
// Handle case where field is an object like {field: 'email', type: 'string'} (legacy format)
@@ -1850,6 +1930,19 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
});
const [conditionOperator, setConditionOperator] = useState(String(config?.operator || 'equals'));
const [conditionValue, setConditionValue] = useState(String(config?.value ?? ''));
const [conditionBranches, setConditionBranches] = useState<
Array<{id: string; name: string; operator: string; value: string}>
>(() => {
if (config?.mode === 'multi' && Array.isArray(config.branches)) {
return config.branches.map((b: any) => ({
id: String(b.id || crypto.randomUUID().slice(0, 8)),
name: String(b.name || ''),
operator: String(b.operator || 'equals'),
value: String(b.value ?? ''),
}));
}
return [{id: crypto.randomUUID().slice(0, 8), name: '', operator: 'equals', value: ''}];
});
const [availableFields, setAvailableFields] = useState<Array<{field: string; type: string; category: string}>>([]);
const [loadingFields, setLoadingFields] = useState(false);
@@ -2020,17 +2113,50 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
}
newConfig = {amount, unit: delayUnit};
} else if (step.type === 'CONDITION') {
// Parse the value based on type
let parsedValue: string | number | boolean = conditionValue;
if (conditionValue === 'true') parsedValue = true;
else if (conditionValue === 'false') parsedValue = false;
else if (!isNaN(Number(conditionValue))) parsedValue = Number(conditionValue);
if (conditionMode === 'multi') {
// Validate branches
const validBranches = conditionBranches.filter(b => b.name.trim());
if (validBranches.length === 0) {
toast.error('At least one branch with a name is required');
setIsSubmitting(false);
return;
}
if (!conditionField) {
toast.error('Please select a field');
setIsSubmitting(false);
return;
}
newConfig = {
field: conditionField,
operator: conditionOperator,
value: parsedValue,
};
newConfig = {
mode: 'multi' as const,
field: conditionField,
branches: validBranches.map(b => {
let parsedValue: string | number | boolean = b.value;
if (b.value === 'true') parsedValue = true;
else if (b.value === 'false') parsedValue = false;
else if (b.value !== '' && !isNaN(Number(b.value))) parsedValue = Number(b.value);
return {
id: b.id,
name: b.name.trim(),
operator: b.operator,
value: parsedValue,
};
}),
};
} else {
// Parse the value based on type
let parsedValue: string | number | boolean = conditionValue;
if (conditionValue === 'true') parsedValue = true;
else if (conditionValue === 'false') parsedValue = false;
else if (!isNaN(Number(conditionValue))) parsedValue = Number(conditionValue);
newConfig = {
field: conditionField,
operator: conditionOperator,
value: parsedValue,
};
}
} else if (step.type === 'EXIT') {
newConfig = {reason: exitReason};
} else if (step.type === 'WEBHOOK') {
@@ -2318,10 +2444,71 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
</div>
<div className="space-y-4 pl-3">
<p className="text-xs text-neutral-600">
Define the condition that will be evaluated to determine which path the workflow should follow
</p>
{/* Mode toggle */}
<div>
<Label className="text-sm font-medium mb-2 block">Condition Mode</Label>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleModeChange('binary')}
disabled={conditionMode === 'multi' && hasMultiBranchConnections()}
className={`flex-1 px-3 py-2 rounded-lg border-2 text-sm font-medium transition-all ${
conditionMode === 'binary'
? 'border-purple-500 bg-purple-50 text-purple-700'
: conditionMode === 'multi' && hasMultiBranchConnections()
? 'border-neutral-200 text-neutral-400 bg-neutral-50 cursor-not-allowed opacity-50'
: 'border-neutral-200 text-neutral-600 hover:border-neutral-300'
}`}
>
Simple (If/Else)
</button>
<button
type="button"
onClick={() => handleModeChange('multi')}
disabled={conditionMode === 'binary' && hasBinaryConnections()}
className={`flex-1 px-3 py-2 rounded-lg border-2 text-sm font-medium transition-all ${
conditionMode === 'multi'
? 'border-purple-500 bg-purple-50 text-purple-700'
: conditionMode === 'binary' && hasBinaryConnections()
? 'border-neutral-200 text-neutral-400 bg-neutral-50 cursor-not-allowed opacity-50'
: 'border-neutral-200 text-neutral-600 hover:border-neutral-300'
}`}
>
Multi-branch (Switch)
</button>
</div>
<p className="text-xs text-neutral-500 mt-1.5">
{conditionMode === 'binary'
? 'Evaluates a single condition with Yes/No paths'
: 'Match a field against multiple values, each routing to its own branch'}
</p>
{hasMultiBranchConnections() && (
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded-lg text-xs text-red-800">
<AlertTriangle className="h-3 w-3 inline mr-1" />
Cannot switch to simple mode: branches have connected nodes. Disconnect all branch connections
first.
</div>
)}
{hasBinaryConnections() && (
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded-lg text-xs text-red-800">
<AlertTriangle className="h-3 w-3 inline mr-1" />
Cannot switch to multi-branch mode: Yes/No branches have connected nodes. Disconnect all branch
connections first.
</div>
)}
{conditionMode !== (config?.mode === 'multi' ? 'multi' : 'binary') &&
!hasMultiBranchConnections() &&
!hasBinaryConnections() &&
(step as any).outgoingTransitions &&
(step as any).outgoingTransitions.length > 0 && (
<div className="mt-2 p-2 bg-amber-50 border border-amber-200 rounded-lg text-xs text-amber-800">
<AlertTriangle className="h-3 w-3 inline mr-1" />
Changing mode will disconnect existing branches. You will need to rewire them.
</div>
)}
</div>
{/* Field selector (shared between modes) */}
<div>
<Label htmlFor="editConditionField">Field to Check *</Label>
{loadingFields ? (
@@ -2396,80 +2583,199 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
)}
</div>
<div>
<Label htmlFor="editConditionOperator">Operator *</Label>
<Select value={conditionOperator} onValueChange={setConditionOperator}>
<SelectTrigger id="editConditionOperator" className="mt-1.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
{validOperators.map(op => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
{currentFieldType && (
<p className="text-xs text-neutral-500 mt-1.5">
Showing operators for{' '}
<span className="font-mono bg-neutral-200 px-1 rounded">{currentFieldType}</span> fields
</p>
)}
</div>
{needsValue && (
<div>
<Label htmlFor="editConditionValue">Value *</Label>
{currentFieldType === 'boolean' ? (
<Select value={conditionValue || 'true'} onValueChange={setConditionValue}>
<SelectTrigger id="editConditionValue" className="mt-1.5">
{/* Binary mode: single operator + value */}
{conditionMode === 'binary' && (
<>
<div>
<Label htmlFor="editConditionOperator">Operator *</Label>
<Select value={conditionOperator} onValueChange={setConditionOperator}>
<SelectTrigger id="editConditionOperator" className="mt-1.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
{validOperators.map(op => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : currentFieldType === 'number' ? (
<>
<Input
id="editConditionValue"
type="number"
value={conditionValue}
onChange={e => setConditionValue(e.target.value)}
required
placeholder="e.g., 100"
className="mt-1.5"
/>
<p className="text-xs text-neutral-500 mt-1.5">Enter a numeric value to compare against</p>
</>
) : currentFieldType === 'date' ? (
<>
<Input
id="editConditionValue"
type="datetime-local"
value={conditionValue}
onChange={e => setConditionValue(e.target.value)}
required
className="mt-1.5"
/>
<p className="text-xs text-neutral-500 mt-1.5">Select a date and time to compare against</p>
</>
) : (
<>
<Input
id="editConditionValue"
type="text"
value={conditionValue}
onChange={e => setConditionValue(e.target.value)}
required
placeholder="e.g., premium, active"
className="mt-1.5"
/>
<p className="text-xs text-neutral-500 mt-1.5">Enter the text value to compare against</p>
</>
{currentFieldType && (
<p className="text-xs text-neutral-500 mt-1.5">
Showing operators for{' '}
<span className="font-mono bg-neutral-200 px-1 rounded">{currentFieldType}</span> fields
</p>
)}
</div>
{needsValue && (
<div>
<Label htmlFor="editConditionValue">Value *</Label>
{currentFieldType === 'boolean' ? (
<Select value={conditionValue || 'true'} onValueChange={setConditionValue}>
<SelectTrigger id="editConditionValue" className="mt-1.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
) : currentFieldType === 'number' ? (
<>
<Input
id="editConditionValue"
type="number"
value={conditionValue}
onChange={e => setConditionValue(e.target.value)}
required
placeholder="e.g., 100"
className="mt-1.5"
/>
<p className="text-xs text-neutral-500 mt-1.5">Enter a numeric value to compare against</p>
</>
) : currentFieldType === 'date' ? (
<>
<Input
id="editConditionValue"
type="datetime-local"
value={conditionValue}
onChange={e => setConditionValue(e.target.value)}
required
className="mt-1.5"
/>
<p className="text-xs text-neutral-500 mt-1.5">Select a date and time to compare against</p>
</>
) : (
<>
<Input
id="editConditionValue"
type="text"
value={conditionValue}
onChange={e => setConditionValue(e.target.value)}
required
placeholder="e.g., premium, active"
className="mt-1.5"
/>
<p className="text-xs text-neutral-500 mt-1.5">Enter the text value to compare against</p>
</>
)}
</div>
)}
</>
)}
{/* Multi-branch mode: dynamic list of branches */}
{conditionMode === 'multi' && (
<div className="space-y-3">
<Label className="text-sm font-medium">Branches</Label>
<p className="text-xs text-neutral-500">
Each branch defines a condition. The first matching branch is taken. Contacts not matching any
branch follow the Default path.
</p>
{conditionBranches.map((branch, idx) => (
<div
key={branch.id}
className="p-3 border border-neutral-200 rounded-lg space-y-3 bg-neutral-50/50"
>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-neutral-500">Branch {idx + 1}</span>
{conditionBranches.length > 1 && (
<button
type="button"
onClick={() => setConditionBranches(prev => prev.filter(b => b.id !== branch.id))}
className="text-xs text-red-500 hover:text-red-700"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
<div>
<Label className="text-xs">Name *</Label>
<Input
type="text"
value={branch.name}
onChange={e => {
const newName = e.target.value;
setConditionBranches(prev =>
prev.map(b => (b.id === branch.id ? {...b, name: newName} : b)),
);
}}
placeholder="e.g., Premium, Free, Enterprise"
className="mt-1 h-8 text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs">Operator</Label>
<Select
value={branch.operator}
onValueChange={val =>
setConditionBranches(prev =>
prev.map(b => (b.id === branch.id ? {...b, operator: val} : b)),
)
}
>
<SelectTrigger className="mt-1 h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{validOperators.map(op => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{!['exists', 'notExists'].includes(branch.operator) && (
<div>
<Label className="text-xs">Value</Label>
<Input
type="text"
value={branch.value}
onChange={e => {
const newValue = e.target.value;
setConditionBranches(prev =>
prev.map(b => (b.id === branch.id ? {...b, value: newValue} : b)),
);
}}
placeholder="Value..."
className="mt-1 h-8 text-sm"
/>
</div>
)}
</div>
</div>
))}
{conditionBranches.length < 20 && (
<button
type="button"
onClick={() =>
setConditionBranches(prev => [
...prev,
{id: crypto.randomUUID().slice(0, 8), name: '', operator: 'equals', value: ''},
])
}
className="flex items-center gap-1.5 text-sm text-purple-600 hover:text-purple-800 font-medium"
>
<Plus className="h-3.5 w-3.5" />
Add Branch
</button>
)}
<div className="p-2 bg-neutral-100 rounded-lg text-xs text-neutral-600 flex items-start gap-2">
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
<span>
Branches are evaluated in order. The first match wins. Contacts not matching any branch will
follow the <strong>Default</strong> path.
</span>
</div>
</div>
)}
</div>