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
359
frontend/app/hooks/useApi.test.ts
Normal file
359
frontend/app/hooks/useApi.test.ts
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { useApi, useApiForm, useApiCache } from './useApi'
|
||||
|
||||
// Mock API function for testing
|
||||
const mockApiFunction = vi.fn()
|
||||
const mockFormSubmitFunction = vi.fn()
|
||||
const mockCacheFunction = vi.fn()
|
||||
|
||||
describe('useApi', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
const { result } = renderHook(() => useApi(mockApiFunction))
|
||||
|
||||
expect(result.current.data).toBeNull()
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('should execute API function successfully', async () => {
|
||||
const mockData = { id: 1, name: 'Test' }
|
||||
mockApiFunction.mockResolvedValueOnce(mockData)
|
||||
|
||||
const { result } = renderHook(() => useApi(mockApiFunction))
|
||||
|
||||
let executeResult: unknown
|
||||
|
||||
await act(async () => {
|
||||
executeResult = await result.current.execute('test-arg')
|
||||
})
|
||||
|
||||
expect(result.current.data).toEqual(mockData)
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(executeResult).toEqual(mockData)
|
||||
expect(mockApiFunction).toHaveBeenCalledWith('test-arg')
|
||||
})
|
||||
|
||||
it('should handle API function errors', async () => {
|
||||
const errorMessage = 'API Error'
|
||||
mockApiFunction.mockRejectedValueOnce({ message: errorMessage })
|
||||
|
||||
const { result } = renderHook(() => useApi(mockApiFunction))
|
||||
|
||||
let executeResult: unknown
|
||||
|
||||
await act(async () => {
|
||||
executeResult = await result.current.execute()
|
||||
})
|
||||
|
||||
expect(result.current.data).toBeNull()
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.error).toBe(errorMessage)
|
||||
expect(executeResult).toBeNull()
|
||||
})
|
||||
|
||||
it('should call onSuccess callback', async () => {
|
||||
const mockData = { id: 1 }
|
||||
const onSuccess = vi.fn()
|
||||
mockApiFunction.mockResolvedValueOnce(mockData)
|
||||
|
||||
const { result } = renderHook(() => useApi(mockApiFunction, { onSuccess }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.execute()
|
||||
})
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledWith(mockData)
|
||||
})
|
||||
|
||||
it('should call onError callback', async () => {
|
||||
const errorMessage = 'Test Error'
|
||||
const onError = vi.fn()
|
||||
mockApiFunction.mockRejectedValueOnce({ message: errorMessage })
|
||||
|
||||
const { result } = renderHook(() => useApi(mockApiFunction, { onError }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.execute()
|
||||
})
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(errorMessage)
|
||||
})
|
||||
|
||||
it('should execute immediately when immediate option is true', async () => {
|
||||
const mockData = { id: 1 }
|
||||
mockApiFunction.mockResolvedValueOnce(mockData)
|
||||
|
||||
renderHook(() => useApi(mockApiFunction, { immediate: true }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApiFunction).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should reset state', async () => {
|
||||
const mockData = { id: 1 }
|
||||
mockApiFunction.mockResolvedValueOnce(mockData)
|
||||
|
||||
const { result } = renderHook(() => useApi(mockApiFunction))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.execute()
|
||||
})
|
||||
|
||||
expect(result.current.data).toEqual(mockData)
|
||||
|
||||
act(() => {
|
||||
result.current.reset()
|
||||
})
|
||||
|
||||
expect(result.current.data).toBeNull()
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('should clear error', async () => {
|
||||
mockApiFunction.mockRejectedValueOnce({ message: 'Test Error' })
|
||||
|
||||
const { result } = renderHook(() => useApi(mockApiFunction))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.execute()
|
||||
})
|
||||
|
||||
expect(result.current.error).toBeTruthy()
|
||||
|
||||
act(() => {
|
||||
result.current.clearError()
|
||||
})
|
||||
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('should cancel previous requests', async () => {
|
||||
let resolveFirst: (value: unknown) => void
|
||||
let resolveSecond: (value: unknown) => void
|
||||
|
||||
const firstPromise = new Promise(resolve => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
const secondPromise = new Promise(resolve => {
|
||||
resolveSecond = resolve
|
||||
})
|
||||
|
||||
mockApiFunction
|
||||
.mockReturnValueOnce(firstPromise)
|
||||
.mockReturnValueOnce(secondPromise)
|
||||
|
||||
const { result } = renderHook(() => useApi(mockApiFunction))
|
||||
|
||||
// Start first request
|
||||
act(() => {
|
||||
result.current.execute('first')
|
||||
})
|
||||
|
||||
// Start second request before first completes
|
||||
act(() => {
|
||||
result.current.execute('second')
|
||||
})
|
||||
|
||||
// Resolve first request (should be cancelled)
|
||||
await act(async () => {
|
||||
resolveFirst({ data: 'first' })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
// Resolve second request
|
||||
await act(async () => {
|
||||
resolveSecond({ data: 'second' })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(result.current.data).toEqual({ data: 'second' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('useApiForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should handle form submission', async () => {
|
||||
const formData = { name: 'Test' }
|
||||
const responseData = { id: 1, name: 'Test' }
|
||||
mockFormSubmitFunction.mockResolvedValueOnce(responseData)
|
||||
|
||||
const { result } = renderHook(() => useApiForm(mockFormSubmitFunction))
|
||||
|
||||
let submitResult: unknown
|
||||
|
||||
await act(async () => {
|
||||
submitResult = await result.current.handleSubmit(formData)
|
||||
})
|
||||
|
||||
expect(submitResult).toEqual(responseData)
|
||||
expect(mockFormSubmitFunction).toHaveBeenCalledWith(formData)
|
||||
expect(result.current.data).toEqual(responseData)
|
||||
})
|
||||
|
||||
it('should reset on success when option is enabled', async () => {
|
||||
const formData = { name: 'Test' }
|
||||
const responseData = { id: 1, name: 'Test' }
|
||||
mockFormSubmitFunction.mockResolvedValueOnce(responseData)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useApiForm(mockFormSubmitFunction, { resetOnSuccess: true })
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSubmit(formData)
|
||||
})
|
||||
|
||||
expect(result.current.data).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useApiCache', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should fetch and cache data', async () => {
|
||||
const mockData = { id: 1, name: 'Test' }
|
||||
mockCacheFunction.mockResolvedValueOnce(mockData)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useApiCache('test-key', mockCacheFunction)
|
||||
)
|
||||
|
||||
let fetchResult: unknown
|
||||
|
||||
await act(async () => {
|
||||
fetchResult = await result.current.fetchData()
|
||||
})
|
||||
|
||||
expect(fetchResult).toEqual(mockData)
|
||||
expect(result.current.data).toEqual(mockData)
|
||||
expect(mockCacheFunction).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should return cached data when fresh', async () => {
|
||||
const mockData = { id: 1, name: 'Test' }
|
||||
mockCacheFunction.mockResolvedValueOnce(mockData)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useApiCache('test-key', mockCacheFunction, { staleTime: 60000 })
|
||||
)
|
||||
|
||||
// First fetch
|
||||
await act(async () => {
|
||||
await result.current.fetchData()
|
||||
})
|
||||
|
||||
// Second fetch should return cached data
|
||||
let secondResult: unknown
|
||||
|
||||
await act(async () => {
|
||||
secondResult = await result.current.fetchData()
|
||||
})
|
||||
|
||||
expect(secondResult).toEqual(mockData)
|
||||
expect(mockCacheFunction).toHaveBeenCalledTimes(1) // Should not call API again
|
||||
})
|
||||
|
||||
it('should fetch fresh data when stale', async () => {
|
||||
const mockData1 = { id: 1, name: 'Test 1' }
|
||||
const mockData2 = { id: 2, name: 'Test 2' }
|
||||
|
||||
mockCacheFunction
|
||||
.mockResolvedValueOnce(mockData1)
|
||||
.mockResolvedValueOnce(mockData2)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useApiCache('test-key', mockCacheFunction, { staleTime: 1000 })
|
||||
)
|
||||
|
||||
// First fetch
|
||||
await act(async () => {
|
||||
await result.current.fetchData()
|
||||
})
|
||||
|
||||
expect(result.current.data).toEqual(mockData1)
|
||||
|
||||
// Advance time past stale time
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000)
|
||||
})
|
||||
|
||||
// Second fetch should get fresh data
|
||||
await act(async () => {
|
||||
await result.current.fetchData()
|
||||
})
|
||||
|
||||
expect(result.current.data).toEqual(mockData2)
|
||||
expect(mockCacheFunction).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should clear cache', async () => {
|
||||
const mockData = { id: 1, name: 'Test' }
|
||||
mockCacheFunction.mockResolvedValueOnce(mockData)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useApiCache('test-key', mockCacheFunction)
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchData()
|
||||
})
|
||||
|
||||
expect(result.current.data).toEqual(mockData)
|
||||
|
||||
await act(async () => {
|
||||
result.current.clearCache()
|
||||
})
|
||||
|
||||
expect(result.current.data).toBeNull()
|
||||
})
|
||||
|
||||
it('should indicate stale status', async () => {
|
||||
const mockData = { id: 1, name: 'Test' }
|
||||
mockCacheFunction.mockResolvedValueOnce(mockData)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useApiCache('test-key', mockCacheFunction, { staleTime: 1000 })
|
||||
)
|
||||
|
||||
// Initially stale (no data)
|
||||
expect(result.current.isStale).toBe(true)
|
||||
|
||||
// Fetch data
|
||||
await act(async () => {
|
||||
await result.current.fetchData()
|
||||
})
|
||||
|
||||
// Should be fresh
|
||||
expect(result.current.isStale).toBe(false)
|
||||
|
||||
// Advance time past stale time
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000)
|
||||
})
|
||||
|
||||
// Should be stale again
|
||||
expect(result.current.isStale).toBe(true)
|
||||
})
|
||||
})
|
||||
227
frontend/app/hooks/useApi.ts
Normal file
227
frontend/app/hooks/useApi.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import type { ApiError } from '~/types/task'
|
||||
|
||||
interface UseApiState<T> {
|
||||
data: T | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
interface UseApiActions<T> {
|
||||
execute: (...args: unknown[]) => Promise<T | null>
|
||||
reset: () => void
|
||||
clearError: () => void
|
||||
}
|
||||
|
||||
interface UseApiOptions {
|
||||
immediate?: boolean
|
||||
onSuccess?: (data: unknown) => void
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
export function useApi<T>(
|
||||
apiFunction: (...args: unknown[]) => Promise<T>,
|
||||
options: UseApiOptions = {}
|
||||
): UseApiState<T> & UseApiActions<T> {
|
||||
const { immediate = false, onSuccess, onError } = options
|
||||
|
||||
const [state, setState] = useState<UseApiState<T>>({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const mountedRef = useRef(true)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setState(prev => ({ ...prev, error: null }))
|
||||
}, [])
|
||||
|
||||
const execute = useCallback(
|
||||
async (...args: unknown[]): Promise<T | null> => {
|
||||
// Cancel previous request if still pending
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
|
||||
abortControllerRef.current = new AbortController()
|
||||
|
||||
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||
|
||||
try {
|
||||
const result = await apiFunction(...args)
|
||||
|
||||
if (!mountedRef.current) return null
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
data: result,
|
||||
loading: false,
|
||||
}))
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess(result)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return null
|
||||
|
||||
const apiError = error as ApiError
|
||||
const errorMessage = apiError.message || 'An unknown error occurred'
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: errorMessage,
|
||||
}))
|
||||
|
||||
if (onError) {
|
||||
onError(errorMessage)
|
||||
}
|
||||
|
||||
return null
|
||||
} finally {
|
||||
abortControllerRef.current = null
|
||||
}
|
||||
},
|
||||
[apiFunction, onSuccess, onError]
|
||||
)
|
||||
|
||||
// Execute immediately if requested
|
||||
useEffect(() => {
|
||||
if (immediate) {
|
||||
execute()
|
||||
}
|
||||
}, [immediate, execute])
|
||||
|
||||
return {
|
||||
...state,
|
||||
execute,
|
||||
reset,
|
||||
clearError,
|
||||
}
|
||||
}
|
||||
|
||||
// Utility hook for handling form submissions
|
||||
export function useApiForm<T>(
|
||||
submitFunction: (data: unknown) => Promise<T>,
|
||||
options: UseApiOptions & { resetOnSuccess?: boolean } = {}
|
||||
) {
|
||||
const { resetOnSuccess = false, ...apiOptions } = options
|
||||
|
||||
const api = useApi(submitFunction, apiOptions)
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (data: unknown) => {
|
||||
const result = await api.execute(data)
|
||||
|
||||
if (result && resetOnSuccess) {
|
||||
api.reset()
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
[api, resetOnSuccess]
|
||||
)
|
||||
|
||||
return {
|
||||
...api,
|
||||
handleSubmit,
|
||||
}
|
||||
}
|
||||
|
||||
// Utility hook for data caching and synchronization
|
||||
export function useApiCache<T>(
|
||||
key: string,
|
||||
fetchFunction: () => Promise<T>,
|
||||
options: { cacheTime?: number; staleTime?: number } = {}
|
||||
) {
|
||||
const { cacheTime = 5 * 60 * 1000, staleTime = 30 * 1000 } = options // 5min cache, 30s stale
|
||||
|
||||
const [cacheData, setCacheData] = useState<{
|
||||
data: T | null
|
||||
timestamp: number
|
||||
} | null>(null)
|
||||
|
||||
const api = useApi(fetchFunction)
|
||||
|
||||
const getCachedData = useCallback((): T | null => {
|
||||
if (!cacheData) return null
|
||||
|
||||
const now = Date.now()
|
||||
const age = now - cacheData.timestamp
|
||||
|
||||
if (age > cacheTime) {
|
||||
setCacheData(null)
|
||||
return null
|
||||
}
|
||||
|
||||
return cacheData.data
|
||||
}, [cacheData, cacheTime])
|
||||
|
||||
const isStale = useCallback((): boolean => {
|
||||
if (!cacheData) return true
|
||||
|
||||
const now = Date.now()
|
||||
const age = now - cacheData.timestamp
|
||||
|
||||
return age > staleTime
|
||||
}, [cacheData, staleTime])
|
||||
|
||||
const fetchData = useCallback(
|
||||
async (force = false): Promise<T | null> => {
|
||||
// Return cached data if fresh and not forced
|
||||
if (!force && !isStale()) {
|
||||
const cached = getCachedData()
|
||||
if (cached) return cached
|
||||
}
|
||||
|
||||
const result = await api.execute()
|
||||
|
||||
if (result) {
|
||||
setCacheData({
|
||||
data: result,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
[api, isStale, getCachedData]
|
||||
)
|
||||
|
||||
const clearCache = useCallback(() => {
|
||||
setCacheData(null)
|
||||
api.reset()
|
||||
}, [api])
|
||||
|
||||
return {
|
||||
data: api.data || getCachedData(),
|
||||
loading: api.loading,
|
||||
error: api.error,
|
||||
fetchData,
|
||||
clearCache,
|
||||
get isStale() {
|
||||
return isStale()
|
||||
},
|
||||
}
|
||||
}
|
||||
211
frontend/app/hooks/useTask.test.ts
Normal file
211
frontend/app/hooks/useTask.test.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { useTask } from './useTask'
|
||||
import type { Task, UpdateTaskRequest } from '~/types/task'
|
||||
import { TaskStatus } from '~/types/task'
|
||||
import { apiClient } from '~/services/api'
|
||||
|
||||
// Mock the API client
|
||||
vi.mock('~/services/api', () => ({
|
||||
apiClient: {
|
||||
getTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const mockApiClient = apiClient as unknown as {
|
||||
getTask: ReturnType<typeof vi.fn>
|
||||
updateTask: ReturnType<typeof vi.fn>
|
||||
deleteTask: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
// Sample task data
|
||||
const mockTask: Task = {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
title: 'Test Task',
|
||||
description: 'Test Description',
|
||||
status: TaskStatus.Todo,
|
||||
created_at: '2023-01-01T00:00:00Z',
|
||||
updated_at: '2023-01-01T00:00:00Z',
|
||||
completed_at: null,
|
||||
}
|
||||
|
||||
describe('useTask', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
const { result } = renderHook(() => useTask())
|
||||
|
||||
expect(result.current.task).toBeNull()
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
describe('getTask', () => {
|
||||
it('should fetch task successfully', async () => {
|
||||
mockApiClient.getTask.mockResolvedValueOnce(mockTask)
|
||||
|
||||
const { result } = renderHook(() => useTask())
|
||||
|
||||
act(() => {
|
||||
result.current.getTask(mockTask.id)
|
||||
})
|
||||
|
||||
expect(result.current.loading).toBe(true)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.task).toEqual(mockTask)
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(mockApiClient.getTask).toHaveBeenCalledWith(mockTask.id)
|
||||
})
|
||||
|
||||
it('should handle fetch errors', async () => {
|
||||
const errorMessage = 'Task not found'
|
||||
mockApiClient.getTask.mockRejectedValueOnce({ message: errorMessage })
|
||||
|
||||
const { result } = renderHook(() => useTask())
|
||||
|
||||
act(() => {
|
||||
result.current.getTask(mockTask.id)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.task).toBeNull()
|
||||
expect(result.current.error).toBe(errorMessage)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateTask', () => {
|
||||
it('should update task with optimistic update', async () => {
|
||||
const updateData: UpdateTaskRequest = {
|
||||
title: 'Updated Task',
|
||||
status: TaskStatus.Done,
|
||||
}
|
||||
const updatedTask = { ...mockTask, ...updateData }
|
||||
|
||||
mockApiClient.getTask.mockResolvedValueOnce(mockTask)
|
||||
mockApiClient.updateTask.mockResolvedValueOnce(updatedTask)
|
||||
|
||||
const { result } = renderHook(() => useTask())
|
||||
|
||||
// Set initial task
|
||||
await act(async () => {
|
||||
await result.current.getTask(mockTask.id)
|
||||
})
|
||||
|
||||
let updateResult: Task | null = null
|
||||
|
||||
await act(async () => {
|
||||
updateResult = await result.current.updateTask(mockTask.id, updateData)
|
||||
})
|
||||
|
||||
expect(updateResult).toEqual(updatedTask)
|
||||
expect(mockApiClient.updateTask).toHaveBeenCalledWith(
|
||||
mockTask.id,
|
||||
updateData
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle update errors and revert optimistic update', async () => {
|
||||
const updateData: UpdateTaskRequest = { status: TaskStatus.Done }
|
||||
const errorMessage = 'Update failed'
|
||||
|
||||
// Setup initial task
|
||||
mockApiClient.getTask.mockResolvedValueOnce(mockTask)
|
||||
|
||||
const { result } = renderHook(() => useTask())
|
||||
|
||||
// Set initial task state
|
||||
await act(async () => {
|
||||
await result.current.getTask(mockTask.id)
|
||||
})
|
||||
|
||||
expect(result.current.task).toEqual(mockTask)
|
||||
|
||||
// Mock update failure and revert call
|
||||
mockApiClient.updateTask.mockRejectedValueOnce({ message: errorMessage })
|
||||
mockApiClient.getTask.mockResolvedValueOnce(mockTask)
|
||||
|
||||
let updateResult: Task | null = null
|
||||
|
||||
await act(async () => {
|
||||
updateResult = await result.current.updateTask(mockTask.id, updateData)
|
||||
})
|
||||
|
||||
expect(updateResult).toBeNull()
|
||||
expect(result.current.error).toBe(errorMessage)
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteTask', () => {
|
||||
it('should delete task successfully', async () => {
|
||||
mockApiClient.deleteTask.mockResolvedValueOnce(undefined)
|
||||
|
||||
const { result } = renderHook(() => useTask())
|
||||
|
||||
// Set initial task
|
||||
await act(async () => {
|
||||
mockApiClient.getTask.mockResolvedValueOnce(mockTask)
|
||||
await result.current.getTask(mockTask.id)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteTask(mockTask.id)
|
||||
})
|
||||
|
||||
expect(result.current.task).toBeNull()
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(mockApiClient.deleteTask).toHaveBeenCalledWith(mockTask.id)
|
||||
})
|
||||
|
||||
it('should handle delete errors', async () => {
|
||||
const errorMessage = 'Delete failed'
|
||||
mockApiClient.deleteTask.mockRejectedValueOnce({ message: errorMessage })
|
||||
|
||||
const { result } = renderHook(() => useTask())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteTask(mockTask.id)
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe(errorMessage)
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearError', () => {
|
||||
it('should clear error state', async () => {
|
||||
mockApiClient.getTask.mockRejectedValueOnce({ message: 'Test error' })
|
||||
|
||||
const { result } = renderHook(() => useTask())
|
||||
|
||||
// Trigger an error
|
||||
await act(async () => {
|
||||
await result.current.getTask(mockTask.id)
|
||||
})
|
||||
|
||||
expect(result.current.error).toBeTruthy()
|
||||
|
||||
// Clear the error
|
||||
act(() => {
|
||||
result.current.clearError()
|
||||
})
|
||||
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
114
frontend/app/hooks/useTask.ts
Normal file
114
frontend/app/hooks/useTask.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { useState, useCallback } from 'react'
|
||||
import type { Task, UpdateTaskRequest, ApiError } from '~/types/task'
|
||||
import { apiClient } from '~/services/api'
|
||||
|
||||
interface UseTaskState {
|
||||
task: Task | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
interface UseTaskActions {
|
||||
getTask: (id: string) => Promise<void>
|
||||
updateTask: (id: string, data: UpdateTaskRequest) => Promise<Task | null>
|
||||
deleteTask: (id: string) => Promise<void>
|
||||
clearError: () => void
|
||||
}
|
||||
|
||||
export function useTask(): UseTaskState & UseTaskActions {
|
||||
const [state, setState] = useState<UseTaskState>({
|
||||
task: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setState(prev => ({ ...prev, error: null }))
|
||||
}, [])
|
||||
|
||||
const getTask = useCallback(async (id: string) => {
|
||||
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||
|
||||
try {
|
||||
const task = await apiClient.getTask(id)
|
||||
setState(prev => ({ ...prev, task, loading: false }))
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: apiError.message,
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const updateTask = useCallback(
|
||||
async (id: string, data: UpdateTaskRequest): Promise<Task | null> => {
|
||||
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||
|
||||
try {
|
||||
// Optimistic update
|
||||
if (state.task && state.task.id === id) {
|
||||
const optimisticTask: Task = {
|
||||
...state.task,
|
||||
...data,
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
setState(prev => ({ ...prev, task: optimisticTask }))
|
||||
}
|
||||
|
||||
const updatedTask = await apiClient.updateTask(id, data)
|
||||
setState(prev => ({ ...prev, task: updatedTask, loading: false }))
|
||||
return updatedTask
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: apiError.message,
|
||||
}))
|
||||
|
||||
// Revert optimistic update on error
|
||||
if (state.task && state.task.id === id) {
|
||||
try {
|
||||
const originalTask = await apiClient.getTask(id)
|
||||
setState(prev => ({ ...prev, task: originalTask }))
|
||||
} catch {
|
||||
// If we can't revert, just keep the optimistic state
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
},
|
||||
[state.task]
|
||||
)
|
||||
|
||||
const deleteTask = useCallback(async (id: string) => {
|
||||
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||
|
||||
try {
|
||||
await apiClient.deleteTask(id)
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
task: prev.task?.id === id ? null : prev.task,
|
||||
loading: false,
|
||||
}))
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: apiError.message,
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
...state,
|
||||
getTask,
|
||||
updateTask,
|
||||
deleteTask,
|
||||
clearError,
|
||||
}
|
||||
}
|
||||
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)
|
||||
})
|
||||
})
|
||||
161
frontend/app/hooks/useTasks.ts
Normal file
161
frontend/app/hooks/useTasks.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { useState, useCallback, useEffect } from 'react'
|
||||
import type { Task, CreateTaskRequest, ApiError } from '~/types/task'
|
||||
import { TaskStatus } from '~/types/task'
|
||||
import { apiClient } from '~/services/api'
|
||||
|
||||
interface UseTasksState {
|
||||
tasks: Task[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
lastFetch: Date | null
|
||||
}
|
||||
|
||||
interface UseTasksActions {
|
||||
fetchTasks: () => Promise<void>
|
||||
createTask: (data: CreateTaskRequest) => Promise<Task | null>
|
||||
refreshTasks: () => Promise<void>
|
||||
clearError: () => void
|
||||
getTaskById: (id: string) => Task | undefined
|
||||
filterTasksByStatus: (status: TaskStatus) => Task[]
|
||||
}
|
||||
|
||||
interface UseTasksOptions {
|
||||
autoFetch?: boolean
|
||||
refreshInterval?: number
|
||||
}
|
||||
|
||||
export function useTasks(
|
||||
options: UseTasksOptions = {}
|
||||
): UseTasksState & UseTasksActions {
|
||||
const { autoFetch = true, refreshInterval } = options
|
||||
|
||||
const [state, setState] = useState<UseTasksState>({
|
||||
tasks: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastFetch: null,
|
||||
})
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setState(prev => ({ ...prev, error: null }))
|
||||
}, [])
|
||||
|
||||
const fetchTasks = useCallback(async () => {
|
||||
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||
|
||||
try {
|
||||
const tasks = await apiClient.listTasks()
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
tasks,
|
||||
loading: false,
|
||||
lastFetch: new Date(),
|
||||
}))
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: apiError.message,
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const createTask = useCallback(
|
||||
async (data: CreateTaskRequest): Promise<Task | null> => {
|
||||
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||
|
||||
try {
|
||||
const newTask = await apiClient.createTask(data)
|
||||
|
||||
// Add the new task to the beginning of the list (most recent first)
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
tasks: [newTask, ...prev.tasks],
|
||||
loading: false,
|
||||
}))
|
||||
|
||||
return newTask
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: apiError.message,
|
||||
}))
|
||||
return null
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const refreshTasks = useCallback(async () => {
|
||||
// Force refresh without showing loading state if tasks already exist
|
||||
const showLoading = state.tasks.length === 0
|
||||
|
||||
if (showLoading) {
|
||||
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||
} else {
|
||||
setState(prev => ({ ...prev, error: null }))
|
||||
}
|
||||
|
||||
try {
|
||||
const tasks = await apiClient.listTasks()
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
tasks,
|
||||
loading: false,
|
||||
lastFetch: new Date(),
|
||||
}))
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: apiError.message,
|
||||
}))
|
||||
}
|
||||
}, [state.tasks])
|
||||
|
||||
const getTaskById = useCallback(
|
||||
(id: string): Task | undefined => {
|
||||
return state.tasks.find(task => task.id === id)
|
||||
},
|
||||
[state.tasks]
|
||||
)
|
||||
|
||||
const filterTasksByStatus = useCallback(
|
||||
(status: TaskStatus): Task[] => {
|
||||
return state.tasks.filter(task => task.status === status)
|
||||
},
|
||||
[state.tasks]
|
||||
)
|
||||
|
||||
// Auto-fetch tasks on mount
|
||||
useEffect(() => {
|
||||
if (autoFetch) {
|
||||
fetchTasks()
|
||||
}
|
||||
}, [autoFetch, fetchTasks])
|
||||
|
||||
// Set up refresh interval if specified
|
||||
useEffect(() => {
|
||||
if (!refreshInterval) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
refreshTasks()
|
||||
}, refreshInterval)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [refreshInterval, refreshTasks])
|
||||
|
||||
return {
|
||||
...state,
|
||||
fetchTasks,
|
||||
createTask,
|
||||
refreshTasks,
|
||||
clearError,
|
||||
getTaskById,
|
||||
filterTasksByStatus,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue