Merge pull request #121 from ArnasDon/feat/flows-clickable-validation-issues

feat(flows): clickable validation issues jump to node
This commit is contained in:
Arnas Donauskas 2026-05-22 15:10:30 +03:00 committed by GitHub
commit 323e0f06cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -15,7 +15,7 @@
* the same file as small components rather than separate modules. * 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 { useRouter } from "next/navigation";
import { import {
ArrowLeft, ArrowLeft,
@ -351,6 +351,9 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) {
const [expanded, setExpanded] = useState<Set<string>>( const [expanded, setExpanded] = useState<Set<string>>(
() => new Set(initialNodes.map((n) => n.node_key)), () => new Set(initialNodes.map((n) => n.node_key)),
); );
// Used by jumpToNode() to scroll the target into view + flash its border.
const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const [flashedKey, setFlashedKey] = useState<string | null>(null);
// ---- Validation ---- // ---- Validation ----
const issues = useMemo<ValidationIssue[]>( const issues = useMemo<ValidationIssue[]>(
@ -529,6 +532,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 ---- // ---- Render ----
return ( return (
<div className="mx-auto flex h-full max-w-4xl flex-col gap-6 p-6"> <div className="mx-auto flex h-full max-w-4xl flex-col gap-6 p-6">
@ -575,6 +608,8 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) {
allNodes={state.nodes} allNodes={state.nodes}
expanded={expanded.has(node.node_key)} expanded={expanded.has(node.node_key)}
isEntry={state.entry_node_id === node.node_key} isEntry={state.entry_node_id === node.node_key}
isFlashed={flashedKey === node.node_key}
cardRef={setNodeRef(node.node_key)}
issues={issues.filter( issues={issues.filter(
(i) => i.scope === "node" && i.node_key === node.node_key, (i) => i.scope === "node" && i.node_key === node.node_key,
)} )}
@ -590,7 +625,7 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) {
)} )}
</section> </section>
<ValidationPanel issues={issues} /> <ValidationPanel issues={issues} onJump={jumpToNode} />
</div> </div>
); );
} }
@ -858,6 +893,8 @@ function NodeCard({
allNodes, allNodes,
expanded, expanded,
isEntry, isEntry,
isFlashed,
cardRef,
issues, issues,
onToggle, onToggle,
onUpdate, onUpdate,
@ -869,6 +906,8 @@ function NodeCard({
allNodes: BuilderNode[]; allNodes: BuilderNode[];
expanded: boolean; expanded: boolean;
isEntry: boolean; isEntry: boolean;
isFlashed: boolean;
cardRef: (el: HTMLDivElement | null) => void;
issues: ValidationIssue[]; issues: ValidationIssue[];
onToggle: () => void; onToggle: () => void;
onUpdate: (patch: Partial<BuilderNode>) => void; onUpdate: (patch: Partial<BuilderNode>) => void;
@ -881,13 +920,16 @@ function NodeCard({
const preview = summarizeNode(node); const preview = summarizeNode(node);
return ( return (
<div <div
ref={cardRef}
className={cn( className={cn(
"rounded-lg border bg-slate-900 transition-colors", "rounded-lg border bg-slate-900 transition-shadow duration-500",
hasError hasError
? "border-red-500/40" ? "border-red-500/40"
: isEntry : isEntry
? "border-violet-500/40" ? "border-violet-500/40"
: "border-slate-800", : "border-slate-800",
isFlashed &&
"ring-2 ring-violet-400 ring-offset-2 ring-offset-slate-950",
)} )}
> >
<button <button
@ -1947,7 +1989,13 @@ function AddNodeButton({ onAdd }: { onAdd: (type: NodeType) => void }) {
// Validation panel — bottom of the editor // Validation panel — bottom of the editor
// ============================================================ // ============================================================
function ValidationPanel({ issues }: { issues: ValidationIssue[] }) { function ValidationPanel({
issues,
onJump,
}: {
issues: ValidationIssue[];
onJump: (key: string) => void;
}) {
if (issues.length === 0) { if (issues.length === 0) {
return ( return (
<div className="flex items-center gap-2 rounded-lg border border-emerald-600/40 bg-emerald-500/10 p-3 text-xs text-emerald-300"> <div className="flex items-center gap-2 rounded-lg border border-emerald-600/40 bg-emerald-500/10 p-3 text-xs text-emerald-300">
@ -1971,28 +2019,28 @@ function ValidationPanel({ issues }: { issues: ValidationIssue[] }) {
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{issues.map((i, ix) => ( {issues.map((i, ix) => (
<IssueLine key={ix} issue={i} /> <IssueLine key={ix} issue={i} onJump={onJump} />
))} ))}
</div> </div>
</div> </div>
); );
} }
function IssueLine({ issue }: { issue: ValidationIssue }) { function IssueLine({
return ( issue,
<div onJump,
className={cn( }: {
"flex items-start gap-2 rounded-md px-2 py-1 text-xs", issue: ValidationIssue;
issue.severity === "error" ? "text-red-300" : "text-amber-300", onJump?: (key: string) => void;
)} }) {
> const tone =
<CircleAlert issue.severity === "error" ? "text-red-300" : "text-amber-300";
className={cn( const iconTone =
"mt-0.5 h-3 w-3 shrink-0", issue.severity === "error" ? "text-red-400" : "text-amber-400";
issue.severity === "error" ? "text-red-400" : "text-amber-400", const body = (
)} <>
/> <CircleAlert className={cn("mt-0.5 h-3 w-3 shrink-0", iconTone)} />
<span> <span className="min-w-0 flex-1">
{issue.node_key && ( {issue.node_key && (
<code className="mr-1 rounded bg-slate-800 px-1 py-0.5 text-[10px] text-slate-400"> <code className="mr-1 rounded bg-slate-800 px-1 py-0.5 text-[10px] text-slate-400">
{issue.node_key} {issue.node_key}
@ -2000,6 +2048,34 @@ function IssueLine({ issue }: { issue: ValidationIssue }) {
)} )}
{issue.message} {issue.message}
</span> </span>
</>
);
// 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 (
<button
type="button"
onClick={() => onJump(issue.node_key!)}
className={cn(
"flex w-full items-start gap-2 rounded-md px-2 py-1 text-left text-xs transition-colors hover:bg-slate-800/60",
tone,
)}
aria-label={`Jump to node ${issue.node_key}`}
>
{body}
</button>
);
}
return (
<div
className={cn(
"flex items-start gap-2 rounded-md px-2 py-1 text-xs",
tone,
)}
>
{body}
</div> </div>
); );
} }