diff --git a/apps/api/src/services/WorkflowExecutionService.ts b/apps/api/src/services/WorkflowExecutionService.ts index 420eb1a..d0d674f 100644 --- a/apps/api/src/services/WorkflowExecutionService.ts +++ b/apps/api/src/services/WorkflowExecutionService.ts @@ -727,7 +727,7 @@ export class WorkflowExecutionService { _stepExecution: WorkflowStepExecution, config: StepConfig, ): Promise { - const {field, operator, value} = WorkflowStepConfigSchemas.condition.parse(config); + const parsed = WorkflowStepConfigSchemas.condition.parse(config); // Get the value to evaluate const contact = execution.contact; @@ -743,7 +743,7 @@ export class WorkflowExecutionService { // - data.firstName, data.lastName, etc. // - workflow.* (execution context - alias for event data) // - event.* (event data that triggered the workflow) - const actualValue = this.resolveField(field, { + const fieldData = { contact: { email: contact.email, subscribed: contact.subscribed, @@ -751,9 +751,39 @@ export class WorkflowExecutionService { data: contactData, workflow: context, event: context, // Alias for easier access to event data - }); + }; - // Evaluate the condition + // Multi-branch mode (switch/case) + if ('mode' in parsed && parsed.mode === 'multi') { + const actualValue = this.resolveField(parsed.field, fieldData); + + for (const branch of parsed.branches) { + if (this.evaluateCondition(actualValue, branch.operator, branch.value)) { + return { + field: parsed.field, + mode: 'multi', + matchedBranch: branch.name, + actualValue, + branch: branch.id, + }; + } + } + + // No branch matched — use default + return { + field: parsed.field, + mode: 'multi', + matchedBranch: 'default', + actualValue, + branch: 'default', + }; + } + + // Legacy binary mode (if/else) + const field = parsed.field; + const operator = 'operator' in parsed ? parsed.operator : 'equals'; + const value = 'value' in parsed ? parsed.value : undefined; + const actualValue = this.resolveField(field, fieldData); const result = this.evaluateCondition(actualValue, operator, value); return { diff --git a/apps/api/src/services/__tests__/WorkflowConditions.operators.test.ts b/apps/api/src/services/__tests__/WorkflowConditions.operators.test.ts index 8d4fe29..cfaf02c 100644 --- a/apps/api/src/services/__tests__/WorkflowConditions.operators.test.ts +++ b/apps/api/src/services/__tests__/WorkflowConditions.operators.test.ts @@ -722,4 +722,260 @@ describe('Workflow CONDITION Step - Comprehensive Operator Tests', () => { expect(branch).toBe('yes'); }); }); + + // ======================================== + // MULTI-BRANCH CONDITIONS (switch/case) + // ======================================== + describe('Multi-Branch Conditions', () => { + /** + * Helper to create a workflow with a multi-branch condition step + * Creates one exit step per branch + one for default + */ + async function createMultiBranchWorkflow( + contactData: Record, + conditionConfig: Record, + ) { + const contact = await factories.createContact({ + projectId, + data: contactData, + }); + + const workflow = await factories.createWorkflow({projectId}); + const triggerStep = await prisma.workflowStep.findFirstOrThrow({ + where: {workflowId: workflow.id, type: WorkflowStepType.TRIGGER}, + }); + + const conditionStep = await prisma.workflowStep.create({ + data: { + workflowId: workflow.id, + type: WorkflowStepType.CONDITION, + name: 'Multi Condition', + position: {x: 100, y: 0}, + config: conditionConfig, + }, + }); + + // Create exit steps for each branch + default + const branches = conditionConfig.branches as Array<{id: string; name: string; operator: string; value?: unknown}>; + const branchExits: Record = {}; + for (let i = 0; i < branches.length; i++) { + const branch = branches[i]!; + const exitStep = await prisma.workflowStep.create({ + data: { + workflowId: workflow.id, + type: WorkflowStepType.EXIT, + name: `${branch.name} Path`, + position: {x: 200, y: i * 100}, + config: {reason: branch.name}, + }, + }); + branchExits[branch.id] = exitStep.id; + + await prisma.workflowTransition.create({ + data: { + fromStepId: conditionStep.id, + toStepId: exitStep.id, + condition: {branch: branch.id}, + priority: i, + }, + }); + } + + // Create default exit + const defaultExit = await prisma.workflowStep.create({ + data: { + workflowId: workflow.id, + type: WorkflowStepType.EXIT, + name: 'Default Path', + position: {x: 200, y: branches.length * 100}, + config: {reason: 'default'}, + }, + }); + + await prisma.workflowTransition.create({ + data: { + fromStepId: conditionStep.id, + toStepId: defaultExit.id, + condition: {branch: 'default'}, + priority: branches.length, + }, + }); + + // Connect trigger to condition + await prisma.workflowTransition.create({ + data: {fromStepId: triggerStep.id, toStepId: conditionStep.id}, + }); + + const execution = await prisma.workflowExecution.create({ + data: { + workflowId: workflow.id, + contactId: contact.id, + status: WorkflowExecutionStatus.RUNNING, + currentStepId: triggerStep.id, + context: {}, + }, + }); + + return {execution, triggerStep, conditionStep, contact, branchExits, defaultExitId: defaultExit.id}; + } + + const planBranches = { + mode: 'multi' as const, + field: 'data.plan', + branches: [ + {id: 'br-premium', name: 'Premium', operator: 'equals', value: 'premium'}, + {id: 'br-free', name: 'Free', operator: 'equals', value: 'free'}, + {id: 'br-enterprise', name: 'Enterprise', operator: 'equals', value: 'enterprise'}, + ], + }; + + it('should match the first matching branch', async () => { + const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow( + {plan: 'premium'}, + planBranches, + ); + + await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id); + await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id); + + const branch = await getConditionBranch(execution.id, conditionStep.id); + expect(branch).toBe('br-premium'); + }); + + it('should match a non-first branch correctly', async () => { + const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow( + {plan: 'enterprise'}, + planBranches, + ); + + await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id); + await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id); + + const branch = await getConditionBranch(execution.id, conditionStep.id); + expect(branch).toBe('br-enterprise'); + }); + + it('should fall through to default when no branch matches', async () => { + const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow( + {plan: 'starter'}, + planBranches, + ); + + await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id); + await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id); + + const branch = await getConditionBranch(execution.id, conditionStep.id); + expect(branch).toBe('default'); + }); + + it('should fall through to default when field is missing', async () => { + const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow( + {other: 'value'}, + planBranches, + ); + + await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id); + await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id); + + const branch = await getConditionBranch(execution.id, conditionStep.id); + expect(branch).toBe('default'); + }); + + it('should support mixed operators across branches', async () => { + const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow( + {score: 85}, + { + mode: 'multi', + field: 'data.score', + branches: [ + {id: 'br-high', name: 'High', operator: 'greaterThanOrEqual', value: 90}, + {id: 'br-medium', name: 'Medium', operator: 'greaterThanOrEqual', value: 70}, + {id: 'br-low', name: 'Low', operator: 'lessThan', value: 70}, + ], + }, + ); + + await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id); + await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id); + + // 85 >= 90 is false, 85 >= 70 is true → should match "Medium" + const branch = await getConditionBranch(execution.id, conditionStep.id); + expect(branch).toBe('br-medium'); + }); + + it('should respect branch evaluation order (first match wins)', async () => { + const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow( + {plan: 'premium'}, + { + mode: 'multi', + field: 'data.plan', + branches: [ + {id: 'br-first', name: 'First', operator: 'contains', value: 'prem'}, + {id: 'br-second', name: 'Second', operator: 'equals', value: 'premium'}, + ], + }, + ); + + await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id); + await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id); + + // Both match, but first one should win + const branch = await getConditionBranch(execution.id, conditionStep.id); + expect(branch).toBe('br-first'); + }); + + it('should support exists/notExists operators in branches', async () => { + const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow( + {plan: 'premium'}, + { + mode: 'multi', + field: 'data.plan', + branches: [ + {id: 'br-has-plan', name: 'Has Plan', operator: 'exists'}, + ], + }, + ); + + await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id); + await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id); + + const branch = await getConditionBranch(execution.id, conditionStep.id); + expect(branch).toBe('br-has-plan'); + }); + + it('should route to correct exit step via transitions', async () => { + const {execution, triggerStep, branchExits, defaultExitId} = await createMultiBranchWorkflow( + {plan: 'free'}, + planBranches, + ); + + await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id); + + // Check the execution completed and ended at the correct exit step + const completedExecution = await prisma.workflowExecution.findUnique({ + where: {id: execution.id}, + }); + + // Workflow completed after reaching exit step + expect(['EXITED', 'COMPLETED']).toContain(completedExecution?.status); + + // Verify it went through the "free" branch exit, not default + const exitStepExecution = await prisma.workflowStepExecution.findFirst({ + where: { + executionId: execution.id, + stepId: branchExits['br-free'], + }, + }); + expect(exitStepExecution).not.toBeNull(); + + // Verify it did NOT go through the default exit + const defaultStepExecution = await prisma.workflowStepExecution.findFirst({ + where: { + executionId: execution.id, + stepId: defaultExitId, + }, + }); + expect(defaultStepExecution).toBeNull(); + }); + }); }); diff --git a/apps/web/src/components/WorkflowBuilder.tsx b/apps/web/src/components/WorkflowBuilder.tsx index 98704b5..be2ba17 100644 --- a/apps/web/src/components/WorkflowBuilder.tsx +++ b/apps/web/src/components/WorkflowBuilder.tsx @@ -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)} -
- {data.config.operator} "{String(data.config.value)}" -
+ {data.config.mode === 'multi' ? ( +
+ {data.config.branches?.length || 0} branch{(data.config.branches?.length || 0) !== 1 ? 'es' : ''} + + default +
+ ) : ( +
+ {data.config.operator} "{String(data.config.value)}" +
+ )} )} @@ -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(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( 'POST', diff --git a/apps/web/src/pages/workflows/[id].tsx b/apps/web/src/pages/workflows/[id].tsx index df336c5..14b2f95 100644 --- a/apps/web/src/pages/workflows/[id].tsx +++ b/apps/web/src/pages/workflows/[id].tsx @@ -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>([]); 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
-

- Define the condition that will be evaluated to determine which path the workflow should follow -

+ {/* Mode toggle */} +
+ +
+ + +
+

+ {conditionMode === 'binary' + ? 'Evaluates a single condition with Yes/No paths' + : 'Match a field against multiple values, each routing to its own branch'} +

+ {hasMultiBranchConnections() && ( +
+ + Cannot switch to simple mode: branches have connected nodes. Disconnect all branch connections + first. +
+ )} + {hasBinaryConnections() && ( +
+ + Cannot switch to multi-branch mode: Yes/No branches have connected nodes. Disconnect all branch + connections first. +
+ )} + {conditionMode !== (config?.mode === 'multi' ? 'multi' : 'binary') && + !hasMultiBranchConnections() && + !hasBinaryConnections() && + (step as any).outgoingTransitions && + (step as any).outgoingTransitions.length > 0 && ( +
+ + Changing mode will disconnect existing branches. You will need to rewire them. +
+ )} +
+ {/* Field selector (shared between modes) */}
{loadingFields ? ( @@ -2396,80 +2583,199 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS )}
-
- - - {currentFieldType && ( -

- Showing operators for{' '} - {currentFieldType} fields -

- )} -
- - {needsValue && ( -
- - {currentFieldType === 'boolean' ? ( - + - True - False + {validOperators.map(op => ( + + {op.label} + + ))} - ) : currentFieldType === 'number' ? ( - <> - setConditionValue(e.target.value)} - required - placeholder="e.g., 100" - className="mt-1.5" - /> -

Enter a numeric value to compare against

- - ) : currentFieldType === 'date' ? ( - <> - setConditionValue(e.target.value)} - required - className="mt-1.5" - /> -

Select a date and time to compare against

- - ) : ( - <> - setConditionValue(e.target.value)} - required - placeholder="e.g., premium, active" - className="mt-1.5" - /> -

Enter the text value to compare against

- + {currentFieldType && ( +

+ Showing operators for{' '} + {currentFieldType} fields +

+ )} +
+ + {needsValue && ( +
+ + {currentFieldType === 'boolean' ? ( + + ) : currentFieldType === 'number' ? ( + <> + setConditionValue(e.target.value)} + required + placeholder="e.g., 100" + className="mt-1.5" + /> +

Enter a numeric value to compare against

+ + ) : currentFieldType === 'date' ? ( + <> + setConditionValue(e.target.value)} + required + className="mt-1.5" + /> +

Select a date and time to compare against

+ + ) : ( + <> + setConditionValue(e.target.value)} + required + placeholder="e.g., premium, active" + className="mt-1.5" + /> +

Enter the text value to compare against

+ + )} +
)} + + )} + + {/* Multi-branch mode: dynamic list of branches */} + {conditionMode === 'multi' && ( +
+ +

+ Each branch defines a condition. The first matching branch is taken. Contacts not matching any + branch follow the Default path. +

+ + {conditionBranches.map((branch, idx) => ( +
+
+ Branch {idx + 1} + {conditionBranches.length > 1 && ( + + )} +
+ +
+ + { + 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" + /> +
+ +
+
+ + +
+ + {!['exists', 'notExists'].includes(branch.operator) && ( +
+ + { + 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" + /> +
+ )} +
+
+ ))} + + {conditionBranches.length < 20 && ( + + )} + +
+ + + Branches are evaluated in order. The first match wins. Contacts not matching any branch will + follow the Default path. + +
)}
diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 9eadd33..c6f0da1 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -260,22 +260,52 @@ export const WorkflowStepConfigSchemas = { eventName: z.string().min(1), timeout: z.number().positive().max(31536000, 'Timeout cannot exceed 365 days (31,536,000 seconds)').optional(), }), - condition: z.object({ - field: z.string().min(1), - operator: z.enum([ - 'equals', - 'notEquals', - 'contains', - 'notContains', - 'greaterThan', - 'lessThan', - 'greaterThanOrEqual', - 'lessThanOrEqual', - 'exists', - 'notExists', - ]), - value: z.any().optional(), - }), + condition: z.union([ + // Legacy binary condition (if/else) + z.object({ + field: z.string().min(1), + operator: z.enum([ + 'equals', + 'notEquals', + 'contains', + 'notContains', + 'greaterThan', + 'lessThan', + 'greaterThanOrEqual', + 'lessThanOrEqual', + 'exists', + 'notExists', + ]), + value: z.any().optional(), + }), + // Multi-branch condition (switch/case) + z.object({ + mode: z.literal('multi'), + field: z.string().min(1), + branches: z + .array( + z.object({ + id: z.string().min(1), + name: z.string().min(1), + operator: z.enum([ + 'equals', + 'notEquals', + 'contains', + 'notContains', + 'greaterThan', + 'lessThan', + 'greaterThanOrEqual', + 'lessThanOrEqual', + 'exists', + 'notExists', + ]), + value: z.any().optional(), + }), + ) + .min(1) + .max(20), + }), + ]), webhook: z.object({ url: z.string().url(), method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).default('POST'),