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:
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user