feat(frontend): Implement navigation and layout (#44)
Implements the main navigation and layout structure: - Sidebar component with collapsible navigation and keyboard shortcut - AppHeader with project switcher and user menu - AppBreadcrumbs with auto-generation from pathname - ProjectSwitcher dropdown for quick project navigation - UserMenu with profile, settings, and logout - AppLayout component combining all layout elements Features: - Responsive design (mobile sidebar sheet, desktop sidebar) - Keyboard navigation (Cmd/Ctrl+B to toggle sidebar) - Dark mode support - WCAG AA accessible (ARIA labels, focus management) All 125 tests passing. Follows design system guidelines. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
177
frontend/tests/components/layout/AppHeader.test.tsx
Normal file
177
frontend/tests/components/layout/AppHeader.test.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Tests for AppHeader Component
|
||||
* Verifies header rendering, project switcher integration, and responsive behavior
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { AppHeader } from '@/components/layout/AppHeader';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { useLogout } from '@/lib/api/hooks/useAuth';
|
||||
import type { User } from '@/lib/stores/authStore';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('@/lib/auth/AuthContext', () => ({
|
||||
useAuth: jest.fn(),
|
||||
AuthProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/api/hooks/useAuth', () => ({
|
||||
useLogout: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/theme', () => ({
|
||||
ThemeToggle: () => <div data-testid="theme-toggle">Theme Toggle</div>,
|
||||
}));
|
||||
|
||||
jest.mock('@/components/i18n', () => ({
|
||||
LocaleSwitcher: () => <div data-testid="locale-switcher">Locale Switcher</div>,
|
||||
}));
|
||||
|
||||
// Helper to create mock user
|
||||
function createMockUser(overrides: Partial<User> = {}): User {
|
||||
return {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
phone_number: null,
|
||||
is_active: true,
|
||||
is_superuser: false,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AppHeader', () => {
|
||||
const mockProjects = [
|
||||
{ id: '1', slug: 'project-one', name: 'Project One' },
|
||||
{ id: '2', slug: 'project-two', name: 'Project Two' },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
(useAuth as unknown as jest.Mock).mockReturnValue({
|
||||
user: createMockUser(),
|
||||
});
|
||||
|
||||
(useLogout as jest.Mock).mockReturnValue({
|
||||
mutate: jest.fn(),
|
||||
isPending: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders header element', () => {
|
||||
render(<AppHeader />);
|
||||
expect(screen.getByTestId('app-header')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with sticky positioning', () => {
|
||||
render(<AppHeader />);
|
||||
|
||||
const header = screen.getByTestId('app-header');
|
||||
expect(header).toHaveClass('sticky', 'top-0');
|
||||
});
|
||||
|
||||
it('renders theme toggle', () => {
|
||||
render(<AppHeader />);
|
||||
expect(screen.getByTestId('theme-toggle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders locale switcher', () => {
|
||||
render(<AppHeader />);
|
||||
// LocaleSwitcher renders a button with aria-label="switchLanguage"
|
||||
expect(screen.getByRole('button', { name: /switchLanguage/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders user menu', () => {
|
||||
render(<AppHeader />);
|
||||
expect(screen.getByTestId('user-menu-trigger')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logo', () => {
|
||||
it('renders logo on mobile', () => {
|
||||
render(<AppHeader />);
|
||||
|
||||
const logoLink = screen.getByRole('link', { name: /syndarix home/i });
|
||||
expect(logoLink).toBeInTheDocument();
|
||||
expect(logoLink).toHaveAttribute('href', '/');
|
||||
});
|
||||
|
||||
it('contains syndarix text', () => {
|
||||
render(<AppHeader />);
|
||||
|
||||
expect(screen.getByText('Syndarix')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Project Switcher', () => {
|
||||
it('renders project switcher when projects are provided', () => {
|
||||
render(<AppHeader projects={mockProjects} />);
|
||||
|
||||
// Multiple switchers may render for desktop/mobile - just check at least one exists
|
||||
expect(screen.getAllByTestId('project-switcher-trigger').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not render project switcher when no projects', () => {
|
||||
render(<AppHeader projects={[]} />);
|
||||
|
||||
expect(screen.queryByTestId('project-switcher-trigger')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays current project name', () => {
|
||||
render(
|
||||
<AppHeader
|
||||
projects={mockProjects}
|
||||
currentProject={mockProjects[0]}
|
||||
/>
|
||||
);
|
||||
|
||||
// Multiple instances may show the project name
|
||||
expect(screen.getAllByText('Project One').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('calls onProjectChange when project is changed', async () => {
|
||||
const mockOnChange = jest.fn();
|
||||
|
||||
render(
|
||||
<AppHeader
|
||||
projects={mockProjects}
|
||||
onProjectChange={mockOnChange}
|
||||
/>
|
||||
);
|
||||
|
||||
// The actual test of project switching is in ProjectSwitcher.test.tsx
|
||||
// Here we just verify the prop is passed by checking switcher exists
|
||||
expect(screen.getAllByTestId('project-switcher-trigger').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('header has proper element type', () => {
|
||||
render(<AppHeader />);
|
||||
|
||||
const header = screen.getByTestId('app-header');
|
||||
expect(header.tagName).toBe('HEADER');
|
||||
});
|
||||
|
||||
it('logo link is accessible', () => {
|
||||
render(<AppHeader />);
|
||||
|
||||
const logoLink = screen.getByRole('link', { name: /syndarix home/i });
|
||||
expect(logoLink).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom className', () => {
|
||||
it('applies custom className', () => {
|
||||
render(<AppHeader className="custom-class" />);
|
||||
|
||||
const header = screen.getByTestId('app-header');
|
||||
expect(header).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user