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 <noreply@anthropic.com>
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
+46 -16
View File
@@ -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'),