"use client"; import { memo, useMemo, useEffect, useLayoutEffect, useState, useCallback, useRef } 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]); // Ref-based handle positioning — measure actual row DOM positions const ruleRowRefs = useRef>({}); const defaultRowRef = useRef(null); const [handleTops, setHandleTops] = useState>({}); // Track rule IDs for re-measurement on add/remove/reorder const ruleIds = useMemo(() => nodeData.rules.map(r => r.id).join(','), [nodeData.rules]); // Measure actual row centers relative to the node element (before paint) useLayoutEffect(() => { const positions: Record = {}; for (const [ruleId, el] of Object.entries(ruleRowRefs.current)) { if (el) { positions[ruleId] = el.offsetTop + el.offsetHeight / 2; } } const defaultEl = defaultRowRef.current; if (defaultEl) { positions['default'] = defaultEl.offsetTop + defaultEl.offsetHeight / 2; } setHandleTops(positions); }, [ruleIds]); // Fallback handle positioning (used before first measurement) const handleSpacing = 32; const fallbackBase = 70; // approximate: header + padding + text preview + half row // Dynamic height based on rule count (rules + default) const ruleCount = nodeData.rules.length; const totalOutputs = ruleCount + 1; // rules + default const lastHandleTop = fallbackBase + totalOutputs * handleSpacing; const minHeight = lastHandleTop + 40; // Extra space for add button // 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, aligned with header */} {/* Body content */}
{/* Text preview — fixed height, above the handle-aligned area */}
{incomingText ? ( <>Input: "{incomingText.slice(0, 50)}{incomingText.length > 50 ? "..." : ""}" ) : ( "No input connected" )}
{/* Rule rows — each 32px tall to align with output handles */} {nodeData.rules.map((rule, index) => (
{ if (el) ruleRowRefs.current[rule.id] = el; else delete ruleRowRefs.current[rule.id]; }} className="flex items-center gap-1 group h-8" > {/* Match status indicator */}
{rule.isMatched ? ( ) : (
)}
{/* 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 && ( )}
))} {/* Default output row — 32px tall, immediately after rules to align with handle */}
{defaultMatched ? ( ) : (
)}
Fallback
{/* Add rule button — after Default so it doesn't displace handle alignment */}
{/* Output handles (right) - one per rule + default */} {nodeData.rules.map((rule, index) => ( ))} {/* Default output handle (always at bottom) */} ); }); ConditionalSwitchNode.displayName = "ConditionalSwitchNode";