feat: Add multi-branch workflow conditions (switch/case)

Add a `mode: 'multi'` variant to CONDITION steps that allows matching a
field against multiple values, each routing to its own named branch.
Branches are evaluated in order; first match wins. A `default` branch
catches unmatched contacts.

- Schema: z.union for legacy binary + multi-branch condition configs
- Backend: executeCondition handles multi-branch evaluation with stable
  branch IDs, falls back to default when no branch matches
- Builder: Dynamic branch rendering with per-branch colors/labels,
  dagre layout spreads branches horizontally
- Config UI: Mode toggle (Simple If/Else vs Multi-branch Switch),
  dynamic branch list with add/remove
- Tests: 8 new multi-branch tests covering matching, ordering, default
  fallback, mixed operators, and transition routing
- Full backward compatibility: all changes gated on config.mode === 'multi'

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
mdwt
2026-03-11 12:57:05 +02:00
co-authored by Claude Opus 4.6
parent 57bfbf874b
commit 92949b7596
5 changed files with 812 additions and 213 deletions
+128 -87
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,15 @@ 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 +438,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 +500,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 +551,10 @@ 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 +562,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 +575,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 +592,34 @@ 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: 'straight',
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({
@@ -647,13 +668,14 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
// 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`
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 +687,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 +772,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',
+348 -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,39 @@ 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 +1868,9 @@ 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';
});
const [conditionField, setConditionField] = useState(() => {
if (!config?.field) return '';
// Handle case where field is an object like {field: 'email', type: 'string'} (legacy format)
@@ -1850,6 +1882,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 +2065,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 +2396,49 @@ 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={() => setConditionMode('binary')}
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'
: 'border-neutral-200 text-neutral-600 hover:border-neutral-300'
}`}
>
Simple (If/Else)
</button>
<button
type="button"
onClick={() => setConditionMode('multi')}
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'
: '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>
{conditionMode !== (config?.mode === 'multi' ? 'multi' : 'binary') &&
(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 +2513,205 @@ 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>