feat(FN-3083): add GraphTaskNode wrapper and integrate into dependency grap
The merge introduces a new `GraphTaskNode` wrapper component for the dependency graph plugin (FN-3083) with visual parity tests, adds an AI security scan gate to the plugin install/unpack flows (FN-3077), updates the dashboard PluginManager UI and adds plugin API routes, and cleans up remaining main Fusion-Task-Id: FN-3083
This commit is contained in:
@@ -54,6 +54,7 @@ Behavior:
|
|||||||
- Uses Sugiyama-style layered auto-layout to place nodes by dependency depth
|
- Uses Sugiyama-style layered auto-layout to place nodes by dependency depth
|
||||||
- Renders directed bezier dependency edges (dependent → dependency) with arrowheads
|
- Renders directed bezier dependency edges (dependent → dependency) with arrowheads
|
||||||
- Supports pan/zoom and fit-to-graph controls via floating toolbar actions
|
- Supports pan/zoom and fit-to-graph controls via floating toolbar actions
|
||||||
|
- Dependency graph nodes reuse the same `TaskCard` UI as board/list views, so status badges, progress/steps, mission badges, retry/archive controls, and active-task glow stay visually consistent
|
||||||
|
|
||||||
## Chat View
|
## Chat View
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ Plugin-provided top-level **Graph** dashboard view for Fusion.
|
|||||||
- **Edge drawing**: SVG bezier curves from source bottom-center to target top-center, with arrowheads showing dependent → dependency direction
|
- **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
|
- **Interaction**: pan, wheel zoom, pinch zoom, zoom-in/out controls, reset, and fit-to-screen
|
||||||
- **Fit-to-screen**: computes node bounding box with layout node dimensions and applies zoom/pan so the graph fits in viewport with padding
|
- **Fit-to-screen**: computes node bounding box with layout node dimensions and applies zoom/pan so the graph fits in viewport with padding
|
||||||
|
- **Node rendering**: each graph node renders the real dashboard `TaskCard` via `GraphTaskNode` (no duplicated card markup)
|
||||||
|
- **In-progress behavior**: steps are visible by default and active-task glow (`agent-active`) is preserved because node cards reuse TaskCard directly
|
||||||
|
- **Graph node classes**: `.graph-task-node`, `.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
|
## Controls
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,15 @@
|
|||||||
transform-origin: top left;
|
transform-origin: top left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dependency-graph__nodes-layer {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-graph__nodes-layer .graph-task-node {
|
||||||
|
max-width: min(100%, calc(var(--space-2xl) * 9));
|
||||||
|
}
|
||||||
|
|
||||||
.dependency-graph-edges {
|
.dependency-graph-edges {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -29,13 +38,6 @@
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dependency-graph__node {
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dependency-graph__node .card {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dependency-graph__empty {
|
.dependency-graph__empty {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Maximize, ZoomIn, ZoomOut } from "lucide-react";
|
import { Maximize, ZoomIn, ZoomOut } from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import type { Task } from "@fusion/core";
|
import type { Task } from "@fusion/core";
|
||||||
import { TaskCard } from "@fusion/dashboard/app/components/TaskCard";
|
import { GraphTaskNode } from "./GraphTaskNode";
|
||||||
import { GraphEdges } from "./edges";
|
import { GraphEdges } from "./edges";
|
||||||
import { filterGraphTasks } from "./filters";
|
import { filterGraphTasks } from "./filters";
|
||||||
import { computeAutoLayout } from "./layout";
|
import { computeAutoLayout } from "./layout";
|
||||||
@@ -15,10 +15,42 @@ const NODE_HEIGHT = 100;
|
|||||||
export interface DependencyGraphProps {
|
export interface DependencyGraphProps {
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
onOpenTaskDetail: (taskId: string) => void;
|
onOpenTaskDetail?: (taskId: string) => void;
|
||||||
|
onOpenDetail?: (task: Task) => void;
|
||||||
|
addToast?: (message: string, type?: "success" | "error" | "info" | "warning") => void;
|
||||||
|
globalPaused?: boolean;
|
||||||
|
onUpdateTask?: (id: string, updates: { title?: string; description?: string; dependencies?: string[] }) => Promise<Task>;
|
||||||
|
onArchiveTask?: (id: string) => Promise<Task>;
|
||||||
|
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||||
|
onDeleteTask?: (id: string, options?: { removeDependencyReferences?: boolean }) => Promise<Task>;
|
||||||
|
onRetryTask?: (id: string) => Promise<Task>;
|
||||||
|
onOpenDetailWithTab?: (task: Task, initialTab: "changes") => void;
|
||||||
|
taskStuckTimeoutMs?: number;
|
||||||
|
onOpenMission?: (missionId: string) => void;
|
||||||
|
onMoveTask?: (id: string, column: Task["column"], optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
|
||||||
|
lastFetchTimeMs?: number;
|
||||||
|
workflowStepNameLookup?: ReadonlyMap<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DependencyGraph({ tasks, projectId, onOpenTaskDetail }: DependencyGraphProps) {
|
export function DependencyGraph({
|
||||||
|
tasks,
|
||||||
|
projectId,
|
||||||
|
onOpenTaskDetail,
|
||||||
|
onOpenDetail,
|
||||||
|
addToast,
|
||||||
|
globalPaused,
|
||||||
|
onUpdateTask,
|
||||||
|
onArchiveTask,
|
||||||
|
onUnarchiveTask,
|
||||||
|
onDeleteTask,
|
||||||
|
onRetryTask,
|
||||||
|
onOpenDetailWithTab,
|
||||||
|
taskStuckTimeoutMs,
|
||||||
|
onOpenMission,
|
||||||
|
onMoveTask,
|
||||||
|
lastFetchTimeMs,
|
||||||
|
workflowStepNameLookup,
|
||||||
|
}: DependencyGraphProps) {
|
||||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
const filteredTasks = useMemo(() => filterGraphTasks(tasks), [tasks]);
|
const filteredTasks = useMemo(() => filterGraphTasks(tasks), [tasks]);
|
||||||
const graphData = useGraphData(filteredTasks);
|
const graphData = useGraphData(filteredTasks);
|
||||||
@@ -78,26 +110,35 @@ export function DependencyGraph({ tasks, projectId, onOpenTaskDetail }: Dependen
|
|||||||
) : (
|
) : (
|
||||||
<div className="dependency-graph__canvas" style={{ transform, width: `${bounds.width}px`, height: `${bounds.height}px` }}>
|
<div className="dependency-graph__canvas" 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} />
|
||||||
{graphData.nodes.map((node) => {
|
<div className="dependency-graph__nodes-layer">
|
||||||
const position = positions.get(node.task.id);
|
{graphData.nodes.map((node) => {
|
||||||
if (!position) return null;
|
const position = positions.get(node.task.id);
|
||||||
|
if (!position) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<GraphTaskNode
|
||||||
key={node.task.id}
|
key={node.task.id}
|
||||||
className="dependency-graph__node"
|
|
||||||
style={{ width: `${NODE_WIDTH}px`, minHeight: `${NODE_HEIGHT}px`, left: `${position.x}px`, top: `${position.y}px` }}
|
|
||||||
>
|
|
||||||
<TaskCard
|
|
||||||
task={node.task}
|
task={node.task}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onOpenDetail={() => onOpenTaskDetail(node.task.id)}
|
style={{ minHeight: `${NODE_HEIGHT}px`, left: `${position.x}px`, top: `${position.y}px` }}
|
||||||
addToast={() => {}}
|
onOpenDetail={onOpenDetail ?? ((task) => onOpenTaskDetail?.(task.id))}
|
||||||
disableDrag={true}
|
addToast={addToast ?? (() => {})}
|
||||||
|
globalPaused={globalPaused}
|
||||||
|
onUpdateTask={onUpdateTask}
|
||||||
|
onArchiveTask={onArchiveTask}
|
||||||
|
onUnarchiveTask={onUnarchiveTask}
|
||||||
|
onDeleteTask={onDeleteTask}
|
||||||
|
onRetryTask={onRetryTask}
|
||||||
|
onOpenDetailWithTab={onOpenDetailWithTab}
|
||||||
|
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||||
|
onOpenMission={onOpenMission}
|
||||||
|
onMoveTask={onMoveTask}
|
||||||
|
lastFetchTimeMs={lastFetchTimeMs}
|
||||||
|
workflowStepNameLookup={workflowStepNameLookup}
|
||||||
/>
|
/>
|
||||||
</div>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
32
plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.css
Normal file
32
plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.css
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
.graph-task-node {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
width: min(100%, var(--graph-task-node-width, calc(var(--space-2xl) * 9)));
|
||||||
|
max-width: var(--graph-task-node-max-width, calc(var(--space-2xl) * 9.5));
|
||||||
|
transition: box-shadow var(--transition-fast), z-index var(--transition-fast), opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-task-node:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-task-node--highlighted {
|
||||||
|
box-shadow: 0 0 0 var(--btn-border-width) var(--todo), var(--shadow-md);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-task-node--dimmed {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-task-node .card {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.graph-task-node {
|
||||||
|
width: min(100%, var(--graph-task-node-mobile-width, calc(var(--space-2xl) * 8)));
|
||||||
|
}
|
||||||
|
}
|
||||||
43
plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx
Normal file
43
plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { CSSProperties, ComponentProps } from "react";
|
||||||
|
import { TaskCard } from "@fusion/dashboard/app/components/TaskCard";
|
||||||
|
import "./GraphTaskNode.css";
|
||||||
|
|
||||||
|
type TaskCardComponentProps = ComponentProps<typeof TaskCard>;
|
||||||
|
|
||||||
|
type TaskCardBridgeProps = Pick<
|
||||||
|
TaskCardComponentProps,
|
||||||
|
| "task"
|
||||||
|
| "projectId"
|
||||||
|
| "onOpenDetail"
|
||||||
|
| "addToast"
|
||||||
|
| "globalPaused"
|
||||||
|
| "onUpdateTask"
|
||||||
|
| "onArchiveTask"
|
||||||
|
| "onUnarchiveTask"
|
||||||
|
| "onDeleteTask"
|
||||||
|
| "onRetryTask"
|
||||||
|
| "onOpenDetailWithTab"
|
||||||
|
| "taskStuckTimeoutMs"
|
||||||
|
| "onOpenMission"
|
||||||
|
| "onMoveTask"
|
||||||
|
| "lastFetchTimeMs"
|
||||||
|
| "workflowStepNameLookup"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface GraphTaskNodeProps extends TaskCardBridgeProps {
|
||||||
|
style?: CSSProperties;
|
||||||
|
isHighlighted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GraphTaskNode({ style, isHighlighted = false, ...taskCardProps }: GraphTaskNodeProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`graph-task-node${isHighlighted ? " graph-task-node--highlighted" : ""}`}
|
||||||
|
style={style}
|
||||||
|
draggable={false}
|
||||||
|
data-testid={`graph-task-node-${taskCardProps.task.id}`}
|
||||||
|
>
|
||||||
|
<TaskCard {...taskCardProps} disableDrag={true} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ const fitToGraph = vi.fn();
|
|||||||
|
|
||||||
vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({
|
vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({
|
||||||
TaskCard: ({ task, onOpenDetail }: { task: Task; onOpenDetail: () => void }) => (
|
TaskCard: ({ task, onOpenDetail }: { task: Task; onOpenDetail: () => void }) => (
|
||||||
<button data-testid={`task-${task.id}`} onClick={onOpenDetail}>{task.id}</button>
|
<button data-testid={`task-${task.id}`} onClick={() => onOpenDetail(task)}>{task.id}</button>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -42,15 +42,23 @@ describe("DependencyGraph", () => {
|
|||||||
expect(screen.getByText(/No active tasks/i)).toBeTruthy();
|
expect(screen.getByText(/No active tasks/i)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders positioned nodes and edges for included tasks", () => {
|
it("renders graph task nodes at layout coordinates and edges", () => {
|
||||||
render(<DependencyGraph tasks={[
|
const { container } = render(<DependencyGraph tasks={[
|
||||||
createTask("A", "todo"),
|
createTask("A", "todo"),
|
||||||
createTask("B", "in-progress", ["A"]),
|
createTask("B", "in-progress", ["A"]),
|
||||||
]} onOpenTaskDetail={vi.fn()} />);
|
]} onOpenTaskDetail={vi.fn()} />);
|
||||||
|
|
||||||
expect(screen.getByTestId("task-A")).toBeTruthy();
|
expect(screen.getByTestId("graph-task-node-A")).toBeTruthy();
|
||||||
expect(screen.getByTestId("task-B")).toBeTruthy();
|
expect(screen.getByTestId("graph-task-node-B")).toBeTruthy();
|
||||||
|
expect(container.querySelector(".dependency-graph__nodes-layer")).toBeTruthy();
|
||||||
expect(screen.getAllByTestId("dependency-edge")).toHaveLength(1);
|
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:");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("excludes done and archived nodes", () => {
|
it("excludes done and archived nodes", () => {
|
||||||
@@ -60,9 +68,16 @@ describe("DependencyGraph", () => {
|
|||||||
createTask("C", "archived"),
|
createTask("C", "archived"),
|
||||||
]} onOpenTaskDetail={vi.fn()} />);
|
]} onOpenTaskDetail={vi.fn()} />);
|
||||||
|
|
||||||
expect(screen.getByTestId("task-A")).toBeTruthy();
|
expect(screen.getByTestId("graph-task-node-A")).toBeTruthy();
|
||||||
expect(screen.queryByTestId("task-B")).toBeNull();
|
expect(screen.queryByTestId("graph-task-node-B")).toBeNull();
|
||||||
expect(screen.queryByTestId("task-C")).toBeNull();
|
expect(screen.queryByTestId("graph-task-node-C")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking a card triggers onOpenDetail", () => {
|
||||||
|
const onOpenDetail = vi.fn();
|
||||||
|
render(<DependencyGraph tasks={[createTask("A", "in-progress")]} onOpenDetail={onOpenDetail} />);
|
||||||
|
fireEvent.click(screen.getByTestId("task-A"));
|
||||||
|
expect(onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "A" }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fit-to-screen button triggers fitToGraph", () => {
|
it("fit-to-screen button triggers fitToGraph", () => {
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import type { Task } from "@fusion/core";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { GraphTaskNode } from "../GraphTaskNode";
|
||||||
|
import { TaskCard } from "@fusion/dashboard/app/components/TaskCard";
|
||||||
|
|
||||||
|
function createTask(overrides: Partial<Task> = {}): Task {
|
||||||
|
return {
|
||||||
|
id: "FN-TEST",
|
||||||
|
description: "Task description",
|
||||||
|
column: "todo",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
...overrides,
|
||||||
|
} as Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProps(task: Task) {
|
||||||
|
return {
|
||||||
|
task,
|
||||||
|
projectId: "proj-1",
|
||||||
|
onOpenDetail: vi.fn(),
|
||||||
|
addToast: vi.fn(),
|
||||||
|
onUpdateTask: vi.fn(),
|
||||||
|
onArchiveTask: vi.fn(),
|
||||||
|
onUnarchiveTask: vi.fn(),
|
||||||
|
onDeleteTask: vi.fn(),
|
||||||
|
onRetryTask: vi.fn(),
|
||||||
|
onOpenDetailWithTab: vi.fn(),
|
||||||
|
onMoveTask: vi.fn(),
|
||||||
|
onOpenMission: vi.fn(),
|
||||||
|
taskStuckTimeoutMs: 60_000,
|
||||||
|
lastFetchTimeMs: Date.now(),
|
||||||
|
workflowStepNameLookup: new Map<string, string>(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GraphTaskNode", () => {
|
||||||
|
it("renders a TaskCard and passes core props through", () => {
|
||||||
|
const props = createProps(createTask());
|
||||||
|
const { container } = render(<GraphTaskNode {...props} style={{ left: 10, top: 20 }} />);
|
||||||
|
|
||||||
|
const node = screen.getByTestId("graph-task-node-FN-TEST");
|
||||||
|
expect(node).toBeTruthy();
|
||||||
|
expect(container.querySelector(".card-title")?.textContent).toContain("Task description");
|
||||||
|
expect(node.getAttribute("draggable")).toBe("false");
|
||||||
|
expect(container.querySelector(".card")?.getAttribute("draggable")).toBe("false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows steps expanded and agent-active styling for in-progress executing tasks", () => {
|
||||||
|
const props = createProps(
|
||||||
|
createTask({
|
||||||
|
column: "in-progress",
|
||||||
|
status: "executing",
|
||||||
|
steps: [
|
||||||
|
{ name: "step one", status: "in-progress" },
|
||||||
|
{ name: "step two", status: "pending" },
|
||||||
|
],
|
||||||
|
currentStep: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { container } = render(<GraphTaskNode {...props} />);
|
||||||
|
expect(container.querySelector(".card")?.className).toContain("agent-active");
|
||||||
|
expect(container.querySelector(".card-steps-list")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking card opens task detail", () => {
|
||||||
|
const props = createProps(createTask());
|
||||||
|
const { container } = render(<GraphTaskNode {...props} />);
|
||||||
|
|
||||||
|
const card = container.querySelector(".card");
|
||||||
|
expect(card).toBeTruthy();
|
||||||
|
fireEvent.click(card!);
|
||||||
|
expect(props.onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-TEST" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies highlighted class only when requested", () => {
|
||||||
|
const highlightedProps = createProps(createTask({ id: "FN-HL" }));
|
||||||
|
const neutralProps = createProps(createTask({ id: "FN-NEUTRAL" }));
|
||||||
|
|
||||||
|
const { unmount } = render(<GraphTaskNode {...highlightedProps} isHighlighted={true} />);
|
||||||
|
expect(screen.getByTestId("graph-task-node-FN-HL").className).toContain("graph-task-node--highlighted");
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
render(<GraphTaskNode {...neutralProps} />);
|
||||||
|
const neutral = screen.getByTestId("graph-task-node-FN-NEUTRAL");
|
||||||
|
expect(neutral.className).not.toContain("graph-task-node--highlighted");
|
||||||
|
expect(neutral.className).not.toContain("graph-task-node--dimmed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the same TaskCard structure as board usage", () => {
|
||||||
|
const task = createTask({
|
||||||
|
id: "FN-SAME",
|
||||||
|
column: "in-progress",
|
||||||
|
status: "executing",
|
||||||
|
error: "Execution failed",
|
||||||
|
missionId: "M-1",
|
||||||
|
sourceType: "automation",
|
||||||
|
sourceAgentId: "agent-1",
|
||||||
|
steps: [{ name: "sync", status: "in-progress" }],
|
||||||
|
currentStep: 0,
|
||||||
|
});
|
||||||
|
const props = createProps(task);
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<div>
|
||||||
|
<TaskCard {...props} disableDrag={true} />
|
||||||
|
<GraphTaskNode {...props} />
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const cards = container.querySelectorAll(".card");
|
||||||
|
expect(cards.length).toBe(2);
|
||||||
|
|
||||||
|
const [boardCard, graphCard] = cards;
|
||||||
|
const selectors = [
|
||||||
|
".card-id",
|
||||||
|
".card-title",
|
||||||
|
".card-status-badge",
|
||||||
|
".card-step-dot",
|
||||||
|
".card-step-name",
|
||||||
|
".card-progress",
|
||||||
|
".card-progress-fill",
|
||||||
|
".card-error",
|
||||||
|
".card-mission-badge",
|
||||||
|
".card-provider-icons",
|
||||||
|
".card-agent-badge",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const selector of selectors) {
|
||||||
|
expect(Boolean(boardCard.querySelector(selector))).toBe(Boolean(graphCard.querySelector(selector)));
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(Boolean(boardCard.querySelector(".card-error"))).toBe(Boolean(graphCard.querySelector(".card-error")));
|
||||||
|
expect(boardCard.querySelector(".card-id")?.textContent).toBe(graphCard.querySelector(".card-id")?.textContent);
|
||||||
|
expect(boardCard.querySelector(".card-title")?.textContent).toBe(graphCard.querySelector(".card-title")?.textContent);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,27 @@
|
|||||||
declare module "@fusion/dashboard/app/components/TaskCard" {
|
declare module "@fusion/dashboard/app/components/TaskCard" {
|
||||||
import type { Task } from "@fusion/core";
|
import type { Column, Task, TaskDetail } from "@fusion/core";
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
interface TaskCardProps {
|
interface TaskCardProps {
|
||||||
task: Task;
|
task: Task;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
onOpenDetail: (task: Task) => void;
|
onOpenDetail: (task: Task | TaskDetail) => void;
|
||||||
addToast: (message: string, type?: "success" | "error" | "info") => void;
|
addToast: (message: string, type?: "success" | "error" | "info" | "warning") => void;
|
||||||
|
globalPaused?: boolean;
|
||||||
|
onUpdateTask?: (
|
||||||
|
id: string,
|
||||||
|
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||||
|
) => Promise<Task>;
|
||||||
|
onArchiveTask?: (id: string) => Promise<Task>;
|
||||||
|
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||||
|
onDeleteTask?: (id: string, options?: { removeDependencyReferences?: boolean }) => Promise<Task>;
|
||||||
|
onRetryTask?: (id: string) => Promise<Task>;
|
||||||
|
onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes") => void;
|
||||||
|
taskStuckTimeoutMs?: number;
|
||||||
|
onOpenMission?: (missionId: string) => void;
|
||||||
|
onMoveTask?: (id: string, column: Column, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
|
||||||
|
lastFetchTimeMs?: number;
|
||||||
|
workflowStepNameLookup?: ReadonlyMap<string, string>;
|
||||||
disableDrag?: boolean;
|
disableDrag?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user