Create a frontend wireframe. (#7)
Sets up API methods and types. Sets up a colorscheme. Sets up a homepage. Removes tailwind in favor of mui for now. Reviewed-on: #7 Co-authored-by: Drew Galbraith <drew@tiramisu.one> Co-committed-by: Drew Galbraith <drew@tiramisu.one>
This commit is contained in:
parent
7d2b7fc90c
commit
d60d834f38
27 changed files with 3114 additions and 977 deletions
318
frontend/app/hooks/useTasks.test.ts
Normal file
318
frontend/app/hooks/useTasks.test.ts
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { useTasks } from './useTasks'
|
||||
import type { Task, CreateTaskRequest } from '~/types/task'
|
||||
import { TaskStatus } from '~/types/task'
|
||||
import { apiClient } from '~/services/api'
|
||||
|
||||
// Mock the API client
|
||||
vi.mock('~/services/api', () => ({
|
||||
apiClient: {
|
||||
listTasks: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const mockApiClient = apiClient as unknown as {
|
||||
listTasks: ReturnType<typeof vi.fn>
|
||||
createTask: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
// Sample task data
|
||||
const mockTasks: Task[] = [
|
||||
{
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
title: 'Test Task 1',
|
||||
description: 'Test Description 1',
|
||||
status: TaskStatus.Todo,
|
||||
created_at: '2023-01-01T00:00:00Z',
|
||||
updated_at: '2023-01-01T00:00:00Z',
|
||||
completed_at: null,
|
||||
},
|
||||
{
|
||||
id: '550e8400-e29b-41d4-a716-446655440001',
|
||||
title: 'Test Task 2',
|
||||
description: 'Test Description 2',
|
||||
status: TaskStatus.Done,
|
||||
created_at: '2023-01-01T01:00:00Z',
|
||||
updated_at: '2023-01-01T01:00:00Z',
|
||||
completed_at: '2023-01-01T02:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '550e8400-e29b-41d4-a716-446655440002',
|
||||
title: 'Test Task 3',
|
||||
description: null,
|
||||
status: TaskStatus.Backlog,
|
||||
created_at: '2023-01-01T02:00:00Z',
|
||||
updated_at: '2023-01-01T02:00:00Z',
|
||||
completed_at: null,
|
||||
},
|
||||
]
|
||||
|
||||
describe('useTasks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||
|
||||
expect(result.current.tasks).toEqual([])
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(result.current.lastFetch).toBeNull()
|
||||
})
|
||||
|
||||
it('should auto-fetch tasks on mount by default', async () => {
|
||||
mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
|
||||
|
||||
renderHook(() => useTasks())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApiClient.listTasks).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not auto-fetch when disabled', () => {
|
||||
renderHook(() => useTasks({ autoFetch: false }))
|
||||
|
||||
expect(mockApiClient.listTasks).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('fetchTasks', () => {
|
||||
it('should fetch tasks successfully', async () => {
|
||||
mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
|
||||
|
||||
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchTasks()
|
||||
})
|
||||
|
||||
expect(result.current.tasks).toEqual(mockTasks)
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(result.current.lastFetch).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it('should handle fetch errors', async () => {
|
||||
const errorMessage = 'Fetch failed'
|
||||
mockApiClient.listTasks.mockRejectedValueOnce({ message: errorMessage })
|
||||
|
||||
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchTasks()
|
||||
})
|
||||
|
||||
expect(result.current.tasks).toEqual([])
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.error).toBe(errorMessage)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createTask', () => {
|
||||
it('should create task successfully', async () => {
|
||||
const newTaskData: CreateTaskRequest = {
|
||||
title: 'New Task',
|
||||
description: 'New Description',
|
||||
}
|
||||
const newTask: Task = {
|
||||
id: 'new-task-id',
|
||||
title: newTaskData.title,
|
||||
description: newTaskData.description || null,
|
||||
status: TaskStatus.Todo,
|
||||
created_at: '2023-01-01T03:00:00Z',
|
||||
updated_at: '2023-01-01T03:00:00Z',
|
||||
completed_at: null,
|
||||
}
|
||||
|
||||
mockApiClient.createTask.mockResolvedValueOnce(newTask)
|
||||
|
||||
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||
|
||||
// Set initial tasks
|
||||
await act(async () => {
|
||||
mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
|
||||
await result.current.fetchTasks()
|
||||
})
|
||||
|
||||
let createResult: Task | null = null
|
||||
|
||||
await act(async () => {
|
||||
createResult = await result.current.createTask(newTaskData)
|
||||
})
|
||||
|
||||
expect(createResult).toEqual(newTask)
|
||||
expect(result.current.tasks[0]).toEqual(newTask) // Should be first in list
|
||||
expect(result.current.tasks).toHaveLength(mockTasks.length + 1)
|
||||
expect(mockApiClient.createTask).toHaveBeenCalledWith(newTaskData)
|
||||
})
|
||||
|
||||
it('should handle create errors', async () => {
|
||||
const newTaskData: CreateTaskRequest = { title: '' }
|
||||
const errorMessage = 'Title must not be empty'
|
||||
|
||||
mockApiClient.createTask.mockRejectedValueOnce({ message: errorMessage })
|
||||
|
||||
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||
|
||||
let createResult: Task | null = null
|
||||
|
||||
await act(async () => {
|
||||
createResult = await result.current.createTask(newTaskData)
|
||||
})
|
||||
|
||||
expect(createResult).toBeNull()
|
||||
expect(result.current.error).toBe(errorMessage)
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshTasks', () => {
|
||||
it('should refresh tasks without loading state when tasks exist', async () => {
|
||||
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||
|
||||
// Set initial tasks
|
||||
await act(async () => {
|
||||
mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
|
||||
await result.current.fetchTasks()
|
||||
})
|
||||
|
||||
// Refresh with updated tasks
|
||||
const updatedTasks = [...mockTasks, { ...mockTasks[0], id: 'new-id' }]
|
||||
mockApiClient.listTasks.mockResolvedValueOnce(updatedTasks)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshTasks()
|
||||
})
|
||||
|
||||
expect(result.current.tasks).toEqual(updatedTasks)
|
||||
})
|
||||
|
||||
it('should show loading state when no tasks exist', async () => {
|
||||
mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
|
||||
|
||||
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||
|
||||
act(() => {
|
||||
result.current.refreshTasks()
|
||||
})
|
||||
|
||||
expect(result.current.loading).toBe(true)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('utility functions', () => {
|
||||
beforeEach(async () => {
|
||||
mockApiClient.listTasks.mockResolvedValueOnce(mockTasks)
|
||||
})
|
||||
|
||||
it('should get task by ID', async () => {
|
||||
const { result } = renderHook(() => useTasks())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toHaveLength(mockTasks.length)
|
||||
})
|
||||
|
||||
const foundTask = result.current.getTaskById(mockTasks[0].id)
|
||||
expect(foundTask).toEqual(mockTasks[0])
|
||||
|
||||
const notFoundTask = result.current.getTaskById('nonexistent-id')
|
||||
expect(notFoundTask).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should filter tasks by status', async () => {
|
||||
const { result } = renderHook(() => useTasks())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toHaveLength(mockTasks.length)
|
||||
})
|
||||
|
||||
const todoTasks = result.current.filterTasksByStatus(TaskStatus.Todo)
|
||||
expect(todoTasks).toHaveLength(1)
|
||||
expect(todoTasks[0].status).toBe(TaskStatus.Todo)
|
||||
|
||||
const doneTasks = result.current.filterTasksByStatus(TaskStatus.Done)
|
||||
expect(doneTasks).toHaveLength(1)
|
||||
expect(doneTasks[0].status).toBe(TaskStatus.Done)
|
||||
|
||||
const backlogTasks = result.current.filterTasksByStatus(
|
||||
TaskStatus.Backlog
|
||||
)
|
||||
expect(backlogTasks).toHaveLength(1)
|
||||
expect(backlogTasks[0].status).toBe(TaskStatus.Backlog)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearError', () => {
|
||||
it('should clear error state', async () => {
|
||||
mockApiClient.listTasks.mockRejectedValueOnce({ message: 'Test error' })
|
||||
|
||||
const { result } = renderHook(() => useTasks({ autoFetch: false }))
|
||||
|
||||
// Trigger an error
|
||||
await act(async () => {
|
||||
await result.current.fetchTasks()
|
||||
})
|
||||
|
||||
expect(result.current.error).toBeTruthy()
|
||||
|
||||
// Clear the error
|
||||
act(() => {
|
||||
result.current.clearError()
|
||||
})
|
||||
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('refresh interval', () => {
|
||||
it('should set up refresh interval', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const updatedTasks = [...mockTasks, { ...mockTasks[0], id: 'new-id' }]
|
||||
|
||||
mockApiClient.listTasks
|
||||
.mockResolvedValueOnce(mockTasks)
|
||||
.mockResolvedValueOnce(updatedTasks)
|
||||
.mockResolvedValue(updatedTasks) // Handle any extra calls
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useTasks({ refreshInterval: 1000, autoFetch: false })
|
||||
)
|
||||
|
||||
// Manual initial fetch
|
||||
await act(async () => {
|
||||
await result.current.fetchTasks()
|
||||
})
|
||||
|
||||
expect(result.current.tasks).toHaveLength(mockTasks.length)
|
||||
|
||||
const initialCallCount = mockApiClient.listTasks.mock.calls.length
|
||||
|
||||
// Advance timer to trigger refresh
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
})
|
||||
|
||||
// Should have made at least one more call
|
||||
expect(mockApiClient.listTasks.mock.calls.length).toBeGreaterThan(
|
||||
initialCallCount
|
||||
)
|
||||
expect(result.current.tasks).toHaveLength(updatedTasks.length)
|
||||
|
||||
unmount()
|
||||
vi.useRealTimers()
|
||||
}, 10000)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue