Merge pull request #310 from mdwt/feature/multi-branch-conditions
This commit is contained in:
@@ -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} "{String(data.config.value)}"
|
||||
</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} "{String(data.config.value)}"
|
||||
</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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user