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
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: "{incomingText.slice(0, 50)}{incomingText.length > 50 ? "..." : ""}"</>
|
|
) : (
|
|
"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";
|
|
|