Improve accessibility to meet WCAG 2.1 Level AA standards. Changes: Added eslint-plugin-jsx-a11y, keyboard navigation, ARIA labels, SkipLink component, main-content landmark. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
366 lines
11 KiB
TypeScript
366 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { ChevronRight, ChevronDown, Folder, FileText, CheckSquare } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { useEpics } from '@/lib/hooks/use-epics';
|
|
import { useStories } from '@/lib/hooks/use-stories';
|
|
import { useTasks } from '@/lib/hooks/use-tasks';
|
|
import type { Epic, Story, Task, WorkItemStatus, WorkItemPriority } from '@/types/project';
|
|
|
|
interface HierarchyTreeProps {
|
|
projectId: string;
|
|
onEpicClick?: (epic: Epic) => void;
|
|
onStoryClick?: (story: Story) => void;
|
|
onTaskClick?: (task: Task) => void;
|
|
}
|
|
|
|
export function HierarchyTree({
|
|
projectId,
|
|
onEpicClick,
|
|
onStoryClick,
|
|
onTaskClick,
|
|
}: HierarchyTreeProps) {
|
|
const { data: epics = [], isLoading: epicsLoading } = useEpics(projectId);
|
|
|
|
if (epicsLoading) {
|
|
return <HierarchyTreeSkeleton />;
|
|
}
|
|
|
|
if (epics.length === 0) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center p-8 text-center">
|
|
<Folder className="h-12 w-12 text-muted-foreground mb-4" />
|
|
<h3 className="text-lg font-semibold mb-2">No Epics Found</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
Create your first epic to start organizing work
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
{epics.map((epic) => (
|
|
<EpicNode
|
|
key={epic.id}
|
|
epic={epic}
|
|
onEpicClick={onEpicClick}
|
|
onStoryClick={onStoryClick}
|
|
onTaskClick={onTaskClick}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface EpicNodeProps {
|
|
epic: Epic;
|
|
onEpicClick?: (epic: Epic) => void;
|
|
onStoryClick?: (story: Story) => void;
|
|
onTaskClick?: (task: Task) => void;
|
|
}
|
|
|
|
function EpicNode({ epic, onEpicClick, onStoryClick, onTaskClick }: EpicNodeProps) {
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
const { data: stories = [], isLoading: storiesLoading } = useStories(
|
|
isExpanded ? epic.id : undefined
|
|
);
|
|
|
|
return (
|
|
<div className="border rounded-lg">
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-expanded={isExpanded}
|
|
className="flex items-center gap-2 p-3 hover:bg-accent cursor-pointer"
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
setIsExpanded(!isExpanded);
|
|
}
|
|
}}
|
|
>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 w-6 p-0"
|
|
aria-label={isExpanded ? 'Collapse epic' : 'Expand epic'}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setIsExpanded(!isExpanded);
|
|
}}
|
|
>
|
|
{isExpanded ? (
|
|
<ChevronDown className="h-4 w-4" />
|
|
) : (
|
|
<ChevronRight className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
|
|
<Folder className="h-5 w-5 text-blue-500" aria-hidden="true" />
|
|
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
className="font-semibold hover:underline text-left bg-transparent border-0 p-0 cursor-pointer"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onEpicClick?.(epic);
|
|
}}
|
|
aria-label={`View epic: ${epic.name}`}
|
|
>
|
|
{epic.name}
|
|
</button>
|
|
<StatusBadge status={epic.status} />
|
|
<PriorityBadge priority={epic.priority} />
|
|
</div>
|
|
{epic.description && (
|
|
<p className="text-sm text-muted-foreground mt-1 line-clamp-1">
|
|
{epic.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{epic.estimatedHours && (
|
|
<div className="text-sm text-muted-foreground" aria-label={`Estimated: ${epic.estimatedHours} hours${epic.actualHours ? `, Actual: ${epic.actualHours} hours` : ''}`}>
|
|
{epic.estimatedHours}h
|
|
{epic.actualHours && ` / ${epic.actualHours}h`}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{isExpanded && (
|
|
<div className="pl-8 pr-3 pb-3 space-y-2">
|
|
{storiesLoading ? (
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-16 w-full" />
|
|
<Skeleton className="h-16 w-full" />
|
|
</div>
|
|
) : stories.length === 0 ? (
|
|
<div className="p-4 text-center text-sm text-muted-foreground border-l-2 border-muted">
|
|
No stories in this epic
|
|
</div>
|
|
) : (
|
|
stories.map((story) => (
|
|
<StoryNode
|
|
key={story.id}
|
|
story={story}
|
|
onStoryClick={onStoryClick}
|
|
onTaskClick={onTaskClick}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface StoryNodeProps {
|
|
story: Story;
|
|
onStoryClick?: (story: Story) => void;
|
|
onTaskClick?: (task: Task) => void;
|
|
}
|
|
|
|
function StoryNode({ story, onStoryClick, onTaskClick }: StoryNodeProps) {
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
const { data: tasks = [], isLoading: tasksLoading } = useTasks(
|
|
isExpanded ? story.id : undefined
|
|
);
|
|
|
|
return (
|
|
<div className="border-l-2 border-muted pl-3">
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-expanded={isExpanded}
|
|
className="flex items-center gap-2 p-2 hover:bg-accent rounded cursor-pointer"
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
setIsExpanded(!isExpanded);
|
|
}
|
|
}}
|
|
>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 w-6 p-0"
|
|
aria-label={isExpanded ? 'Collapse story' : 'Expand story'}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setIsExpanded(!isExpanded);
|
|
}}
|
|
>
|
|
{isExpanded ? (
|
|
<ChevronDown className="h-4 w-4" />
|
|
) : (
|
|
<ChevronRight className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
|
|
<FileText className="h-4 w-4 text-green-500" aria-hidden="true" />
|
|
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
className="font-medium hover:underline text-left bg-transparent border-0 p-0 cursor-pointer"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onStoryClick?.(story);
|
|
}}
|
|
aria-label={`View story: ${story.title}`}
|
|
>
|
|
{story.title}
|
|
</button>
|
|
<StatusBadge status={story.status} size="sm" />
|
|
<PriorityBadge priority={story.priority} size="sm" />
|
|
</div>
|
|
{story.description && (
|
|
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">
|
|
{story.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{story.estimatedHours && (
|
|
<div className="text-xs text-muted-foreground" aria-label={`Estimated: ${story.estimatedHours} hours${story.actualHours ? `, Actual: ${story.actualHours} hours` : ''}`}>
|
|
{story.estimatedHours}h
|
|
{story.actualHours && ` / ${story.actualHours}h`}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{isExpanded && (
|
|
<div className="ml-6 mt-2 space-y-1">
|
|
{tasksLoading ? (
|
|
<div className="space-y-1">
|
|
<Skeleton className="h-12 w-full" />
|
|
<Skeleton className="h-12 w-full" />
|
|
</div>
|
|
) : tasks.length === 0 ? (
|
|
<div className="p-3 text-center text-xs text-muted-foreground border-l-2 border-muted">
|
|
No tasks in this story
|
|
</div>
|
|
) : (
|
|
tasks.map((task) => <TaskNode key={task.id} task={task} onTaskClick={onTaskClick} />)
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface TaskNodeProps {
|
|
task: Task;
|
|
onTaskClick?: (task: Task) => void;
|
|
}
|
|
|
|
function TaskNode({ task, onTaskClick }: TaskNodeProps) {
|
|
return (
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
className="flex items-center gap-2 p-2 hover:bg-accent rounded cursor-pointer border-l-2 border-muted pl-3"
|
|
onClick={() => onTaskClick?.(task)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
onTaskClick?.(task);
|
|
}
|
|
}}
|
|
aria-label={`View task: ${task.title}`}
|
|
>
|
|
<CheckSquare className="h-4 w-4 text-purple-500" aria-hidden="true" />
|
|
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium">{task.title}</span>
|
|
<StatusBadge status={task.status} size="xs" />
|
|
<PriorityBadge priority={task.priority} size="xs" />
|
|
</div>
|
|
{task.description && (
|
|
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">{task.description}</p>
|
|
)}
|
|
</div>
|
|
|
|
{task.estimatedHours && (
|
|
<div className="text-xs text-muted-foreground" aria-label={`Estimated: ${task.estimatedHours} hours${task.actualHours ? `, Actual: ${task.actualHours} hours` : ''}`}>
|
|
{task.estimatedHours}h
|
|
{task.actualHours && ` / ${task.actualHours}h`}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface StatusBadgeProps {
|
|
status: WorkItemStatus;
|
|
size?: 'default' | 'sm' | 'xs';
|
|
}
|
|
|
|
function StatusBadge({ status, size = 'default' }: StatusBadgeProps) {
|
|
const variants: Record<WorkItemStatus, 'default' | 'secondary' | 'outline' | 'destructive'> = {
|
|
Backlog: 'secondary',
|
|
Todo: 'outline',
|
|
InProgress: 'default',
|
|
Done: 'outline',
|
|
};
|
|
|
|
const sizeClasses = {
|
|
default: 'text-xs',
|
|
sm: 'text-xs px-1.5 py-0',
|
|
xs: 'text-[10px] px-1 py-0',
|
|
};
|
|
|
|
return (
|
|
<Badge variant={variants[status]} className={sizeClasses[size]}>
|
|
{status}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
interface PriorityBadgeProps {
|
|
priority: WorkItemPriority;
|
|
size?: 'default' | 'sm' | 'xs';
|
|
}
|
|
|
|
function PriorityBadge({ priority, size = 'default' }: PriorityBadgeProps) {
|
|
const colors: Record<WorkItemPriority, string> = {
|
|
Low: 'bg-gray-100 text-gray-700',
|
|
Medium: 'bg-blue-100 text-blue-700',
|
|
High: 'bg-orange-100 text-orange-700',
|
|
Critical: 'bg-red-100 text-red-700',
|
|
};
|
|
|
|
const sizeClasses = {
|
|
default: 'text-xs',
|
|
sm: 'text-xs px-1.5 py-0',
|
|
xs: 'text-[10px] px-1 py-0',
|
|
};
|
|
|
|
return <Badge className={`${colors[priority]} ${sizeClasses[size]}`}>{priority}</Badge>;
|
|
}
|
|
|
|
function HierarchyTreeSkeleton() {
|
|
return (
|
|
<div className="space-y-2">
|
|
{[1, 2, 3].map((i) => (
|
|
<div key={i} className="border rounded-lg p-3">
|
|
<div className="flex items-center gap-2">
|
|
<Skeleton className="h-6 w-6" />
|
|
<Skeleton className="h-5 w-5" />
|
|
<Skeleton className="h-6 flex-1" />
|
|
<Skeleton className="h-5 w-16" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|