mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 20:43:36 +00:00
✨ feat: add dependency graph view for task visualization
Add a new interactive graph view alongside the kanban board for visualizing task dependencies. The graph view uses React Flow with dagre auto-layout to display tasks as nodes connected by dependency edges. Key features: - Toggle between kanban and graph view via new control buttons - Custom TaskNode component matching existing card styling/themes - Animated edges that flow when tasks are in progress - Status-aware node colors (backlog, in-progress, waiting, verified) - Blocked tasks show lock icon with dependency count tooltip - MiniMap for navigation in large graphs - Zoom, pan, fit-view, and lock controls - Horizontal/vertical layout options via dagre - Click node to view details, double-click to edit - Respects all 32 themes via CSS variables - Reduced motion support for animations New dependencies: @xyflow/react, dagre
This commit is contained in:
2
apps/ui/src/components/views/graph-view/hooks/index.ts
Normal file
2
apps/ui/src/components/views/graph-view/hooks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { useGraphNodes, type TaskNode, type DependencyEdge, type TaskNodeData } from './use-graph-nodes';
|
||||
export { useGraphLayout } from './use-graph-layout';
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import dagre from 'dagre';
|
||||
import { Node, Edge, useReactFlow } from '@xyflow/react';
|
||||
import { TaskNode, DependencyEdge } from './use-graph-nodes';
|
||||
|
||||
const NODE_WIDTH = 280;
|
||||
const NODE_HEIGHT = 120;
|
||||
|
||||
interface UseGraphLayoutProps {
|
||||
nodes: TaskNode[];
|
||||
edges: DependencyEdge[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies dagre layout to position nodes in a hierarchical DAG
|
||||
* Dependencies flow left-to-right
|
||||
*/
|
||||
export function useGraphLayout({ nodes, edges }: UseGraphLayoutProps) {
|
||||
const { fitView, setNodes } = useReactFlow();
|
||||
|
||||
const getLayoutedElements = useCallback(
|
||||
(
|
||||
inputNodes: TaskNode[],
|
||||
inputEdges: DependencyEdge[],
|
||||
direction: 'LR' | 'TB' = 'LR'
|
||||
): { nodes: TaskNode[]; edges: DependencyEdge[] } => {
|
||||
const dagreGraph = new dagre.graphlib.Graph();
|
||||
dagreGraph.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
const isHorizontal = direction === 'LR';
|
||||
dagreGraph.setGraph({
|
||||
rankdir: direction,
|
||||
nodesep: 50,
|
||||
ranksep: 100,
|
||||
marginx: 50,
|
||||
marginy: 50,
|
||||
});
|
||||
|
||||
inputNodes.forEach((node) => {
|
||||
dagreGraph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
|
||||
});
|
||||
|
||||
inputEdges.forEach((edge) => {
|
||||
dagreGraph.setEdge(edge.source, edge.target);
|
||||
});
|
||||
|
||||
dagre.layout(dagreGraph);
|
||||
|
||||
const layoutedNodes = inputNodes.map((node) => {
|
||||
const nodeWithPosition = dagreGraph.node(node.id);
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: nodeWithPosition.x - NODE_WIDTH / 2,
|
||||
y: nodeWithPosition.y - NODE_HEIGHT / 2,
|
||||
},
|
||||
targetPosition: isHorizontal ? 'left' : 'top',
|
||||
sourcePosition: isHorizontal ? 'right' : 'bottom',
|
||||
} as TaskNode;
|
||||
});
|
||||
|
||||
return { nodes: layoutedNodes, edges: inputEdges };
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Initial layout
|
||||
const layoutedElements = useMemo(() => {
|
||||
if (nodes.length === 0) {
|
||||
return { nodes: [], edges: [] };
|
||||
}
|
||||
return getLayoutedElements(nodes, edges, 'LR');
|
||||
}, [nodes, edges, getLayoutedElements]);
|
||||
|
||||
// Manual re-layout function
|
||||
const runLayout = useCallback(
|
||||
(direction: 'LR' | 'TB' = 'LR') => {
|
||||
const { nodes: layoutedNodes } = getLayoutedElements(nodes, edges, direction);
|
||||
setNodes(layoutedNodes);
|
||||
// Fit view after layout with a small delay to allow DOM updates
|
||||
setTimeout(() => {
|
||||
fitView({ padding: 0.2, duration: 300 });
|
||||
}, 50);
|
||||
},
|
||||
[nodes, edges, getLayoutedElements, setNodes, fitView]
|
||||
);
|
||||
|
||||
return {
|
||||
layoutedNodes: layoutedElements.nodes,
|
||||
layoutedEdges: layoutedElements.edges,
|
||||
runLayout,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Node, Edge } from '@xyflow/react';
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||
|
||||
export interface TaskNodeData extends Feature {
|
||||
isBlocked: boolean;
|
||||
isRunning: boolean;
|
||||
blockingDependencies: string[];
|
||||
}
|
||||
|
||||
export type TaskNode = Node<TaskNodeData, 'task'>;
|
||||
export type DependencyEdge = Edge<{ sourceStatus: Feature['status']; targetStatus: Feature['status'] }>;
|
||||
|
||||
interface UseGraphNodesProps {
|
||||
features: Feature[];
|
||||
runningAutoTasks: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms features into React Flow nodes and edges
|
||||
* Creates dependency edges based on feature.dependencies array
|
||||
*/
|
||||
export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps) {
|
||||
const { nodes, edges } = useMemo(() => {
|
||||
const nodeList: TaskNode[] = [];
|
||||
const edgeList: DependencyEdge[] = [];
|
||||
const featureMap = new Map<string, Feature>();
|
||||
|
||||
// Create feature map for quick lookups
|
||||
features.forEach((f) => featureMap.set(f.id, f));
|
||||
|
||||
// Create nodes
|
||||
features.forEach((feature) => {
|
||||
const isRunning = runningAutoTasks.includes(feature.id);
|
||||
const blockingDeps = getBlockingDependencies(feature, features);
|
||||
|
||||
const node: TaskNode = {
|
||||
id: feature.id,
|
||||
type: 'task',
|
||||
position: { x: 0, y: 0 }, // Will be set by layout
|
||||
data: {
|
||||
...feature,
|
||||
isBlocked: blockingDeps.length > 0,
|
||||
isRunning,
|
||||
blockingDependencies: blockingDeps,
|
||||
},
|
||||
};
|
||||
|
||||
nodeList.push(node);
|
||||
|
||||
// Create edges for dependencies
|
||||
if (feature.dependencies && feature.dependencies.length > 0) {
|
||||
feature.dependencies.forEach((depId: string) => {
|
||||
// Only create edge if the dependency exists in current view
|
||||
if (featureMap.has(depId)) {
|
||||
const sourceFeature = featureMap.get(depId)!;
|
||||
const edge: DependencyEdge = {
|
||||
id: `${depId}->${feature.id}`,
|
||||
source: depId,
|
||||
target: feature.id,
|
||||
type: 'dependency',
|
||||
animated: isRunning || runningAutoTasks.includes(depId),
|
||||
data: {
|
||||
sourceStatus: sourceFeature.status,
|
||||
targetStatus: feature.status,
|
||||
},
|
||||
};
|
||||
edgeList.push(edge);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { nodes: nodeList, edges: edgeList };
|
||||
}, [features, runningAutoTasks]);
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
Reference in New Issue
Block a user