diff --git a/frontend/src/components/GlobalSearchBar/GlobalSearchBar.tsx b/frontend/src/components/GlobalSearchBar/GlobalSearchBar.tsx index 509d2a8..8ce1522 100644 --- a/frontend/src/components/GlobalSearchBar/GlobalSearchBar.tsx +++ b/frontend/src/components/GlobalSearchBar/GlobalSearchBar.tsx @@ -367,7 +367,11 @@ const GlobalSearchBar: React.FC = ({ sx, ...props }) => { }} sx={{ width: '100%', - minWidth: 600, + minWidth: { + xs: '200px', // Mobile: minimum viable width + sm: '400px', // Small tablets + md: 600, // Desktop: original size + }, maxWidth: 1200, '& .MuiOutlinedInput-root': { background: theme.palette.mode === 'light' diff --git a/frontend/src/components/Labels/LabelCreateDialog.tsx b/frontend/src/components/Labels/LabelCreateDialog.tsx index e7e0466..67b8a29 100644 --- a/frontend/src/components/Labels/LabelCreateDialog.tsx +++ b/frontend/src/components/Labels/LabelCreateDialog.tsx @@ -130,7 +130,9 @@ const LabelCreateDialog: React.FC = ({ background_color: formData.background_color || undefined, icon: formData.icon || undefined, }); - handleClose(); + // Call onClose directly after successful submission + // Don't use handleClose() here to avoid race conditions with loading state + onClose(); } catch (error) { console.error('Failed to save label:', error); // Could add error handling UI here diff --git a/frontend/src/components/Layout/AppLayout.tsx b/frontend/src/components/Layout/AppLayout.tsx index 03a2a55..de117ef 100644 --- a/frontend/src/components/Layout/AppLayout.tsx +++ b/frontend/src/components/Layout/AppLayout.tsx @@ -46,6 +46,8 @@ import GlobalSearchBar from '../GlobalSearchBar'; import ThemeToggle from '../ThemeToggle/ThemeToggle'; import NotificationPanel from '../Notifications/NotificationPanel'; import LanguageSwitcher from '../LanguageSwitcher'; +import BottomNavigation from './BottomNavigation'; +import { usePWA } from '../../hooks/usePWA'; import { useTranslation } from 'react-i18next'; const drawerWidth = 280; @@ -80,6 +82,7 @@ const getNavigationItems = (t: (key: string) => string): NavigationItem[] => [ const AppLayout: React.FC = ({ children }) => { const theme = useMuiTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); + const isPWA = usePWA(); const [mobileOpen, setMobileOpen] = useState(false); const [anchorEl, setAnchorEl] = useState(null); const [notificationAnchorEl, setNotificationAnchorEl] = useState(null); @@ -438,6 +441,7 @@ const AppLayout: React.FC = ({ children }) => { fontWeight: 700, mr: 1, fontSize: '1.1rem', + display: isPWA ? 'none' : 'block', background: theme.palette.mode === 'light' ? 'linear-gradient(135deg, #1e293b 0%, #6366f1 100%)' : 'linear-gradient(135deg, #f8fafc 0%, #a855f7 100%)', @@ -452,15 +456,24 @@ const AppLayout: React.FC = ({ children }) => { {/* Global Search Bar */} - + {/* Notifications */} - = ({ children }) => { }, }} > - = ({ children }) => { {/* Language Switcher */} = ({ children }) => { {/* Theme Toggle */} = ({ children }) => { }} > - + {children} {/* Notification Panel */} - + + {/* Bottom Navigation (PWA only) */} + ); }; diff --git a/frontend/src/components/Layout/BottomNavigation.tsx b/frontend/src/components/Layout/BottomNavigation.tsx new file mode 100644 index 0000000..f96a5c6 --- /dev/null +++ b/frontend/src/components/Layout/BottomNavigation.tsx @@ -0,0 +1,192 @@ +import React from 'react'; +import { + BottomNavigation as MuiBottomNavigation, + BottomNavigationAction, + Paper, + useTheme, +} from '@mui/material'; +import { + Dashboard as DashboardIcon, + CloudUpload as UploadIcon, + Label as LabelIcon, + Settings as SettingsIcon, +} from '@mui/icons-material'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { usePWA } from '../../hooks/usePWA'; + +const BottomNavigation: React.FC = () => { + const navigate = useNavigate(); + const location = useLocation(); + const theme = useTheme(); + const { t } = useTranslation(); + const isPWA = usePWA(); + + // Don't render if not in PWA mode + if (!isPWA) { + return null; + } + + // Map paths to nav values + const getNavValue = (pathname: string): string => { + if (pathname === '/dashboard') return 'dashboard'; + if (pathname === '/upload') return 'upload'; + if (pathname === '/labels') return 'labels'; + if (pathname === '/settings' || pathname === '/profile') return 'settings'; + return 'dashboard'; + }; + + const handleNavigation = (_event: React.SyntheticEvent, newValue: string) => { + switch (newValue) { + case 'dashboard': + navigate('/dashboard'); + break; + case 'upload': + navigate('/upload'); + break; + case 'labels': + navigate('/labels'); + break; + case 'settings': + navigate('/settings'); + break; + } + }; + + return ( + + + } + sx={{ + '&.Mui-selected': { + '& .MuiBottomNavigationAction-label': { + background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', + backgroundClip: 'text', + WebkitBackgroundClip: 'text', + WebkitTextFillColor: 'transparent', + fontWeight: 600, + }, + }, + }} + /> + } + sx={{ + '&.Mui-selected': { + '& .MuiBottomNavigationAction-label': { + background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', + backgroundClip: 'text', + WebkitBackgroundClip: 'text', + WebkitTextFillColor: 'transparent', + fontWeight: 600, + }, + }, + }} + /> + } + sx={{ + '&.Mui-selected': { + '& .MuiBottomNavigationAction-label': { + background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', + backgroundClip: 'text', + WebkitBackgroundClip: 'text', + WebkitTextFillColor: 'transparent', + fontWeight: 600, + }, + }, + }} + /> + } + sx={{ + '&.Mui-selected': { + '& .MuiBottomNavigationAction-label': { + background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', + backgroundClip: 'text', + WebkitBackgroundClip: 'text', + WebkitTextFillColor: 'transparent', + fontWeight: 600, + }, + }, + }} + /> + + + ); +}; + +export default BottomNavigation; diff --git a/frontend/src/components/Layout/__tests__/BottomNavigation.test.tsx b/frontend/src/components/Layout/__tests__/BottomNavigation.test.tsx new file mode 100644 index 0000000..1ab1b0d --- /dev/null +++ b/frontend/src/components/Layout/__tests__/BottomNavigation.test.tsx @@ -0,0 +1,265 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, fireEvent, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import BottomNavigation from '../BottomNavigation'; +import { renderWithPWA, renderWithProviders } from '../../../test/test-utils'; +import { setupPWAMode, resetPWAMocks } from '../../../test/pwa-test-utils'; +import { MemoryRouter } from 'react-router-dom'; + +// Mock the usePWA hook +vi.mock('../../../hooks/usePWA'); + +const mockNavigate = vi.fn(); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + BrowserRouter: ({ children, ...props }: { children: React.ReactNode; [key: string]: any }) => ( + + {children} + + ), + }; +}); + +describe('BottomNavigation', () => { + beforeEach(() => { + mockNavigate.mockClear(); + resetPWAMocks(); + }); + + describe('PWA Detection', () => { + it('returns null when not in PWA mode', () => { + setupPWAMode(false); + + const { container } = renderWithProviders(, { + routerProps: { initialEntries: ['/dashboard'] }, + }); + + expect(container.firstChild).toBeNull(); + }); + + it('renders when in PWA mode', () => { + setupPWAMode(true); + + renderWithPWA(, { + routerProps: { initialEntries: ['/dashboard'] }, + }); + + // Check that the navigation is rendered by looking for nav items text + expect(screen.getByText(/dashboard/i)).toBeInTheDocument(); + }); + }); + + describe('Navigation Items', () => { + beforeEach(() => { + setupPWAMode(true); + }); + + it('renders all 4 navigation items', () => { + renderWithPWA(, { + routerProps: { initialEntries: ['/dashboard'] }, + }); + + expect(screen.getByText(/dashboard/i)).toBeInTheDocument(); + expect(screen.getByText(/upload/i)).toBeInTheDocument(); + expect(screen.getByText(/labels/i)).toBeInTheDocument(); + expect(screen.getByText(/settings/i)).toBeInTheDocument(); + }); + + it('renders clickable Dashboard nav button', () => { + renderWithPWA(, { + routerProps: { initialEntries: ['/upload'] }, + }); + + const buttons = screen.getAllByRole('button'); + const dashboardButton = buttons.find(btn => btn.textContent?.includes('Dashboard'))!; + + expect(dashboardButton).toBeInTheDocument(); + expect(dashboardButton).not.toBeDisabled(); + }); + + it('renders clickable Upload nav button', () => { + renderWithPWA(, { + routerProps: { initialEntries: ['/dashboard'] }, + }); + + const buttons = screen.getAllByRole('button'); + const uploadButton = buttons.find(btn => btn.textContent?.includes('Upload'))!; + + expect(uploadButton).toBeInTheDocument(); + expect(uploadButton).not.toBeDisabled(); + }); + + it('renders clickable Labels nav button', () => { + renderWithPWA(, { + routerProps: { initialEntries: ['/dashboard'] }, + }); + + const buttons = screen.getAllByRole('button'); + const labelsButton = buttons.find(btn => btn.textContent?.includes('Labels'))!; + + expect(labelsButton).toBeInTheDocument(); + expect(labelsButton).not.toBeDisabled(); + }); + + it('renders clickable Settings nav button', () => { + renderWithPWA(, { + routerProps: { initialEntries: ['/dashboard'] }, + }); + + const buttons = screen.getAllByRole('button'); + const settingsButton = buttons.find(btn => btn.textContent?.includes('Settings'))!; + + expect(settingsButton).toBeInTheDocument(); + expect(settingsButton).not.toBeDisabled(); + }); + }); + + describe('Routing Integration', () => { + beforeEach(() => { + setupPWAMode(true); + }); + + it('uses location pathname to determine active navigation item', () => { + renderWithPWA(, { + routerProps: { initialEntries: ['/dashboard'] }, + }); + + // Verify all navigation buttons are present + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(4); + + // Verify buttons have the expected text content + expect(buttons.some(btn => btn.textContent?.includes('Dashboard'))).toBe(true); + expect(buttons.some(btn => btn.textContent?.includes('Upload'))).toBe(true); + expect(buttons.some(btn => btn.textContent?.includes('Labels'))).toBe(true); + expect(buttons.some(btn => btn.textContent?.includes('Settings'))).toBe(true); + }); + }); + + describe('Styling', () => { + beforeEach(() => { + setupPWAMode(true); + }); + + it('has safe-area-inset padding', () => { + const { container } = renderWithPWA(, { + routerProps: { initialEntries: ['/dashboard'] }, + }); + + const paper = container.querySelector('[class*="MuiPaper-root"]'); + expect(paper).toBeInTheDocument(); + + // Check for safe-area padding in style (MUI applies this via sx prop) + const computedStyle = window.getComputedStyle(paper!); + // Note: We can't directly test the calc() value in JSDOM, + // but we verify the component renders without error + expect(paper).toBeInTheDocument(); + }); + + it('has correct z-index for overlay', () => { + const { container } = renderWithPWA(, { + routerProps: { initialEntries: ['/dashboard'] }, + }); + + const paper = container.querySelector('[class*="MuiPaper-root"]'); + expect(paper).toBeInTheDocument(); + }); + + it('has fixed position at bottom', () => { + const { container } = renderWithPWA(, { + routerProps: { initialEntries: ['/dashboard'] }, + }); + + const paper = container.querySelector('[class*="MuiPaper-root"]'); + expect(paper).toBeInTheDocument(); + }); + }); + + describe('Accessibility', () => { + beforeEach(() => { + setupPWAMode(true); + }); + + it('has visible text labels for all nav items', () => { + renderWithPWA(, { + routerProps: { initialEntries: ['/dashboard'] }, + }); + + // All buttons should have visible text + expect(screen.getByText(/dashboard/i)).toBeInTheDocument(); + expect(screen.getByText(/upload/i)).toBeInTheDocument(); + expect(screen.getByText(/labels/i)).toBeInTheDocument(); + expect(screen.getByText(/settings/i)).toBeInTheDocument(); + }); + + it('all nav items are keyboard accessible', () => { + renderWithPWA(, { + routerProps: { initialEntries: ['/dashboard'] }, + }); + + const buttons = screen.getAllByRole('button'); + const dashboardButton = buttons.find(btn => btn.textContent?.includes('Dashboard'))!; + const uploadButton = buttons.find(btn => btn.textContent?.includes('Upload'))!; + const labelsButton = buttons.find(btn => btn.textContent?.includes('Labels'))!; + const settingsButton = buttons.find(btn => btn.textContent?.includes('Settings'))!; + + // All should be focusable (button elements) + expect(dashboardButton.tagName).toBe('BUTTON'); + expect(uploadButton.tagName).toBe('BUTTON'); + expect(labelsButton.tagName).toBe('BUTTON'); + expect(settingsButton.tagName).toBe('BUTTON'); + }); + + it('shows visual labels for screen readers', () => { + renderWithPWA(, { + routerProps: { initialEntries: ['/dashboard'] }, + }); + + // Text content should be visible (not just icons) + expect(screen.getByText(/dashboard/i)).toBeInTheDocument(); + expect(screen.getByText(/upload/i)).toBeInTheDocument(); + expect(screen.getByText(/labels/i)).toBeInTheDocument(); + expect(screen.getByText(/settings/i)).toBeInTheDocument(); + }); + }); + + describe('Responsive Behavior', () => { + beforeEach(() => { + setupPWAMode(true); + }); + + it('renders in PWA mode', () => { + const { container } = renderWithPWA(, { + routerProps: { initialEntries: ['/dashboard'] }, + }); + + // Should render when in PWA mode + expect(container.querySelector('[class*="MuiPaper-root"]')).toBeInTheDocument(); + }); + }); + + describe('Component Stability', () => { + beforeEach(() => { + setupPWAMode(true); + }); + + it('renders consistently across re-renders', () => { + const { rerender } = renderWithPWA(, { + routerProps: { initialEntries: ['/dashboard'] }, + }); + + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(4); + + // Re-render should maintain same structure + rerender(); + + const buttonsAfterRerender = screen.getAllByRole('button'); + expect(buttonsAfterRerender).toHaveLength(4); + }); + }); +}); diff --git a/frontend/src/hooks/__tests__/usePWA.test.ts b/frontend/src/hooks/__tests__/usePWA.test.ts new file mode 100644 index 0000000..87d948c --- /dev/null +++ b/frontend/src/hooks/__tests__/usePWA.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { usePWA } from '../usePWA'; +import { setupPWAMode, setupIOSPWAMode, resetPWAMocks } from '../../test/pwa-test-utils'; + +describe('usePWA', () => { + // Clean up after each test to prevent pollution + afterEach(() => { + resetPWAMocks(); + }); + + describe('PWA Detection', () => { + it('returns false when not in standalone mode', () => { + // Setup: not in PWA mode + setupPWAMode(false); + + const { result } = renderHook(() => usePWA()); + + expect(result.current).toBe(false); + }); + + it('returns true when display-mode is standalone', () => { + // Setup: PWA mode via display-mode + setupPWAMode(true); + + const { result } = renderHook(() => usePWA()); + + expect(result.current).toBe(true); + }); + + it('returns true when navigator.standalone is true (iOS)', () => { + // Setup: iOS PWA mode (not using matchMedia) + setupPWAMode(false); // matchMedia returns false + setupIOSPWAMode(true); // But iOS standalone is true + + const { result } = renderHook(() => usePWA()); + + expect(result.current).toBe(true); + }); + + it('returns true when both display-mode and iOS standalone are true', () => { + // Setup: Both detection methods return true + setupPWAMode(true); + setupIOSPWAMode(true); + + const { result } = renderHook(() => usePWA()); + + expect(result.current).toBe(true); + }); + }); + + describe('Event Listener Management', () => { + it('registers event listener on mount', () => { + const addEventListener = vi.fn(); + const removeEventListener = vi.fn(); + + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: vi.fn().mockImplementation(() => ({ + matches: false, + media: '(display-mode: standalone)', + addEventListener, + removeEventListener, + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + + renderHook(() => usePWA()); + + expect(addEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + }); + + it('removes event listener on unmount', () => { + const addEventListener = vi.fn(); + const removeEventListener = vi.fn(); + + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: vi.fn().mockImplementation(() => ({ + matches: false, + media: '(display-mode: standalone)', + addEventListener, + removeEventListener, + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + + const { unmount } = renderHook(() => usePWA()); + + // Capture the registered handler + const registeredHandler = addEventListener.mock.calls[0][1]; + + unmount(); + + expect(removeEventListener).toHaveBeenCalledWith('change', registeredHandler); + }); + + it('handles multiple mount/unmount cycles correctly', () => { + setupPWAMode(false); + + // First mount + const { unmount: unmount1 } = renderHook(() => usePWA()); + unmount1(); + + // Second mount (should not cause errors) + const { result: result2, unmount: unmount2 } = renderHook(() => usePWA()); + expect(result2.current).toBe(false); + unmount2(); + + // Third mount with PWA enabled + setupPWAMode(true); + const { result: result3 } = renderHook(() => usePWA()); + expect(result3.current).toBe(true); + }); + }); + + describe('Display Mode Changes', () => { + it('updates state when display-mode changes', () => { + let matchesValue = false; + const listeners: Array<() => void> = []; + + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: vi.fn().mockImplementation(() => ({ + get matches() { + return matchesValue; + }, + media: '(display-mode: standalone)', + addEventListener: vi.fn((event: string, handler: () => void) => { + listeners.push(handler); + }), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + + const { result, rerender } = renderHook(() => usePWA()); + + // Initially not in PWA mode + expect(result.current).toBe(false); + + // Simulate entering PWA mode + act(() => { + matchesValue = true; + // Trigger the change event + listeners.forEach(handler => handler()); + }); + rerender(); + + // Should now detect PWA mode + expect(result.current).toBe(true); + }); + + it('updates state when exiting PWA mode', () => { + let matchesValue = true; + const listeners: Array<() => void> = []; + + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: vi.fn().mockImplementation(() => ({ + get matches() { + return matchesValue; + }, + media: '(display-mode: standalone)', + addEventListener: vi.fn((event: string, handler: () => void) => { + listeners.push(handler); + }), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + + const { result, rerender } = renderHook(() => usePWA()); + + // Initially in PWA mode + expect(result.current).toBe(true); + + // Simulate exiting PWA mode + act(() => { + matchesValue = false; + // Trigger the change event + listeners.forEach(handler => handler()); + }); + rerender(); + + // Should now detect non-PWA mode + expect(result.current).toBe(false); + }); + }); + + describe('Edge Cases', () => { + it('handles missing navigator.standalone gracefully', () => { + // Setup matchMedia to return false + setupPWAMode(false); + + // Ensure navigator.standalone is undefined + const originalStandalone = (window.navigator as any).standalone; + delete (window.navigator as any).standalone; + + const { result } = renderHook(() => usePWA()); + + expect(result.current).toBe(false); + + // Restore original value if it existed + if (originalStandalone !== undefined) { + (window.navigator as any).standalone = originalStandalone; + } + }); + }); + + describe('Consistency', () => { + it('returns the same value on re-renders if conditions unchanged', () => { + setupPWAMode(true); + + const { result, rerender } = renderHook(() => usePWA()); + + expect(result.current).toBe(true); + + // Re-render multiple times + rerender(); + expect(result.current).toBe(true); + + rerender(); + expect(result.current).toBe(true); + }); + + it('maintains state across re-renders', () => { + setupPWAMode(false); + + const { result, rerender } = renderHook(() => usePWA()); + + expect(result.current).toBe(false); + + rerender(); + expect(result.current).toBe(false); + }); + }); +}); diff --git a/frontend/src/hooks/usePWA.ts b/frontend/src/hooks/usePWA.ts new file mode 100644 index 0000000..64bdec7 --- /dev/null +++ b/frontend/src/hooks/usePWA.ts @@ -0,0 +1,31 @@ +import { useState, useEffect } from 'react'; + +/** + * Hook to detect if the app is running in PWA/standalone mode + * @returns boolean indicating if running as installed PWA + */ +export const usePWA = (): boolean => { + const [isPWA, setIsPWA] = useState(false); + + useEffect(() => { + const checkPWAMode = () => { + // Check if running in standalone mode (installed PWA) + const isStandalone = window.matchMedia('(display-mode: standalone)').matches; + // iOS Safari specific check + const isIOSStandalone = (window.navigator as any).standalone === true; + + setIsPWA(isStandalone || isIOSStandalone); + }; + + checkPWAMode(); + + // Listen for display mode changes + const mediaQuery = window.matchMedia('(display-mode: standalone)'); + const handleChange = () => checkPWAMode(); + + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, []); + + return isPWA; +}; diff --git a/frontend/src/pages/DocumentsPage.tsx b/frontend/src/pages/DocumentsPage.tsx index 66b64f2..afd7431 100644 --- a/frontend/src/pages/DocumentsPage.tsx +++ b/frontend/src/pages/DocumentsPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { @@ -108,10 +108,11 @@ const DocumentsPage: React.FC = () => { const [error, setError] = useState(null); const [viewMode, setViewMode] = useState('grid'); const [searchQuery, setSearchQuery] = useState(''); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(''); const [sortBy, setSortBy] = useState('created_at'); const [sortOrder, setSortOrder] = useState('desc'); const [ocrFilter, setOcrFilter] = useState(''); - + // Labels state const [availableLabels, setAvailableLabels] = useState([]); const [labelsLoading, setLabelsLoading] = useState(false); @@ -140,24 +141,57 @@ const DocumentsPage: React.FC = () => { const [retryHistoryModalOpen, setRetryHistoryModalOpen] = useState(false); const [selectedDocumentForHistory, setSelectedDocumentForHistory] = useState(null); + // Debounce search query to avoid making too many requests + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchQuery(searchQuery); + // Reset to first page when search query changes + if (searchQuery !== debouncedSearchQuery) { + setPagination(prev => ({ ...prev, offset: 0 })); + } + }, 300); // 300ms debounce delay + + return () => clearTimeout(timer); + }, [searchQuery]); + useEffect(() => { fetchDocuments(); fetchLabels(); - }, [pagination?.limit, pagination?.offset, ocrFilter]); + }, [pagination?.limit, pagination?.offset, ocrFilter, debouncedSearchQuery]); const fetchDocuments = async (): Promise => { if (!pagination) return; - + try { setLoading(true); - const response = await documentService.listWithPagination( - pagination.limit, - pagination.offset, - ocrFilter || undefined - ); - // Backend returns wrapped object with documents and pagination - setDocuments(response.data.documents || []); - setPagination(response.data.pagination || { total: 0, limit: 20, offset: 0, has_more: false }); + + // If there's a search query, use the search API to search all documents + if (debouncedSearchQuery.trim()) { + const response = await documentService.enhancedSearch({ + query: debouncedSearchQuery.trim(), + limit: pagination.limit, + offset: pagination.offset, + include_snippets: false, + }); + + setDocuments(response.data.documents || []); + setPagination({ + total: response.data.total || 0, + limit: pagination.limit, + offset: pagination.offset, + has_more: (pagination.offset + pagination.limit) < (response.data.total || 0) + }); + } else { + // Otherwise, use normal pagination to list recent documents + const response = await documentService.listWithPagination( + pagination.limit, + pagination.offset, + ocrFilter || undefined + ); + // Backend returns wrapped object with documents and pagination + setDocuments(response.data.documents || []); + setPagination(response.data.pagination || { total: 0, limit: 20, offset: 0, has_more: false }); + } } catch (err) { setError(t('common.status.error')); console.error(err); @@ -263,12 +297,9 @@ const DocumentsPage: React.FC = () => { }); }; - const filteredDocuments = (documents || []).filter(doc => - doc.original_filename.toLowerCase().includes(searchQuery.toLowerCase()) || - doc.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase())) - ); - - const sortedDocuments = [...filteredDocuments].sort((a, b) => { + // No need for client-side filtering anymore - search is done on the server + // When searchQuery is set, documents are already filtered by the server-side search API + const sortedDocuments = [...(documents || [])].sort((a, b) => { let aValue: any = a[sortBy]; let bValue: any = b[sortBy]; diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index ace59f9..8ddbd41 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -47,6 +47,7 @@ import { useAuth } from '../contexts/AuthContext'; import api, { queueService, ErrorHelper, ErrorCodes, userWatchService, UserWatchDirectoryResponse } from '../services/api'; import OcrLanguageSelector from '../components/OcrLanguageSelector'; import LanguageSelector from '../components/LanguageSelector'; +import { usePWA } from '../hooks/usePWA'; import { useTranslation } from 'react-i18next'; interface User { @@ -194,6 +195,7 @@ function useDebounce any>(func: T, delay: number): const SettingsPage: React.FC = () => { const { t } = useTranslation(); const { user: currentUser } = useAuth(); + const isPWA = usePWA(); const [tabValue, setTabValue] = useState(0); const [settings, setSettings] = useState({ ocrLanguage: 'eng', @@ -837,20 +839,41 @@ const SettingsPage: React.FC = () => { }; return ( - - + + {t('settings.title')} - + - + {tabValue === 0 && ( diff --git a/frontend/src/test/pwa-test-utils.ts b/frontend/src/test/pwa-test-utils.ts new file mode 100644 index 0000000..1962ede --- /dev/null +++ b/frontend/src/test/pwa-test-utils.ts @@ -0,0 +1,98 @@ +import { vi } from 'vitest'; + +/** + * Creates a matchMedia mock that can be configured for different query responses + * @param standaloneMode - Whether to simulate PWA standalone mode + * @returns Mock implementation of window.matchMedia + */ +export const createMatchMediaMock = (standaloneMode: boolean = false) => { + return vi.fn().mockImplementation((query: string) => ({ + matches: query.includes('standalone') ? standaloneMode : false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), // Deprecated but still supported + removeListener: vi.fn(), // Deprecated but still supported + dispatchEvent: vi.fn(), + })); +}; + +/** + * Sets up window.matchMedia to simulate PWA standalone mode + * @param enabled - Whether PWA mode should be enabled (default: true) + */ +export const setupPWAMode = (enabled: boolean = true) => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: createMatchMediaMock(enabled), + }); +}; + +/** + * Sets up iOS-specific PWA detection via navigator.standalone + * @param enabled - Whether iOS PWA mode should be enabled (default: true) + */ +export const setupIOSPWAMode = (enabled: boolean = true) => { + Object.defineProperty(window.navigator, 'standalone', { + writable: true, + configurable: true, + value: enabled, + }); +}; + +/** + * Resets PWA-related window properties to their default state + * Useful for cleanup between tests + */ +export const resetPWAMocks = () => { + // Reset matchMedia to default non-PWA state + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: createMatchMediaMock(false), + }); + + // Reset iOS standalone if it exists + if ('standalone' in window.navigator) { + Object.defineProperty(window.navigator, 'standalone', { + writable: true, + configurable: true, + value: undefined, + }); + } +}; + +/** + * Creates a matchMedia mock that supports multiple query patterns + * @param queries - Map of query patterns to their match states + * @returns Mock implementation that responds to different queries + * + * @example + * ```typescript + * const mockFn = createResponsiveMatchMediaMock({ + * 'standalone': true, // PWA mode + * 'max-width: 900px': true, // Mobile + * }); + * ``` + */ +export const createResponsiveMatchMediaMock = ( + queries: Record +) => { + return vi.fn().mockImplementation((query: string) => { + // Check if any of the query patterns match the input query + const matches = Object.entries(queries).some(([pattern, shouldMatch]) => + query.includes(pattern) ? shouldMatch : false + ); + + return { + matches, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }; + }); +}; diff --git a/frontend/src/test/test-utils.tsx b/frontend/src/test/test-utils.tsx index 0bb3879..206903d 100644 --- a/frontend/src/test/test-utils.tsx +++ b/frontend/src/test/test-utils.tsx @@ -6,6 +6,7 @@ import { I18nextProvider } from 'react-i18next' import i18n from 'i18next' import { initReactI18next } from 'react-i18next' import { NotificationProvider } from '../contexts/NotificationContext' +import { createMatchMediaMock, createResponsiveMatchMediaMock } from './pwa-test-utils' // Initialize i18n for tests i18n @@ -246,6 +247,77 @@ export const renderWithAdminUser = ( return renderWithAuthenticatedUser(ui, createMockAdminUser(), options) } +/** + * Renders component with PWA mode enabled + * Sets up window.matchMedia to simulate standalone display mode + */ +export const renderWithPWA = ( + ui: React.ReactElement, + options?: Omit & { + authValues?: Partial + routerProps?: any + } +) => { + // Set up matchMedia to return true for standalone mode + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: createMatchMediaMock(true), + }) + + return renderWithProviders(ui, options) +} + +/** + * Renders component with mobile viewport simulation + * Mocks useMediaQuery to return true for mobile breakpoints + */ +export const renderWithMobile = ( + ui: React.ReactElement, + options?: Omit & { + authValues?: Partial + routerProps?: any + } +) => { + // Set up matchMedia to simulate mobile viewport (max-width: 900px) + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: createResponsiveMatchMediaMock({ + '(max-width: 900px)': true, + '(max-width:900px)': true, // Without spaces variant + }), + }) + + return renderWithProviders(ui, options) +} + +/** + * Renders component with both PWA mode and mobile viewport + * Combines PWA standalone mode with mobile breakpoint simulation + */ +export const renderWithPWAMobile = ( + ui: React.ReactElement, + options?: Omit & { + authValues?: Partial + routerProps?: any + } +) => { + // Set up matchMedia to handle both PWA and mobile queries + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: createResponsiveMatchMediaMock({ + 'standalone': true, + '(display-mode: standalone)': true, + '(max-width: 900px)': true, + '(max-width:900px)': true, + }), + }) + + return renderWithProviders(ui, options) +} + // Mock localStorage consistently across tests export const createMockLocalStorage = () => { const storage: Record = {}