diff --git a/src/components/nodes/ConditionalSwitchNode.tsx b/src/components/nodes/ConditionalSwitchNode.tsx new file mode 100644 index 00000000..a748618e --- /dev/null +++ b/src/components/nodes/ConditionalSwitchNode.tsx @@ -0,0 +1,404 @@ +"use client"; + +import { memo, useMemo, useEffect, useState, useCallback } from "react"; +import { Handle, Position, useUpdateNodeInternals, useReactFlow, NodeProps, useEdges } from "@xyflow/react"; +import { BaseNode } from "./BaseNode"; +import { useWorkflowStore } from "@/store/workflowStore"; +import type { WorkflowNode, ConditionalSwitchNodeData, ConditionalSwitchRule, MatchMode } from "@/types"; + +/** + * Evaluates a rule against incoming text with the specified match mode. + * Returns true if any comma-separated value matches using OR logic. + */ +function evaluateRule(text: string | null, ruleValue: string, mode: MatchMode): boolean { + // Guard: no match if text or ruleValue is empty + if (!text || !ruleValue) return false; + + // Normalize text + const normalizedText = text.toLowerCase().trim(); + + // Split and normalize rule values (comma-separated) + const values = ruleValue + .split(',') + .map(v => v.toLowerCase().trim()) + .filter(v => v.length > 0); + + if (values.length === 0) return false; + + // OR logic: match if ANY value matches + return values.some(value => { + switch (mode) { + case "exact": + return normalizedText === value; + case "contains": + return normalizedText.includes(value); + case "starts-with": + return normalizedText.startsWith(value); + case "ends-with": + return normalizedText.endsWith(value); + default: + return false; + } + }); +} + +export const ConditionalSwitchNode = memo(({ id, data, selected }: NodeProps) => { + const nodeData = data as ConditionalSwitchNodeData; + const edges = useEdges(); + const updateNodeData = useWorkflowStore((state) => state.updateNodeData); + const getConnectedInputs = useWorkflowStore((state) => state.getConnectedInputs); + const updateNodeInternals = useUpdateNodeInternals(); + const { setNodes, setEdges } = useReactFlow(); + const [editingId, setEditingId] = useState(null); + + // Get incoming text from connected edges + const incomingText = useMemo(() => { + const { text } = getConnectedInputs(id); + return text; + }, [edges, id, getConnectedInputs]); + + // Evaluate all rules and update match status + useEffect(() => { + const updatedRules = nodeData.rules.map(rule => ({ + ...rule, + isMatched: evaluateRule(incomingText, rule.value, rule.mode) + })); + + // Check if any rule matched + const anyMatched = updatedRules.some(r => r.isMatched); + + // Only update if something changed + const hasChanges = + nodeData.incomingText !== incomingText || + updatedRules.some((r, i) => r.isMatched !== nodeData.rules[i].isMatched); + + if (hasChanges) { + updateNodeData(id, { + incomingText, + rules: updatedRules + }); + } + }, [incomingText, nodeData.rules, nodeData.incomingText, id, updateNodeData]); + + // Calculate handle positioning + const handleSpacing = 32; + const baseOffset = 38; // Clear the header bar + + // Dynamic height based on rule count (rules + default) + const ruleCount = nodeData.rules.length; + const totalOutputs = ruleCount + 1; // rules + default + const lastHandleTop = baseOffset + totalOutputs * handleSpacing; + const minHeight = lastHandleTop + 40; // Extra space + + // Resize node and notify React Flow when rule count changes + useEffect(() => { + setNodes((nodes) => + nodes.map((node) => { + if (node.id === id) { + const currentHeight = (node.style?.height as number) || 0; + if (currentHeight < minHeight) { + return { ...node, style: { ...node.style, height: minHeight } }; + } + } + return node; + }) + ); + updateNodeInternals(id); + }, [ruleCount, id, minHeight, setNodes, updateNodeInternals]); + + // Handle rule value change + const handleRuleValueChange = useCallback( + (ruleId: string, newValue: string) => { + const updatedRules = nodeData.rules.map((rule) => + rule.id === ruleId ? { ...rule, value: newValue } : rule + ); + updateNodeData(id, { rules: updatedRules }); + }, + [id, nodeData.rules, updateNodeData] + ); + + // Handle mode change + const handleModeChange = useCallback( + (ruleId: string, newMode: MatchMode) => { + const updatedRules = nodeData.rules.map((rule) => + rule.id === ruleId ? { ...rule, mode: newMode } : rule + ); + updateNodeData(id, { rules: updatedRules }); + }, + [id, nodeData.rules, updateNodeData] + ); + + // Handle label edit + const handleLabelEdit = useCallback( + (ruleId: string, newLabel: string) => { + const updatedRules = nodeData.rules.map((rule) => + rule.id === ruleId ? { ...rule, label: newLabel } : rule + ); + updateNodeData(id, { rules: updatedRules }); + setEditingId(null); + }, + [id, nodeData.rules, updateNodeData] + ); + + // Handle delete rule + const handleDelete = useCallback( + (ruleId: string) => { + // Don't allow deletion if only one rule + if (nodeData.rules.length <= 1) return; + + const updatedRules = nodeData.rules.filter((rule) => rule.id !== ruleId); + updateNodeData(id, { rules: updatedRules }); + + // Remove edges connected to this handle + setEdges((edges) => edges.filter((e) => !(e.source === id && e.sourceHandle === ruleId))); + }, + [id, nodeData.rules, updateNodeData, setEdges] + ); + + // Handle add rule + const handleAddRule = useCallback(() => { + const newRule: ConditionalSwitchRule = { + id: "rule-" + Math.random().toString(36).slice(2, 9), + value: "", + mode: "contains", + label: `Rule ${nodeData.rules.length + 1}`, + isMatched: false, + }; + updateNodeData(id, { rules: [...nodeData.rules, newRule] }); + }, [id, nodeData.rules, updateNodeData]); + + // Handle reorder (move up) + const handleMoveUp = useCallback( + (index: number) => { + if (index === 0) return; + const updatedRules = [...nodeData.rules]; + [updatedRules[index - 1], updatedRules[index]] = [updatedRules[index], updatedRules[index - 1]]; + updateNodeData(id, { rules: updatedRules }); + }, + [id, nodeData.rules, updateNodeData] + ); + + // Handle reorder (move down) + const handleMoveDown = useCallback( + (index: number) => { + if (index === nodeData.rules.length - 1) return; + const updatedRules = [...nodeData.rules]; + [updatedRules[index + 1], updatedRules[index]] = [updatedRules[index], updatedRules[index + 1]]; + updateNodeData(id, { rules: updatedRules }); + }, + [id, nodeData.rules, updateNodeData] + ); + + // Check if default is matched (no rules matched) + const defaultMatched = !nodeData.rules.some(r => r.isMatched); + + return ( + updateNodeData(id, { customTitle })} + onCommentChange={(comment) => updateNodeData(id, { comment })} + selected={selected} + minWidth={260} + minHeight={minHeight} + className="bg-teal-950/50 border-teal-600" + > + {/* Input handle (left) - text only */} + + + {/* Body content */} +
+ {/* Text preview */} +
+ {incomingText ? ( + <>Input: "{incomingText.slice(0, 50)}{incomingText.length > 50 ? "..." : ""}" + ) : ( + "No input connected" + )} +
+ + {/* Rule rows */} + {nodeData.rules.map((rule, index) => ( +
+ {/* Match status indicator */} +
+ {rule.isMatched ? ( + // Green checkmark + + + + ) : ( + // Gray dot +
+ )} +
+ + {/* Reorder buttons */} +
+ + +
+ + {/* Label */} + {editingId === rule.id ? ( + handleLabelEdit(rule.id, e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleLabelEdit(rule.id, e.currentTarget.value); + } else if (e.key === "Escape") { + setEditingId(null); + } + }} + /> + ) : ( + setEditingId(rule.id)} + title={rule.label} + > + {rule.label} + + )} + + {/* Mode dropdown */} + + + {/* Value input */} + handleRuleValueChange(rule.id, e.target.value)} + /> + + {/* Delete button (hidden if only one rule) */} + {nodeData.rules.length > 1 && ( + + )} +
+ ))} + + {/* Add rule button */} + + + {/* Default output row */} +
+ {/* Match status indicator */} +
+ {defaultMatched ? ( + // Green checkmark + + + + ) : ( + // Gray dot +
+ )} +
+ + Default +
+
+ + {/* Output handles (right) - one per rule + default */} + {nodeData.rules.map((rule, index) => ( + + ))} + + {/* Default output handle (always at bottom) */} + + + ); +}); + +ConditionalSwitchNode.displayName = "ConditionalSwitchNode";