Create a frontend wireframe. #7
10 changed files with 1810 additions and 4 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 { 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()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
210
frontend/app/hooks/useTask.test.ts
Normal file
210
frontend/app/hooks/useTask.test.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||||
|
import { useTask } from './useTask'
|
||||||
|
import { Task, TaskStatus, UpdateTaskRequest } 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 {
|
||||||
|
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 { 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
317
frontend/app/hooks/useTasks.test.ts
Normal file
317
frontend/app/hooks/useTasks.test.ts
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||||
|
import { useTasks } from './useTasks'
|
||||||
|
import { Task, TaskStatus, CreateTaskRequest } 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 {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
160
frontend/app/hooks/useTasks.ts
Normal file
160
frontend/app/hooks/useTasks.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
import { useState, useCallback, useEffect } from 'react'
|
||||||
|
import { Task, CreateTaskRequest, TaskStatus, ApiError } 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
271
frontend/app/services/api.test.ts
Normal file
271
frontend/app/services/api.test.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { apiClient } from './api'
|
||||||
|
import {
|
||||||
|
Task,
|
||||||
|
TaskStatus,
|
||||||
|
CreateTaskRequest,
|
||||||
|
UpdateTaskRequest,
|
||||||
|
} from '~/types/task'
|
||||||
|
|
||||||
|
// Mock fetch globally
|
||||||
|
const mockFetch = vi.fn()
|
||||||
|
global.fetch = mockFetch
|
||||||
|
|
||||||
|
// Sample task data for testing
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockTasks: Task[] = [
|
||||||
|
mockTask,
|
||||||
|
{
|
||||||
|
...mockTask,
|
||||||
|
id: '550e8400-e29b-41d4-a716-446655440001',
|
||||||
|
title: 'Another Task',
|
||||||
|
status: TaskStatus.Done,
|
||||||
|
completed_at: '2023-01-01T01:00:00Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('API Client', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch.mockClear()
|
||||||
|
// Silence console.log during tests
|
||||||
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('listTasks', () => {
|
||||||
|
it('should fetch all tasks successfully', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: 'OK',
|
||||||
|
json: () => Promise.resolve(mockTasks),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await apiClient.listTasks()
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/api/tasks', {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
expect(result).toEqual(mockTasks)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty task list', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: 'OK',
|
||||||
|
json: () => Promise.resolve([]),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await apiClient.listTasks()
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw error on failed request', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
statusText: 'Internal Server Error',
|
||||||
|
json: () => Promise.resolve({ message: 'Server error' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(apiClient.listTasks()).rejects.toThrow('Server error')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getTask', () => {
|
||||||
|
it('should fetch single task successfully', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: 'OK',
|
||||||
|
json: () => Promise.resolve(mockTask),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await apiClient.getTask(mockTask.id)
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(`/api/tasks/${mockTask.id}`, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
expect(result).toEqual(mockTask)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle task not found', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
statusText: 'Not Found',
|
||||||
|
json: () => Promise.resolve({ message: 'Task not found' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(apiClient.getTask('nonexistent-id')).rejects.toThrow(
|
||||||
|
'Task not found'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createTask', () => {
|
||||||
|
it('should create task successfully', async () => {
|
||||||
|
const newTaskData: CreateTaskRequest = {
|
||||||
|
title: 'New Task',
|
||||||
|
description: 'New Description',
|
||||||
|
}
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 201,
|
||||||
|
statusText: 'Created',
|
||||||
|
json: () => Promise.resolve(mockTask),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await apiClient.createTask(newTaskData)
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/api/tasks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(newTaskData),
|
||||||
|
})
|
||||||
|
expect(result).toEqual(mockTask)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle validation errors', async () => {
|
||||||
|
const invalidData: CreateTaskRequest = {
|
||||||
|
title: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 422,
|
||||||
|
statusText: 'Unprocessable Entity',
|
||||||
|
json: () => Promise.resolve({ message: 'Title must not be empty' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(apiClient.createTask(invalidData)).rejects.toThrow(
|
||||||
|
'Title must not be empty'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updateTask', () => {
|
||||||
|
it('should update task successfully', async () => {
|
||||||
|
const updateData: UpdateTaskRequest = {
|
||||||
|
title: 'Updated Task',
|
||||||
|
status: TaskStatus.Done,
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedTask = { ...mockTask, ...updateData }
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: 'OK',
|
||||||
|
json: () => Promise.resolve(updatedTask),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await apiClient.updateTask(mockTask.id, updateData)
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(`/api/tasks/${mockTask.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updateData),
|
||||||
|
})
|
||||||
|
expect(result).toEqual(updatedTask)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle partial updates', async () => {
|
||||||
|
const updateData: UpdateTaskRequest = {
|
||||||
|
status: TaskStatus.Done,
|
||||||
|
}
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: 'OK',
|
||||||
|
json: () => Promise.resolve({ ...mockTask, status: TaskStatus.Done }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await apiClient.updateTask(mockTask.id, updateData)
|
||||||
|
|
||||||
|
expect(result.status).toBe(TaskStatus.Done)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deleteTask', () => {
|
||||||
|
it('should delete task successfully', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 204,
|
||||||
|
statusText: 'No Content',
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await apiClient.deleteTask(mockTask.id)
|
||||||
|
expect(result).toBeNull()
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(`/api/tasks/${mockTask.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle delete errors', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
statusText: 'Not Found',
|
||||||
|
json: () => Promise.resolve({ message: 'Task not found' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(apiClient.deleteTask('nonexistent-id')).rejects.toThrow(
|
||||||
|
'Task not found'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('should handle network errors', async () => {
|
||||||
|
mockFetch.mockRejectedValueOnce(new Error('Network error'))
|
||||||
|
|
||||||
|
await expect(apiClient.listTasks()).rejects.toThrow('Network error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle malformed JSON responses', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
statusText: 'Internal Server Error',
|
||||||
|
json: () => Promise.reject(new Error('Invalid JSON')),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(apiClient.listTasks()).rejects.toThrow(
|
||||||
|
'HTTP 500: Internal Server Error'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle responses with no error message', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
statusText: 'Bad Request',
|
||||||
|
json: () => Promise.resolve({}),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(apiClient.listTasks()).rejects.toThrow(
|
||||||
|
'HTTP 400: Bad Request'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
113
frontend/app/services/api.ts
Normal file
113
frontend/app/services/api.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import {
|
||||||
|
Task,
|
||||||
|
CreateTaskRequest,
|
||||||
|
UpdateTaskRequest,
|
||||||
|
ApiError,
|
||||||
|
} from '~/types/task'
|
||||||
|
|
||||||
|
const API_BASE_URL = '/api'
|
||||||
|
|
||||||
|
class ApiClient {
|
||||||
|
private async fetchWrapper<T>(
|
||||||
|
endpoint: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${API_BASE_URL}${endpoint}`
|
||||||
|
|
||||||
|
const config: RequestInit = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.log(`API Request: ${config.method || 'GET'} ${url}`, {
|
||||||
|
body: config.body,
|
||||||
|
headers: config.headers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, config)
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.log(`API Response: ${response.status} ${response.statusText}`, {
|
||||||
|
url,
|
||||||
|
status: response.status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = `HTTP ${response.status}: ${response.statusText}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const errorData = await response.json()
|
||||||
|
errorMessage = errorData.message || errorMessage
|
||||||
|
} catch {
|
||||||
|
// If JSON parsing fails, use the default error message
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return null as T
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.log('API Response Data:', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
} catch (error) {
|
||||||
|
const apiError: ApiError = {
|
||||||
|
message:
|
||||||
|
error instanceof Error ? error.message : 'Unknown error occurred',
|
||||||
|
status:
|
||||||
|
error instanceof Error && 'status' in error
|
||||||
|
? (error as { status?: number }).status
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.error('API Error:', apiError)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw apiError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTasks(): Promise<Task[]> {
|
||||||
|
return this.fetchWrapper<Task[]>('/tasks')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTask(id: string): Promise<Task> {
|
||||||
|
return this.fetchWrapper<Task>(`/tasks/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTask(data: CreateTaskRequest): Promise<Task> {
|
||||||
|
return this.fetchWrapper<Task>('/tasks', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTask(id: string, data: UpdateTaskRequest): Promise<Task> {
|
||||||
|
return this.fetchWrapper<Task>(`/tasks/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTask(id: string): Promise<void> {
|
||||||
|
return this.fetchWrapper<void>(`/tasks/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiClient = new ApiClient()
|
||||||
35
frontend/app/types/task.ts
Normal file
35
frontend/app/types/task.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
export enum TaskStatus {
|
||||||
|
Todo = 'todo',
|
||||||
|
Done = 'done',
|
||||||
|
Backlog = 'backlog',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
status: TaskStatus
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
completed_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTaskRequest {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTaskRequest {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
status?: TaskStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
message: string
|
||||||
|
status?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskListResponse {
|
||||||
|
tasks: Task[]
|
||||||
|
}
|
||||||
|
|
@ -75,14 +75,14 @@ captains-log/
|
||||||
## Phase 2: Core API Integration (Days 3-4)
|
## Phase 2: Core API Integration (Days 3-4)
|
||||||
|
|
||||||
### Task 2.1: Define TypeScript Types
|
### Task 2.1: Define TypeScript Types
|
||||||
- [ ] **File**: `frontend/src/types/task.ts`
|
- [x] **File**: `frontend/src/types/task.ts`
|
||||||
- Create Task interface matching backend TaskModel
|
- Create Task interface matching backend TaskModel
|
||||||
- Add TaskStatus enum (Todo, Done, Backlog)
|
- Add TaskStatus enum (Todo, Done, Backlog)
|
||||||
- Include API response types and error types
|
- Include API response types and error types
|
||||||
- **Expected outcome**: Type definitions compile without errors
|
- **Expected outcome**: Type definitions compile without errors
|
||||||
|
|
||||||
### Task 2.2: Backend API Client
|
### Task 2.2: Backend API Client
|
||||||
- [ ] **File**: `frontend/src/services/api.ts`
|
- [x] **File**: `frontend/src/services/api.ts`
|
||||||
- Implement API client with fetch wrapper
|
- Implement API client with fetch wrapper
|
||||||
- Add all task endpoints: GET, POST, PUT, DELETE /api/tasks
|
- Add all task endpoints: GET, POST, PUT, DELETE /api/tasks
|
||||||
- Include error handling and response parsing
|
- Include error handling and response parsing
|
||||||
|
|
@ -90,7 +90,7 @@ captains-log/
|
||||||
- **Expected outcome**: API client can communicate with backend
|
- **Expected outcome**: API client can communicate with backend
|
||||||
|
|
||||||
### Task 2.3: Custom React Hooks for API
|
### Task 2.3: Custom React Hooks for API
|
||||||
- [ ] **Files**: `frontend/src/hooks/useTask.ts`, `frontend/src/hooks/useTasks.ts`
|
- [x] **Files**: `frontend/src/hooks/useTask.ts`, `frontend/src/hooks/useTasks.ts`
|
||||||
- Create useTask hook for single task operations (get, update, delete)
|
- Create useTask hook for single task operations (get, update, delete)
|
||||||
- Create useTasks hook for task list operations (list, create)
|
- Create useTasks hook for task list operations (list, create)
|
||||||
- Include loading states, error handling, and optimistic updates
|
- Include loading states, error handling, and optimistic updates
|
||||||
|
|
@ -98,7 +98,7 @@ captains-log/
|
||||||
- **Expected outcome**: Hooks provide clean API for components
|
- **Expected outcome**: Hooks provide clean API for components
|
||||||
|
|
||||||
### Task 2.4: API Integration Tests
|
### Task 2.4: API Integration Tests
|
||||||
- [ ] **File**: `frontend/tests/api.test.ts`
|
- [x] **File**: `frontend/tests/api.test.ts`
|
||||||
- Test API client with mock responses
|
- Test API client with mock responses
|
||||||
- Test custom hooks with mock API calls
|
- Test custom hooks with mock API calls
|
||||||
- Test error handling scenarios
|
- Test error handling scenarios
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue