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