Compare commits
1 Commits
main
...
renovate/m
| Author | SHA1 | Date |
|---|---|---|
|
|
3b4dff56f9 |
File diff suppressed because it is too large
Load Diff
|
|
@ -55,6 +55,6 @@
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^7.0.0",
|
"vite": "^7.0.0",
|
||||||
"vitest": "^0.28.0"
|
"vitest": "^4.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -367,11 +367,7 @@ const GlobalSearchBar: React.FC<GlobalSearchBarProps> = ({ sx, ...props }) => {
|
||||||
}}
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
minWidth: {
|
minWidth: 600,
|
||||||
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'
|
||||||
|
|
|
||||||
|
|
@ -130,9 +130,7 @@ 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,
|
||||||
});
|
});
|
||||||
// Call onClose directly after successful submission
|
handleClose();
|
||||||
// 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
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,6 @@ 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;
|
||||||
|
|
@ -82,7 +80,6 @@ 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);
|
||||||
|
|
@ -441,7 +438,6 @@ 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%)',
|
||||||
|
|
@ -456,24 +452,15 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* Global Search Bar */}
|
{/* Global Search Bar */}
|
||||||
<Box sx={{
|
<Box sx={{ flexGrow: 2, display: 'flex', justifyContent: 'center', mx: 1, flex: '1 1 auto' }}>
|
||||||
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>
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={handleNotificationClick}
|
onClick={handleNotificationClick}
|
||||||
sx={{
|
sx={{
|
||||||
mr: { xs: 1, md: 2 },
|
mr: 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%)'
|
||||||
|
|
@ -493,8 +480,8 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Badge
|
<Badge
|
||||||
badgeContent={unreadCount}
|
badgeContent={unreadCount}
|
||||||
sx={{
|
sx={{
|
||||||
'& .MuiBadge-badge': {
|
'& .MuiBadge-badge': {
|
||||||
background: 'linear-gradient(135deg, #ef4444 0%, #f97316 100%)',
|
background: 'linear-gradient(135deg, #ef4444 0%, #f97316 100%)',
|
||||||
|
|
@ -510,8 +497,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
||||||
|
|
||||||
{/* Language Switcher */}
|
{/* Language Switcher */}
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
mr: { xs: 1, md: 2 },
|
mr: 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%)',
|
||||||
|
|
@ -532,7 +518,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
||||||
|
|
||||||
{/* Theme Toggle */}
|
{/* Theme Toggle */}
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
mr: { xs: 1, md: 2 },
|
mr: 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%)',
|
||||||
|
|
@ -674,23 +660,16 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Toolbar />
|
<Toolbar />
|
||||||
<Box sx={{
|
<Box sx={{ p: 3 }}>
|
||||||
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>
|
||||||
|
|
||||||
{/* Notification Panel */}
|
{/* Notification Panel */}
|
||||||
<NotificationPanel
|
<NotificationPanel
|
||||||
anchorEl={notificationAnchorEl}
|
anchorEl={notificationAnchorEl}
|
||||||
onClose={handleNotificationClose}
|
onClose={handleNotificationClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Bottom Navigation (PWA only) */}
|
|
||||||
<BottomNavigation />
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,192 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,265 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,250 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect } 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,11 +108,10 @@ 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>('');
|
||||||
|
|
||||||
// Labels state
|
// Labels state
|
||||||
const [availableLabels, setAvailableLabels] = useState<LabelData[]>([]);
|
const [availableLabels, setAvailableLabels] = useState<LabelData[]>([]);
|
||||||
const [labelsLoading, setLabelsLoading] = useState<boolean>(false);
|
const [labelsLoading, setLabelsLoading] = useState<boolean>(false);
|
||||||
|
|
@ -141,57 +140,24 @@ 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, debouncedSearchQuery]);
|
}, [pagination?.limit, pagination?.offset, ocrFilter]);
|
||||||
|
|
||||||
const fetchDocuments = async (): Promise<void> => {
|
const fetchDocuments = async (): Promise<void> => {
|
||||||
if (!pagination) return;
|
if (!pagination) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
const response = await documentService.listWithPagination(
|
||||||
// If there's a search query, use the search API to search all documents
|
pagination.limit,
|
||||||
if (debouncedSearchQuery.trim()) {
|
pagination.offset,
|
||||||
const response = await documentService.enhancedSearch({
|
ocrFilter || undefined
|
||||||
query: debouncedSearchQuery.trim(),
|
);
|
||||||
limit: pagination.limit,
|
// Backend returns wrapped object with documents and pagination
|
||||||
offset: pagination.offset,
|
setDocuments(response.data.documents || []);
|
||||||
include_snippets: false,
|
setPagination(response.data.pagination || { total: 0, limit: 20, offset: 0, has_more: 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) {
|
} catch (err) {
|
||||||
setError(t('common.status.error'));
|
setError(t('common.status.error'));
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
@ -297,9 +263,12 @@ const DocumentsPage: React.FC = () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// No need for client-side filtering anymore - search is done on the server
|
const filteredDocuments = (documents || []).filter(doc =>
|
||||||
// When searchQuery is set, documents are already filtered by the server-side search API
|
doc.original_filename.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
const sortedDocuments = [...(documents || [])].sort((a, b) => {
|
doc.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||||
|
);
|
||||||
|
|
||||||
|
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];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,6 @@ 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 {
|
||||||
|
|
@ -195,7 +194,6 @@ 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',
|
||||||
|
|
@ -839,41 +837,20 @@ const SettingsPage: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||||
maxWidth="lg"
|
<Typography variant="h4" sx={{ mb: 4 }}>
|
||||||
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
|
<Tabs value={tabValue} onChange={handleTabChange} aria-label="settings 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: { xs: 2, sm: 3 } }}>
|
<Box sx={{ p: 3 }}>
|
||||||
{tabValue === 0 && (
|
{tabValue === 0 && (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h6" sx={{ mb: 3 }}>
|
<Typography variant="h6" sx={{ mb: 3 }}>
|
||||||
|
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
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(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -6,7 +6,6 @@ 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
|
||||||
|
|
@ -247,77 +246,6 @@ 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> = {}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue