diff --git a/frontend/components/SearchErrorModal.tsx b/frontend/components/SearchErrorModal.tsx new file mode 100644 index 00000000..43a74629 --- /dev/null +++ b/frontend/components/SearchErrorModal.tsx @@ -0,0 +1,39 @@ +'use client'; + +interface SearchErrorModalProps { + isOpen: boolean; + error: string | null; + onRetry?: () => void; + onSkip?: () => void; + canRetry?: boolean; +} + +export function SearchErrorModal({ isOpen, error, onRetry, onSkip, canRetry = true }: SearchErrorModalProps) { + if (!isOpen) return null; + + return ( +
+
+

Search failed

+

{error || 'Unable to retrieve specs from the web'}

+ +
+ {canRetry && ( + + )} + +
+
+
+ ); +} diff --git a/frontend/components/SearchLoadingModal.tsx b/frontend/components/SearchLoadingModal.tsx new file mode 100644 index 00000000..693d1bb0 --- /dev/null +++ b/frontend/components/SearchLoadingModal.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +interface SearchLoadingModalProps { + isOpen: boolean; + onTimeout?: () => void; + maxSeconds?: number; +} + +export function SearchLoadingModal({ isOpen, onTimeout, maxSeconds = 30 }: SearchLoadingModalProps) { + const [seconds, setSeconds] = useState(maxSeconds); + + useEffect(() => { + if (!isOpen) { + setSeconds(maxSeconds); + return; + } + + const interval = setInterval(() => { + setSeconds(prev => { + if (prev <= 1) { + clearInterval(interval); + onTimeout?.(); + return maxSeconds; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(interval); + }, [isOpen, maxSeconds, onTimeout]); + + if (!isOpen) return null; + + return ( +
+
+

Searching for specs...

+

This may take up to {maxSeconds} seconds

+ +
+
+
+
+
+ +

+ {seconds} seconds remaining +

+
+
+ ); +} diff --git a/frontend/hooks/useItemSearch.ts b/frontend/hooks/useItemSearch.ts new file mode 100644 index 00000000..9e72ecf9 --- /dev/null +++ b/frontend/hooks/useItemSearch.ts @@ -0,0 +1,89 @@ +'use client'; + +import { useState, useCallback } from 'react'; + +export interface SearchResult { + category?: string; + type?: string; + description?: string; + notes?: string; +} + +export interface SearchState { + isSearching: boolean; + searchError: string | null; + searchResults: SearchResult | null; + searchStatus: 'idle' | 'searching' | 'success' | 'timeout' | 'error' | 'no_results' | 'skipped'; + retryCount: number; +} + +export function useItemSearch(options: { maxRetries?: number; timeout?: number } = {}) { + const { maxRetries = 2, timeout = 30000 } = options; + + const [state, setState] = useState({ + isSearching: false, + searchError: null, + searchResults: null, + searchStatus: 'idle', + retryCount: 0, + }); + + const performSearch = useCallback( + async (partNumber: string, category: string): Promise => { + if (!partNumber || !category) { + setState(prev => ({ ...prev, searchStatus: 'skipped', searchError: null })); + return null; + } + + setState(prev => ({ ...prev, isSearching: true, searchStatus: 'searching', searchError: null, retryCount: 0 })); + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + const response = await fetch('/api/onboarding/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ partNumber, category }), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) throw new Error(`Search failed: ${response.status}`); + + const results = await response.json(); + setState(prev => ({ ...prev, isSearching: false, searchStatus: 'success', searchResults: results })); + return results; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Search failed'; + setState(prev => ({ + ...prev, + isSearching: false, + searchStatus: errorMsg.includes('abort') ? 'timeout' : 'error', + searchError: errorMsg, + })); + return null; + } + }, + [timeout] + ); + + const retrySearch = useCallback( + async (partNumber: string, category: string) => { + if (state.retryCount >= maxRetries) { + setState(prev => ({ ...prev, searchStatus: 'error' })); + return null; + } + setState(prev => ({ ...prev, retryCount: prev.retryCount + 1 })); + return performSearch(partNumber, category); + }, + [state.retryCount, maxRetries, performSearch] + ); + + const skipSearch = useCallback(() => { + setState(prev => ({ ...prev, searchStatus: 'skipped', isSearching: false })); + }, []); + + return { state, performSearch, retrySearch, skipSearch }; +} diff --git a/frontend/tests/SearchErrorModal.test.tsx b/frontend/tests/SearchErrorModal.test.tsx new file mode 100644 index 00000000..0ef22f37 --- /dev/null +++ b/frontend/tests/SearchErrorModal.test.tsx @@ -0,0 +1,51 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SearchErrorModal } from '../components/SearchErrorModal'; + +describe('SearchErrorModal', () => { + it('renders when open', () => { + render(); + expect(screen.getByText(/Search failed/i)).toBeInTheDocument(); + }); + + it('does not render when closed', () => { + const { container } = render(); + expect(container.firstChild?.childNodes.length).toBe(0); + }); + + it('displays error message', () => { + const errorMsg = 'Unable to connect to server'; + render(); + expect(screen.getByText(errorMsg)).toBeInTheDocument(); + }); + + it('calls onRetry when Retry clicked', async () => { + const onRetry = vi.fn(); + const user = userEvent.setup(); + + render(); + + const retryBtn = screen.getByRole('button', { name: /Retry/i }); + await user.click(retryBtn); + + expect(onRetry).toHaveBeenCalled(); + }); + + it('calls onSkip when Skip clicked', async () => { + const onSkip = vi.fn(); + const user = userEvent.setup(); + + render(); + + const skipBtn = screen.getByRole('button', { name: /Skip/i }); + await user.click(skipBtn); + + expect(onSkip).toHaveBeenCalled(); + }); + + it('hides Retry button when canRetry is false', () => { + render(); + expect(screen.queryByRole('button', { name: /Retry/i })).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/SearchLoadingModal.test.tsx b/frontend/tests/SearchLoadingModal.test.tsx new file mode 100644 index 00000000..c9992ff8 --- /dev/null +++ b/frontend/tests/SearchLoadingModal.test.tsx @@ -0,0 +1,35 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { SearchLoadingModal } from '../components/SearchLoadingModal'; + +describe('SearchLoadingModal', () => { + it('renders when open', () => { + render(); + expect(screen.getByText(/Searching for specs/i)).toBeInTheDocument(); + }); + + it('does not render when closed', () => { + const { container } = render(); + expect(container.firstChild?.childNodes.length).toBe(0); + }); + + it('displays countdown timer', () => { + render(); + expect(screen.getByText(/30 seconds remaining/i)).toBeInTheDocument(); + }); + + it('calls onTimeout when timer expires', async () => { + const onTimeout = vi.fn(); + render(); + + await waitFor(() => { + expect(onTimeout).toHaveBeenCalled(); + }, { timeout: 2000 }); + }); + + it('shows progress bar', () => { + const { container } = render(); + const progressBar = container.querySelector('div[class*="bg-primary"]'); + expect(progressBar).toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/useItemSearch.test.tsx b/frontend/tests/useItemSearch.test.tsx new file mode 100644 index 00000000..7afc3f93 --- /dev/null +++ b/frontend/tests/useItemSearch.test.tsx @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useItemSearch } from '../hooks/useItemSearch'; + +describe('useItemSearch', () => { + beforeEach(() => { + vi.clearAllMocks(); + global.fetch = vi.fn(); + }); + + it('initializes with idle state', () => { + const { result } = renderHook(() => useItemSearch()); + expect(result.current.state.searchStatus).toBe('idle'); + expect(result.current.state.isSearching).toBe(false); + }); + + it('performs search with part number and category', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ type: 'DDR4', description: 'Kingston RAM' }), + }); + + const { result } = renderHook(() => useItemSearch()); + + await act(async () => { + await result.current.performSearch('KF466C40RS-16', 'DDR4'); + }); + + expect(result.current.state.searchStatus).toBe('success'); + expect(result.current.state.searchResults?.type).toBe('DDR4'); + }); + + it('handles search timeout', async () => { + global.fetch = vi.fn().mockImplementation( + () => + new Promise((_, reject) => { + setTimeout(() => reject(new DOMException('Aborted', 'AbortError')), 100); + }) + ); + + const { result } = renderHook(() => useItemSearch({ timeout: 50 })); + + await act(async () => { + await result.current.performSearch('KF466C40RS-16', 'DDR4'); + }); + + await waitFor(() => { + expect(result.current.state.searchStatus).toBe('timeout'); + }); + }); + + it('skips search when part number is missing', async () => { + const { result } = renderHook(() => useItemSearch()); + + await act(async () => { + await result.current.performSearch('', 'DDR4'); + }); + + expect(result.current.state.searchStatus).toBe('skipped'); + }); + + it('handles search error', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + }); + + const { result } = renderHook(() => useItemSearch()); + + await act(async () => { + await result.current.performSearch('KF466C40RS-16', 'DDR4'); + }); + + expect(result.current.state.searchStatus).toBe('error'); + expect(result.current.state.searchError).toBeTruthy(); + }); + + it('retries search', async () => { + const { result } = renderHook(() => useItemSearch({ maxRetries: 2 })); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ type: 'DDR4' }), + }); + + await act(async () => { + await result.current.retrySearch('KF466C40RS-16', 'DDR4'); + }); + + expect(result.current.state.retryCount).toBe(1); + }); + + it('skips search', async () => { + const { result } = renderHook(() => useItemSearch()); + + act(() => { + result.current.skipSearch(); + }); + + expect(result.current.state.searchStatus).toBe('skipped'); + expect(result.current.state.isSearching).toBe(false); + }); +});