backlog-md/src/web/components/DependencyView.tsx

310 lines
9.6 KiB
TypeScript

import React, { useEffect, useRef, useMemo, useCallback, useState } from "react";
import type { Task } from "../../types";
import type { GraphFilters } from "../types/graph";
import {
buildDependencyGraph,
generateMermaidFlowchart,
filterGraph,
extractTaskIdFromNodeId,
} from "../utils/dependency-graph";
import { ensureMermaid } from "../utils/mermaid";
interface DependencyViewProps {
tasks: Task[];
onEditTask: (task: Task) => void;
filters?: GraphFilters;
}
export default function DependencyView({ tasks, onEditTask, filters }: DependencyViewProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Create task lookup map
const taskMap = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks]);
// Build and filter graph, generate Mermaid code
const { mermaidCode, nodeCount, edgeCount } = useMemo(() => {
const fullGraph = buildDependencyGraph(tasks);
const graph = filters ? filterGraph(fullGraph, filters) : fullGraph;
return {
mermaidCode: generateMermaidFlowchart(graph),
nodeCount: graph.nodes.length,
edgeCount: graph.edges.length,
};
}, [tasks, filters]);
// Attach click handlers to rendered SVG nodes
const attachClickHandlers = useCallback(
(svg: SVGElement) => {
// Find all node groups (Mermaid uses .node class)
const nodes = svg.querySelectorAll(".node");
for (const node of nodes) {
const nodeEl = node as SVGElement;
const nodeId = nodeEl.getAttribute("id") || "";
const taskId = extractTaskIdFromNodeId(nodeId);
if (taskId) {
nodeEl.style.cursor = "pointer";
// Click to open task details
nodeEl.addEventListener("click", () => {
const task = taskMap.get(taskId);
if (task) onEditTask(task);
});
// Hover effects for dependency highlighting
nodeEl.addEventListener("mouseenter", () => {
highlightDependencyPath(svg, taskId, taskMap);
});
nodeEl.addEventListener("mouseleave", () => {
clearHighlights(svg);
});
}
}
},
[taskMap, onEditTask],
);
// Render Mermaid diagram
useEffect(() => {
if (!containerRef.current) return;
const render = async () => {
setIsLoading(true);
setError(null);
try {
const m = await ensureMermaid();
// Initialize with loose security to allow click handlers
m.default.initialize({
startOnLoad: false,
securityLevel: "loose",
theme: "default",
});
// Generate unique ID for this render
const id = `dependency-graph-${Date.now()}`;
// Render the diagram
const result = await m.default.render(id, mermaidCode);
if (containerRef.current) {
containerRef.current.innerHTML = result.svg;
// Bind any Mermaid-provided functions
if (result.bindFunctions && containerRef.current) {
result.bindFunctions(containerRef.current);
}
// Attach our custom click handlers
const svgElement = containerRef.current.querySelector("svg");
if (svgElement) {
attachClickHandlers(svgElement);
}
}
} catch (err) {
console.error("Failed to render dependency graph:", err);
setError(err instanceof Error ? err.message : "Failed to render graph");
} finally {
setIsLoading(false);
}
};
render();
}, [mermaidCode, attachClickHandlers]);
// Empty state
if (tasks.length === 0) {
return (
<div className="flex items-center justify-center h-96 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="text-center text-gray-500 dark:text-gray-400">
<svg className="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1}
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
/>
</svg>
<p className="text-lg font-medium">No tasks to display</p>
<p className="text-sm mt-1">Create some tasks to see the dependency graph</p>
</div>
</div>
);
}
// No nodes after filtering
if (nodeCount === 0) {
return (
<div className="flex items-center justify-center h-96 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="text-center text-gray-500 dark:text-gray-400">
<svg className="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1}
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
/>
</svg>
<p className="text-lg font-medium">No tasks match filters</p>
<p className="text-sm mt-1">Try adjusting your filter criteria</p>
</div>
</div>
);
}
return (
<div className="relative">
{/* Stats bar */}
<div className="flex items-center justify-between mb-2 text-sm text-gray-500 dark:text-gray-400">
<span>
{nodeCount} task{nodeCount !== 1 ? "s" : ""} {edgeCount} dependenc{edgeCount !== 1 ? "ies" : "y"}
</span>
<span className="text-xs">Click a task to view details Hover to highlight dependencies</span>
</div>
{/* Loading overlay */}
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-gray-800/50 z-10">
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-300">
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span>Loading graph...</span>
</div>
</div>
)}
{/* Error state */}
{error && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-300">
<p className="font-medium">Failed to render graph</p>
<p className="text-sm mt-1">{error}</p>
</div>
)}
{/* Graph container */}
<div
className="w-full overflow-auto bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
style={{ minHeight: "500px" }}
>
<div
ref={containerRef}
className="min-h-[500px] p-4 dependency-graph-container"
style={{ minWidth: "100%" }}
/>
</div>
{/* Legend */}
<div className="flex flex-wrap items-center gap-4 mt-4 text-sm">
<span className="text-gray-500 dark:text-gray-400">Legend:</span>
<div className="flex items-center gap-2">
<span className="w-4 h-4 rounded" style={{ backgroundColor: "#22c55e" }} />
<span className="text-gray-600 dark:text-gray-300">Done</span>
</div>
<div className="flex items-center gap-2">
<span className="w-4 h-4 rounded" style={{ backgroundColor: "#3b82f6" }} />
<span className="text-gray-600 dark:text-gray-300">In Progress</span>
</div>
<div className="flex items-center gap-2">
<span className="w-4 h-4 rounded" style={{ backgroundColor: "#6b7280" }} />
<span className="text-gray-600 dark:text-gray-300">To Do</span>
</div>
<div className="flex items-center gap-2">
<span className="w-4 h-4 rounded border-2" style={{ borderColor: "#ef4444" }} />
<span className="text-gray-600 dark:text-gray-300">High Priority</span>
</div>
</div>
</div>
);
}
/**
* Highlight the dependency path for a task (its dependencies and dependents)
*/
function highlightDependencyPath(svg: SVGElement, taskId: string, taskMap: Map<string, Task>): void {
const task = taskMap.get(taskId);
if (!task) return;
// Build set of connected task IDs
const connectedIds = new Set<string>([taskId]);
for (const depId of task.dependencies || []) {
connectedIds.add(depId);
}
// Find tasks that depend on this one
for (const [id, t] of taskMap) {
if (t.dependencies?.includes(taskId)) {
connectedIds.add(id);
}
}
// Dim non-connected nodes
const allNodes = svg.querySelectorAll(".node");
for (const node of allNodes) {
const nodeEl = node as SVGElement;
const nodeId = nodeEl.getAttribute("id") || "";
const nodeTaskId = extractTaskIdFromNodeId(nodeId);
if (nodeTaskId && !connectedIds.has(nodeTaskId)) {
nodeEl.style.opacity = "0.3";
}
}
// Dim non-connected edges
const allEdges = svg.querySelectorAll(".edgePath");
for (const edge of allEdges) {
const edgeEl = edge as SVGElement;
const edgeId = edgeEl.getAttribute("id") || "";
// Check if edge connects to our task
let isConnected = false;
for (const connectedId of connectedIds) {
if (edgeId.includes(connectedId)) {
isConnected = true;
break;
}
}
if (!isConnected) {
edgeEl.style.opacity = "0.1";
} else {
// Highlight connected edges
const path = edgeEl.querySelector("path");
if (path) {
path.style.stroke = "#3b82f6";
path.style.strokeWidth = "3";
}
}
}
}
/**
* Clear all highlight effects
*/
function clearHighlights(svg: SVGElement): void {
// Reset node opacity
const allNodes = svg.querySelectorAll(".node");
for (const node of allNodes) {
(node as SVGElement).style.opacity = "";
}
// Reset edge opacity and styles
const allEdges = svg.querySelectorAll(".edgePath");
for (const edge of allEdges) {
const edgeEl = edge as SVGElement;
edgeEl.style.opacity = "";
const path = edgeEl.querySelector("path");
if (path) {
path.style.stroke = "";
path.style.strokeWidth = "";
}
}
}