feat(frontend): Create Sprint 4 Stories and Tasks for Story Management
Created comprehensive Story and Task files for Sprint 4 frontend implementation: Story 1: Story Detail Page Foundation (P0 Critical - 3 days) - 6 tasks: route creation, header, sidebar, data loading, Edit/Delete, responsive design - Fixes critical 404 error when clicking Story cards - Two-column layout consistent with Epic detail page Story 2: Task Management in Story Detail (P0 Critical - 2 days) - 6 tasks: API verification, hooks, TaskList, TaskCard, TaskForm, integration - Complete Task CRUD with checkbox status toggle - Filters, sorting, and optimistic UI updates Story 3: Enhanced Story Form (P1 High - 2 days) - 6 tasks: acceptance criteria, assignee selector, tags, story points, integration - Aligns with UX design specification - Backward compatible with existing Stories Story 4: Quick Add Story Workflow (P1 High - 2 days) - 5 tasks: inline form, keyboard shortcuts, batch creation, navigation - Rapid Story creation with minimal fields - Keyboard shortcut (Cmd/Ctrl + N) Story 5: Story Card Component (P2 Medium - 1 day) - 4 tasks: component variants, visual states, Task count, optimization - Reusable component with list/kanban/compact variants - React.memo optimization Story 6: Kanban Story Creation Enhancement (P2 Optional - 2 days) - 4 tasks: Epic card enhancement, inline form, animation, real-time updates - Contextual Story creation from Kanban - Stretch goal - implement only if ahead of schedule Total: 6 Stories, 31 Tasks, 12 days estimated Priority breakdown: P0 (2), P1 (2), P2 (2 optional) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
402
docs/plans/sprint_4_story_2_task_3.md
Normal file
402
docs/plans/sprint_4_story_2_task_3.md
Normal file
@@ -0,0 +1,402 @@
|
||||
---
|
||||
task_id: sprint_4_story_2_task_3
|
||||
story_id: sprint_4_story_2
|
||||
sprint_id: sprint_4
|
||||
status: not_started
|
||||
type: frontend
|
||||
assignee: Frontend Developer 2
|
||||
created_date: 2025-11-05
|
||||
estimated_hours: 4
|
||||
---
|
||||
|
||||
# Task 3: Implement TaskList Component with Filters and Sorting
|
||||
|
||||
## Description
|
||||
|
||||
Create the TaskList component that displays all Tasks for a Story, with filtering by status/priority/assignee and sorting capabilities. This component is the container for Task management UI.
|
||||
|
||||
## What to Do
|
||||
|
||||
1. Create `components/projects/task-list.tsx`
|
||||
2. Display Task count badge (e.g., "Tasks (8)")
|
||||
3. Add "Add Task" button to open inline form
|
||||
4. Implement Task filters (All, Todo, InProgress, Done)
|
||||
5. Implement Task sorting (Priority, Status, Created date, Assignee)
|
||||
6. Show empty state when no Tasks exist
|
||||
7. Render TaskCard components for each Task
|
||||
8. Add loading skeleton during data fetch
|
||||
9. Integrate with useTasks hook
|
||||
10. Test with various filter/sort combinations
|
||||
|
||||
## Files to Create
|
||||
|
||||
- `components/projects/task-list.tsx` (new, ~300-400 lines)
|
||||
|
||||
## Implementation Details
|
||||
|
||||
```typescript
|
||||
// components/projects/task-list.tsx
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Plus, Filter, ArrowUpDown } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { TaskCard } from './task-card';
|
||||
import { TaskForm } from './task-form';
|
||||
import { useTasks } from '@/lib/hooks/use-tasks';
|
||||
import type { Task, WorkItemStatus, WorkItemPriority } from '@/types/project';
|
||||
|
||||
interface TaskListProps {
|
||||
storyId: string;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
type FilterStatus = 'All' | WorkItemStatus;
|
||||
type FilterPriority = 'All' | WorkItemPriority;
|
||||
type SortOption = 'priority' | 'status' | 'createdAt' | 'assignee';
|
||||
|
||||
export function TaskList({ storyId, readonly = false }: TaskListProps) {
|
||||
// State
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [filterStatus, setFilterStatus] = useState<FilterStatus>('All');
|
||||
const [filterPriority, setFilterPriority] = useState<FilterPriority>('All');
|
||||
const [sortBy, setSortBy] = useState<SortOption>('priority');
|
||||
|
||||
// Fetch Tasks
|
||||
const { data: tasks = [], isLoading, error } = useTasks(storyId);
|
||||
|
||||
// Filter and sort Tasks
|
||||
const filteredAndSortedTasks = useMemo(() => {
|
||||
let result = [...tasks];
|
||||
|
||||
// Apply status filter
|
||||
if (filterStatus !== 'All') {
|
||||
result = result.filter((task) => task.status === filterStatus);
|
||||
}
|
||||
|
||||
// Apply priority filter
|
||||
if (filterPriority !== 'All') {
|
||||
result = result.filter((task) => task.priority === filterPriority);
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
result.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'priority':
|
||||
return getPriorityValue(b.priority) - getPriorityValue(a.priority);
|
||||
case 'status':
|
||||
return getStatusValue(a.status) - getStatusValue(b.status);
|
||||
case 'createdAt':
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
case 'assignee':
|
||||
return (a.assigneeId || '').localeCompare(b.assigneeId || '');
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [tasks, filterStatus, filterPriority, sortBy]);
|
||||
|
||||
// Task count
|
||||
const taskCount = tasks.length;
|
||||
const completedCount = tasks.filter((t) => t.status === 'Done').length;
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return <TaskListSkeleton />;
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<p className="text-sm text-destructive">Failed to load Tasks. Please try again.</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">Tasks</h2>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{taskCount} total
|
||||
</Badge>
|
||||
{completedCount > 0 && (
|
||||
<Badge variant="success" className="text-xs">
|
||||
{completedCount} done
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!readonly && (
|
||||
<Button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
size="sm"
|
||||
data-testid="add-task-button"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Task
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters and Sorting */}
|
||||
{taskCount > 0 && (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Filter:</span>
|
||||
</div>
|
||||
|
||||
<Select value={filterStatus} onValueChange={(v) => setFilterStatus(v as FilterStatus)}>
|
||||
<SelectTrigger className="w-[140px] h-8">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All">All Status</SelectItem>
|
||||
<SelectItem value="Backlog">Backlog</SelectItem>
|
||||
<SelectItem value="Todo">Todo</SelectItem>
|
||||
<SelectItem value="InProgress">In Progress</SelectItem>
|
||||
<SelectItem value="Done">Done</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={filterPriority} onValueChange={(v) => setFilterPriority(v as FilterPriority)}>
|
||||
<SelectTrigger className="w-[140px] h-8">
|
||||
<SelectValue placeholder="Priority" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All">All Priority</SelectItem>
|
||||
<SelectItem value="Low">Low</SelectItem>
|
||||
<SelectItem value="Medium">Medium</SelectItem>
|
||||
<SelectItem value="High">High</SelectItem>
|
||||
<SelectItem value="Critical">Critical</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<ArrowUpDown className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Sort:</span>
|
||||
<Select value={sortBy} onValueChange={(v) => setSortBy(v as SortOption)}>
|
||||
<SelectTrigger className="w-[140px] h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="priority">Priority</SelectItem>
|
||||
<SelectItem value="status">Status</SelectItem>
|
||||
<SelectItem value="createdAt">Created Date</SelectItem>
|
||||
<SelectItem value="assignee">Assignee</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Task Form */}
|
||||
{showAddForm && (
|
||||
<Card className="p-4 border-primary">
|
||||
<TaskForm
|
||||
storyId={storyId}
|
||||
onSuccess={() => setShowAddForm(false)}
|
||||
onCancel={() => setShowAddForm(false)}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Task List */}
|
||||
{filteredAndSortedTasks.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{filteredAndSortedTasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} readonly={readonly} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
hasFilters={filterStatus !== 'All' || filterPriority !== 'All'}
|
||||
onReset={() => {
|
||||
setFilterStatus('All');
|
||||
setFilterPriority('All');
|
||||
}}
|
||||
onAddTask={() => setShowAddForm(true)}
|
||||
readonly={readonly}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper functions for sorting
|
||||
function getPriorityValue(priority: WorkItemPriority): number {
|
||||
const values = { Low: 1, Medium: 2, High: 3, Critical: 4 };
|
||||
return values[priority] || 0;
|
||||
}
|
||||
|
||||
function getStatusValue(status: WorkItemStatus): number {
|
||||
const values = { Backlog: 1, Todo: 2, InProgress: 3, Done: 4 };
|
||||
return values[status] || 0;
|
||||
}
|
||||
|
||||
// Empty state component
|
||||
function EmptyState({
|
||||
hasFilters,
|
||||
onReset,
|
||||
onAddTask,
|
||||
readonly,
|
||||
}: {
|
||||
hasFilters: boolean;
|
||||
onReset: () => void;
|
||||
onAddTask: () => void;
|
||||
readonly: boolean;
|
||||
}) {
|
||||
if (hasFilters) {
|
||||
return (
|
||||
<Card className="p-8 text-center">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
No Tasks match the selected filters.
|
||||
</p>
|
||||
<Button onClick={onReset} variant="outline" size="sm">
|
||||
Reset Filters
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-8 text-center">
|
||||
<div className="text-4xl mb-4">☑️</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Tasks Yet</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Break down this Story into technical Tasks to track implementation progress.
|
||||
</p>
|
||||
{!readonly && (
|
||||
<Button onClick={onAddTask} size="sm">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Your First Task
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading skeleton
|
||||
function TaskListSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-9 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] TaskList component displays all Tasks for Story
|
||||
- [ ] Task count badge shows total and completed count
|
||||
- [ ] "Add Task" button opens inline Task form
|
||||
- [ ] Status filter works (All, Backlog, Todo, InProgress, Done)
|
||||
- [ ] Priority filter works (All, Low, Medium, High, Critical)
|
||||
- [ ] Sorting works (Priority, Status, Created date, Assignee)
|
||||
- [ ] Filters and sorting can be combined
|
||||
- [ ] Empty state displays when no Tasks exist
|
||||
- [ ] Empty state with filters shows "Reset Filters" button
|
||||
- [ ] Loading skeleton displays during data fetch
|
||||
- [ ] Error state displays on fetch failure
|
||||
- [ ] Component integrates with useTasks hook
|
||||
- [ ] Readonly mode hides "Add Task" button
|
||||
- [ ] Performance: Filters/sorts without lag (<100ms)
|
||||
|
||||
## Testing
|
||||
|
||||
**Manual Testing**:
|
||||
1. Navigate to Story detail page
|
||||
2. Verify Task count badge shows correctly (e.g., "8 total, 3 done")
|
||||
3. Click "Add Task" → Verify inline form appears
|
||||
4. Create 5 Tasks with different statuses and priorities
|
||||
5. Test status filter → Select "Done" → Verify only Done Tasks show
|
||||
6. Test priority filter → Select "High" → Verify only High priority Tasks show
|
||||
7. Test combined filters → Status: InProgress + Priority: High
|
||||
8. Test sorting → Sort by Priority → Verify High/Critical at top
|
||||
9. Test sorting → Sort by Status → Verify Backlog → Todo → InProgress → Done
|
||||
10. Reset filters → Verify all Tasks show again
|
||||
11. Test empty state (Story with no Tasks) → Verify helpful message
|
||||
|
||||
**Unit Test**:
|
||||
```typescript
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { TaskList } from './task-list';
|
||||
|
||||
describe('TaskList', () => {
|
||||
const mockTasks = [
|
||||
{ id: '1', title: 'Task 1', status: 'Todo', priority: 'High' },
|
||||
{ id: '2', title: 'Task 2', status: 'Done', priority: 'Low' },
|
||||
{ id: '3', title: 'Task 3', status: 'InProgress', priority: 'Critical' },
|
||||
];
|
||||
|
||||
it('renders task count badge', () => {
|
||||
render(<TaskList storyId="story-123" />);
|
||||
expect(screen.getByText('3 total')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 done')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters tasks by status', () => {
|
||||
render(<TaskList storyId="story-123" />);
|
||||
fireEvent.click(screen.getByText('All Status'));
|
||||
fireEvent.click(screen.getByText('Done'));
|
||||
expect(screen.getAllByTestId('task-card')).toHaveLength(1);
|
||||
expect(screen.getByText('Task 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sorts tasks by priority', () => {
|
||||
render(<TaskList storyId="story-123" />);
|
||||
const tasks = screen.getAllByTestId('task-card');
|
||||
expect(tasks[0]).toHaveTextContent('Task 3'); // Critical first
|
||||
expect(tasks[1]).toHaveTextContent('Task 1'); // High second
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
**Prerequisites**:
|
||||
- Task 2 (useTasks hook must exist)
|
||||
- TaskCard component (Task 4 - can use placeholder initially)
|
||||
- TaskForm component (Task 5 - can use placeholder initially)
|
||||
- ✅ shadcn/ui Card, Button, Badge, Select components
|
||||
|
||||
**Blocks**:
|
||||
- Task 6 (Story detail page integration)
|
||||
|
||||
## Estimated Time
|
||||
|
||||
4 hours
|
||||
|
||||
## Notes
|
||||
|
||||
**Performance**: Use `useMemo` for filtering/sorting to avoid recalculating on every render. With 100+ Tasks, this prevents performance issues.
|
||||
|
||||
**Empty State**: Different empty states for "no Tasks" vs "no Tasks matching filters" improve UX.
|
||||
|
||||
**Progressive Enhancement**: Component works with TaskCard and TaskForm placeholders initially. Replace with real components in Tasks 4 and 5.
|
||||
Reference in New Issue
Block a user