Skip to main content
Glama

Agile Backlog MCP

by ehartye
StoryDetailView.tsx•12.9 kB
import { useEffect, useState } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; import { ArrowLeft, Edit, Trash, Calendar, Plus, X } from 'lucide-react'; import { api } from '../utils/api'; import type { Story, Task, EntityStatus, Priority, Sprint } from '../types'; import RelationshipManager from './RelationshipManager'; import NotesPanel from './NotesPanel'; import StoryFormModal from './StoryFormModal'; const statusColors: Record<EntityStatus, string> = { todo: 'bg-gray-200 text-gray-700', in_progress: 'bg-blue-100 text-blue-700', review: 'bg-yellow-100 text-yellow-700', done: 'bg-green-100 text-green-700', blocked: 'bg-red-100 text-red-700', }; const priorityColors: Record<Priority, string> = { low: 'text-gray-500', medium: 'text-blue-600', high: 'text-orange-600', critical: 'text-red-600 font-bold', }; export default function StoryDetailView() { const { projectId: projectIdParam, storyId } = useParams<{ projectId: string; storyId: string }>(); const navigate = useNavigate(); const [story, setStory] = useState<Story | null>(null); const [tasks, setTasks] = useState<Task[]>([]); const [currentSprint, setCurrentSprint] = useState<Sprint | null>(null); const [availableSprints, setAvailableSprints] = useState<Sprint[]>([]); const [showAddToSprint, setShowAddToSprint] = useState(false); const [loading, setLoading] = useState(true); const [editModalOpen, setEditModalOpen] = useState(false); const projectId = projectIdParam ? parseInt(projectIdParam) : null; useEffect(() => { loadStory(); }, [storyId]); async function loadStory() { if (!storyId || !projectId) return; try { setLoading(true); const [storyData, sprintsData] = await Promise.all([ api.stories.get(parseInt(storyId)), api.sprints.list({ project_id: projectId }), ]); setStory(storyData.story); setTasks(storyData.tasks || []); // Find which sprint this story is in by checking all sprints let foundSprint: Sprint | null = null; for (const sprint of sprintsData) { const sprintDetails = await api.sprints.get(sprint.id); if (sprintDetails.stories.some(s => s.id === parseInt(storyId))) { foundSprint = sprint; break; } } setCurrentSprint(foundSprint); // Available sprints are planning or active sprints (not the current one) setAvailableSprints( sprintsData.filter(s => (s.status === 'planning' || s.status === 'active') && s.id !== foundSprint?.id ) ); } catch (error) { console.error('Failed to load story:', error); } finally { setLoading(false); } } const handleAddToSprint = async (sprintId: number) => { if (!story) return; try { await api.sprints.addStory(sprintId, story.id); setShowAddToSprint(false); loadStory(); // Reload to update sprint info } catch (error) { console.error('Failed to add story to sprint:', error); alert('Failed to add story to sprint: ' + (error as Error).message); } }; const handleRemoveFromSprint = async () => { if (!story || !currentSprint) return; if (!confirm(`Remove this story from ${currentSprint.name}?`)) return; try { await api.sprints.removeStory(currentSprint.id, story.id); loadStory(); // Reload to update sprint info } catch (error) { console.error('Failed to remove story from sprint:', error); alert('Failed to remove story from sprint: ' + (error as Error).message); } }; const handleDelete = async () => { if (!story || !confirm('Are you sure you want to delete this story?')) return; try { await api.stories.delete(story.id); navigate(projectId ? `/project/${projectId}` : '/'); } catch (error) { console.error('Failed to delete story:', error); } }; const handleBack = () => { navigate(projectId ? `/project/${projectId}` : '/'); }; if (loading) { return ( <div className="flex items-center justify-center h-full"> <div className="text-gray-500">Loading...</div> </div> ); } if (!story) { return ( <div className="flex flex-col items-center justify-center h-full"> <div className="text-gray-500 mb-4">Story not found</div> <button onClick={handleBack} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700" > Back to Backlog </button> </div> ); } return ( <div className="h-full flex flex-col bg-gray-50"> {/* Header */} <div className="bg-white shadow-sm border-b px-4 md:px-6 py-4"> <div className="max-w-6xl mx-auto"> <button onClick={handleBack} className="flex items-center gap-2 text-gray-600 hover:text-gray-800 mb-4" > <ArrowLeft size={20} /> Back to Backlog </button> <div className="flex items-start justify-between gap-4"> <div className="flex-1"> <div className="flex items-center gap-3 mb-2"> <h1 className="text-2xl md:text-3xl font-bold text-gray-800">{story.title}</h1> <span className={`px-3 py-1 rounded text-sm ${statusColors[story.status]}`}> {story.status.replace('_', ' ')} </span> <span className={`text-sm ${priorityColors[story.priority]}`}> {story.priority.toUpperCase()} </span> </div> <div className="flex items-center gap-4 text-sm text-gray-500"> <span>Story #{story.id}</span> {story.epic_id && <span>Epic #{story.epic_id}</span>} {story.points && <span>{story.points} points</span>} </div> </div> <div className="flex gap-2"> <button onClick={() => setEditModalOpen(true)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg" title="Edit story" > <Edit size={20} /> </button> <button onClick={handleDelete} className="p-2 text-red-600 hover:bg-red-50 rounded-lg" title="Delete story" > <Trash size={20} /> </button> </div> </div> </div> </div> {/* Content */} <div className="flex-1 overflow-auto p-4 md:p-6"> <div className="max-w-6xl mx-auto space-y-6"> {/* Description */} <div className="bg-white rounded-lg shadow p-6"> <h2 className="text-lg font-semibold mb-3">Description</h2> <p className="text-gray-700 whitespace-pre-wrap">{story.description}</p> </div> {/* Acceptance Criteria */} {story.acceptance_criteria && ( <div className="bg-white rounded-lg shadow p-6"> <h2 className="text-lg font-semibold mb-3">Acceptance Criteria</h2> <div className="p-4 bg-green-50 rounded border-l-4 border-green-500"> <p className="text-gray-700 whitespace-pre-wrap">{story.acceptance_criteria}</p> </div> </div> )} {/* Sprint Info */} <div className="bg-white rounded-lg shadow p-6"> <div className="flex items-center justify-between mb-3"> <h2 className="text-lg font-semibold flex items-center gap-2"> <Calendar size={20} /> Sprint </h2> {!currentSprint && !showAddToSprint && availableSprints.length > 0 && ( <button onClick={() => setShowAddToSprint(true)} className="flex items-center gap-1 px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700" > <Plus size={16} /> Add to Sprint </button> )} </div> {currentSprint ? ( <div className="flex items-center justify-between p-4 bg-blue-50 rounded border-l-4 border-blue-500"> <div> <Link to={`/project/${projectId}/sprint/${currentSprint.id}`} className="font-medium text-blue-600 hover:text-blue-800 hover:underline" > {currentSprint.name} </Link> <div className="text-sm text-gray-600 mt-1"> <span className={`px-2 py-1 rounded text-xs font-medium ${ currentSprint.status === 'planning' ? 'bg-gray-100 text-gray-700' : currentSprint.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-700' }`}> {currentSprint.status} </span> <span className="ml-2"> {new Date(currentSprint.start_date).toLocaleDateString()} - {new Date(currentSprint.end_date).toLocaleDateString()} </span> </div> </div> <button onClick={handleRemoveFromSprint} className="p-2 text-red-600 hover:bg-red-50 rounded" title="Remove from sprint" > <X size={20} /> </button> </div> ) : showAddToSprint ? ( <div className="space-y-2"> {availableSprints.map(sprint => ( <button key={sprint.id} onClick={() => handleAddToSprint(sprint.id)} className="w-full text-left p-3 border rounded hover:bg-gray-50" > <div className="font-medium">{sprint.name}</div> <div className="text-sm text-gray-600"> <span className={`px-2 py-1 rounded text-xs font-medium ${ sprint.status === 'planning' ? 'bg-gray-100 text-gray-700' : sprint.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-700' }`}> {sprint.status} </span> <span className="ml-2"> {new Date(sprint.start_date).toLocaleDateString()} - {new Date(sprint.end_date).toLocaleDateString()} </span> </div> </button> ))} <button onClick={() => setShowAddToSprint(false)} className="w-full px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 rounded border" > Cancel </button> </div> ) : ( <p className="text-gray-500 text-sm"> {availableSprints.length === 0 ? 'No active or planning sprints available. Create a sprint to add this story.' : 'Not in any sprint. Click "Add to Sprint" to assign this story to a sprint.'} </p> )} </div> {/* Tasks */} {tasks.length > 0 && ( <div className="bg-white rounded-lg shadow p-6"> <h2 className="text-lg font-semibold mb-3">Tasks ({tasks.length})</h2> <div className="space-y-2"> {tasks.map(task => ( <div key={task.id} className="p-3 border rounded flex items-center justify-between"> <div> <div className="font-medium">{task.title}</div> <div className="text-sm text-gray-500">{task.description}</div> </div> <span className={`px-2 py-1 rounded text-xs ${statusColors[task.status]}`}> {task.status.replace('_', ' ')} </span> </div> ))} </div> </div> )} {/* Relationships */} <RelationshipManager entityType="story" entityId={story.id} projectId={story.project_id} /> {/* Notes */} <NotesPanel entityType="story" entityId={story.id} projectId={story.project_id} /> </div> </div> {/* Edit Modal */} <StoryFormModal isOpen={editModalOpen} onClose={() => setEditModalOpen(false)} onSave={loadStory} story={story} projectId={story.project_id} /> </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