forked from cardosofelipe/fast-next-template
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>
323 lines
9.5 KiB
TypeScript
323 lines
9.5 KiB
TypeScript
/**
|
|
* Tests for Sidebar Component
|
|
* Verifies navigation, collapsible behavior, project context, and accessibility
|
|
*/
|
|
|
|
import { render, screen, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { Sidebar } from '@/components/layout/Sidebar';
|
|
import { useAuth } from '@/lib/auth/AuthContext';
|
|
import { mockUsePathname } from 'next-intl/navigation';
|
|
import type { User } from '@/lib/stores/authStore';
|
|
|
|
// Mock dependencies
|
|
jest.mock('@/lib/auth/AuthContext', () => ({
|
|
useAuth: jest.fn(),
|
|
AuthProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
}));
|
|
|
|
// Helper to create mock user
|
|
function createMockUser(overrides: Partial<User> = {}): User {
|
|
return {
|
|
id: 'user-123',
|
|
email: 'user@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('Sidebar', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockUsePathname.mockReturnValue('/projects');
|
|
(useAuth as unknown as jest.Mock).mockReturnValue({
|
|
user: createMockUser(),
|
|
});
|
|
});
|
|
|
|
describe('Rendering', () => {
|
|
it('renders sidebar with navigation header', () => {
|
|
render(<Sidebar />);
|
|
expect(screen.getByText('Navigation')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders sidebar with correct test id', () => {
|
|
render(<Sidebar />);
|
|
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders main navigation items', () => {
|
|
render(<Sidebar />);
|
|
expect(screen.getByTestId('nav-projects')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders collapse toggle button', () => {
|
|
render(<Sidebar />);
|
|
|
|
const toggleButton = screen.getByTestId('sidebar-toggle');
|
|
expect(toggleButton).toBeInTheDocument();
|
|
expect(toggleButton).toHaveAttribute('aria-label', 'Collapse sidebar');
|
|
});
|
|
});
|
|
|
|
describe('Project Navigation', () => {
|
|
it('renders project-specific navigation when projectSlug is provided', () => {
|
|
render(<Sidebar projectSlug="my-project" />);
|
|
|
|
expect(screen.getByTestId('nav-dashboard')).toBeInTheDocument();
|
|
expect(screen.getByTestId('nav-issues')).toBeInTheDocument();
|
|
expect(screen.getByTestId('nav-sprints')).toBeInTheDocument();
|
|
expect(screen.getByTestId('nav-agents')).toBeInTheDocument();
|
|
expect(screen.getByTestId('nav-settings')).toBeInTheDocument();
|
|
});
|
|
|
|
it('does not render project navigation without projectSlug', () => {
|
|
render(<Sidebar />);
|
|
|
|
expect(screen.queryByTestId('nav-dashboard')).not.toBeInTheDocument();
|
|
expect(screen.queryByTestId('nav-issues')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('generates correct hrefs for project navigation', () => {
|
|
render(<Sidebar projectSlug="test-project" />);
|
|
|
|
expect(screen.getByTestId('nav-dashboard')).toHaveAttribute(
|
|
'href',
|
|
'/projects/test-project/dashboard'
|
|
);
|
|
expect(screen.getByTestId('nav-issues')).toHaveAttribute(
|
|
'href',
|
|
'/projects/test-project/issues'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Admin Navigation', () => {
|
|
it('renders admin navigation for superusers', () => {
|
|
(useAuth as unknown as jest.Mock).mockReturnValue({
|
|
user: createMockUser({ is_superuser: true }),
|
|
});
|
|
|
|
render(<Sidebar />);
|
|
|
|
expect(screen.getByTestId('nav-agent-types')).toBeInTheDocument();
|
|
expect(screen.getByTestId('nav-admin-panel')).toBeInTheDocument();
|
|
});
|
|
|
|
it('does not render admin navigation for regular users', () => {
|
|
(useAuth as unknown as jest.Mock).mockReturnValue({
|
|
user: createMockUser({ is_superuser: false }),
|
|
});
|
|
|
|
render(<Sidebar />);
|
|
|
|
expect(screen.queryByTestId('nav-agent-types')).not.toBeInTheDocument();
|
|
expect(screen.queryByTestId('nav-admin-panel')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Active State Highlighting', () => {
|
|
it('highlights projects link when on /projects', () => {
|
|
mockUsePathname.mockReturnValue('/projects');
|
|
|
|
render(<Sidebar />);
|
|
|
|
const projectsLink = screen.getByTestId('nav-projects');
|
|
expect(projectsLink).toHaveClass('bg-accent');
|
|
});
|
|
|
|
it('highlights issues link when on project issues page', () => {
|
|
mockUsePathname.mockReturnValue('/projects/my-project/issues');
|
|
|
|
render(<Sidebar projectSlug="my-project" />);
|
|
|
|
const issuesLink = screen.getByTestId('nav-issues');
|
|
expect(issuesLink).toHaveClass('bg-accent');
|
|
});
|
|
|
|
it('sets aria-current on active link', () => {
|
|
mockUsePathname.mockReturnValue('/projects');
|
|
|
|
render(<Sidebar />);
|
|
|
|
const projectsLink = screen.getByTestId('nav-projects');
|
|
expect(projectsLink).toHaveAttribute('aria-current', 'page');
|
|
});
|
|
});
|
|
|
|
describe('Collapsible Behavior', () => {
|
|
it('starts in expanded state', () => {
|
|
render(<Sidebar />);
|
|
|
|
expect(screen.getByText('Navigation')).toBeInTheDocument();
|
|
expect(screen.getByText('Projects')).toBeInTheDocument();
|
|
});
|
|
|
|
it('collapses when toggle button is clicked', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(<Sidebar />);
|
|
|
|
const toggleButton = screen.getByTestId('sidebar-toggle');
|
|
await user.click(toggleButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByText('Navigation')).not.toBeInTheDocument();
|
|
});
|
|
|
|
expect(toggleButton).toHaveAttribute('aria-label', 'Expand sidebar');
|
|
});
|
|
|
|
it('expands when toggle button is clicked twice', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(<Sidebar />);
|
|
|
|
const toggleButton = screen.getByTestId('sidebar-toggle');
|
|
|
|
// Collapse
|
|
await user.click(toggleButton);
|
|
await waitFor(() => {
|
|
expect(screen.queryByText('Navigation')).not.toBeInTheDocument();
|
|
});
|
|
|
|
// Expand
|
|
await user.click(toggleButton);
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Navigation')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('adds title attribute to links when collapsed', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(<Sidebar />);
|
|
|
|
const projectsLink = screen.getByTestId('nav-projects');
|
|
|
|
// No title in expanded state
|
|
expect(projectsLink).not.toHaveAttribute('title');
|
|
|
|
// Click to collapse
|
|
const toggleButton = screen.getByTestId('sidebar-toggle');
|
|
await user.click(toggleButton);
|
|
|
|
// Title should be present in collapsed state
|
|
await waitFor(() => {
|
|
expect(projectsLink).toHaveAttribute('title', 'Projects');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('User Info Display', () => {
|
|
it('displays user info when expanded', () => {
|
|
(useAuth as unknown as jest.Mock).mockReturnValue({
|
|
user: createMockUser({
|
|
first_name: 'John',
|
|
last_name: 'Doe',
|
|
email: 'john@example.com',
|
|
}),
|
|
});
|
|
|
|
render(<Sidebar />);
|
|
|
|
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
|
expect(screen.getByText('john@example.com')).toBeInTheDocument();
|
|
});
|
|
|
|
it('displays user initial from first name', () => {
|
|
(useAuth as unknown as jest.Mock).mockReturnValue({
|
|
user: createMockUser({
|
|
first_name: 'Alice',
|
|
email: 'alice@example.com',
|
|
}),
|
|
});
|
|
|
|
render(<Sidebar />);
|
|
|
|
expect(screen.getByText('A')).toBeInTheDocument();
|
|
});
|
|
|
|
it('displays email initial when no first name', () => {
|
|
(useAuth as unknown as jest.Mock).mockReturnValue({
|
|
user: createMockUser({
|
|
first_name: '',
|
|
email: 'test@example.com',
|
|
}),
|
|
});
|
|
|
|
render(<Sidebar />);
|
|
|
|
expect(screen.getByText('T')).toBeInTheDocument();
|
|
});
|
|
|
|
it('hides user info when collapsed', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
(useAuth as unknown as jest.Mock).mockReturnValue({
|
|
user: createMockUser({
|
|
first_name: 'John',
|
|
last_name: 'Doe',
|
|
email: 'john@example.com',
|
|
}),
|
|
});
|
|
|
|
render(<Sidebar />);
|
|
|
|
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
|
|
|
const toggleButton = screen.getByTestId('sidebar-toggle');
|
|
await user.click(toggleButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('john@example.com')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Mobile Navigation', () => {
|
|
it('renders mobile menu trigger button', () => {
|
|
render(<Sidebar />);
|
|
|
|
expect(screen.getByTestId('mobile-menu-trigger')).toBeInTheDocument();
|
|
});
|
|
|
|
it('has accessible label on mobile trigger', () => {
|
|
render(<Sidebar />);
|
|
|
|
const trigger = screen.getByTestId('mobile-menu-trigger');
|
|
expect(trigger).toHaveAttribute('aria-label', 'Open navigation menu');
|
|
});
|
|
});
|
|
|
|
describe('Accessibility', () => {
|
|
it('has proper aria-label on sidebar', () => {
|
|
render(<Sidebar />);
|
|
|
|
const sidebar = screen.getByTestId('sidebar');
|
|
expect(sidebar).toHaveAttribute('aria-label', 'Main navigation');
|
|
});
|
|
|
|
it('navigation links are keyboard accessible', () => {
|
|
render(<Sidebar />);
|
|
|
|
const projectsLink = screen.getByTestId('nav-projects');
|
|
expect(projectsLink.tagName).toBe('A');
|
|
});
|
|
|
|
it('has proper focus styling classes on links', () => {
|
|
render(<Sidebar />);
|
|
|
|
const projectsLink = screen.getByTestId('nav-projects');
|
|
expect(projectsLink).toHaveClass('focus-visible:ring-2');
|
|
});
|
|
});
|
|
});
|