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
@@ -727,7 +727,7 @@ export class WorkflowExecutionService {
_stepExecution: WorkflowStepExecution,
config: StepConfig,
): Promise<StepResult> {
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 {