feat(FN-3088): add dependency graph highlighting with hover and selection s

The merge adds visual highlighting to the dependency graph plugin. A new `useDependencyChain` hook tracks dependency relationships, while `GraphTaskNode` and `DependencyGraph` gained hover, selection, and highlight visual states backed by new CSS. Tests cover the hook logic, highlighting behavior, a

Fusion-Task-Id: FN-3088
This commit is contained in:
Fusion
2026-05-07 04:41:15 -07:00
committed by gsxdsm
parent 8fa0df56f6
commit 77d628594c
9 changed files with 350 additions and 16 deletions

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { renderHook } from "@testing-library/react";
import type { Task } from "@fusion/core";
import { useDependencyChain } from "../useDependencyChain";
function createTask(id: string, dependencies: string[] = []): Task {
return {
id,
description: id,
column: "todo",
dependencies,
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as Task;
}
describe("useDependencyChain", () => {
it("returns empty set for unknown task in empty list", () => {
const { result } = renderHook(() => useDependencyChain([]));
expect(result.current.getChain("A").size).toBe(0);
});
it("returns single task when no dependencies", () => {
const { result } = renderHook(() => useDependencyChain([createTask("A")]));
expect(result.current.getChain("A")).toEqual(new Set(["A"]));
});
it("returns full linear chain", () => {
const tasks = [createTask("A"), createTask("B", ["A"]), createTask("C", ["B"]), createTask("D")];
const { result } = renderHook(() => useDependencyChain(tasks));
expect(result.current.getChain("C")).toEqual(new Set(["A", "B", "C"]));
});
it("returns full diamond chain", () => {
const tasks = [createTask("A"), createTask("B", ["A"]), createTask("C", ["A"]), createTask("D", ["B", "C"]), createTask("E")];
const { result } = renderHook(() => useDependencyChain(tasks));
expect(result.current.getChain("D")).toEqual(new Set(["A", "B", "C", "D"]));
});
it("does not include disconnected tasks", () => {
const { result } = renderHook(() => useDependencyChain([createTask("A"), createTask("B")]));
expect(result.current.getChain("A")).toEqual(new Set(["A"]));
});
it("handles circular dependencies safely", () => {
const tasks = [createTask("A", ["B"]), createTask("B", ["A"]), createTask("C")];
const { result } = renderHook(() => useDependencyChain(tasks));
expect(result.current.getChain("A")).toEqual(new Set(["A", "B"]));
});
});

View File

@@ -0,0 +1,60 @@
import { useCallback, useMemo } from "react";
import type { Task } from "@fusion/core";
export function useDependencyChain(tasks: Task[]) {
const { upstreamMap, downstreamMap } = useMemo(() => {
const upstream = new Map<string, Set<string>>();
const downstream = new Map<string, Set<string>>();
for (const task of tasks) {
upstream.set(task.id, new Set(task.dependencies ?? []));
if (!downstream.has(task.id)) downstream.set(task.id, new Set());
}
for (const task of tasks) {
for (const dependencyId of task.dependencies ?? []) {
if (!downstream.has(dependencyId)) downstream.set(dependencyId, new Set());
downstream.get(dependencyId)?.add(task.id);
}
}
return { upstreamMap: upstream, downstreamMap: downstream };
}, [tasks]);
const getChain = useCallback(
(taskId: string): Set<string> => {
if (!upstreamMap.has(taskId) && !downstreamMap.has(taskId)) {
return new Set();
}
const chain = new Set<string>([taskId]);
const visit = (origin: string, adjacency: Map<string, Set<string>>) => {
const queue = [origin];
const visited = new Set<string>([origin]);
while (queue.length > 0) {
const current = queue.shift();
if (!current) continue;
const neighbors = adjacency.get(current);
if (!neighbors) continue;
for (const neighbor of neighbors) {
if (visited.has(neighbor)) continue;
visited.add(neighbor);
chain.add(neighbor);
queue.push(neighbor);
}
}
};
visit(taskId, upstreamMap);
visit(taskId, downstreamMap);
return chain;
},
[downstreamMap, upstreamMap],
);
return { getChain };
}