You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

430 lines
16 KiB

"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<WorkflowNode>) => {
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<string | null>(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<Record<string, HTMLDivElement | null>>({});
const defaultRowRef = useRef<HTMLDivElement | null>(null);
const [handleTops, setHandleTops] = useState<Record<string, number>>({});
// 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<string, number> = {};
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 (
<BaseNode
id={id}
title="Conditional Switch"
customTitle={nodeData.customTitle}
comment={nodeData.comment}
onCustomTitleChange={(customTitle) => 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 */}
<Handle
type="target"
position={Position.Left}
id="text"
data-handletype="text"
style={{
top: 38,
backgroundColor: "#3b82f6", // blue for text
width: 12,
height: 12,
border: "2px solid #1e1e1e",
}}
/>
{/* Body content */}
<div className="px-2 py-1">
{/* Text preview — fixed height, above the handle-aligned area */}
<div className="text-[10px] text-neutral-400 truncate h-5 flex items-center">
{incomingText ? (
<>Input: &quot;{incomingText.slice(0, 50)}{incomingText.length > 50 ? "..." : ""}&quot;</>
) : (
"No input connected"
)}
</div>
{/* Rule rows — each 32px tall to align with output handles */}
{nodeData.rules.map((rule, index) => (
<div
key={rule.id}
ref={(el) => {
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 */}
<div className="w-3 h-3 flex items-center justify-center flex-shrink-0">
{rule.isMatched ? (
<svg className="w-3 h-3 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
) : (
<div className="w-2 h-2 rounded-full bg-neutral-600" />
)}
</div>
{/* Reorder buttons */}
<div className="flex flex-col gap-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button
className="text-neutral-400 hover:text-white disabled:opacity-30 disabled:hover:text-neutral-400"
onClick={() => handleMoveUp(index)}
disabled={index === 0}
title="Move up"
>
<svg className="w-2 h-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 14l5-5 5 5z" />
</svg>
</button>
<button
className="text-neutral-400 hover:text-white disabled:opacity-30 disabled:hover:text-neutral-400"
onClick={() => handleMoveDown(index)}
disabled={index === nodeData.rules.length - 1}
title="Move down"
>
<svg className="w-2 h-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 10l5 5 5-5z" />
</svg>
</button>
</div>
{/* Label */}
{editingId === rule.id ? (
<input
type="text"
className="w-14 bg-neutral-700 text-neutral-100 text-[10px] px-1 py-0.5 rounded border border-teal-500 outline-none"
defaultValue={rule.label}
autoFocus
onBlur={(e) => 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);
}
}}
/>
) : (
<span
className="w-14 text-[10px] text-neutral-300 cursor-text truncate"
onDoubleClick={() => setEditingId(rule.id)}
title={rule.label}
>
{rule.label}
</span>
)}
{/* Mode dropdown */}
<select
className="bg-neutral-700 text-neutral-100 text-[9px] px-1 py-0.5 rounded border border-neutral-600 outline-none"
value={rule.mode}
onChange={(e) => handleModeChange(rule.id, e.target.value as MatchMode)}
>
<option value="exact">exact</option>
<option value="contains">contains</option>
<option value="starts-with">starts</option>
<option value="ends-with">ends</option>
</select>
{/* Value input */}
<input
type="text"
className="flex-1 bg-neutral-700 text-neutral-100 text-[10px] px-1 py-0.5 rounded border border-neutral-600 outline-none"
placeholder="value,value2,..."
value={rule.value}
onChange={(e) => handleRuleValueChange(rule.id, e.target.value)}
/>
{/* Delete button (hidden if only one rule) */}
{nodeData.rules.length > 1 && (
<button
className="opacity-0 group-hover:opacity-100 text-neutral-400 hover:text-red-400 transition-opacity flex-shrink-0"
onClick={() => handleDelete(rule.id)}
title="Delete rule"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
))}
{/* Default output row — 32px tall, immediately after rules to align with handle */}
<div ref={defaultRowRef} className="flex items-center gap-1 h-8 border-t border-neutral-700">
<div className="w-3 h-3 flex items-center justify-center flex-shrink-0">
{defaultMatched ? (
<svg className="w-3 h-3 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
) : (
<div className="w-2 h-2 rounded-full bg-neutral-600" />
)}
</div>
<span className="text-[10px] text-neutral-300 ml-4">Fallback</span>
</div>
{/* Add rule button — after Default so it doesn't displace handle alignment */}
<button
className="w-full flex items-center justify-center gap-1 text-neutral-400 hover:text-white text-[10px] py-1 mt-1 rounded hover:bg-teal-900/30 transition-colors"
onClick={handleAddRule}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add Rule
</button>
</div>
{/* Output handles (right) - one per rule + default */}
{nodeData.rules.map((rule, index) => (
<Handle
key={`output-${rule.id}`}
type="source"
position={Position.Right}
id={rule.id}
data-handletype="text"
style={{
top: handleTops[rule.id] ?? (fallbackBase + index * handleSpacing),
backgroundColor: "#3b82f6", // blue for text
width: 12,
height: 12,
border: "2px solid #1e1e1e",
}}
/>
))}
{/* Default output handle (always at bottom) */}
<Handle
type="source"
position={Position.Right}
id="default"
data-handletype="text"
style={{
top: handleTops['default'] ?? (fallbackBase + ruleCount * handleSpacing),
backgroundColor: "#3b82f6", // blue for text
width: 12,
height: 12,
border: "2px solid #1e1e1e",
}}
/>
</BaseNode>
);
});
ConditionalSwitchNode.displayName = "ConditionalSwitchNode";