Merge pull request #387 from readur/feat-pwa-support

feat: PWA support take 2
This commit is contained in:
Alex 2025-12-11 20:11:28 -08:00 committed by GitHub
commit fcf7905a65
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1025 additions and 36 deletions

View File

@ -367,7 +367,11 @@ const GlobalSearchBar: React.FC<GlobalSearchBarProps> = ({ sx, ...props }) => {
}} }}
sx={{ sx={{
width: '100%', width: '100%',
minWidth: 600, minWidth: {
xs: '200px', // Mobile: minimum viable width
sm: '400px', // Small tablets
md: 600, // Desktop: original size
},
maxWidth: 1200, maxWidth: 1200,
'& .MuiOutlinedInput-root': { '& .MuiOutlinedInput-root': {
background: theme.palette.mode === 'light' background: theme.palette.mode === 'light'

View File

@ -130,7 +130,9 @@ const LabelCreateDialog: React.FC<LabelCreateDialogProps> = ({
background_color: formData.background_color || undefined, background_color: formData.background_color || undefined,
icon: formData.icon || 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) { } catch (error) {
console.error('Failed to save label:', error); console.error('Failed to save label:', error);
// Could add error handling UI here // Could add error handling UI here

View File

@ -46,6 +46,8 @@ import GlobalSearchBar from '../GlobalSearchBar';
import ThemeToggle from '../ThemeToggle/ThemeToggle'; import ThemeToggle from '../ThemeToggle/ThemeToggle';
import NotificationPanel from '../Notifications/NotificationPanel'; import NotificationPanel from '../Notifications/NotificationPanel';
import LanguageSwitcher from '../LanguageSwitcher'; import LanguageSwitcher from '../LanguageSwitcher';
import BottomNavigation from './BottomNavigation';
import { usePWA } from '../../hooks/usePWA';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
const drawerWidth = 280; const drawerWidth = 280;
@ -80,6 +82,7 @@ const getNavigationItems = (t: (key: string) => string): NavigationItem[] => [
const AppLayout: React.FC<AppLayoutProps> = ({ children }) => { const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
const theme = useMuiTheme(); const theme = useMuiTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md')); const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const isPWA = usePWA();
const [mobileOpen, setMobileOpen] = useState<boolean>(false); const [mobileOpen, setMobileOpen] = useState<boolean>(false);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [notificationAnchorEl, setNotificationAnchorEl] = useState<null | HTMLElement>(null); const [notificationAnchorEl, setNotificationAnchorEl] = useState<null | HTMLElement>(null);
@ -438,6 +441,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
fontWeight: 700, fontWeight: 700,
mr: 1, mr: 1,
fontSize: '1.1rem', fontSize: '1.1rem',
display: isPWA ? 'none' : 'block',
background: theme.palette.mode === 'light' background: theme.palette.mode === 'light'
? 'linear-gradient(135deg, #1e293b 0%, #6366f1 100%)' ? 'linear-gradient(135deg, #1e293b 0%, #6366f1 100%)'
: 'linear-gradient(135deg, #f8fafc 0%, #a855f7 100%)', : 'linear-gradient(135deg, #f8fafc 0%, #a855f7 100%)',
@ -452,7 +456,15 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
</Typography> </Typography>
{/* Global Search Bar */} {/* Global Search Bar */}
<Box sx={{ flexGrow: 2, display: 'flex', justifyContent: 'center', mx: 1, flex: '1 1 auto' }}> <Box sx={{
flexGrow: 2,
display: 'flex',
justifyContent: 'center',
mx: { xs: 0.5, md: 1 },
flex: '1 1 auto',
minWidth: { xs: 0, md: 'auto' },
overflow: 'hidden',
}}>
<GlobalSearchBar /> <GlobalSearchBar />
</Box> </Box>
@ -460,7 +472,8 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
<IconButton <IconButton
onClick={handleNotificationClick} onClick={handleNotificationClick}
sx={{ sx={{
mr: 2, mr: { xs: 1, md: 2 },
display: isPWA ? 'none' : 'flex',
color: 'text.secondary', color: 'text.secondary',
background: theme.palette.mode === 'light' background: theme.palette.mode === 'light'
? 'linear-gradient(135deg, rgba(255,255,255,0.8) 0%, rgba(248,250,252,0.6) 100%)' ? 'linear-gradient(135deg, rgba(255,255,255,0.8) 0%, rgba(248,250,252,0.6) 100%)'
@ -497,7 +510,8 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
{/* Language Switcher */} {/* Language Switcher */}
<Box sx={{ <Box sx={{
mr: 2, mr: { xs: 1, md: 2 },
display: isPWA ? 'none' : { xs: 'none', sm: 'block' },
background: theme.palette.mode === 'light' background: theme.palette.mode === 'light'
? 'linear-gradient(135deg, rgba(255,255,255,0.8) 0%, rgba(248,250,252,0.6) 100%)' ? 'linear-gradient(135deg, rgba(255,255,255,0.8) 0%, rgba(248,250,252,0.6) 100%)'
: 'linear-gradient(135deg, rgba(50,50,50,0.8) 0%, rgba(30,30,30,0.6) 100%)', : 'linear-gradient(135deg, rgba(50,50,50,0.8) 0%, rgba(30,30,30,0.6) 100%)',
@ -518,7 +532,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
{/* Theme Toggle */} {/* Theme Toggle */}
<Box sx={{ <Box sx={{
mr: 2, mr: { xs: 1, md: 2 },
background: theme.palette.mode === 'light' background: theme.palette.mode === 'light'
? 'linear-gradient(135deg, rgba(255,255,255,0.8) 0%, rgba(248,250,252,0.6) 100%)' ? 'linear-gradient(135deg, rgba(255,255,255,0.8) 0%, rgba(248,250,252,0.6) 100%)'
: 'linear-gradient(135deg, rgba(50,50,50,0.8) 0%, rgba(30,30,30,0.6) 100%)', : 'linear-gradient(135deg, rgba(50,50,50,0.8) 0%, rgba(30,30,30,0.6) 100%)',
@ -660,7 +674,11 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
}} }}
> >
<Toolbar /> <Toolbar />
<Box sx={{ p: 3 }}> <Box sx={{
p: 3,
// Add bottom padding when bottom nav is visible (PWA mode on mobile)
pb: isPWA && isMobile ? 'calc(64px + 24px + 8px + env(safe-area-inset-bottom, 0px))' : 3,
}}>
{children} {children}
</Box> </Box>
</Box> </Box>
@ -670,6 +688,9 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
anchorEl={notificationAnchorEl} anchorEl={notificationAnchorEl}
onClose={handleNotificationClose} onClose={handleNotificationClose}
/> />
{/* Bottom Navigation (PWA only) */}
<BottomNavigation />
</Box> </Box>
); );
}; };

View File

@ -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 (
<Paper
sx={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 1100,
display: { xs: 'block', md: 'none' },
background: theme.palette.mode === 'light'
? 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(248,250,252,0.98) 100%)'
: 'linear-gradient(180deg, rgba(30,30,30,0.98) 0%, rgba(18,18,18,0.98) 100%)',
backdropFilter: 'blur(20px)',
borderTop: theme.palette.mode === 'light'
? '1px solid rgba(226,232,240,0.5)'
: '1px solid rgba(255,255,255,0.1)',
boxShadow: theme.palette.mode === 'light'
? '0 -4px 32px rgba(0,0,0,0.08)'
: '0 -4px 32px rgba(0,0,0,0.3)',
// iOS safe area support - add 8px fixed padding for extra space
paddingBottom: 'calc(8px + env(safe-area-inset-bottom, 0px))',
}}
elevation={0}
>
<MuiBottomNavigation
value={getNavValue(location.pathname)}
onChange={handleNavigation}
sx={{
background: 'transparent',
height: '64px',
'& .MuiBottomNavigationAction-root': {
color: 'text.secondary',
minWidth: 'auto',
padding: '8px 12px',
gap: '4px',
transition: 'all 0.2s ease-in-out',
'& .MuiBottomNavigationAction-label': {
fontSize: '0.75rem',
fontWeight: 500,
letterSpacing: '0.025em',
marginTop: '4px',
transition: 'all 0.2s ease-in-out',
'&.Mui-selected': {
fontSize: '0.75rem',
},
},
'& .MuiSvgIcon-root': {
fontSize: '1.5rem',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
},
'&.Mui-selected': {
color: '#6366f1',
'& .MuiSvgIcon-root': {
transform: 'scale(1.1)',
filter: 'drop-shadow(0 2px 8px rgba(99,102,241,0.3))',
},
},
// iOS-style touch feedback
'@media (pointer: coarse)': {
minHeight: '56px',
'&:active': {
transform: 'scale(0.95)',
},
},
},
}}
>
<BottomNavigationAction
label={t('navigation.dashboard')}
value="dashboard"
icon={<DashboardIcon />}
sx={{
'&.Mui-selected': {
'& .MuiBottomNavigationAction-label': {
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
fontWeight: 600,
},
},
}}
/>
<BottomNavigationAction
label={t('navigation.upload')}
value="upload"
icon={<UploadIcon />}
sx={{
'&.Mui-selected': {
'& .MuiBottomNavigationAction-label': {
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
fontWeight: 600,
},
},
}}
/>
<BottomNavigationAction
label={t('navigation.labels')}
value="labels"
icon={<LabelIcon />}
sx={{
'&.Mui-selected': {
'& .MuiBottomNavigationAction-label': {
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
fontWeight: 600,
},
},
}}
/>
<BottomNavigationAction
label={t('settings.title')}
value="settings"
icon={<SettingsIcon />}
sx={{
'&.Mui-selected': {
'& .MuiBottomNavigationAction-label': {
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
fontWeight: 600,
},
},
}}
/>
</MuiBottomNavigation>
</Paper>
);
};
export default BottomNavigation;

View File

@ -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 }) => (
<actual.MemoryRouter initialEntries={props.initialEntries || ['/dashboard']} {...props}>
{children}
</actual.MemoryRouter>
),
};
});
describe('BottomNavigation', () => {
beforeEach(() => {
mockNavigate.mockClear();
resetPWAMocks();
});
describe('PWA Detection', () => {
it('returns null when not in PWA mode', () => {
setupPWAMode(false);
const { container } = renderWithProviders(<BottomNavigation />, {
routerProps: { initialEntries: ['/dashboard'] },
});
expect(container.firstChild).toBeNull();
});
it('renders when in PWA mode', () => {
setupPWAMode(true);
renderWithPWA(<BottomNavigation />, {
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(<BottomNavigation />, {
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(<BottomNavigation />, {
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(<BottomNavigation />, {
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(<BottomNavigation />, {
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(<BottomNavigation />, {
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(<BottomNavigation />, {
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(<BottomNavigation />, {
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(<BottomNavigation />, {
routerProps: { initialEntries: ['/dashboard'] },
});
const paper = container.querySelector('[class*="MuiPaper-root"]');
expect(paper).toBeInTheDocument();
});
it('has fixed position at bottom', () => {
const { container } = renderWithPWA(<BottomNavigation />, {
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(<BottomNavigation />, {
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(<BottomNavigation />, {
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(<BottomNavigation />, {
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(<BottomNavigation />, {
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(<BottomNavigation />, {
routerProps: { initialEntries: ['/dashboard'] },
});
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(4);
// Re-render should maintain same structure
rerender(<BottomNavigation />);
const buttonsAfterRerender = screen.getAllByRole('button');
expect(buttonsAfterRerender).toHaveLength(4);
});
});
});

View File

@ -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);
});
});
});

View File

@ -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;
};

View File

@ -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 { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@ -108,6 +108,7 @@ const DocumentsPage: React.FC = () => {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<ViewMode>('grid'); const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [searchQuery, setSearchQuery] = useState<string>(''); const [searchQuery, setSearchQuery] = useState<string>('');
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState<string>('');
const [sortBy, setSortBy] = useState<SortField>('created_at'); const [sortBy, setSortBy] = useState<SortField>('created_at');
const [sortOrder, setSortOrder] = useState<SortOrder>('desc'); const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
const [ocrFilter, setOcrFilter] = useState<string>(''); const [ocrFilter, setOcrFilter] = useState<string>('');
@ -140,16 +141,48 @@ const DocumentsPage: React.FC = () => {
const [retryHistoryModalOpen, setRetryHistoryModalOpen] = useState<boolean>(false); const [retryHistoryModalOpen, setRetryHistoryModalOpen] = useState<boolean>(false);
const [selectedDocumentForHistory, setSelectedDocumentForHistory] = useState<string | null>(null); const [selectedDocumentForHistory, setSelectedDocumentForHistory] = useState<string | null>(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(() => { useEffect(() => {
fetchDocuments(); fetchDocuments();
fetchLabels(); fetchLabels();
}, [pagination?.limit, pagination?.offset, ocrFilter]); }, [pagination?.limit, pagination?.offset, ocrFilter, debouncedSearchQuery]);
const fetchDocuments = async (): Promise<void> => { const fetchDocuments = async (): Promise<void> => {
if (!pagination) return; if (!pagination) return;
try { try {
setLoading(true); setLoading(true);
// 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( const response = await documentService.listWithPagination(
pagination.limit, pagination.limit,
pagination.offset, pagination.offset,
@ -158,6 +191,7 @@ const DocumentsPage: React.FC = () => {
// Backend returns wrapped object with documents and pagination // Backend returns wrapped object with documents and pagination
setDocuments(response.data.documents || []); setDocuments(response.data.documents || []);
setPagination(response.data.pagination || { total: 0, limit: 20, offset: 0, has_more: false }); setPagination(response.data.pagination || { total: 0, limit: 20, offset: 0, has_more: false });
}
} catch (err) { } catch (err) {
setError(t('common.status.error')); setError(t('common.status.error'));
console.error(err); console.error(err);
@ -263,12 +297,9 @@ const DocumentsPage: React.FC = () => {
}); });
}; };
const filteredDocuments = (documents || []).filter(doc => // No need for client-side filtering anymore - search is done on the server
doc.original_filename.toLowerCase().includes(searchQuery.toLowerCase()) || // When searchQuery is set, documents are already filtered by the server-side search API
doc.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase())) const sortedDocuments = [...(documents || [])].sort((a, b) => {
);
const sortedDocuments = [...filteredDocuments].sort((a, b) => {
let aValue: any = a[sortBy]; let aValue: any = a[sortBy];
let bValue: any = b[sortBy]; let bValue: any = b[sortBy];

View File

@ -47,6 +47,7 @@ import { useAuth } from '../contexts/AuthContext';
import api, { queueService, ErrorHelper, ErrorCodes, userWatchService, UserWatchDirectoryResponse } from '../services/api'; import api, { queueService, ErrorHelper, ErrorCodes, userWatchService, UserWatchDirectoryResponse } from '../services/api';
import OcrLanguageSelector from '../components/OcrLanguageSelector'; import OcrLanguageSelector from '../components/OcrLanguageSelector';
import LanguageSelector from '../components/LanguageSelector'; import LanguageSelector from '../components/LanguageSelector';
import { usePWA } from '../hooks/usePWA';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
interface User { interface User {
@ -194,6 +195,7 @@ function useDebounce<T extends (...args: any[]) => any>(func: T, delay: number):
const SettingsPage: React.FC = () => { const SettingsPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
const isPWA = usePWA();
const [tabValue, setTabValue] = useState<number>(0); const [tabValue, setTabValue] = useState<number>(0);
const [settings, setSettings] = useState<Settings>({ const [settings, setSettings] = useState<Settings>({
ocrLanguage: 'eng', ocrLanguage: 'eng',
@ -837,20 +839,41 @@ const SettingsPage: React.FC = () => {
}; };
return ( return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}> <Container
<Typography variant="h4" sx={{ mb: 4 }}> maxWidth="lg"
sx={{
mt: 4,
mb: 4,
px: isPWA ? { xs: 1, sm: 2, md: 3 } : undefined,
}}
>
<Typography variant="h4" sx={{ mb: 4, px: isPWA ? { xs: 1, sm: 0 } : 0 }}>
{t('settings.title')} {t('settings.title')}
</Typography> </Typography>
<Paper sx={{ width: '100%' }}> <Paper sx={{ width: '100%' }}>
<Tabs value={tabValue} onChange={handleTabChange} aria-label="settings tabs"> <Tabs
value={tabValue}
onChange={handleTabChange}
aria-label="settings tabs"
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
sx={{
'& .MuiTabs-scrollButtons': {
'&.Mui-disabled': {
opacity: 0.3,
},
},
}}
>
<Tab label={t('settings.tabs.general')} /> <Tab label={t('settings.tabs.general')} />
<Tab label={t('settings.tabs.ocrSettings')} /> <Tab label={t('settings.tabs.ocrSettings')} />
<Tab label={t('settings.tabs.userManagement')} /> <Tab label={t('settings.tabs.userManagement')} />
<Tab label={t('settings.tabs.serverConfiguration')} /> <Tab label={t('settings.tabs.serverConfiguration')} />
</Tabs> </Tabs>
<Box sx={{ p: 3 }}> <Box sx={{ p: { xs: 2, sm: 3 } }}>
{tabValue === 0 && ( {tabValue === 0 && (
<Box> <Box>
<Typography variant="h6" sx={{ mb: 3 }}> <Typography variant="h6" sx={{ mb: 3 }}>

View File

@ -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<string, boolean>
) => {
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(),
};
});
};

View File

@ -6,6 +6,7 @@ import { I18nextProvider } from 'react-i18next'
import i18n from 'i18next' import i18n from 'i18next'
import { initReactI18next } from 'react-i18next' import { initReactI18next } from 'react-i18next'
import { NotificationProvider } from '../contexts/NotificationContext' import { NotificationProvider } from '../contexts/NotificationContext'
import { createMatchMediaMock, createResponsiveMatchMediaMock } from './pwa-test-utils'
// Initialize i18n for tests // Initialize i18n for tests
i18n i18n
@ -246,6 +247,77 @@ export const renderWithAdminUser = (
return renderWithAuthenticatedUser(ui, createMockAdminUser(), options) 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<RenderOptions, 'wrapper'> & {
authValues?: Partial<MockAuthContextType>
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<RenderOptions, 'wrapper'> & {
authValues?: Partial<MockAuthContextType>
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<RenderOptions, 'wrapper'> & {
authValues?: Partial<MockAuthContextType>
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 // Mock localStorage consistently across tests
export const createMockLocalStorage = () => { export const createMockLocalStorage = () => {
const storage: Record<string, string> = {} const storage: Record<string, string> = {}