captains-log/frontend/app/components/TaskList.test.tsx
Drew Galbraith 8e6ac03f70 Split the Dashboard from All Tasks. (#10)
Reviewed-on: #10
Co-authored-by: Drew Galbraith <drew@tiramisu.one>
Co-committed-by: Drew Galbraith <drew@tiramisu.one>
2025-09-24 01:02:23 +00:00

380 lines
12 KiB
TypeScript

import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { MemoryRouter } from 'react-router'
import { TaskList } from './TaskList'
import type { Task } from '~/types/task'
import { TaskStatus } from '~/types/task'
import { useTasks } from '~/hooks/useTasks'
// Mock the useTasks hook
vi.mock('~/hooks/useTasks')
const mockUseTasks = useTasks as unknown as ReturnType<typeof vi.fn>
// Sample task data for testing
const mockTasks: Task[] = [
{
id: '1',
title: 'Alpha Task',
description: 'First task description',
status: TaskStatus.Todo,
created_at: '2023-01-01T00:00:00Z',
updated_at: '2023-01-01T00:00:00Z',
completed_at: null,
},
{
id: '2',
title: 'Beta Task',
description: 'Second task description',
status: TaskStatus.Done,
created_at: '2023-01-02T00:00:00Z',
updated_at: '2023-01-02T00:00:00Z',
completed_at: '2023-01-02T01:00:00Z',
},
{
id: '3',
title: 'Charlie Task',
description: null,
status: TaskStatus.Backlog,
created_at: '2023-01-03T00:00:00Z',
updated_at: '2023-01-03T00:00:00Z',
completed_at: null,
},
]
const defaultMockUseTasks = {
tasks: mockTasks,
loading: false,
error: null,
lastFetch: new Date(),
fetchTasks: vi.fn(),
createTask: vi.fn(),
refreshTasks: vi.fn(),
deleteTask: vi.fn().mockResolvedValue(true),
getTaskById: vi.fn(),
filterTasksByStatus: vi.fn(),
clearError: vi.fn(),
}
const renderTaskList = (initialTasks?: Task[]) => {
return render(
<MemoryRouter>
<TaskList initialTasks={initialTasks} />
</MemoryRouter>
)
}
describe('TaskList Sorting Functionality', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseTasks.mockReturnValue(defaultMockUseTasks)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('Default Sorting', () => {
it('should default to created_desc sorting (newest first)', () => {
renderTaskList()
// Tasks should be ordered by creation date descending (newest first)
const taskRows = screen.getAllByRole('row')
// Skip header row (index 0)
const firstTaskRow = taskRows[1]
const secondTaskRow = taskRows[2]
const thirdTaskRow = taskRows[3]
// Charlie Task (2023-01-03) should be first
expect(firstTaskRow).toHaveTextContent('Charlie Task')
// Beta Task (2023-01-02) should be second
expect(secondTaskRow).toHaveTextContent('Beta Task')
// Alpha Task (2023-01-01) should be third
expect(thirdTaskRow).toHaveTextContent('Alpha Task')
})
it('should show correct sort indicator for created column by default', () => {
renderTaskList()
const createdHeader = screen.getByRole('button', { name: /created/i })
expect(createdHeader).toHaveClass('Mui-active')
// Should have descending indicator (direction class)
expect(createdHeader).toHaveClass('MuiTableSortLabel-directionDesc')
})
})
describe('Title Column Sorting', () => {
it('should sort by title ascending when clicked first time', async () => {
renderTaskList()
const titleHeader = screen.getByRole('button', { name: /title/i })
fireEvent.click(titleHeader)
await waitFor(() => {
const taskRows = screen.getAllByRole('row')
// Skip header row
const firstTaskRow = taskRows[1]
const secondTaskRow = taskRows[2]
const thirdTaskRow = taskRows[3]
// Should be in alphabetical order
expect(firstTaskRow).toHaveTextContent('Alpha Task')
expect(secondTaskRow).toHaveTextContent('Beta Task')
expect(thirdTaskRow).toHaveTextContent('Charlie Task')
})
})
it('should sort by title descending when clicked second time', async () => {
renderTaskList()
const titleHeader = screen.getByRole('button', { name: /title/i })
// First click - ascending
fireEvent.click(titleHeader)
// Second click - descending
fireEvent.click(titleHeader)
await waitFor(() => {
const taskRows = screen.getAllByRole('row')
const firstTaskRow = taskRows[1]
const secondTaskRow = taskRows[2]
const thirdTaskRow = taskRows[3]
// Should be in reverse alphabetical order
expect(firstTaskRow).toHaveTextContent('Charlie Task')
expect(secondTaskRow).toHaveTextContent('Beta Task')
expect(thirdTaskRow).toHaveTextContent('Alpha Task')
})
})
it('should show correct sort indicators for title column', async () => {
renderTaskList()
const titleHeader = screen.getByRole('button', { name: /title/i })
// Initially not active
expect(titleHeader).not.toHaveClass('Mui-active')
// First click - ascending
fireEvent.click(titleHeader)
await waitFor(() => {
expect(titleHeader).toHaveClass('Mui-active')
expect(titleHeader).toHaveClass('MuiTableSortLabel-directionAsc')
})
// Second click - descending
fireEvent.click(titleHeader)
await waitFor(() => {
expect(titleHeader).toHaveClass('Mui-active')
expect(titleHeader).toHaveClass('MuiTableSortLabel-directionDesc')
})
})
})
describe('Status Column Sorting', () => {
it('should sort by status priority (Todo, Backlog, Done) when clicked', async () => {
renderTaskList()
const statusHeader = screen.getByRole('button', { name: /status/i })
fireEvent.click(statusHeader)
await waitFor(() => {
const taskRows = screen.getAllByRole('row')
const firstTaskRow = taskRows[1]
const secondTaskRow = taskRows[2]
const thirdTaskRow = taskRows[3]
// Should be ordered by status priority
expect(firstTaskRow).toHaveTextContent('Alpha Task') // Todo
expect(secondTaskRow).toHaveTextContent('Charlie Task') // Backlog
expect(thirdTaskRow).toHaveTextContent('Beta Task') // Done
})
})
it('should show correct sort indicator for status column', async () => {
renderTaskList()
const statusHeader = screen.getByRole('button', { name: /status/i })
// Initially not active
expect(statusHeader).not.toHaveClass('Mui-active')
fireEvent.click(statusHeader)
await waitFor(() => {
expect(statusHeader).toHaveClass('Mui-active')
expect(statusHeader).toHaveClass('MuiTableSortLabel-directionAsc')
})
})
})
describe('Created Column Sorting', () => {
it('should toggle between ascending and descending when clicked', async () => {
renderTaskList()
const createdHeader = screen.getByRole('button', { name: /created/i })
// First click should change to ascending (oldest first)
fireEvent.click(createdHeader)
await waitFor(() => {
const taskRows = screen.getAllByRole('row')
const firstTaskRow = taskRows[1]
const thirdTaskRow = taskRows[3]
expect(firstTaskRow).toHaveTextContent('Alpha Task') // 2023-01-01
expect(thirdTaskRow).toHaveTextContent('Charlie Task') // 2023-01-03
})
// Second click should change back to descending (newest first)
fireEvent.click(createdHeader)
await waitFor(() => {
const taskRows = screen.getAllByRole('row')
const firstTaskRow = taskRows[1]
const thirdTaskRow = taskRows[3]
expect(firstTaskRow).toHaveTextContent('Charlie Task') // 2023-01-03
expect(thirdTaskRow).toHaveTextContent('Alpha Task') // 2023-01-01
})
})
})
describe('Sort Persistence', () => {
it('should maintain sort order after clicking different headers', async () => {
renderTaskList()
// Sort by title ascending
const titleHeader = screen.getByRole('button', { name: /title/i })
fireEvent.click(titleHeader)
await waitFor(() => {
const taskRows = screen.getAllByRole('row')
expect(taskRows[1]).toHaveTextContent('Alpha Task')
expect(taskRows[2]).toHaveTextContent('Beta Task')
expect(taskRows[3]).toHaveTextContent('Charlie Task')
})
// Sort by status
const statusHeader = screen.getByRole('button', { name: /status/i })
fireEvent.click(statusHeader)
await waitFor(() => {
const taskRows = screen.getAllByRole('row')
// Should be ordered by status priority: Todo, Backlog, Done
expect(taskRows[1]).toHaveTextContent('Alpha Task') // Todo
expect(taskRows[2]).toHaveTextContent('Charlie Task') // Backlog
expect(taskRows[3]).toHaveTextContent('Beta Task') // Done
})
})
})
describe('Empty State', () => {
it('should show empty state message when no tasks', () => {
mockUseTasks.mockReturnValue({
...defaultMockUseTasks,
tasks: [],
})
renderTaskList()
// Should show empty state
expect(screen.getByText(/No tasks found/i)).toBeInTheDocument()
expect(
screen.getByText(/Create your first task to get started!/i)
).toBeInTheDocument()
// Table headers should not be rendered in empty state
expect(
screen.queryByRole('button', { name: /title/i })
).not.toBeInTheDocument()
})
})
describe('Mobile Responsive Sorting', () => {
it('should show sortable headers on mobile', () => {
renderTaskList()
// All sortable headers should be visible
expect(screen.getByRole('button', { name: /title/i })).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /status/i })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /created/i })
).toBeInTheDocument()
// Description column should be visible but not sortable (no button)
expect(screen.getByText('Description')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /description/i })
).not.toBeInTheDocument()
})
})
describe('Tooltip Functionality', () => {
it('should show cursor help styling on created date with tooltip', () => {
renderTaskList()
// Find created date elements (they should have relative dates like "2y ago")
const dateElements = screen.getAllByText(/ago$|Today|Yesterday/)
// Verify at least one date element exists with proper cursor styling
expect(dateElements.length).toBeGreaterThan(0)
// Check that date elements have cursor help styling
dateElements.forEach(element => {
expect(element).toHaveStyle('cursor: help')
})
})
it('should have tooltip structure in place for completed dates', () => {
renderTaskList()
// Find the table to ensure it rendered
const table = screen.getByRole('table')
expect(table).toBeInTheDocument()
// Verify that the TaskList component rendered without errors
// The tooltip functionality would be tested in e2e tests for actual hover behavior
expect(screen.getByText('Beta Task')).toBeInTheDocument()
})
})
describe('Loading and Error States', () => {
it('should not render sort headers during loading', () => {
mockUseTasks.mockReturnValue({
...defaultMockUseTasks,
loading: true,
tasks: [],
})
renderTaskList()
// Should show loading spinner instead of table
expect(
screen.queryByRole('button', { name: /title/i })
).not.toBeInTheDocument()
expect(screen.getByText('Loading...')).toBeInTheDocument()
})
it('should not render sort headers during error state', () => {
mockUseTasks.mockReturnValue({
...defaultMockUseTasks,
error: 'Failed to load tasks',
tasks: [],
})
renderTaskList()
// Should show error message instead of table
expect(
screen.queryByRole('button', { name: /title/i })
).not.toBeInTheDocument()
expect(screen.getByText(/Error loading tasks/i)).toBeInTheDocument()
})
})
})