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:
@@ -19,9 +19,19 @@ Plugin-provided top-level **Graph** dashboard view for Fusion.
|
||||
- **Current-step highlighting**: active nodes set `data-current-step` for valid native step indices so CSS selectors highlight the currently executing `.card-step-item` and pulse its step dot
|
||||
- **Zoom-out differentiation**: `.graph-task-node--active` adds amplified glow and subtle scale/border tint so active nodes remain distinguishable at reduced zoom levels
|
||||
- **In-review visual treatment**: `in-review` nodes get a static `.graph-task-node--in-review` left accent in `--in-review` to distinguish waiting-review work from active execution nodes
|
||||
- **Graph node classes**: `.graph-task-node`, `.graph-task-node--active`, `.graph-task-node--in-review`, `.graph-task-node--highlighted`, and `.graph-task-node--dimmed` are available for graph-specific layering/highlight states while card internals remain owned by `TaskCard.css`
|
||||
- **Graph node classes**: `.graph-task-node`, `.graph-task-node--active`, `.graph-task-node--in-review`, `.graph-task-node--highlighted`, `.graph-task-node--dimmed`, `.graph-node--highlighted`, and `.graph-node--dimmed` are available for graph-specific layering/highlight states while card internals remain owned by `TaskCard.css`
|
||||
- **Graph edge classes**: `.graph-edge--highlighted` and `.graph-edge--dimmed` are applied during dependency-chain emphasis states
|
||||
- **Drag behavior**: graph nodes pass `disableDrag={true}` to `TaskCard` so card-level HTML5 drag does not conflict with canvas pan/zoom
|
||||
|
||||
## Dependency chain highlighting
|
||||
|
||||
- **Hover** a node to highlight the full transitive upstream + downstream chain for that task.
|
||||
- **Click** a node to persist selection highlighting until the same node is clicked again or the canvas pane is clicked.
|
||||
- **Priority**: hover state overrides selected state; when hover leaves, selected highlighting reappears.
|
||||
- **Dimming**: when a chain is active, unrelated nodes and edges are dimmed.
|
||||
- **Neutral state**: when nothing is hovered/selected, no highlight/dim classes are applied.
|
||||
- **Edge rule**: an edge is highlighted only when both its source and target nodes are in the active chain.
|
||||
|
||||
## Controls
|
||||
|
||||
### Toolbar (bottom-right)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { GraphTaskNode } from "./GraphTaskNode";
|
||||
import { GraphToolbar } from "./GraphToolbar";
|
||||
@@ -7,6 +7,7 @@ import { filterGraphTasks } from "./filters";
|
||||
import { computeAutoLayout } from "./layout";
|
||||
import { useGraphData } from "./useGraphData";
|
||||
import { useGraphInteraction } from "./useGraphInteraction";
|
||||
import { useDependencyChain } from "./hooks/useDependencyChain";
|
||||
import "./DependencyGraph.css";
|
||||
|
||||
const NODE_WIDTH = 280;
|
||||
@@ -40,6 +41,8 @@ export interface DependencyGraphProps {
|
||||
workflowStepNameLookup?: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
const POINTER_MOVE_THRESHOLD = 4;
|
||||
|
||||
export function DependencyGraph({
|
||||
tasks,
|
||||
projectId,
|
||||
@@ -61,8 +64,16 @@ export function DependencyGraph({
|
||||
}: DependencyGraphProps) {
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const initialFitDoneRef = useRef(false);
|
||||
const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const pointerDraggedRef = useRef(false);
|
||||
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const filteredTasks = useMemo(() => filterGraphTasks(tasks), [tasks]);
|
||||
const graphData = useGraphData(filteredTasks);
|
||||
const { getChain } = useDependencyChain(filteredTasks);
|
||||
const activeTaskId = hoveredTaskId ?? selectedTaskId;
|
||||
const highlightedTaskIds = useMemo(() => (activeTaskId ? getChain(activeTaskId) : new Set<string>()), [activeTaskId, getChain]);
|
||||
|
||||
const positions = useMemo(
|
||||
() => computeAutoLayout(graphData, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT, horizontalGap: 40, verticalGap: 80 }),
|
||||
[graphData],
|
||||
@@ -113,14 +124,33 @@ export function DependencyGraph({
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className="dependency-graph__viewport"
|
||||
onPointerDown={(event) => onPointerDown(event.pointerId, { x: event.clientX, y: event.clientY })}
|
||||
onPointerDown={(event) => {
|
||||
pointerDownRef.current = { x: event.clientX, y: event.clientY };
|
||||
pointerDraggedRef.current = false;
|
||||
onPointerDown(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
const pointerDown = pointerDownRef.current;
|
||||
if (pointerDown) {
|
||||
const deltaX = Math.abs(event.clientX - pointerDown.x);
|
||||
const deltaY = Math.abs(event.clientY - pointerDown.y);
|
||||
if (deltaX > POINTER_MOVE_THRESHOLD || deltaY > POINTER_MOVE_THRESHOLD) {
|
||||
pointerDraggedRef.current = true;
|
||||
}
|
||||
}
|
||||
onPointerMove(event.pointerId, { x: event.clientX, y: event.clientY }, viewport.clientWidth, viewport.clientHeight);
|
||||
}}
|
||||
onPointerUp={(event) => onPointerUp(event.pointerId)}
|
||||
onPointerCancel={(event) => onPointerUp(event.pointerId)}
|
||||
onPointerUp={(event) => {
|
||||
onPointerUp(event.pointerId);
|
||||
pointerDownRef.current = null;
|
||||
}}
|
||||
onPointerCancel={(event) => {
|
||||
onPointerUp(event.pointerId);
|
||||
pointerDownRef.current = null;
|
||||
pointerDraggedRef.current = false;
|
||||
}}
|
||||
onWheel={(event) => {
|
||||
event.preventDefault();
|
||||
const viewport = viewportRef.current;
|
||||
@@ -135,12 +165,30 @@ export function DependencyGraph({
|
||||
}}
|
||||
tabIndex={0}
|
||||
style={{ outline: "none" }}
|
||||
onClick={() => {
|
||||
if (pointerDraggedRef.current) return;
|
||||
setSelectedTaskId(null);
|
||||
}}
|
||||
>
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div className="dependency-graph__empty">No active tasks to display in graph view.</div>
|
||||
) : (
|
||||
<div className={`graph-canvas-transform${transitioning ? " graph-canvas-transform--animate" : ""}`} style={{ transform, width: `${bounds.width}px`, height: `${bounds.height}px` }}>
|
||||
<GraphEdges edges={graphData.edges} positions={positions} nodeWidth={NODE_WIDTH} nodeHeight={NODE_HEIGHT} />
|
||||
<GraphEdges
|
||||
edges={graphData.edges}
|
||||
positions={positions}
|
||||
nodeWidth={NODE_WIDTH}
|
||||
nodeHeight={NODE_HEIGHT}
|
||||
highlightedEdgeIds={
|
||||
highlightedTaskIds.size > 0
|
||||
? new Set(
|
||||
graphData.edges
|
||||
.filter((edge) => highlightedTaskIds.has(edge.source) && highlightedTaskIds.has(edge.target))
|
||||
.map((edge) => `${edge.source}->${edge.target}`),
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<div className="dependency-graph__nodes-layer">
|
||||
{graphData.nodes.map((node) => {
|
||||
const position = positions.get(node.task.id);
|
||||
@@ -152,6 +200,8 @@ export function DependencyGraph({
|
||||
task={node.task}
|
||||
projectId={projectId}
|
||||
style={{ minHeight: `${NODE_HEIGHT}px`, left: `${position.x}px`, top: `${position.y}px` }}
|
||||
isHighlighted={highlightedTaskIds.size > 0 && highlightedTaskIds.has(node.task.id)}
|
||||
isDimmed={highlightedTaskIds.size > 0 && !highlightedTaskIds.has(node.task.id)}
|
||||
onOpenDetail={onOpenDetail ?? ((task) => onOpenTaskDetail?.(task.id))}
|
||||
addToast={addToast ?? (() => {})}
|
||||
globalPaused={globalPaused}
|
||||
@@ -166,6 +216,13 @@ export function DependencyGraph({
|
||||
onMoveTask={onMoveTask}
|
||||
lastFetchTimeMs={lastFetchTimeMs}
|
||||
workflowStepNameLookup={workflowStepNameLookup}
|
||||
onMouseEnter={() => setHoveredTaskId(node.task.id)}
|
||||
onMouseLeave={() => setHoveredTaskId(null)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
pointerDraggedRef.current = false;
|
||||
setSelectedTaskId((current) => (current === node.task.id ? null : node.task.id));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
.graph-node--highlighted {
|
||||
opacity: 1;
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
|
||||
.graph-node--dimmed {
|
||||
opacity: 0.25;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.graph-edge--highlighted {
|
||||
opacity: 1;
|
||||
stroke: var(--todo);
|
||||
stroke-width: var(--space-xs);
|
||||
}
|
||||
|
||||
.graph-edge--dimmed {
|
||||
opacity: 0.15;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.graph-node--dimmed,
|
||||
.graph-edge--dimmed {
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { CSSProperties, ComponentProps } from "react";
|
||||
import type { CSSProperties, ComponentProps, HTMLAttributes } from "react";
|
||||
import { TaskCard } from "@fusion/dashboard/app/components/TaskCard";
|
||||
import { isTaskStuck } from "@fusion/dashboard/app/utils/taskStuck";
|
||||
import "./GraphTaskNode.css";
|
||||
import "./GraphHighlight.css";
|
||||
|
||||
type TaskCardComponentProps = ComponentProps<typeof TaskCard>;
|
||||
|
||||
@@ -25,9 +26,10 @@ type TaskCardBridgeProps = Pick<
|
||||
| "workflowStepNameLookup"
|
||||
>;
|
||||
|
||||
export interface GraphTaskNodeProps extends TaskCardBridgeProps {
|
||||
export interface GraphTaskNodeProps extends TaskCardBridgeProps, Pick<HTMLAttributes<HTMLDivElement>, "onMouseEnter" | "onMouseLeave" | "onClick"> {
|
||||
style?: CSSProperties;
|
||||
isHighlighted?: boolean;
|
||||
isDimmed?: boolean;
|
||||
}
|
||||
|
||||
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]);
|
||||
@@ -40,7 +42,15 @@ function getStatusLabel(status?: string): string {
|
||||
return status.charAt(0).toUpperCase() + status.slice(1);
|
||||
}
|
||||
|
||||
export function GraphTaskNode({ style, isHighlighted = false, ...taskCardProps }: GraphTaskNodeProps) {
|
||||
export function GraphTaskNode({
|
||||
style,
|
||||
isHighlighted = false,
|
||||
isDimmed = false,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
onClick,
|
||||
...taskCardProps
|
||||
}: GraphTaskNodeProps) {
|
||||
const { task, globalPaused, taskStuckTimeoutMs, lastFetchTimeMs } = taskCardProps;
|
||||
const isFailed = task.status === "failed";
|
||||
const isPaused = task.paused === true;
|
||||
@@ -63,11 +73,14 @@ export function GraphTaskNode({ style, isHighlighted = false, ...taskCardProps }
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`graph-task-node${isHighlighted ? " graph-task-node--highlighted" : ""}${isActive ? " graph-task-node--active" : ""}${isInReview ? " graph-task-node--in-review" : ""}`}
|
||||
className={`graph-task-node${isHighlighted ? " graph-task-node--highlighted graph-node--highlighted" : ""}${isDimmed ? " graph-task-node--dimmed graph-node--dimmed" : ""}${isActive ? " graph-task-node--active" : ""}${isInReview ? " graph-task-node--in-review" : ""}`}
|
||||
style={style}
|
||||
draggable={false}
|
||||
data-testid={`graph-task-node-${task.id}`}
|
||||
data-current-step={isActive && hasValidCurrentStep ? String(task.currentStep) : undefined}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onClick={onClick}
|
||||
>
|
||||
{isActive ? (
|
||||
<div className="graph-task-active-indicator">
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { DependencyGraph } from "../DependencyGraph";
|
||||
|
||||
vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({
|
||||
TaskCard: ({ task, onOpenDetail }: { task: Task; onOpenDetail: (task: Task) => void }) => (
|
||||
<button data-testid={`task-${task.id}`} onClick={() => onOpenDetail(task)}>{task.id}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../useGraphInteraction", () => ({
|
||||
useGraphInteraction: () => ({
|
||||
transform: "translate(0px, 0px) scale(1)",
|
||||
zoom: 1,
|
||||
transitioning: false,
|
||||
zoomIn: vi.fn(),
|
||||
zoomOut: vi.fn(),
|
||||
resetView: vi.fn(),
|
||||
fitToGraph: vi.fn(),
|
||||
onPointerDown: vi.fn(),
|
||||
onPointerMove: vi.fn(),
|
||||
onPointerUp: vi.fn(),
|
||||
onWheelZoom: vi.fn(),
|
||||
handleKeyDown: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("DependencyGraph highlighting", () => {
|
||||
const tasks = [createTask("A"), createTask("B", ["A"]), createTask("C", ["B"]), createTask("D")];
|
||||
|
||||
it("highlights chain on hover and returns to neutral on mouse leave", () => {
|
||||
render(<DependencyGraph tasks={tasks} onOpenDetail={vi.fn()} />);
|
||||
|
||||
fireEvent.mouseEnter(screen.getByTestId("graph-task-node-C"));
|
||||
expect(screen.getByTestId("graph-task-node-A").className).toContain("graph-task-node--highlighted");
|
||||
expect(screen.getByTestId("graph-task-node-B").className).toContain("graph-task-node--highlighted");
|
||||
expect(screen.getByTestId("graph-task-node-C").className).toContain("graph-task-node--highlighted");
|
||||
expect(screen.getByTestId("graph-task-node-D").className).toContain("graph-task-node--dimmed");
|
||||
|
||||
fireEvent.mouseLeave(screen.getByTestId("graph-task-node-C"));
|
||||
expect(screen.getByTestId("graph-task-node-A").className).not.toContain("graph-task-node--highlighted");
|
||||
expect(screen.getByTestId("graph-task-node-D").className).not.toContain("graph-task-node--dimmed");
|
||||
});
|
||||
|
||||
it("keeps selection until toggled or pane clicked", () => {
|
||||
render(<DependencyGraph tasks={tasks} onOpenDetail={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("graph-task-node-B"));
|
||||
expect(screen.getByTestId("graph-task-node-A").className).toContain("graph-task-node--highlighted");
|
||||
|
||||
fireEvent.click(screen.getByTestId("graph-task-node-B"));
|
||||
expect(screen.getByTestId("graph-task-node-A").className).not.toContain("graph-task-node--highlighted");
|
||||
|
||||
fireEvent.click(screen.getByTestId("graph-task-node-B"));
|
||||
fireEvent.click(document.querySelector(".dependency-graph__viewport") as Element);
|
||||
expect(screen.getByTestId("graph-task-node-B").className).not.toContain("graph-task-node--highlighted");
|
||||
});
|
||||
|
||||
it("hover overrides selection and reverts when hover leaves", () => {
|
||||
render(<DependencyGraph tasks={tasks} onOpenDetail={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("graph-task-node-B"));
|
||||
fireEvent.mouseEnter(screen.getByTestId("graph-task-node-D"));
|
||||
expect(screen.getByTestId("graph-task-node-D").className).toContain("graph-task-node--highlighted");
|
||||
expect(screen.getByTestId("graph-task-node-A").className).toContain("graph-task-node--dimmed");
|
||||
|
||||
fireEvent.mouseLeave(screen.getByTestId("graph-task-node-D"));
|
||||
expect(screen.getByTestId("graph-task-node-A").className).toContain("graph-task-node--highlighted");
|
||||
});
|
||||
|
||||
it("applies edge dimming/highlighting and preserves click-to-detail", () => {
|
||||
const onOpenDetail = vi.fn();
|
||||
render(<DependencyGraph tasks={tasks} onOpenDetail={onOpenDetail} />);
|
||||
|
||||
fireEvent.mouseEnter(screen.getByTestId("graph-task-node-C"));
|
||||
const edges = screen.getAllByTestId("dependency-edge");
|
||||
const edgeAB = edges.find((edge) => edge.getAttribute("data-edge-id") === "B->A");
|
||||
const edgeCB = edges.find((edge) => edge.getAttribute("data-edge-id") === "C->B");
|
||||
|
||||
expect(edgeAB?.className.baseVal || edgeAB?.className).toContain("graph-edge--highlighted");
|
||||
expect(edgeCB?.className.baseVal || edgeCB?.className).toContain("graph-edge--highlighted");
|
||||
|
||||
fireEvent.click(screen.getByTestId("task-C"));
|
||||
expect(onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "C" }));
|
||||
});
|
||||
|
||||
it("highlights only isolated node with no dependencies", () => {
|
||||
render(<DependencyGraph tasks={[createTask("X")]} onOpenDetail={vi.fn()} />);
|
||||
fireEvent.mouseEnter(screen.getByTestId("graph-task-node-X"));
|
||||
expect(screen.getByTestId("graph-task-node-X").className).toContain("graph-task-node--highlighted");
|
||||
});
|
||||
});
|
||||
@@ -68,6 +68,8 @@ describe("GraphEdges", () => {
|
||||
const dimmed = all.find((edge) => edge.getAttribute("data-edge-id") === "A->C");
|
||||
|
||||
expect(highlighted?.getAttribute("opacity")).toBe("1");
|
||||
expect(dimmed?.getAttribute("opacity")).toBe("0.2");
|
||||
expect(highlighted?.getAttribute("class") ?? "").toContain("graph-edge--highlighted");
|
||||
expect(dimmed?.getAttribute("opacity")).toBe("0.15");
|
||||
expect(dimmed?.getAttribute("class") ?? "").toContain("graph-edge--dimmed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GraphEdge, GraphPosition } from "./types";
|
||||
import "./GraphHighlight.css";
|
||||
|
||||
interface GraphEdgesProps {
|
||||
edges: GraphEdge[];
|
||||
@@ -53,14 +54,14 @@ export function GraphEdges({
|
||||
key={edgeId}
|
||||
data-testid="dependency-edge"
|
||||
data-edge-id={edgeId}
|
||||
className={`dependency-graph-edge${isActiveHighlight ? " is-related" : ""}${hasHighlights && !isActiveHighlight ? " is-dimmed" : ""}`}
|
||||
className={`dependency-graph-edge${isActiveHighlight ? " graph-edge--highlighted" : ""}${hasHighlights && !isActiveHighlight ? " graph-edge--dimmed" : ""}`}
|
||||
d={`M ${x1} ${y1} C ${x1} ${controlY}, ${x2} ${controlY}, ${x2} ${y2}`}
|
||||
fill="none"
|
||||
stroke={isActiveHighlight ? "var(--text-muted)" : "var(--border)"}
|
||||
strokeWidth="1"
|
||||
opacity={hasHighlights && !isActiveHighlight ? 0.2 : 1}
|
||||
stroke={isActiveHighlight ? "var(--todo)" : "var(--border)"}
|
||||
strokeWidth={isActiveHighlight ? "var(--space-xs)" : "var(--btn-border-width)"}
|
||||
opacity={hasHighlights && !isActiveHighlight ? 0.15 : 1}
|
||||
markerEnd="url(#dependency-graph-arrowhead)"
|
||||
style={{ transition: "opacity var(--transition-fast), stroke var(--transition-fast)" }}
|
||||
style={{ transition: "opacity var(--transition-fast), stroke var(--transition-fast), stroke-width var(--transition-fast)" }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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