/** * Routing Tree View — left-to-right flow lane. * * Source → conditions (shared prefix / OR merge) → rule node → target node(s) * * OR branches are split into parallel paths that converge on the same * shared child via subtree-hash deduplication. Each rule target gets its * own leaf node. Nodes are draggable for exploration; nothing is editable. */ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useGetRoutingRulesQuery } from "@/lib/store/apis/routingRulesApi"; import { useNavigate } from "@tanstack/react-router"; import type { Node, NodeChange } from "@xyflow/react"; import { Background, BackgroundVariant, Controls, Panel, ReactFlow, useEdgesState, useNodesState } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import { AlertCircle, ArrowLeft, GitBranch, Info, Link2, Loader2, RotateCcw, Search } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCookies } from "react-cookie"; import { FIT_VIEW_PADDING, SCOPE_CONFIG, SCOPE_ORDER } from "./constants"; import { buildGraph } from "./graphBuilder"; import { RFConditionNode } from "./node/rfConditionNode"; import { RFRuleNode } from "./node/rfRuleNode"; import { RFSourceNode } from "./node/rfSourceNode"; import { POSITIONS_COOKIE, PositionCookie, computeFingerprint } from "./positionPersistence"; import { RfChainEdge } from "./rfChainEdge"; // ─── Node types (stable reference) ──────────────────────────────────────── const nodeTypes = { rfSource: RFSourceNode, rfCondition: RFConditionNode, rfRule: RFRuleNode, }; const edgeTypes = { rfChain: RfChainEdge }; // ─── Main component ──────────────────────────────────────────────────────── export function RoutingTreeView() { const navigate = useNavigate(); const { data, isLoading, isError } = useGetRoutingRulesQuery({ limit: 500 }); const rules = data?.rules ?? []; // ── Position persistence ─────────────────────────────────────────────── const [cookies, setCookie, removeCookie] = useCookies([POSITIONS_COOKIE]); // React Flow instance — captured via onInit so we can call fitView imperatively. // eslint-disable-next-line @typescript-eslint/no-explicit-any const rfInstanceRef = useRef(null); const resetTimeoutRef = useRef | null>(null); useEffect( () => () => { if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current); }, [], ); // Capture cookie value once on mount so re-saves don't trigger re-renders. const [initialCookie] = useState(() => cookies[POSITIONS_COOKIE] as PositionCookie | undefined); const fingerprint = useMemo(() => computeFingerprint(rules), [rules]); const { baseNodes, baseEdges } = useMemo(() => { const g = buildGraph(rules); return { baseNodes: g.nodes, baseEdges: g.edges }; }, [rules]); // If the cookie fingerprint matches current rules, restore saved positions. const { mergedNodes, positionsRestored } = useMemo(() => { if (initialCookie?.fingerprint === fingerprint && initialCookie?.positions && Object.keys(initialCookie.positions).length > 0) { return { mergedNodes: baseNodes.map((n) => ({ ...n, position: initialCookie.positions[n.id] ?? n.position, })), positionsRestored: true, }; } return { mergedNodes: baseNodes, positionsRestored: false }; }, [baseNodes, fingerprint, initialCookie]); const [nodes, setNodes, onNodesChange] = useNodesState(mergedNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(baseEdges); useEffect(() => { setNodes(mergedNodes); }, [mergedNodes, setNodes]); useEffect(() => { setEdges(baseEdges); }, [baseEdges, setEdges]); // Always reflect the latest nodes in a ref so the save handler is not stale. const nodesRef = useRef(nodes); nodesRef.current = nodes; // Tracks the last data written so position-save and viewport-save don't clobber each other. const cookieDataRef = useRef>({ positions: {}, viewport: undefined }); // Once positions are known to be restored, seed the ref so viewport-only saves keep positions. useEffect(() => { if (positionsRestored && initialCookie) { cookieDataRef.current = { positions: initialCookie.positions, viewport: initialCookie.viewport }; } }, [positionsRestored, initialCookie]); const writeCookie = useCallback( (update: Partial>) => { cookieDataRef.current = { ...cookieDataRef.current, ...update }; setCookie(POSITIONS_COOKIE, { fingerprint, ...cookieDataRef.current } satisfies PositionCookie, { path: "/", maxAge: 30 * 24 * 60 * 60, // 30 days }); }, [fingerprint, setCookie], ); // Save positions to cookie when a drag ends. const handleNodesChange = useCallback( (changes: NodeChange[]) => { onNodesChange(changes); const hasDragEnd = changes.some((c) => c.type === "position" && c.dragging === false); if (!hasDragEnd) return; const posMap: Record = {}; for (const n of nodesRef.current) posMap[n.id] = n.position; // Apply the final positions from the change events themselves (state not yet flushed). for (const c of changes) { if (c.type === "position" && c.dragging === false && c.position) { posMap[c.id] = c.position; } } writeCookie({ positions: posMap }); }, [onNodesChange, writeCookie], ); // Save viewport (pan + zoom) when the user stops moving. const handleMoveEnd = useCallback( (_: unknown, viewport: { x: number; y: number; zoom: number }) => { writeCookie({ viewport }); }, [writeCookie], ); // Reset all saved positions and re-fit to the computed default layout. const handleResetLayout = useCallback(() => { removeCookie(POSITIONS_COOKIE, { path: "/" }); cookieDataRef.current = { positions: {}, viewport: undefined }; setNodes(baseNodes); setSelectedNodeId(null); setSelectedEdgeId(null); if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current); resetTimeoutRef.current = setTimeout(() => rfInstanceRef.current?.fitView({ padding: FIT_VIEW_PADDING, duration: 300 }), 50); }, [baseNodes, removeCookie, setNodes]); // ── Selection / path highlight ──────────────────────────────────────── const [selectedNodeId, setSelectedNodeId] = useState(null); const [selectedEdgeId, setSelectedEdgeId] = useState(null); /** * BFS up (forward edges only — skips chain-backs) to find all ancestors, * and BFS down (all edges including chain-backs) to find all descendants. * Chain-back edges are identified by their id ending in "-chain". */ const selectedHighlightIds = useMemo | null>(() => { // Edge selection: highlight only the two endpoint nodes + the edge itself. if (selectedEdgeId) { const edge = edges.find((e) => e.id === selectedEdgeId); if (!edge) return null; return new Set([edge.source, edge.target]); } if (!selectedNodeId) return null; const childrenOf = new Map(); const parentsOf = new Map(); for (const e of edges) { if (!childrenOf.has(e.source)) childrenOf.set(e.source, []); childrenOf.get(e.source)!.push(e.target); // Exclude chain-back edges from the ancestor map (they reverse flow direction). if (!e.id.endsWith("-chain")) { if (!parentsOf.has(e.target)) parentsOf.set(e.target, []); parentsOf.get(e.target)!.push(e.source); } } const highlighted = new Set([selectedNodeId]); const upQ = [selectedNodeId]; while (upQ.length) { const id = upQ.pop()!; for (const p of parentsOf.get(id) ?? []) { if (!highlighted.has(p)) { highlighted.add(p); upQ.push(p); } } } const downQ = [selectedNodeId]; while (downQ.length) { const id = downQ.pop()!; for (const c of childrenOf.get(id) ?? []) { if (!highlighted.has(c)) { highlighted.add(c); downQ.push(c); } } } return highlighted; }, [selectedNodeId, selectedEdgeId, edges]); const handleNodeClick = useCallback((_: React.MouseEvent, node: Node) => { setSelectedEdgeId(null); setSelectedNodeId((prev) => (prev === node.id ? null : node.id)); }, []); const handleEdgeClick = useCallback((_: React.MouseEvent, edge: { id: string }) => { setSelectedNodeId(null); setSelectedEdgeId((prev) => (prev === edge.id ? null : edge.id)); }, []); const handlePaneClick = useCallback(() => { setSelectedNodeId(null); setSelectedEdgeId(null); }, []); // ── Search / highlight ───────────────────────────────────────────────── const [search, setSearch] = useState(""); /** * Returns null when search is empty (no filtering). * Returns an empty Set when there are no matches (dim everything). * Otherwise returns the set of node IDs that should stay visible: * directly matching nodes + all their ancestors + all their descendants. */ const highlightedIds = useMemo | null>(() => { const q = search.trim().toLowerCase(); if (!q) return null; const childrenOf = new Map(); const parentsOf = new Map(); for (const e of edges) { if (!childrenOf.has(e.source)) childrenOf.set(e.source, []); childrenOf.get(e.source)!.push(e.target); if (!parentsOf.has(e.target)) parentsOf.set(e.target, []); parentsOf.get(e.target)!.push(e.source); } const matched = new Set(); for (const n of nodes) { const d = n.data as any; const cond = (d?.condition as string | undefined)?.toLowerCase(); const ruleName = (d?.rule?.name as string | undefined)?.toLowerCase(); const ruleCel = (d?.rule?.cel_expression as string | undefined)?.toLowerCase(); if (cond?.includes(q) || ruleName?.includes(q) || ruleCel?.includes(q)) { matched.add(n.id); } } if (matched.size === 0) return new Set(); const highlighted = new Set(matched); // BFS upstream → source const upQ = [...matched]; while (upQ.length) { const id = upQ.pop()!; for (const p of parentsOf.get(id) ?? []) { if (!highlighted.has(p)) { highlighted.add(p); upQ.push(p); } } } // BFS downstream → rule leaves const downQ = [...matched]; while (downQ.length) { const id = downQ.pop()!; for (const c of childrenOf.get(id) ?? []) { if (!highlighted.has(c)) { highlighted.add(c); downQ.push(c); } } } return highlighted; }, [search, nodes, edges]); const matchCount = useMemo(() => { if (!highlightedIds) return 0; return nodes.filter((n) => n.type === "rfRule" && highlightedIds.has(n.id)).length; }, [highlightedIds, nodes]); // Selection takes priority; search acts as fallback when nothing is selected. const activeHighlightIds = selectedHighlightIds ?? highlightedIds; // Derived display nodes/edges — keeps opacity layered on top without // disturbing drag state (positions stay in the underlying `nodes` state). const displayNodes = useMemo(() => { const h = activeHighlightIds; const dimOpacity = selectedNodeId ? 0.12 : 0.25; return nodes.map((n) => { const isSelected = n.id === selectedNodeId; if (!h) return { ...n, selected: isSelected }; const active = h.size > 0; return { ...n, selected: isSelected, style: { ...n.style, opacity: active && !h.has(n.id) ? dimOpacity : 1, transition: "opacity 0.15s", }, }; }); }, [nodes, activeHighlightIds, selectedNodeId]); const displayEdges = useMemo(() => { const h = activeHighlightIds; const dimOpacity = selectedNodeId || selectedEdgeId ? 0.1 : 0.2; if (!h) return edges; const active = h.size > 0; return edges.map((e) => { const endpointsLit = h.has(e.source) && h.has(e.target); const isSelectedEdge = e.id === selectedEdgeId; const lit = endpointsLit || isSelectedEdge; return { ...e, style: { ...e.style, opacity: active && !lit ? dimOpacity : 1, transition: "opacity 0.15s", }, }; }); }, [edges, activeHighlightIds, selectedNodeId, selectedEdgeId]); if (isLoading) { return (
); } if (isError) { return (
Failed to load routing rules
); } if (rules.length === 0) { return (

No routing rules to display

); } return ( { rfInstanceRef.current = instance; }} nodeTypes={nodeTypes} edgeTypes={edgeTypes} fitView={!positionsRestored} fitViewOptions={{ padding: FIT_VIEW_PADDING }} defaultViewport={positionsRestored ? (initialCookie?.viewport ?? { x: 0, y: 0, zoom: 1 }) : undefined} onMoveEnd={handleMoveEnd} nodesDraggable={true} nodesConnectable={false} elementsSelectable={true} zoomOnDoubleClick={false} proOptions={{ hideAttribution: true }} >
{/* Main toolbar */}

Routing Tree

{search ? highlightedIds && highlightedIds.size > 0 ? `${matchCount} rule${matchCount !== 1 ? "s" : ""}` : "no match" : `${rules.length} rule${rules.length !== 1 ? "s" : ""}`}

setSearch(e.target.value)} placeholder="Search conditions or rules…" className="h-8 w-56 pl-8 text-sm" />
{/* Scope + edge legend — floats below */}
{SCOPE_ORDER.map((s) => (
{SCOPE_CONFIG[s].label}
))}
Chain rule
{/* Chain edge styles — both dashed (long = static, short = dynamic); arrows at path midpoint */}
Static chain Re-entry point is fully proven by static analysis — every condition on the path evaluated to a known value.
Dynamic chain Re-entry point is a conditional — one or more conditions on the path are not fully evaluated at build time.
); }