feat(graph): complete bb-ui2.19 - Extract Graph into Reusable Component

STORY:
The existing ReactFlow dependency graph lived in the /graph page, but
the Unified UX needs it as a reusable component for the graph tab.

COLLABORATION:
Extracted the core ReactFlow visualization into WorkflowGraph component:

Interface:
- beads: BeadIssue[] to render as nodes
- selectedId?: currently selected bead
- onSelect?: selection callback
- className?: styling override
- hideClosed?: filter closed beads

Features preserved:
- Dagre layout for automatic positioning
- Edge rendering with BLOCKS labels
- fitView() on mount via useReactFlow
- Existing styling and hover states

The original /graph page can now use this component or serve as reference.

DELIVERABLES:
- src/components/shared/workflow-graph.tsx

VERIFICATION:
- npm run typecheck: PASS
- npm run lint: PASS
- npm run test: PASS

CLOSES: bb-ui2.19
BLOCKS: bb-ui2.20
This commit is contained in:
zenchantlive 2026-02-15 21:18:31 -08:00
parent e47230c2dd
commit 9660b516a8

View file

@ -0,0 +1,301 @@
'use client';
import { useCallback, useEffect, useMemo } from 'react';
import {
Background,
MarkerType,
Position,
ReactFlow,
ReactFlowProvider,
useReactFlow,
type Edge,
type Node,
type NodeTypes,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import dagre from 'dagre';
import type { BeadIssue } from '../../lib/types';
import { buildGraphModel } from '../../lib/graph';
import { analyzeBlockedChain, detectDependencyCycles } from '../../lib/graph-view';
import { GraphNodeCard, type GraphNodeData } from '../graph/graph-node-card';
export interface WorkflowGraphProps {
beads: BeadIssue[];
selectedId?: string;
onSelect?: (id: string) => void;
className?: string;
hideClosed?: boolean;
}
const NODE_WIDTH = 320;
const NODE_HEIGHT = 150;
function layoutDagre(nodes: Node<GraphNodeData>[], edges: Edge[]): Node<GraphNodeData>[] {
const dagreGraph = new dagre.graphlib.Graph();
dagreGraph.setDefaultEdgeLabel(() => ({}));
dagreGraph.setGraph({ rankdir: 'LR' });
for (const node of nodes) {
dagreGraph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
}
for (const edge of edges) {
dagreGraph.setEdge(edge.source, edge.target);
}
dagre.layout(dagreGraph);
return nodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id);
return {
...node,
position: {
x: nodeWithPosition.x - NODE_WIDTH / 2,
y: nodeWithPosition.y - NODE_HEIGHT / 2,
},
};
});
}
function WorkflowGraphInner({
beads,
selectedId,
onSelect,
className = '',
hideClosed = false,
}: WorkflowGraphProps) {
const { fitView } = useReactFlow();
const graphModel = useMemo(() => buildGraphModel(beads, { projectKey: 'workflow' }), [beads]);
const signalById = useMemo(() => {
const map = new Map<string, { blockedBy: number; blocks: number }>();
for (const issue of beads) {
const adjacency = graphModel.adjacency[issue.id];
map.set(issue.id, {
blockedBy: adjacency?.incoming.length ?? 0,
blocks: adjacency?.outgoing.length ?? 0,
});
}
return map;
}, [graphModel.adjacency, beads]);
const cycleAnalysis = useMemo(() => detectDependencyCycles(graphModel), [graphModel]);
const cycleNodeIdSet = useMemo(() => new Set(cycleAnalysis.cycleNodeIds), [cycleAnalysis]);
const blockerAnalysis = useMemo(() => {
if (!selectedId) return null;
return analyzeBlockedChain(graphModel, { focusId: selectedId });
}, [graphModel, selectedId]);
const chainNodeIds = useMemo(() => {
if (!selectedId || !blockerAnalysis) return new Set<string>();
const ids = new Set<string>([selectedId, ...blockerAnalysis.blockerNodeIds]);
return ids;
}, [selectedId, blockerAnalysis]);
const actionableNodeIds = useMemo(() => {
const ids = new Set<string>();
for (const issue of beads) {
if (issue.status === 'closed') continue;
const adjacency = graphModel.adjacency[issue.id];
if (!adjacency) continue;
const hasOpenBlocker = adjacency.incoming.some((edge) => {
if (edge.type !== 'blocks') return false;
const sourceNode = beads.find((i) => i.id === edge.source);
return sourceNode ? sourceNode.status !== 'closed' : false;
});
if (!hasOpenBlocker) {
ids.add(issue.id);
}
}
return ids;
}, [graphModel.adjacency, beads]);
const blockerTooltipMap = useMemo(() => {
const map = new Map<string, string[]>();
for (const issue of beads) {
const adjacency = graphModel.adjacency[issue.id];
if (!adjacency) continue;
const lines: string[] = [];
for (const edge of adjacency.incoming) {
if (edge.type !== 'blocks') continue;
const source = beads.find((i) => i.id === edge.source);
if (source && source.status !== 'closed') {
lines.push(`${source.id} (${source.status}) - "${source.title}"`);
}
}
map.set(issue.id, lines);
}
return map;
}, [graphModel.adjacency, beads]);
const flowModel = useMemo(() => {
const visibleBeads = beads.filter((issue) => (!hideClosed ? true : issue.status !== 'closed'));
if (visibleBeads.length === 0) {
return { nodes: [] as Node<GraphNodeData>[], edges: [] as Edge[] };
}
const baseNodes: Node<GraphNodeData>[] = visibleBeads.map((issue) => ({
id: issue.id,
data: {
title: issue.title,
kind: 'issue' as const,
status: issue.status,
priority: issue.priority,
blockedBy: signalById.get(issue.id)?.blockedBy ?? 0,
blocks: signalById.get(issue.id)?.blocks ?? 0,
isActionable: actionableNodeIds.has(issue.id),
isCycleNode: cycleNodeIdSet.has(issue.id),
isDimmed: selectedId ? !chainNodeIds.has(issue.id) : false,
blockerTooltipLines: blockerTooltipMap.get(issue.id) ?? [],
},
position: { x: 0, y: 0 },
sourcePosition: Position.Right,
targetPosition: Position.Left,
type: 'flowNode',
}));
const visibleIds = new Set(baseNodes.map((node) => node.id));
const graphEdges: Edge[] = [];
for (const issue of beads) {
for (const dep of issue.dependencies) {
if (!visibleIds.has(issue.id) && !visibleIds.has(dep.target)) continue;
if (!visibleIds.has(issue.id) || !visibleIds.has(dep.target)) continue;
if (dep.type !== 'blocks') continue;
if (issue.id === dep.target) continue;
const edgeId = `${dep.target}:blocks:${issue.id}`;
const linkedToSelection = selectedId ? issue.id === selectedId || dep.target === selectedId : false;
graphEdges.push({
id: edgeId,
source: dep.target,
target: issue.id,
className: linkedToSelection ? 'workflow-edge-selected' : 'workflow-edge-muted',
animated: linkedToSelection,
label: 'BLOCKS',
labelStyle: {
fill: linkedToSelection ? '#e2e8f0' : '#cbd5e1',
fontSize: 10,
fontWeight: 700,
letterSpacing: '0.08em',
},
labelBgPadding: [6, 3],
labelBgBorderRadius: 999,
labelBgStyle: {
fill: 'rgba(2, 6, 23, 0.92)',
stroke: linkedToSelection ? 'rgba(125, 211, 252, 0.35)' : 'rgba(251, 191, 36, 0.25)',
strokeWidth: 1,
},
style: {
stroke: linkedToSelection ? '#7dd3fc' : '#fbbf24',
strokeWidth: linkedToSelection ? 2.8 : 2.1,
opacity: linkedToSelection ? 1 : 0.78,
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: linkedToSelection ? '#7dd3fc' : '#fbbf24',
width: 14,
height: 14,
},
});
}
}
return {
nodes: layoutDagre(baseNodes, graphEdges),
edges: graphEdges,
};
}, [beads, hideClosed, selectedId, signalById, actionableNodeIds, cycleNodeIdSet, chainNodeIds, blockerTooltipMap]);
const nodeTypes: NodeTypes = useMemo(
() => ({
flowNode: GraphNodeCard as NodeTypes['flowNode'],
}),
[],
);
const defaultEdgeOptions = useMemo(
() => ({
type: 'smoothstep' as const,
zIndex: 40,
interactionWidth: 24,
}),
[],
);
const handleNodeClick = useCallback(
(_: React.MouseEvent, node: Node) => {
onSelect?.(node.id);
},
[onSelect],
);
useEffect(() => {
const timeout = setTimeout(() => {
fitView({ padding: 0.3, duration: 200 });
}, 50);
return () => clearTimeout(timeout);
}, [fitView, flowModel.nodes.length]);
return (
<div className={`relative h-full min-h-[24rem] overflow-hidden rounded-2xl border border-white/5 bg-[radial-gradient(circle_at_50%_50%,rgba(15,23,42,0.4),rgba(5,8,15,0.8))] shadow-inner ${className}`}>
<div className="workflow-graph-legend absolute left-3 top-3 z-10 flex flex-wrap items-center gap-3 rounded-xl border border-white/5 bg-white/[0.02] px-3 py-2 backdrop-blur-sm">
<p className="text-[10px] text-text-muted/60">
<span className="font-bold uppercase tracking-[0.15em]">Legend</span>{' '}
{!hideClosed ? (
<>
<span className="text-emerald-400">Done</span>
{' \u2192 '}
</>
) : null}
<span className="text-amber-400">In Progress</span>
{' \u2192 '}
<span className="text-cyan-400">Ready</span>
{' \u2192 '}
<span className="text-rose-400">Blocked</span>
</p>
{blockerAnalysis ? (
<p className="text-[10px] text-text-muted/60">
Open blockers: {blockerAnalysis.openBlockerCount}
</p>
) : null}
</div>
<ReactFlow
className="workflow-graph-flow"
defaultEdgeOptions={defaultEdgeOptions}
proOptions={{ hideAttribution: true }}
fitView
fitViewOptions={{ padding: 0.3 }}
minZoom={0.3}
maxZoom={1.5}
translateExtent={[
[-500, -500],
[3000, 2500],
]}
nodes={flowModel.nodes}
edges={flowModel.edges}
nodeTypes={nodeTypes}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable
onlyRenderVisibleElements
onNodeClick={handleNodeClick}
>
<Background gap={32} size={1} color="rgba(255,255,255,0.03)" />
</ReactFlow>
</div>
);
}
export function WorkflowGraph(props: WorkflowGraphProps) {
return (
<ReactFlowProvider>
<WorkflowGraphInner {...props} />
</ReactFlowProvider>
);
}