feat(FN-3085): harden graph task filtering with in-review and orphan edge s

Merges graph task filtering hardening (FN-3085) — adding in-review node treatment and orphan dependency edge handling with comprehensive tests and documentation — alongside drag interaction wiring (FN-3625) that enables TaskCard drag override and graph drag opt-out.

Fusion-Task-Id: FN-3085
This commit is contained in:
Fusion
2026-05-07 03:35:58 -07:00
committed by gsxdsm
parent 80ee9c25fc
commit d9030257e5
8 changed files with 166 additions and 76 deletions

View File

@@ -6,6 +6,7 @@ Plugin-provided top-level **Graph** dashboard view for Fusion.
- **Filtering**: includes `triage`, `todo`, `in-progress`, `in-review`; excludes `done`, `archived`
- **Graph build**: edges are resolved only from `task.dependencies` as `source=dependent`, `target=dependency`
- **Orphan dependency handling**: if a visible task depends on an excluded/missing dependency (for example `done`/`archived` after filtering), the missing edge is silently dropped and graph rendering continues without broken connectors
- **Auto-layout**: Sugiyama-style layered layout (`computeAutoLayout`) groups nodes by dependency depth and spaces layers consistently
- **Edge drawing**: SVG bezier curves from source bottom-center to target top-center, with arrowheads showing dependent → dependency direction
- **Interaction**: pan, wheel zoom, pinch zoom, zoom-in/out controls, reset, and fit-to-screen
@@ -15,7 +16,8 @@ Plugin-provided top-level **Graph** dashboard view for Fusion.
- **Active-state indicator bar**: active nodes render a compact top bar (`.graph-task-active-indicator`) with the current execution status label (for example `Executing`, `Planning`) and pulsing `--in-progress` emphasis
- **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
- **Graph node classes**: `.graph-task-node`, `.graph-task-node--active`, `.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`
- **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`
- **Drag behavior**: graph nodes pass `disableDrag={true}` to `TaskCard` so card-level HTML5 drag does not conflict with canvas pan/zoom
## Controls

View File

@@ -32,6 +32,12 @@
transform: scale(1.01);
}
.graph-task-node--in-review {
border-inline-start: calc(var(--btn-border-width) * 3) solid var(--in-review);
border-start-start-radius: var(--radius-md);
border-end-start-radius: var(--radius-md);
}
.graph-task-active-indicator {
display: flex;
align-items: center;

View File

@@ -59,10 +59,11 @@ export function GraphTaskNode({ style, isHighlighted = false, ...taskCardProps }
task.currentStep >= 0 &&
Array.isArray(task.steps) &&
task.currentStep < task.steps.length;
const isInReview = task.column === "in-review";
return (
<div
className={`graph-task-node${isHighlighted ? " graph-task-node--highlighted" : ""}${isActive ? " graph-task-node--active" : ""}`}
className={`graph-task-node${isHighlighted ? " graph-task-node--highlighted" : ""}${isActive ? " graph-task-node--active" : ""}${isInReview ? " graph-task-node--in-review" : ""}`}
style={style}
draggable={false}
data-testid={`graph-task-node-${task.id}`}

View File

@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
import { afterEach, beforeEach, 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";
@@ -42,35 +42,61 @@ describe("DependencyGraph", () => {
expect(screen.getByText(/No active tasks/i)).toBeTruthy();
});
it("renders graph task nodes at layout coordinates and edges", () => {
const { container } = render(<DependencyGraph tasks={[
createTask("A", "todo"),
createTask("B", "in-progress", ["A"]),
]} onOpenTaskDetail={vi.fn()} />);
it("renders only triage/todo/in-progress/in-review nodes from mixed columns", () => {
render(
<DependencyGraph
tasks={[
createTask("A", "triage"),
createTask("B", "todo"),
createTask("C", "in-progress"),
createTask("D", "in-review"),
createTask("E", "done"),
createTask("F", "archived"),
]}
onOpenTaskDetail={vi.fn()}
/>,
);
expect(screen.getByTestId("graph-task-node-A")).toBeTruthy();
expect(screen.getByTestId("graph-task-node-B")).toBeTruthy();
expect(container.querySelector(".dependency-graph__nodes-layer")).toBeTruthy();
expect(screen.getAllByTestId("dependency-edge")).toHaveLength(1);
const nodeAStyle = screen.getByTestId("graph-task-node-A").getAttribute("style") ?? "";
const nodeBStyle = screen.getByTestId("graph-task-node-B").getAttribute("style") ?? "";
expect(nodeAStyle).toContain("left:");
expect(nodeAStyle).toContain("top:");
expect(nodeBStyle).toContain("left:");
expect(nodeBStyle).toContain("top:");
expect(screen.getByTestId("graph-task-node-C")).toBeTruthy();
expect(screen.getByTestId("graph-task-node-D")).toBeTruthy();
expect(screen.queryByTestId("graph-task-node-E")).toBeNull();
expect(screen.queryByTestId("graph-task-node-F")).toBeNull();
});
it("excludes done and archived nodes", () => {
render(<DependencyGraph tasks={[
createTask("A", "todo"),
createTask("B", "done"),
createTask("C", "archived"),
]} onOpenTaskDetail={vi.fn()} />);
it("renders zero nodes and edges when only done tasks are provided", () => {
const { container } = render(<DependencyGraph tasks={[createTask("A", "done", ["B"]), createTask("B", "done")]} onOpenTaskDetail={vi.fn()} />);
expect(container.querySelectorAll("[data-testid^='graph-task-node-']")).toHaveLength(0);
expect(screen.queryAllByTestId("dependency-edge")).toHaveLength(0);
});
it("renders zero nodes and edges when only archived tasks are provided", () => {
const { container } = render(
<DependencyGraph tasks={[createTask("A", "archived", ["B"]), createTask("B", "archived")]} onOpenTaskDetail={vi.fn()} />,
);
expect(container.querySelectorAll("[data-testid^='graph-task-node-']")).toHaveLength(0);
expect(screen.queryAllByTestId("dependency-edge")).toHaveLength(0);
});
it("drops edge from in-review task to done dependency while keeping node", () => {
const { container } = render(<DependencyGraph tasks={[createTask("A", "in-review", ["B"]), createTask("B", "done")]} onOpenTaskDetail={vi.fn()} />);
expect(screen.getByTestId("graph-task-node-A")).toBeTruthy();
expect(screen.queryByTestId("graph-task-node-B")).toBeNull();
expect(screen.queryByTestId("graph-task-node-C")).toBeNull();
expect(screen.queryAllByTestId("dependency-edge")).toHaveLength(0);
expect(container.querySelector(".graph-task-node--in-review")).toBeTruthy();
});
it("renders edge between in-progress task and in-review dependency", () => {
render(<DependencyGraph tasks={[createTask("A", "in-progress", ["B"]), createTask("B", "in-review")]} onOpenTaskDetail={vi.fn()} />);
expect(screen.getByTestId("graph-task-node-A")).toBeTruthy();
expect(screen.getByTestId("graph-task-node-B")).toBeTruthy();
expect(screen.getAllByTestId("dependency-edge")).toHaveLength(1);
expect(screen.getByTestId("graph-task-node-B").className).toContain("graph-task-node--in-review");
});
it("renders embedded cards with native dragging disabled", () => {

View File

@@ -78,12 +78,21 @@ describe("GraphTaskNode", () => {
expect(screen.getByText("Executing")).toBeTruthy();
});
it("does not render active indicator for non-active tasks", () => {
it("applies in-review visual class and does not apply active class for in-review tasks", () => {
const props = createProps(createTask({ column: "in-review", status: "idle" }));
const { container } = render(<GraphTaskNode {...props} />);
const node = screen.getByTestId("graph-task-node-FN-TEST");
expect(container.querySelector(".graph-task-active-indicator")).toBeFalsy();
expect(screen.getByTestId("graph-task-node-FN-TEST").className).not.toContain("graph-task-node--active");
expect(node.className).toContain("graph-task-node--in-review");
expect(node.className).not.toContain("graph-task-node--active");
});
it.each(["todo", "triage", "in-progress"] as const)("does not apply in-review class for %s tasks", (column) => {
const props = createProps(createTask({ column, status: column === "in-progress" ? "executing" : "idle" }));
render(<GraphTaskNode {...props} />);
expect(screen.getByTestId("graph-task-node-FN-TEST").className).not.toContain("graph-task-node--in-review");
});
it("does not render active indicator for paused in-progress tasks", () => {

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { Task } from "@fusion/core";
import { filterGraphTasks } from "../filters";
import { EXCLUDED_COLUMNS, filterGraphTasks, INCLUDED_COLUMNS } from "../filters";
function createTask(id: string, column: Task["column"], dependencies: string[] = []): Task {
return {
@@ -19,7 +19,19 @@ describe("filterGraphTasks", () => {
expect(filterGraphTasks([])).toEqual([]);
});
it("includes triage/todo/in-progress/in-review and excludes done/archived", () => {
it("includes tasks from all included columns", () => {
const tasks = Array.from(INCLUDED_COLUMNS).map((column, index) => createTask(`FN-${index + 1}`, column));
expect(filterGraphTasks(tasks)).toEqual(tasks);
});
it("returns empty when only excluded columns are present", () => {
const tasks = Array.from(EXCLUDED_COLUMNS).map((column, index) => createTask(`FN-${index + 1}`, column));
expect(filterGraphTasks(tasks)).toEqual([]);
});
it("includes and excludes exact columns for mixed input", () => {
const tasks = [
createTask("FN-1", "triage"),
createTask("FN-2", "todo"),
@@ -32,20 +44,35 @@ describe("filterGraphTasks", () => {
expect(filterGraphTasks(tasks).map((task) => task.id)).toEqual(["FN-1", "FN-2", "FN-3", "FN-4"]);
});
it("returns empty when only excluded columns are present", () => {
const tasks = [createTask("FN-1", "done"), createTask("FN-2", "archived")];
it.each([
["triage", true],
["todo", true],
["in-progress", true],
["in-review", true],
["done", false],
["archived", false],
] as const)("column %s inclusion=%s", (column, included) => {
const task = createTask("FN-1", column);
const result = filterGraphTasks([task]);
expect(filterGraphTasks(tasks)).toEqual([]);
expect(result.length > 0).toBe(included);
});
it("keeps included tasks even when dependencies reference excluded tasks", () => {
const tasks = [
createTask("FN-1", "done"),
createTask("FN-2", "todo", ["FN-1"]),
createTask("FN-3", "in-review", ["FN-2", "FN-1"]),
createTask("FN-4", "archived", ["FN-2"]),
];
it("gracefully excludes tasks with invalid columns", () => {
const invalidTask = {
...createTask("FN-invalid", "todo"),
column: undefined,
} as unknown as Task;
expect(filterGraphTasks(tasks).map((task) => task.id)).toEqual(["FN-2", "FN-3"]);
expect(filterGraphTasks([invalidTask])).toEqual([]);
});
it("preserves task object identity", () => {
const taskA = createTask("FN-1", "todo");
const taskB = createTask("FN-2", "in-review");
const result = filterGraphTasks([taskA, taskB]);
expect(result[0]).toBe(taskA);
expect(result[1]).toBe(taskB);
});
});

View File

@@ -3,11 +3,11 @@ import { renderHook } from "@testing-library/react";
import type { Task } from "@fusion/core";
import { useGraphData } from "../useGraphData";
function createTask(id: string, dependencies: string[] = []): Task {
function createTask(id: string, column: Task["column"] = "todo", dependencies: string[] = []): Task {
return {
id,
description: id,
column: "todo",
column,
dependencies,
steps: [],
currentStep: 0,
@@ -27,42 +27,60 @@ describe("useGraphData", () => {
expect(result.current.edges).toEqual([]);
});
it("creates edges in dependent-to-dependency direction for chain", () => {
const tasks = [createTask("A", ["B"]), createTask("B", ["C"]), createTask("C")];
const { result } = renderHook(() => useGraphData(tasks));
expect(result.current.edges).toEqual([
{ source: "A", target: "B" },
{ source: "B", target: "C" },
]);
describe("orphan dependencies to excluded tasks", () => {
it("drops dependency edge to done task while keeping dependent node", () => {
const filteredTasks = [createTask("A", "in-progress", ["DONE-1"])];
const { result } = renderHook(() => useGraphData(filteredTasks));
expect(result.current.nodes.map((node) => node.task.id)).toEqual(["A"]);
expect(result.current.edges).toEqual([]);
});
it("drops dependency edge to archived task while keeping dependent node", () => {
const filteredTasks = [createTask("A", "triage", ["ARCH-1"])];
const { result } = renderHook(() => useGraphData(filteredTasks));
expect(result.current.nodes.map((node) => node.task.id)).toEqual(["A"]);
expect(result.current.edges).toEqual([]);
});
it("keeps only included dependency edges when mixed dependencies are present", () => {
const filteredTasks = [createTask("A", "in-progress", ["B", "DONE-1", "ARCH-1"]), createTask("B", "todo")];
const { result } = renderHook(() => useGraphData(filteredTasks));
expect(result.current.nodes.map((node) => node.task.id)).toEqual(["A", "B"]);
expect(result.current.edges).toEqual([{ source: "A", target: "B" }]);
});
it("shows zero edges when all dependencies are excluded", () => {
const filteredTasks = [createTask("A", "in-progress", ["DONE-1", "ARCH-1"])];
const { result } = renderHook(() => useGraphData(filteredTasks));
expect(result.current.nodes.map((node) => node.task.id)).toEqual(["A"]);
expect(result.current.edges).toEqual([]);
});
});
it("creates diamond dependency edges", () => {
const tasks = [
createTask("A", ["B", "C"]),
createTask("B", ["D"]),
createTask("C", ["D"]),
createTask("D"),
];
const { result } = renderHook(() => useGraphData(tasks));
expect(result.current.edges).toEqual([
{ source: "A", target: "B" },
{ source: "A", target: "C" },
{ source: "B", target: "D" },
{ source: "C", target: "D" },
]);
});
describe("in-review dependency edges", () => {
it("renders edges between in-review tasks", () => {
const tasks = [createTask("A", "in-review", ["B"]), createTask("B", "in-review")];
const { result } = renderHook(() => useGraphData(tasks));
it("drops orphan dependency references", () => {
const { result } = renderHook(() => useGraphData([createTask("A", ["Z"]), createTask("B", ["A"])]));
expect(result.current.edges).toEqual([{ source: "B", target: "A" }]);
});
expect(result.current.edges).toEqual([{ source: "A", target: "B" }]);
});
it("supports disconnected subgraphs", () => {
const tasks = [createTask("A", ["B"]), createTask("B"), createTask("X", ["Y"]), createTask("Y")];
const { result } = renderHook(() => useGraphData(tasks));
expect(result.current.edges).toEqual([
{ source: "A", target: "B" },
{ source: "X", target: "Y" },
]);
it("renders edge from in-review task to in-progress task", () => {
const tasks = [createTask("A", "in-review", ["B"]), createTask("B", "in-progress")];
const { result } = renderHook(() => useGraphData(tasks));
expect(result.current.edges).toEqual([{ source: "A", target: "B" }]);
});
it("renders edge from in-progress task to in-review task", () => {
const tasks = [createTask("A", "in-progress", ["B"]), createTask("B", "in-review")];
const { result } = renderHook(() => useGraphData(tasks));
expect(result.current.edges).toEqual([{ source: "A", target: "B" }]);
});
});
});

View File

@@ -1,6 +1,7 @@
import type { Task } from "@fusion/core";
import type { Column, Task } from "@fusion/core";
const INCLUDED_COLUMNS = new Set<Task["column"]>(["triage", "todo", "in-progress", "in-review"]);
export const INCLUDED_COLUMNS: ReadonlySet<Column> = new Set(["triage", "todo", "in-progress", "in-review"]);
export const EXCLUDED_COLUMNS: ReadonlySet<Column> = new Set(["done", "archived"]);
export function filterGraphTasks(tasks: Task[]): Task[] {
return tasks.filter((task) => INCLUDED_COLUMNS.has(task.column));