Browse Source
- Text input handle on left (blue, text type) - Text preview section showing truncated incoming text (50 chars) - Rule rows with: - Match status indicator (green checkmark when matched, gray dot when not) - Up/down arrow buttons for reordering rules - Editable label (double-click to edit, inline editing pattern from SwitchNode) - Mode dropdown (exact, contains, starts-with, ends-with) - Value text field (comma-separated match values) - Delete button (hidden if only one rule to prevent empty state) - Add rule button (creates new rule with auto-generated label) - Default output row (always at bottom, not editable/deletable) - Active (green) when NO rules match, inactive (gray) otherwise - Dynamic output handles per rule + default handle (all text type) - Non-matching rule handles at 30% opacity (same as Switch disabled pattern) - evaluateRule function: case-insensitive, multi-value OR logic, all 4 modes - Match status updates reactively via useEffect watching incoming text - Dynamic handle sync via useUpdateNodeInternals on rule count change - Auto-resize height based on rule count - Teal/cyan theme (bg-teal-950/50 border-teal-600) to differentiate from Router/Switchhandoff-20260429-1057
1 changed files with 404 additions and 0 deletions
@ -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<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]); |
|||
|
|||
// 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 ( |
|||
<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 */} |
|||
<Handle |
|||
type="target" |
|||
position={Position.Left} |
|||
id="text" |
|||
data-handletype="text" |
|||
style={{ |
|||
top: baseOffset, |
|||
backgroundColor: "#3b82f6", // blue for text
|
|||
width: 12, |
|||
height: 12, |
|||
border: "2px solid #1e1e1e", |
|||
}} |
|||
/> |
|||
|
|||
{/* Body content */} |
|||
<div className="px-2 py-1 space-y-1"> |
|||
{/* Text preview */} |
|||
<div className="text-[10px] text-neutral-400 mb-2 truncate"> |
|||
{incomingText ? ( |
|||
<>Input: "{incomingText.slice(0, 50)}{incomingText.length > 50 ? "..." : ""}"</> |
|||
) : ( |
|||
"No input connected" |
|||
)} |
|||
</div> |
|||
|
|||
{/* Rule rows */} |
|||
{nodeData.rules.map((rule, index) => ( |
|||
<div key={rule.id} className="flex items-center gap-1 group py-0.5"> |
|||
{/* Match status indicator */} |
|||
<div className="w-3 h-3 flex items-center justify-center flex-shrink-0"> |
|||
{rule.isMatched ? ( |
|||
// Green checkmark
|
|||
<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> |
|||
) : ( |
|||
// Gray dot
|
|||
<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> |
|||
))} |
|||
|
|||
{/* Add rule button */} |
|||
<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> |
|||
|
|||
{/* Default output row */} |
|||
<div className="flex items-center gap-1 pt-1 border-t border-neutral-700"> |
|||
{/* Match status indicator */} |
|||
<div className="w-3 h-3 flex items-center justify-center flex-shrink-0"> |
|||
{defaultMatched ? ( |
|||
// Green checkmark
|
|||
<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> |
|||
) : ( |
|||
// Gray dot
|
|||
<div className="w-2 h-2 rounded-full bg-neutral-600" /> |
|||
)} |
|||
</div> |
|||
|
|||
<span className="text-[10px] text-neutral-300 ml-4">Default</span> |
|||
</div> |
|||
</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: baseOffset + index * handleSpacing, |
|||
backgroundColor: "#3b82f6", // blue for text
|
|||
opacity: rule.isMatched ? 1 : 0.3, |
|||
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: baseOffset + ruleCount * handleSpacing, |
|||
backgroundColor: "#3b82f6", // blue for text
|
|||
opacity: defaultMatched ? 1 : 0.3, |
|||
width: 12, |
|||
height: 12, |
|||
border: "2px solid #1e1e1e", |
|||
}} |
|||
/> |
|||
</BaseNode> |
|||
); |
|||
}); |
|||
|
|||
ConditionalSwitchNode.displayName = "ConditionalSwitchNode"; |
|||
Loading…
Reference in new issue