feat(ui): add task management UI with list, detail, and create pages
Implements Task 19: Task List + Task Detail + Status Transitions UI New components: - useTasks hook: TanStack Query hooks (useTasks, useTask, useCreateTask, useUpdateTask) - Task list page: shadcn Table with status filter, pagination, status badges - Task detail page: Full task info with valid status transition buttons - New task form: Create task with title, description, assigneeId, dueDate Key features: - Status transitions match backend logic: Open→Assigned→InProgress→Review→Done - Review status allows back-transition to InProgress (only bidirectional) - Only valid next states shown as buttons (VALID_TRANSITIONS map) - Status badge colors: Open=gray, Assigned=blue, InProgress=yellow, Review=red, Done=green - TanStack Query with automatic cache invalidation on mutations - Next.js 15+ async params pattern (use() hook) TDD: - 3 task list tests (renders rows, status badges, new task button) - 3 task detail tests (Open→Assigned, InProgress→Review, Review→Done+InProgress) All tests pass (31/31). Build succeeds.
This commit is contained in:
@@ -1548,3 +1548,16 @@ frontend/
|
|||||||
|
|
||||||
- **Testing Radix UI DropdownMenu**: When testing Radix UI components like `DropdownMenu` with React Testing Library, you often need to either use complex test setups waiting for portal rendering and pointer events, or simply mock the Radix UI components out to test just the integration logic. Mocking `DropdownMenu`, `DropdownMenuTrigger`, etc., makes checking dropdown logic faster and less prone to portal-related DOM test issues.
|
- **Testing Radix UI DropdownMenu**: When testing Radix UI components like `DropdownMenu` with React Testing Library, you often need to either use complex test setups waiting for portal rendering and pointer events, or simply mock the Radix UI components out to test just the integration logic. Mocking `DropdownMenu`, `DropdownMenuTrigger`, etc., makes checking dropdown logic faster and less prone to portal-related DOM test issues.
|
||||||
- **Provider Architecture in Next.js App Router**: Combining multiple providers like `SessionProvider`, `QueryProvider`, and a custom context provider like `TenantProvider` in `app/layout.tsx` is an effective way to handle global state. Custom components needing hooks must have `"use client"` at the top.
|
- **Provider Architecture in Next.js App Router**: Combining multiple providers like `SessionProvider`, `QueryProvider`, and a custom context provider like `TenantProvider` in `app/layout.tsx` is an effective way to handle global state. Custom components needing hooks must have `"use client"` at the top.
|
||||||
|
|
||||||
|
|
||||||
|
## Task 19: Task Management UI (2026-03-03)
|
||||||
|
|
||||||
|
### Key Learnings
|
||||||
|
|
||||||
|
- **TanStack Query Patterns**: Successfully used `useQuery` for data fetching and `useMutation` for updates across the task pages, combining them with `useTenant` hook to auto-inject `activeClubId` in API calls and query cache keys. Invalidation happens seamlessly.
|
||||||
|
- **Next.js 15+ React `use()` Testing**: When page components use `params` as a Promise (e.g., Next.js 15+ convention for dynamic routes), using `use()` in the component causes it to suspend. Vitest tests for such components must either be wrapped in `await act(async () => ...)` or wrapped in a `<Suspense>` boundary while awaiting UI changes with `findByText`.
|
||||||
|
- **Status Badge Colors**: Implemented mapped `WorkItemStatus` enum values to shadcn Badge colors, ensuring an intuitive UI mapping for transitions (e.g. Open->Assigned->InProgress->Review->Done).
|
||||||
|
- **Valid Transitions**: Built client-side validation logic that perfectly mirrors the backend `CanTransitionTo` logic (including the back-transition from Review to InProgress).
|
||||||
|
- **UI Component Usage**: Leveraged shadcn `Table` for the list and `Card` for details and new task forms, alongside raw inputs for simplified creation without needing heavy forms libraries.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1769,7 +1769,7 @@ Max Concurrent: 6 (Wave 1)
|
|||||||
- Files: `frontend/src/app/layout.tsx`, `frontend/src/components/club-switcher.tsx`, `frontend/src/components/auth-guard.tsx`
|
- Files: `frontend/src/app/layout.tsx`, `frontend/src/components/club-switcher.tsx`, `frontend/src/components/auth-guard.tsx`
|
||||||
- Pre-commit: `bun run build && bun run test` (in frontend/)
|
- Pre-commit: `bun run build && bun run test` (in frontend/)
|
||||||
|
|
||||||
- [ ] 19. Task List + Task Detail + Status Transitions UI
|
- [x] 19. Task List + Task Detail + Status Transitions UI
|
||||||
|
|
||||||
**What to do**:
|
**What to do**:
|
||||||
- Create `/frontend/src/app/(protected)/tasks/page.tsx`:
|
- Create `/frontend/src/app/(protected)/tasks/page.tsx`:
|
||||||
|
|||||||
116
frontend/src/app/(protected)/tasks/[id]/page.tsx
Normal file
116
frontend/src/app/(protected)/tasks/[id]/page.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { use } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useTask, useUpdateTask } from '@/hooks/useTasks';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
|
const VALID_TRANSITIONS: Record<string, string[]> = {
|
||||||
|
Open: ['Assigned'],
|
||||||
|
Assigned: ['InProgress'],
|
||||||
|
InProgress: ['Review'],
|
||||||
|
Review: ['Done', 'InProgress'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
Open: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
Assigned: 'bg-primary text-primary-foreground hover:bg-primary/80',
|
||||||
|
InProgress: 'bg-yellow-500 text-black hover:bg-yellow-500/80',
|
||||||
|
Review: 'bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||||
|
Done: 'bg-green-500 text-white hover:bg-green-500/80',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TaskDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const resolvedParams = use(params);
|
||||||
|
const router = useRouter();
|
||||||
|
const { data: task, isLoading, error } = useTask(resolvedParams.id);
|
||||||
|
const { mutate: updateTask, isPending } = useUpdateTask();
|
||||||
|
|
||||||
|
if (isLoading) return <div className="p-8">Loading task...</div>;
|
||||||
|
if (error || !task) return <div className="p-8 text-red-500">Failed to load task.</div>;
|
||||||
|
|
||||||
|
const validTransitions = VALID_TRANSITIONS[task.status] || [];
|
||||||
|
|
||||||
|
const handleTransition = (newStatus: string) => {
|
||||||
|
updateTask({ id: task.id, data: { status: newStatus } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTransitionLabel = (status: string, newStatus: string) => {
|
||||||
|
if (status === 'Review' && newStatus === 'InProgress') return 'Back to InProgress';
|
||||||
|
if (newStatus === 'Done') return 'Mark as Done';
|
||||||
|
return `Move to ${newStatus}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 space-y-4 p-8 pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Button variant="ghost" size="sm" asChild className="mb-2 -ml-3">
|
||||||
|
<Link href="/tasks">← Back to Tasks</Link>
|
||||||
|
</Button>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Task Details</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-2xl">{task.title}</CardTitle>
|
||||||
|
<CardDescription className="mt-2 text-base">
|
||||||
|
{task.description || 'No description provided.'}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge className={`text-sm px-3 py-1 ${statusColors[task.status] || 'bg-secondary'}`}>
|
||||||
|
{task.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Assignee</p>
|
||||||
|
<p className="mt-1">{task.assigneeId || 'Unassigned'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Created By</p>
|
||||||
|
<p className="mt-1">{task.createdById}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Created At</p>
|
||||||
|
<p className="mt-1">{new Date(task.createdAt).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
{task.dueDate && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Due Date</p>
|
||||||
|
<p className="mt-1">{new Date(task.dueDate).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{validTransitions.length > 0 && (
|
||||||
|
<div className="pt-6 border-t">
|
||||||
|
<h3 className="text-lg font-medium mb-4">Actions</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{validTransitions.map((nextStatus) => (
|
||||||
|
<Button
|
||||||
|
key={nextStatus}
|
||||||
|
onClick={() => handleTransition(nextStatus)}
|
||||||
|
disabled={isPending}
|
||||||
|
variant={nextStatus === 'Done' ? 'default' : 'outline'}
|
||||||
|
className={nextStatus === 'Done' ? 'bg-green-600 hover:bg-green-700' : ''}
|
||||||
|
>
|
||||||
|
{isPending ? 'Updating...' : getTransitionLabel(task.status, nextStatus)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
frontend/src/app/(protected)/tasks/new/page.tsx
Normal file
129
frontend/src/app/(protected)/tasks/new/page.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useCreateTask } from '@/hooks/useTasks';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
|
export default function NewTaskPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { mutate: createTask, isPending, error } = useCreateTask();
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
assigneeId: '',
|
||||||
|
dueDate: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [e.target.name]: e.target.value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
createTask(
|
||||||
|
{
|
||||||
|
title: formData.title,
|
||||||
|
description: formData.description || undefined,
|
||||||
|
assigneeId: formData.assigneeId || undefined,
|
||||||
|
dueDate: formData.dueDate ? new Date(formData.dueDate).toISOString() : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (data) => {
|
||||||
|
router.push(`/tasks/${data.id}`);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 space-y-4 p-8 pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Button variant="ghost" size="sm" asChild className="mb-2 -ml-3">
|
||||||
|
<Link href="/tasks">← Back to Tasks</Link>
|
||||||
|
</Button>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Create New Task</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="max-w-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Task Details</CardTitle>
|
||||||
|
<CardDescription>Fill in the details for the new task.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title">Title <span className="text-red-500">*</span></Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
required
|
||||||
|
value={formData.title}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="e.g. Repair tennis net"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Provide additional details..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="assigneeId">Assignee ID</Label>
|
||||||
|
<Input
|
||||||
|
id="assigneeId"
|
||||||
|
name="assigneeId"
|
||||||
|
value={formData.assigneeId}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="dueDate">Due Date</Label>
|
||||||
|
<Input
|
||||||
|
id="dueDate"
|
||||||
|
name="dueDate"
|
||||||
|
type="date"
|
||||||
|
value={formData.dueDate}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm text-red-500 mt-2">
|
||||||
|
Error creating task: {error instanceof Error ? error.message : 'Unknown error'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-4">
|
||||||
|
<Button type="button" variant="outline" className="mr-2" asChild>
|
||||||
|
<Link href="/tasks">Cancel</Link>
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isPending || !formData.title.trim()}>
|
||||||
|
{isPending ? 'Creating...' : 'Create Task'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
frontend/src/app/(protected)/tasks/page.tsx
Normal file
131
frontend/src/app/(protected)/tasks/page.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useTasks } from '@/hooks/useTasks';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
Open: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
Assigned: 'bg-primary text-primary-foreground hover:bg-primary/80',
|
||||||
|
InProgress: 'bg-yellow-500 text-black hover:bg-yellow-500/80',
|
||||||
|
Review: 'bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||||
|
Done: 'bg-green-500 text-white hover:bg-green-500/80',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TaskListPage() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useTasks({
|
||||||
|
page,
|
||||||
|
status: statusFilter || undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) return <div className="p-4 text-red-500">Failed to load tasks.</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 space-y-4 p-8 pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Tasks</h2>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/tasks/new">New Task</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<select
|
||||||
|
className="flex h-10 w-[200px] items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStatusFilter(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="Open">Open</option>
|
||||||
|
<option value="Assigned">Assigned</option>
|
||||||
|
<option value="InProgress">In Progress</option>
|
||||||
|
<option value="Review">Review</option>
|
||||||
|
<option value="Done">Done</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Title</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Assignee</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center">Loading tasks...</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : data?.items.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center">No tasks found.</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
data?.items.map((task) => (
|
||||||
|
<TableRow key={task.id}>
|
||||||
|
<TableCell className="font-medium">{task.title}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className={statusColors[task.status] || 'bg-secondary'}>
|
||||||
|
{task.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{task.assigneeId || 'Unassigned'}</TableCell>
|
||||||
|
<TableCell>{new Date(task.createdAt).toLocaleDateString()}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link href={`/tasks/${task.id}`}>View</Link>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data && data.total > data.pageSize && (
|
||||||
|
<div className="flex items-center justify-end space-x-2 py-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Page {page} of {Math.ceil(data.total / data.pageSize)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(p => p + 1)}
|
||||||
|
disabled={page >= Math.ceil(data.total / data.pageSize)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
frontend/src/components/__tests__/task-detail.test.tsx
Normal file
77
frontend/src/components/__tests__/task-detail.test.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { render, screen, act } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import TaskDetailPage from '@/app/(protected)/tasks/[id]/page';
|
||||||
|
import { useTask, useUpdateTask } from '@/hooks/useTasks';
|
||||||
|
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
useRouter: vi.fn(() => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
replace: vi.fn(),
|
||||||
|
back: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/hooks/useTasks', () => ({
|
||||||
|
useTask: vi.fn(),
|
||||||
|
useUpdateTask: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('TaskDetailPage', () => {
|
||||||
|
const mockMutate = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(useUpdateTask).mockReturnValue({
|
||||||
|
mutate: mockMutate,
|
||||||
|
isPending: false,
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows valid transitions for Open status', async () => {
|
||||||
|
vi.mocked(useTask).mockReturnValue({
|
||||||
|
data: { id: '1', title: 'Task 1', status: 'Open', description: 'Desc', createdAt: '2024-01-01', updatedAt: '2024-01-01' },
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const params = Promise.resolve({ id: '1' });
|
||||||
|
await act(async () => {
|
||||||
|
render(<TaskDetailPage params={params} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('Move to Assigned')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Move to InProgress')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Mark as Done')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows valid transitions for InProgress status', async () => {
|
||||||
|
vi.mocked(useTask).mockReturnValue({
|
||||||
|
data: { id: '1', title: 'Task 1', status: 'InProgress', description: 'Desc', createdAt: '2024-01-01', updatedAt: '2024-01-01' },
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const params = Promise.resolve({ id: '1' });
|
||||||
|
await act(async () => {
|
||||||
|
render(<TaskDetailPage params={params} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('Move to Review')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Mark as Done')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows valid transitions for Review status (including back transition)', async () => {
|
||||||
|
vi.mocked(useTask).mockReturnValue({
|
||||||
|
data: { id: '1', title: 'Task 1', status: 'Review', description: 'Desc', createdAt: '2024-01-01', updatedAt: '2024-01-01' },
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const params = Promise.resolve({ id: '1' });
|
||||||
|
await act(async () => {
|
||||||
|
render(<TaskDetailPage params={params} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('Mark as Done')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Back to InProgress')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
65
frontend/src/components/__tests__/task-list.test.tsx
Normal file
65
frontend/src/components/__tests__/task-list.test.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import TaskListPage from '@/app/(protected)/tasks/page';
|
||||||
|
import { useTasks } from '@/hooks/useTasks';
|
||||||
|
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
useRouter: vi.fn(() => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
replace: vi.fn(),
|
||||||
|
})),
|
||||||
|
usePathname: vi.fn(() => '/tasks'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/contexts/tenant-context', () => ({
|
||||||
|
useTenant: vi.fn(() => ({
|
||||||
|
activeClubId: 'club-123',
|
||||||
|
clubs: [{ id: 'club-123', name: 'Tennis Club' }],
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/hooks/useTasks', () => ({
|
||||||
|
useTasks: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('TaskListPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(useTasks).mockReturnValue({
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{ id: '1', title: 'Test Task 1', status: 'Open', assigneeId: null, createdAt: '2024-01-01' },
|
||||||
|
{ id: '2', title: 'Test Task 2', status: 'InProgress', assigneeId: 'user-1', createdAt: '2024-01-02' },
|
||||||
|
{ id: '3', title: 'Test Task 3', status: 'Done', assigneeId: 'user-2', createdAt: '2024-01-03' },
|
||||||
|
],
|
||||||
|
total: 3,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders task list with 3 data rows', () => {
|
||||||
|
render(<TaskListPage />);
|
||||||
|
expect(screen.getAllByRole('row')).toHaveLength(4);
|
||||||
|
expect(screen.getByText('Test Task 1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Test Task 2')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows status badge with correct color classes', () => {
|
||||||
|
render(<TaskListPage />);
|
||||||
|
const openBadge = screen.getByText('Open', { selector: 'span' });
|
||||||
|
const inProgressBadge = screen.getByText('InProgress', { selector: 'span' });
|
||||||
|
const doneBadge = screen.getByText('Done', { selector: 'span' });
|
||||||
|
|
||||||
|
expect(openBadge).toBeInTheDocument();
|
||||||
|
expect(inProgressBadge).toBeInTheDocument();
|
||||||
|
expect(doneBadge).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "New Task" button', () => {
|
||||||
|
render(<TaskListPage />);
|
||||||
|
expect(screen.getByRole('link', { name: /new task/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
122
frontend/src/hooks/useTasks.ts
Normal file
122
frontend/src/hooks/useTasks.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTenant } from '@/contexts/tenant-context';
|
||||||
|
import { apiClient } from '@/lib/api';
|
||||||
|
|
||||||
|
export interface TaskListDto {
|
||||||
|
items: TaskListItemDto[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskListItemDto {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
assigneeId: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskDetailDto {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
status: string;
|
||||||
|
assigneeId: string | null;
|
||||||
|
createdById: string;
|
||||||
|
clubId: string;
|
||||||
|
dueDate: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTaskRequest {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
clubId: string;
|
||||||
|
assigneeId?: string;
|
||||||
|
dueDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTaskRequest {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
status?: string;
|
||||||
|
assigneeId?: string;
|
||||||
|
dueDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTasks(filters?: { status?: string; page?: number }) {
|
||||||
|
const { activeClubId } = useTenant();
|
||||||
|
|
||||||
|
return useQuery<TaskListDto>({
|
||||||
|
queryKey: ['tasks', activeClubId, filters],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters?.status) params.append('status', filters.status);
|
||||||
|
if (filters?.page) params.append('page', filters.page.toString());
|
||||||
|
|
||||||
|
const res = await apiClient(`/api/tasks?${params}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch tasks');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
enabled: !!activeClubId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTask(id: string) {
|
||||||
|
const { activeClubId } = useTenant();
|
||||||
|
|
||||||
|
return useQuery<TaskDetailDto>({
|
||||||
|
queryKey: ['tasks', activeClubId, id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient(`/api/tasks/${id}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch task');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
enabled: !!activeClubId && !!id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateTask() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { activeClubId } = useTenant();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (data: Omit<CreateTaskRequest, 'clubId'>) => {
|
||||||
|
const payload: CreateTaskRequest = {
|
||||||
|
...data,
|
||||||
|
clubId: activeClubId!,
|
||||||
|
};
|
||||||
|
const res = await apiClient('/api/tasks', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to create task');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks', activeClubId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateTask() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { activeClubId } = useTenant();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, data }: { id: string; data: UpdateTaskRequest }) => {
|
||||||
|
const res = await apiClient(`/api/tasks/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to update task');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks', activeClubId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks', activeClubId, variables.id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user