Skip to main content
Glama

Agile Backlog MCP

by ehartye
HierarchyTreeView.tsx6.39 kB
import { useEffect, useState, useRef } from 'react'; import { useParams } from 'react-router-dom'; import * as d3 from 'd3'; import { api } from '../utils/api'; import type { HierarchyNode, EntityStatus } from '../types'; const statusColors: Record<EntityStatus, string> = { todo: '#d1d5db', in_progress: '#3b82f6', review: '#eab308', done: '#22c55e', blocked: '#ef4444', }; interface HierarchyTreeViewProps { projectId?: number | null; } export default function HierarchyTreeView({ projectId: projectIdProp }: HierarchyTreeViewProps) { const { projectId: projectIdParam } = useParams<{ projectId: string }>(); const projectId = projectIdParam ? parseInt(projectIdParam) : projectIdProp; const svgRef = useRef<SVGSVGElement>(null); const [data, setData] = useState<HierarchyNode[]>([]); const [loading, setLoading] = useState(true); const [isMobile, setIsMobile] = useState(window.innerWidth < 768); useEffect(() => { const handleResize = () => { setIsMobile(window.innerWidth < 768); }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); useEffect(() => { if (projectId) { loadHierarchy(); } }, [projectId]); useEffect(() => { if (data.length > 0 && svgRef.current) { renderTree(); } }, [data, isMobile]); async function loadHierarchy() { try { setLoading(true); const hierarchy = await api.graph.hierarchy(projectId || undefined); setData(hierarchy); } catch (error) { console.error('Failed to load hierarchy:', error); } finally { setLoading(false); } } function renderTree() { if (!svgRef.current || data.length === 0) return; const svg = d3.select(svgRef.current); svg.selectAll('*').remove(); const width = svgRef.current.clientWidth; const height = svgRef.current.clientHeight; const g = svg.append('g'); // Create root hierarchy with all epics as children const root = d3.hierarchy<HierarchyNode>({ id: 0, type: 'epic' as const, title: 'Root', status: 'todo' as const, children: data, }); const horizontalMargin = isMobile ? 100 : 300; const verticalMargin = isMobile ? 50 : 100; const offsetX = isMobile ? 50 : 150; const offsetY = isMobile ? 30 : 50; const treeLayout = d3.tree<HierarchyNode>() .size([height - verticalMargin, width - horizontalMargin]) .separation((a, b) => (a.parent === b.parent ? 1 : 1.2)); const treeData = treeLayout(root); // Add zoom behavior const zoom = d3.zoom<SVGSVGElement, unknown>() .scaleExtent([0.1, 3]) .on('zoom', (event) => { g.attr('transform', event.transform); }); svg.call(zoom); // Center the view g.attr('transform', `translate(${offsetX}, ${offsetY})`); // Draw links g.selectAll('.link') .data(treeData.links().filter(d => d.source.data.id !== 0)) .enter() .append('path') .attr('class', 'link') .attr('fill', 'none') .attr('stroke', '#9ca3af') .attr('stroke-width', 2) .attr('d', d3.linkHorizontal<any, any>() .x(d => d.y) .y(d => d.x) ); // Draw nodes const nodes = g.selectAll('.node') .data(treeData.descendants().filter(d => d.data.id !== 0)) .enter() .append('g') .attr('class', 'node') .attr('transform', d => `translate(${d.y},${d.x})`); // Node circles nodes.append('circle') .attr('r', d => { if (d.data.type === 'epic') return 12; if (d.data.type === 'story') return 10; return 8; }) .attr('fill', d => statusColors[d.data.status]) .attr('stroke', '#374151') .attr('stroke-width', 2); // Node labels nodes.append('text') .attr('dy', '.35em') .attr('x', d => d.children ? -15 : 15) .attr('text-anchor', d => d.children ? 'end' : 'start') .style('font-size', d => { if (isMobile) { if (d.data.type === 'epic') return '12px'; if (d.data.type === 'story') return '11px'; return '10px'; } if (d.data.type === 'epic') return '14px'; if (d.data.type === 'story') return '12px'; return '11px'; }) .style('font-weight', d => d.data.type === 'epic' ? 'bold' : 'normal') .text(d => { const maxLength = isMobile ? (d.data.type === 'epic' ? 20 : 25) : (d.data.type === 'epic' ? 30 : 40); return d.data.title.length > maxLength ? d.data.title.substring(0, maxLength) + '...' : d.data.title; }); // Type badges nodes.append('text') .attr('dy', '1.8em') .attr('x', d => d.children ? -15 : 15) .attr('text-anchor', d => d.children ? 'end' : 'start') .style('font-size', '10px') .style('fill', '#6b7280') .text(d => `[${d.data.type}]`); } if (!projectId) { return ( <div className="flex items-center justify-center h-full bg-gray-50"> <div className="text-gray-500">Please select a project to view hierarchy</div> </div> ); } if (loading) { return ( <div className="flex items-center justify-center h-full bg-gray-50"> <div className="text-gray-500">Loading hierarchy tree...</div> </div> ); } if (data.length === 0) { return ( <div className="flex flex-col items-center justify-center h-full bg-gray-50"> <div className="text-gray-500 text-lg mb-2">No data to display</div> <div className="text-sm text-gray-400"> Create epics and stories to visualize the hierarchy </div> </div> ); } return ( <div className="h-full flex flex-col"> <div className="bg-white shadow-sm border-b px-4 md:px-6 py-4"> <h2 className="text-xl md:text-2xl font-bold text-gray-800">Hierarchy Tree</h2> <p className="text-sm text-gray-500 mt-1"> Epic → Story → Task hierarchy view </p> <div className="mt-2 text-xs text-gray-600"> Scroll to zoom • Drag to pan </div> </div> <div className="flex-1 bg-gray-50"> <svg ref={svgRef} className="w-full h-full" style={{ cursor: 'grab' }} /> </div> </div> ); }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ehartye/agile-backlog-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server