310 lines
9.6 KiB
TypeScript
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 = "";
|
|
}
|
|
}
|
|
}
|