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:
@@ -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"]));
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user