From edbbb894f6d14b395bd52f070c6f2327b5dc0dce Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Fri, 22 May 2026 15:05:44 +0300 Subject: [PATCH] feat(flows): make validation issues clickable to jump to node The validation panel sits at the bottom of the editor. When it surfaced an error ("node X is missing next_node_key") the user had to scroll up, find that node, expand it, find the broken field. Long feedback loop. Each node-scoped IssueLine becomes a button: click it and the editor expands the offending node, scrolls it into view, and briefly rings its border so the eye lands on the right card. Trigger-scoped issues stay non-interactive (the trigger panel is already at the top of the page). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/flows/flow-builder.tsx | 116 +++++++++++++++++++++----- 1 file changed, 96 insertions(+), 20 deletions(-) diff --git a/src/components/flows/flow-builder.tsx b/src/components/flows/flow-builder.tsx index 095d097..85dcb1e 100644 --- a/src/components/flows/flow-builder.tsx +++ b/src/components/flows/flow-builder.tsx @@ -15,7 +15,7 @@ * the same file as small components rather than separate modules. */ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { ArrowLeft, @@ -247,6 +247,9 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) { const [expanded, setExpanded] = useState>( () => new Set(initialNodes.map((n) => n.node_key)), ); + // Used by jumpToNode() to scroll the target into view + flash its border. + const nodeRefs = useRef>(new Map()); + const [flashedKey, setFlashedKey] = useState(null); // ---- Validation ---- const issues = useMemo( @@ -425,6 +428,36 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) { }); }, []); + // Jump-to-node: invoked when a user clicks an issue in the validation + // panel. Expand the offending card (so the broken field is visible), + // scroll it into the viewport, then flash its border so the eye lands + // on it. requestAnimationFrame defers the scroll until after React + // commits the expanded layout. + const jumpToNode = useCallback((key: string) => { + setExpanded((prev) => { + if (prev.has(key)) return prev; + const next = new Set(prev); + next.add(key); + return next; + }); + setFlashedKey(key); + requestAnimationFrame(() => { + const el = nodeRefs.current.get(key); + el?.scrollIntoView({ behavior: "smooth", block: "center" }); + }); + window.setTimeout(() => { + setFlashedKey((cur) => (cur === key ? null : cur)); + }, 1600); + }, []); + + const setNodeRef = useCallback( + (key: string) => (el: HTMLDivElement | null) => { + if (el) nodeRefs.current.set(key, el); + else nodeRefs.current.delete(key); + }, + [], + ); + // ---- Render ---- return (
@@ -471,6 +504,8 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) { allNodes={state.nodes} expanded={expanded.has(node.node_key)} isEntry={state.entry_node_id === node.node_key} + isFlashed={flashedKey === node.node_key} + cardRef={setNodeRef(node.node_key)} issues={issues.filter( (i) => i.scope === "node" && i.node_key === node.node_key, )} @@ -486,7 +521,7 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) { )} - +
); } @@ -754,6 +789,8 @@ function NodeCard({ allNodes, expanded, isEntry, + isFlashed, + cardRef, issues, onToggle, onUpdate, @@ -765,6 +802,8 @@ function NodeCard({ allNodes: BuilderNode[]; expanded: boolean; isEntry: boolean; + isFlashed: boolean; + cardRef: (el: HTMLDivElement | null) => void; issues: ValidationIssue[]; onToggle: () => void; onUpdate: (patch: Partial) => void; @@ -776,13 +815,16 @@ function NodeCard({ const hasError = issues.some((i) => i.severity === "error"); return (
); } -function IssueLine({ issue }: { issue: ValidationIssue }) { - return ( -
- - +function IssueLine({ + issue, + onJump, +}: { + issue: ValidationIssue; + onJump?: (key: string) => void; +}) { + const tone = + issue.severity === "error" ? "text-red-300" : "text-amber-300"; + const iconTone = + issue.severity === "error" ? "text-red-400" : "text-amber-400"; + const body = ( + <> + + {issue.node_key && ( {issue.node_key} @@ -1793,6 +1841,34 @@ function IssueLine({ issue }: { issue: ValidationIssue }) { )} {issue.message} + + ); + + // Only node-scoped issues can jump; trigger-scoped issues have no + // destination (the trigger panel is already at the top of the page). + if (issue.node_key && onJump) { + return ( + + ); + } + return ( +
+ {body}
); }