--- wave: 3 depends_on: ["4.1-PLAN-01.md", "4.1-PLAN-02.md"] files_modified: - path: "frontend/components/AIOnboarding.tsx" - path: "frontend/hooks/useItemSearch.ts" - path: "frontend/components/SearchLoadingModal.tsx" - path: "frontend/components/SearchErrorModal.tsx" - path: "frontend/tests/useItemSearch.test.tsx" - path: "frontend/tests/SearchLoadingModal.test.tsx" - path: "frontend/tests/SearchErrorModal.test.tsx" autonomous: true --- # Phase 4.1 Wave 3: Frontend Integration & End-to-End Testing **Objective:** Integrate search results into AIOnboarding component, implement loading/error modals, add frontend hooks for search flow, and provide comprehensive component tests. **Prerequisites:** Waves 1-2 must be complete (classification, AI prompts, search services, backend API integration). --- ## Task 1: Create useItemSearch Custom Hook ```xml Build React custom hook managing search state, API calls, retries, and timeout handling for spare-parts search integration. - 4.1-RESEARCH.md section 6 (Frontend AIOnboarding Integration) — State Additions and UI Flow - 4.1-CONTEXT.md decisions D-06, D-07, D-08 (search blocking, error handling, user review) - frontend/components/AIOnboarding.tsx (understand existing item extraction flow) - PROJECT_ARCHITECTURE.md section 2.2 (Next.js 15+, async patterns) Create file: frontend/hooks/useItemSearch.ts **Hook Structure (TypeScript strict mode):** ```typescript 'use client'; import { useState, useCallback } from 'react'; export interface SearchResult { category?: string; item_type?: string; notes?: string; } export interface SearchState { isSearching: boolean; searchError: string | null; searchResults: SearchResult | null; searchStatus: 'idle' | 'searching' | 'success' | 'timeout' | 'error' | 'no_results' | 'skipped' | 'not_spare_part'; retryCount: number; } interface UseItemSearchOptions { maxRetries?: number; timeout?: number; } export function useItemSearch(options: UseItemSearchOptions = {}) { 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, manufacturer?: string ): Promise => { if (!partNumber || !category) { setState(prev => ({ ...prev, searchStatus: 'skipped', searchError: null, })); return null; } setState(prev => ({ ...prev, isSearching: true, searchStatus: 'searching', searchError: null, })); try { // Call backend /api/onboarding/extract endpoint // Note: This is called from AIOnboarding component which handles image upload // This hook assumes backend has already extracted AI data // Frontend receives { ai_data, search_results, search_status, search_error } // For standalone search (if needed), use: // const response = await fetch('/api/onboarding/search', { // method: 'POST', // headers: { 'Content-Type': 'application/json' }, // body: JSON.stringify({ part_number: partNumber, category, manufacturer }), // }); // Actual implementation: state managed by parent AIOnboarding component // This hook provides reusable retry and error handling logic return state.searchResults; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Search failed'; setState(prev => ({ ...prev, isSearching: false, searchStatus: 'error', searchError: errorMessage, })); return null; } }, [state.searchResults] ); const handleSearchResults = useCallback( (searchStatus: string, searchResults: SearchResult | null, searchError: string | null) => { setState(prev => ({ ...prev, isSearching: false, searchStatus: searchStatus as any, searchResults, searchError, })); }, [] ); const retrySearch = useCallback(async () => { if (state.retryCount >= maxRetries) { setState(prev => ({ ...prev, searchError: `Max retries (${maxRetries}) exceeded`, })); return; } setState(prev => ({ ...prev, retryCount: prev.retryCount + 1, isSearching: true, searchStatus: 'searching', searchError: null, })); // Retry logic: parent component re-triggers search // This hook manages UI state transitions }, [state.retryCount, maxRetries]); const skipSearch = useCallback(() => { setState(prev => ({ ...prev, isSearching: false, searchStatus: 'skipped', searchError: null, })); }, []); const resetSearch = useCallback(() => { setState({ isSearching: false, searchError: null, searchResults: null, searchStatus: 'idle', retryCount: 0, }); }, []); return { ...state, performSearch, handleSearchResults, retrySearch, skipSearch, resetSearch, }; } ``` **Code Quality:** - TypeScript strict mode (no `any` except status enum) - All state transitions explicit (use setState callbacks) - Retry count tracking for max retries enforcement - Clear error messages for users - No side effects outside useCallback - Docstring comments on exported types and functions - File exists: frontend/hooks/useItemSearch.ts - Hook exports: `useItemSearch` function - Hook exports: `SearchState`, `SearchResult` TypeScript interfaces - Hook accepts options: `maxRetries`, `timeout` - Hook provides methods: `performSearch`, `handleSearchResults`, `retrySearch`, `skipSearch`, `resetSearch` - Grep finds: `export function useItemSearch(` in file - Module imports without error: `import { useItemSearch } from '@/hooks/useItemSearch'` - All types use strict TypeScript (no implicit `any`) - Retry count increments on each retry, checked against maxRetries ``` --- ## Task 2: Create SearchLoadingModal Component ```xml Build non-dismissible loading modal with countdown timer showing search progress to user during spare-parts lookup. - 4.1-RESEARCH.md section 6 (Frontend Integration) — Loading State Design - 4.1-CONTEXT.md decision D-06 (block onboarding UI until search completes) - PROJECT_ARCHITECTURE.md section 2.2 (React, Tailwind, Lucide Icons) - AI_RULES.md section 3 (UI Fidelity: no UPPERCASE, no BOLD, use Lucide Icons) Create file: frontend/components/SearchLoadingModal.tsx **Component Structure (TypeScript strict mode, React 18+):** ```typescript 'use client'; import React, { useState, useEffect } from 'react'; import { Loader2 } from 'lucide-react'; interface SearchLoadingModalProps { isOpen: boolean; timeout?: number; // seconds onTimeout?: () => void; } export function SearchLoadingModal({ isOpen, timeout = 30, onTimeout, }: SearchLoadingModalProps) { const [secondsElapsed, setSecondsElapsed] = useState(0); useEffect(() => { if (!isOpen) { setSecondsElapsed(0); return; } const interval = setInterval(() => { setSecondsElapsed(prev => { if (prev >= timeout) { onTimeout?.(); return prev; } return prev + 1; }); }, 1000); return () => clearInterval(interval); }, [isOpen, timeout, onTimeout]); if (!isOpen) return null; const secondsRemaining = timeout - secondsElapsed; return ( Searching for specifications... {secondsElapsed}s / {timeout}s ); } export default SearchLoadingModal; ``` **Design Specifications (from RESEARCH.md and UI_RULES.md):** - **Backdrop:** `bg-black/50` (semi-transparent black) - **Modal:** `bg-white p-8 rounded-lg max-w-md shadow-lg` - **Icon:** Lucide `Loader2` with `animate-spin` (no custom spinner) - **Typography:** - Title: `text-lg font-normal` (no UPPERCASE, no BOLD) - Timer: `text-sm text-slate-500 font-normal` - **Non-dismissible:** No close button, no click-outside handler - **Timer display:** "Xs / 30s" format (current / total) - **Accessibility:** Semantic structure, loading announcement via aria-label **Code Quality:** - TypeScript strict mode - React hooks: useState for elapsed time, useEffect for interval cleanup - Proper cleanup on unmount (clearInterval) - Timer increments every 1000ms - Callback fired when timeout reached - File exists: frontend/components/SearchLoadingModal.tsx - Component exports: `SearchLoadingModal` (named export) - Component accepts props: `isOpen: boolean`, `timeout?: number`, `onTimeout?: () => void` - Grep finds: ` ``` --- ## Task 3: Create SearchErrorModal Component ```xml Build error modal displaying failed search with Retry and Skip buttons, allowing user to recover or proceed with AI data only. - 4.1-RESEARCH.md section 6 (Frontend Integration) — Error Handling UI - 4.1-CONTEXT.md decision D-07 (error UI with Retry and Skip buttons) - PROJECT_ARCHITECTURE.md section 2.2 (React, Tailwind, Lucide Icons) - AI_RULES.md section 3 (UI Fidelity: no BOLD fonts, use Lucide Icons) Create file: frontend/components/SearchErrorModal.tsx **Component Structure (TypeScript strict mode, React 18+):** ```typescript 'use client'; import React from 'react'; import { AlertCircle } from 'lucide-react'; interface SearchErrorModalProps { error: string; onRetry: () => void; onSkip: () => void; isRetrying?: boolean; } export function SearchErrorModal({ error, onRetry, onSkip, isRetrying = false, }: SearchErrorModalProps) { return ( Search failed {error || 'Could not retrieve specifications. You can retry or skip to continue with AI extraction data.'} {isRetrying ? 'Retrying...' : 'Retry Search'} Skip ); } export default SearchErrorModal; ``` **Design Specifications (from RESEARCH.md and UI_RULES.md):** - **Layout:** Modal with backdrop (fixed, z-50) - **Icon:** Lucide `AlertCircle` with `text-rose-500` (error color) - **Typography:** - Title: `text-lg font-normal` (no UPPERCASE, no BOLD) - Error message: `text-sm text-slate-600 font-normal` - **Buttons:** - Retry: Primary button (`bg-primary text-white`) - Skip: Secondary/outline button (`border border-slate-300`) - Both: `font-normal` (no bold), disabled state when retrying - **States:** Disable both buttons while retrying - **Accessibility:** Semantic button elements, proper focus states **Code Quality:** - TypeScript strict mode - Props interface clearly documented - Button state management (isRetrying) - Hover/disabled transition effects - No custom spinner or complex logic - File exists: frontend/components/SearchErrorModal.tsx - Component exports: `SearchErrorModal` (named export) - Component accepts props: `error: string`, `onRetry: () => void`, `onSkip: () => void`, `isRetrying?: boolean` - Grep finds: ` ``` --- ## Task 4: Integrate Search into AIOnboarding Component ```xml Modify AIOnboarding component to call search endpoint, show loading/error modals, pre-populate item fields, and allow user edits before save. - frontend/components/AIOnboarding.tsx (current item extraction and confirmation flow) - 4.1-RESEARCH.md section 6 (Frontend AIOnboarding Integration) — UI Flow and State Additions - 4.1-CONTEXT.md decisions D-05 through D-08 (search trigger, blocking UI, error handling, user review) - Tasks 1-3 output (useItemSearch hook and modals) Modify file: frontend/components/AIOnboarding.tsx **Action Steps:** 1. Add imports: ```typescript import { useItemSearch } from '@/hooks/useItemSearch'; import { SearchLoadingModal } from '@/components/SearchLoadingModal'; import { SearchErrorModal } from '@/components/SearchErrorModal'; ``` 2. Update component state to include search state: ```typescript const searchState = useItemSearch({ maxRetries: 2, timeout: 30000 }); ``` 3. Update extraction flow AFTER AI extraction completes: ```typescript // Existing: AI extraction (Gemini/Claude) returns ai_data const ai_data = await extractWithAI(image); // NEW: Handle search results from backend const { search_results, search_status, search_error } = responseData; // From backend /api/onboarding/extract // Update hook state with search results searchState.handleSearchResults(search_status, search_results, search_error); // If search succeeded, pre-populate fields if (search_results) { setFormData(prev => ({ ...prev, category: search_results.category || prev.category, item_type: search_results.item_type || prev.item_type, notes: search_results.notes || prev.notes, })); } ``` 4. Add loading modal ABOVE the form: ```typescript searchState.skipSearch()} /> ``` 5. Add error modal ABOVE the form: ```typescript { // Re-trigger search: call /api/onboarding/search with part_number, category searchState.retrySearch(); }} onSkip={() => searchState.skipSearch()} isRetrying={searchState.isSearching && searchState.retryCount > 0} /> ``` 6. Block form submission while searching: ```typescript Save Item ``` 7. Display search status message ABOVE form (optional): ```typescript {searchState.searchStatus === 'success' && ( Specifications loaded. Review below and make any changes. )} ``` 8. Allow field editing: Ensure Category, Type, Notes inputs are NOT read-only after search - User can edit any search-populated value before saving **Code Quality:** - No changes to existing AI extraction logic (only add search handling) - Search state management through custom hook - Modals are conditional based on search status - Form remains editable after search completes - Error handling catches all search failures gracefully - File frontend/components/AIOnboarding.tsx modified - Grep finds: `import { useItemSearch }` in file - Grep finds: `import { SearchLoadingModal }` in file - Grep finds: `import { SearchErrorModal }` in file - Grep finds: `const searchState = useItemSearch(` in file - Grep finds: ` ``` --- ## Task 5: Create useItemSearch Hook Tests ```xml Write comprehensive React Hook Testing Library tests for useItemSearch custom hook covering state management and retry logic. - Task 1 output: frontend/hooks/useItemSearch.ts - 4.1-RESEARCH.md section 8 (Testing & Validation Strategy) — Frontend Tests subsection - PROJECT_ARCHITECTURE.md section 2.2 (Testing: Vitest for React Hook Testing) Create file: frontend/tests/useItemSearch.test.tsx **Test Structure (Vitest + React Hook Testing Library):** ```typescript import { describe, it, expect, beforeEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useItemSearch } from '@/hooks/useItemSearch'; describe('useItemSearch', () => { describe('initial state', () => { it('should initialize with idle state', () => { const { result } = renderHook(() => useItemSearch()); expect(result.current.isSearching).toBe(false); expect(result.current.searchError).toBeNull(); expect(result.current.searchResults).toBeNull(); expect(result.current.searchStatus).toBe('idle'); expect(result.current.retryCount).toBe(0); }); }); describe('search state transitions', () => { it('should handle search results successfully', () => { const { result } = renderHook(() => useItemSearch()); const mockResults = { category: 'RAM', item_type: 'Kingston DDR4', notes: '16GB DDR4-3200MHz', }; act(() => { result.current.handleSearchResults('success', mockResults, null); }); expect(result.current.isSearching).toBe(false); expect(result.current.searchStatus).toBe('success'); expect(result.current.searchResults).toEqual(mockResults); expect(result.current.searchError).toBeNull(); }); it('should handle search timeout', () => { const { result } = renderHook(() => useItemSearch()); act(() => { result.current.handleSearchResults('timeout', null, 'Search exceeded timeout'); }); expect(result.current.searchStatus).toBe('timeout'); expect(result.current.searchResults).toBeNull(); expect(result.current.searchError).toBe('Search exceeded timeout'); }); it('should handle search error', () => { const { result } = renderHook(() => useItemSearch()); act(() => { result.current.handleSearchResults('error', null, 'Network error'); }); expect(result.current.searchStatus).toBe('error'); expect(result.current.searchError).toBe('Network error'); }); it('should handle no results', () => { const { result } = renderHook(() => useItemSearch()); act(() => { result.current.handleSearchResults('no_results', null, null); }); expect(result.current.searchStatus).toBe('no_results'); expect(result.current.searchResults).toBeNull(); }); }); describe('retry logic', () => { it('should increment retry count on retry', () => { const { result } = renderHook(() => useItemSearch({ maxRetries: 3 })); expect(result.current.retryCount).toBe(0); act(() => { result.current.retrySearch(); }); expect(result.current.retryCount).toBe(1); expect(result.current.isSearching).toBe(true); }); it('should prevent retries after max retries exceeded', () => { const { result } = renderHook(() => useItemSearch({ maxRetries: 1 })); // First retry act(() => { result.current.retrySearch(); }); expect(result.current.retryCount).toBe(1); // Attempt second retry (should fail) act(() => { result.current.retrySearch(); }); expect(result.current.searchError).toContain('Max retries'); expect(result.current.retryCount).toBe(1); }); }); describe('skip and reset', () => { it('should mark search as skipped', () => { const { result } = renderHook(() => useItemSearch()); act(() => { result.current.skipSearch(); }); expect(result.current.searchStatus).toBe('skipped'); expect(result.current.isSearching).toBe(false); }); it('should reset all state on resetSearch', () => { const { result } = renderHook(() => useItemSearch()); // Set some state act(() => { result.current.handleSearchResults('success', { category: 'RAM' }, null); }); // Reset act(() => { result.current.resetSearch(); }); expect(result.current.searchStatus).toBe('idle'); expect(result.current.searchResults).toBeNull(); expect(result.current.searchError).toBeNull(); expect(result.current.retryCount).toBe(0); expect(result.current.isSearching).toBe(false); }); }); }); ``` **Test Execution:** - All tests must pass: `vitest frontend/tests/useItemSearch.test.tsx` - Use renderHook from @testing-library/react for hook testing - Use act() wrapper for state updates - Minimum 10 test cases **Code Quality:** - Descriptive test names following "should..." pattern - Clear assertions with expected values - No external API calls (all state-based) - Test both happy path and error scenarios - File exists: frontend/tests/useItemSearch.test.tsx - Test suite runs without errors: `vitest frontend/tests/useItemSearch.test.tsx` - Minimum 10 test cases implemented - Test passes: `should initialize with idle state` - Test passes: `should handle search results successfully` - Test passes: `should handle search timeout` - Test passes: `should prevent retries after max retries exceeded` - Test passes: `should reset all state on resetSearch` - All tests use renderHook and act() correctly - No console errors or warnings during test run ``` --- ## Task 6: Create SearchLoadingModal Component Tests ```xml Write React component tests for SearchLoadingModal covering timer countdown and timeout callback. - Task 2 output: frontend/components/SearchLoadingModal.tsx - 4.1-RESEARCH.md section 8 (Testing & Validation Strategy) — Frontend Tests subsection - PROJECT_ARCHITECTURE.md section 2.2 (Testing: Vitest for React) Create file: frontend/tests/SearchLoadingModal.test.tsx **Test Structure (Vitest + React Testing Library):** ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import { SearchLoadingModal } from '@/components/SearchLoadingModal'; describe('SearchLoadingModal', () => { describe('visibility', () => { it('should render when isOpen is true', () => { render(); expect(screen.getByText(/Searching for specifications/)).toBeInTheDocument(); }); it('should not render when isOpen is false', () => { const { container } = render(); expect(container.firstChild).toBeNull(); }); }); describe('timer display', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it('should display initial timer as "0s / 30s"', () => { render(); expect(screen.getByText(/0s \/ 30s/)).toBeInTheDocument(); }); it('should increment timer every second', async () => { render(); expect(screen.getByText(/0s \/ 30s/)).toBeInTheDocument(); vi.advanceTimersByTime(1000); await waitFor(() => { expect(screen.getByText(/1s \/ 30s/)).toBeInTheDocument(); }); vi.advanceTimersByTime(2000); await waitFor(() => { expect(screen.getByText(/3s \/ 30s/)).toBeInTheDocument(); }); }); it('should call onTimeout when timer reaches timeout', async () => { const onTimeout = vi.fn(); render(); vi.advanceTimersByTime(2000); await waitFor(() => { expect(onTimeout).toHaveBeenCalled(); }); }); it('should stop incrementing after timeout', async () => { const onTimeout = vi.fn(); render(); vi.advanceTimersByTime(2000); const textAtTimeout = screen.getByText(/2s \/ 2s/); vi.advanceTimersByTime(1000); expect(screen.getByText(/2s \/ 2s/)).toBeInTheDocument(); expect(onTimeout).toHaveBeenCalledTimes(1); }); }); describe('animation and styling', () => { it('should render spinner with animate-spin class', () => { render(); const spinner = document.querySelector('.animate-spin'); expect(spinner).toBeInTheDocument(); }); it('should apply correct styling to modal', () => { render(); const backdrop = document.querySelector('.fixed.inset-0.bg-black/50'); expect(backdrop).toBeInTheDocument(); }); }); describe('lifecycle', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it('should reset timer when closed and reopened', async () => { const { rerender } = render(); vi.advanceTimersByTime(5000); expect(screen.getByText(/5s \/ 30s/)).toBeInTheDocument(); rerender(); rerender(); expect(screen.getByText(/0s \/ 30s/)).toBeInTheDocument(); }); }); }); ``` **Test Execution:** - All tests must pass: `vitest frontend/tests/SearchLoadingModal.test.tsx` - Use vi.useFakeTimers() for timer testing - Use waitFor for async assertions - Minimum 8 test cases **Code Quality:** - Descriptive test names - Clear assertions - Proper timer cleanup in afterEach - Test both rendering and interaction - File exists: frontend/tests/SearchLoadingModal.test.tsx - Test suite runs without errors: `vitest frontend/tests/SearchLoadingModal.test.tsx` - Minimum 8 test cases implemented - Test passes: `should render when isOpen is true` - Test passes: `should increment timer every second` - Test passes: `should call onTimeout when timer reaches timeout` - Test passes: `should reset timer when closed and reopened` - Timer tests use vi.useFakeTimers() and vi.advanceTimersByTime() - All timers cleaned up in afterEach ``` --- ## Task 7: Create SearchErrorModal Component Tests ```xml Write React component tests for SearchErrorModal covering button interactions and disabled states. - Task 3 output: frontend/components/SearchErrorModal.tsx - 4.1-RESEARCH.md section 8 (Testing & Validation Strategy) — Frontend Tests subsection - PROJECT_ARCHITECTURE.md section 2.2 (Testing: Vitest for React) Create file: frontend/tests/SearchErrorModal.test.tsx **Test Structure (Vitest + React Testing Library):** ```typescript 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', () => { const defaultProps = { error: 'Network error: unable to reach server', onRetry: vi.fn(), onSkip: vi.fn(), }; describe('rendering', () => { it('should display error message', () => { render(); expect(screen.getByText(/Network error/)).toBeInTheDocument(); }); it('should display error icon', () => { render(); expect(document.querySelector('.text-rose-500')).toBeInTheDocument(); }); it('should display Retry button', () => { render(); expect(screen.getByRole('button', { name: /Retry Search/ })).toBeInTheDocument(); }); it('should display Skip button', () => { render(); expect(screen.getByRole('button', { name: /Skip/ })).toBeInTheDocument(); }); it('should display "Search failed" heading', () => { render(); expect(screen.getByText('Search failed')).toBeInTheDocument(); }); }); describe('button interactions', () => { it('should call onRetry when Retry button clicked', async () => { const user = userEvent.setup(); const onRetry = vi.fn(); render( ); const retryButton = screen.getByRole('button', { name: /Retry Search/ }); await user.click(retryButton); expect(onRetry).toHaveBeenCalledTimes(1); }); it('should call onSkip when Skip button clicked', async () => { const user = userEvent.setup(); const onSkip = vi.fn(); render( ); const skipButton = screen.getByRole('button', { name: /Skip/ }); await user.click(skipButton); expect(onSkip).toHaveBeenCalledTimes(1); }); }); describe('disabled state', () => { it('should disable buttons when isRetrying is true', () => { render( ); const retryButton = screen.getByRole('button', { name: /Retrying/ }); const skipButton = screen.getByRole('button', { name: /Skip/ }); expect(retryButton).toBeDisabled(); expect(skipButton).toBeDisabled(); }); it('should show "Retrying..." text when isRetrying is true', () => { render( ); expect(screen.getByText(/Retrying.../)).toBeInTheDocument(); }); it('should show "Retry Search" text when isRetrying is false', () => { render( ); expect(screen.getByText(/Retry Search/)).toBeInTheDocument(); }); it('should enable buttons when isRetrying becomes false', async () => { const { rerender } = render( ); const retryButton = screen.getByRole('button', { name: /Retrying/ }); expect(retryButton).toBeDisabled(); rerender( ); const enabledRetryButton = screen.getByRole('button', { name: /Retry Search/ }); expect(enabledRetryButton).not.toBeDisabled(); }); }); describe('custom error messages', () => { it('should display custom error message', () => { const customError = 'Timeout: search took longer than expected'; render( ); expect(screen.getByText(customError)).toBeInTheDocument(); }); }); }); ``` **Test Execution:** - All tests must pass: `vitest frontend/tests/SearchErrorModal.test.tsx` - Use userEvent for button interactions (not fireEvent) - Minimum 10 test cases **Code Quality:** - Descriptive test names - Clear assertions on button states and callbacks - Test both enabled and disabled states - Test text content changes based on props - File exists: frontend/tests/SearchErrorModal.test.tsx - Test suite runs without errors: `vitest frontend/tests/SearchErrorModal.test.tsx` - Minimum 10 test cases implemented - Test passes: `should call onRetry when Retry button clicked` - Test passes: `should call onSkip when Skip button clicked` - Test passes: `should disable buttons when isRetrying is true` - Test passes: `should show "Retrying..." text when isRetrying is true` - All button interactions tested with userEvent - All tests verify correct text content and element states ``` --- ## Wave 3 Summary **What this wave accomplishes:** - Creates reusable useItemSearch custom hook for search state management - Implements SearchLoadingModal with countdown timer (non-dismissible) - Implements SearchErrorModal with Retry/Skip buttons - Integrates search into AIOnboarding component with proper UI flow - Provides comprehensive component tests covering all user scenarios - Enables automatic spec population and user field editing before save **Completion Criteria:** - All 7 tasks pass acceptance criteria - Frontend tests pass: ```bash vitest frontend/tests/useItemSearch.test.tsx vitest frontend/tests/SearchLoadingModal.test.tsx vitest frontend/tests/SearchErrorModal.test.tsx ``` - All components import without error: ```bash import { useItemSearch } from '@/hooks/useItemSearch' import { SearchLoadingModal } from '@/components/SearchLoadingModal' import { SearchErrorModal } from '@/components/SearchErrorModal' ``` - AIOnboarding component modified with search integration - UI displays correctly on desktop and mobile (Tailwind responsive) **End-to-End User Flow:** 1. User uploads item image in AIOnboarding 2. AI extracts: name, category, part_number, manufacturer 3. If category is spare part AND part_number exists → backend triggers search 4. Frontend shows loading modal with countdown (non-dismissible, max 30s) 5. If search succeeds → fields pre-populate, user can edit 6. If search fails → error modal with Retry/Skip buttons 7. User clicks Save → item saved with search-enriched data 8. Item saved to database with AuditLog entry --- ## PLANNING COMPLETE 3 plan(s) created for Phase 4.1 in 3 wave(s), totaling 21 tasks. ---
Searching for specifications...
{secondsElapsed}s / {timeout}s
Search failed
{error || 'Could not retrieve specifications. You can retry or skip to continue with AI extraction data.'}
Specifications loaded. Review below and make any changes.