forked from cardosofelipe/fast-next-template
Merge branch 'feature/44-navigation-layout' into dev
This commit is contained in:
149
frontend/src/components/layout/AppBreadcrumbs.tsx
Normal file
149
frontend/src/components/layout/AppBreadcrumbs.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* Application Breadcrumbs
|
||||||
|
* Displays navigation breadcrumb trail for application pages
|
||||||
|
* Supports dynamic breadcrumb items and project context
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Link } from '@/lib/i18n/routing';
|
||||||
|
import { usePathname } from '@/lib/i18n/routing';
|
||||||
|
import { ChevronRight, Home } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface BreadcrumbItem {
|
||||||
|
/** Display label */
|
||||||
|
label: string;
|
||||||
|
/** Navigation href */
|
||||||
|
href: string;
|
||||||
|
/** If true, this is the current page */
|
||||||
|
current?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppBreadcrumbsProps {
|
||||||
|
/** Custom breadcrumb items (overrides auto-generation) */
|
||||||
|
items?: BreadcrumbItem[];
|
||||||
|
/** Show home icon as first item */
|
||||||
|
showHome?: boolean;
|
||||||
|
/** Additional className */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path segment to label mappings */
|
||||||
|
const pathLabels: Record<string, string> = {
|
||||||
|
projects: 'Projects',
|
||||||
|
issues: 'Issues',
|
||||||
|
sprints: 'Sprints',
|
||||||
|
agents: 'Agents',
|
||||||
|
settings: 'Settings',
|
||||||
|
dashboard: 'Dashboard',
|
||||||
|
admin: 'Admin',
|
||||||
|
'agent-types': 'Agent Types',
|
||||||
|
users: 'Users',
|
||||||
|
organizations: 'Organizations',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate breadcrumb items from the current pathname
|
||||||
|
*/
|
||||||
|
function generateBreadcrumbs(pathname: string): BreadcrumbItem[] {
|
||||||
|
const segments = pathname.split('/').filter(Boolean);
|
||||||
|
const breadcrumbs: BreadcrumbItem[] = [];
|
||||||
|
|
||||||
|
let currentPath = '';
|
||||||
|
|
||||||
|
segments.forEach((segment, index) => {
|
||||||
|
currentPath += `/${segment}`;
|
||||||
|
|
||||||
|
// Skip locale segments (2-letter codes)
|
||||||
|
if (segment.length === 2 && /^[a-z]{2}$/i.test(segment)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if segment is a dynamic ID/slug (not in pathLabels)
|
||||||
|
const label = pathLabels[segment] || segment;
|
||||||
|
const isLast = index === segments.length - 1;
|
||||||
|
|
||||||
|
breadcrumbs.push({
|
||||||
|
label,
|
||||||
|
href: currentPath,
|
||||||
|
current: isLast,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return breadcrumbs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppBreadcrumbs({
|
||||||
|
items,
|
||||||
|
showHome = true,
|
||||||
|
className,
|
||||||
|
}: AppBreadcrumbsProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// Use provided items or generate from pathname
|
||||||
|
const breadcrumbs = items ?? generateBreadcrumbs(pathname);
|
||||||
|
|
||||||
|
// Don't render if no breadcrumbs or only home
|
||||||
|
if (breadcrumbs.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="Breadcrumb"
|
||||||
|
className={cn('border-b bg-background px-4 py-3 lg:px-6', className)}
|
||||||
|
data-testid="breadcrumbs"
|
||||||
|
>
|
||||||
|
<ol className="flex flex-wrap items-center gap-1 text-sm">
|
||||||
|
{/* Home link */}
|
||||||
|
{showHome && (
|
||||||
|
<li className="flex items-center">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-sm"
|
||||||
|
aria-label="Home"
|
||||||
|
data-testid="breadcrumb-home"
|
||||||
|
>
|
||||||
|
<Home className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Breadcrumb items */}
|
||||||
|
{breadcrumbs.map((breadcrumb, index) => {
|
||||||
|
const showSeparator = showHome || index > 0;
|
||||||
|
const testId = `breadcrumb-${breadcrumb.label.toLowerCase().replace(/\s+/g, '-')}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={breadcrumb.href} className="flex items-center">
|
||||||
|
{showSeparator && (
|
||||||
|
<ChevronRight
|
||||||
|
className="mx-1 h-4 w-4 text-muted-foreground flex-shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{breadcrumb.current ? (
|
||||||
|
<span
|
||||||
|
className="font-medium text-foreground"
|
||||||
|
aria-current="page"
|
||||||
|
data-testid={testId}
|
||||||
|
>
|
||||||
|
{breadcrumb.label}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={breadcrumb.href}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-sm"
|
||||||
|
data-testid={testId}
|
||||||
|
>
|
||||||
|
{breadcrumb.label}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
frontend/src/components/layout/AppHeader.tsx
Normal file
100
frontend/src/components/layout/AppHeader.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Application Header Component
|
||||||
|
* Top header bar for the main application layout
|
||||||
|
* Includes logo, project switcher, and user menu
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { Link } from '@/lib/i18n/routing';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ThemeToggle } from '@/components/theme';
|
||||||
|
import { LocaleSwitcher } from '@/components/i18n';
|
||||||
|
import { ProjectSwitcher } from './ProjectSwitcher';
|
||||||
|
import { UserMenu } from './UserMenu';
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppHeaderProps {
|
||||||
|
/** Currently selected project */
|
||||||
|
currentProject?: Project;
|
||||||
|
/** List of available projects */
|
||||||
|
projects?: Project[];
|
||||||
|
/** Callback when project is changed */
|
||||||
|
onProjectChange?: (projectSlug: string) => void;
|
||||||
|
/** Additional className */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppHeader({
|
||||||
|
currentProject,
|
||||||
|
projects = [],
|
||||||
|
onProjectChange,
|
||||||
|
className,
|
||||||
|
}: AppHeaderProps) {
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={cn(
|
||||||
|
'sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-testid="app-header"
|
||||||
|
>
|
||||||
|
<div className="flex h-14 items-center px-4 lg:px-6">
|
||||||
|
{/* Left side - Logo and Project Switcher */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Logo - visible on mobile, hidden on desktop when sidebar is visible */}
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-2 lg:hidden"
|
||||||
|
aria-label="Syndarix home"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/logo-icon.svg"
|
||||||
|
alt=""
|
||||||
|
width={28}
|
||||||
|
height={28}
|
||||||
|
className="h-7 w-7"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="font-semibold text-foreground">Syndarix</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Project Switcher */}
|
||||||
|
{projects.length > 0 && (
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<ProjectSwitcher
|
||||||
|
currentProject={currentProject}
|
||||||
|
projects={projects}
|
||||||
|
onProjectChange={onProjectChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Actions */}
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<ThemeToggle />
|
||||||
|
<LocaleSwitcher />
|
||||||
|
<UserMenu />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Project Switcher */}
|
||||||
|
{projects.length > 0 && (
|
||||||
|
<div className="border-t px-4 py-2 sm:hidden">
|
||||||
|
<ProjectSwitcher
|
||||||
|
currentProject={currentProject}
|
||||||
|
projects={projects}
|
||||||
|
onProjectChange={onProjectChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
170
frontend/src/components/layout/AppLayout.tsx
Normal file
170
frontend/src/components/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* Application Layout Component
|
||||||
|
* Main layout wrapper that combines sidebar, header, breadcrumbs, and content area
|
||||||
|
* Provides the standard application shell for authenticated pages
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Sidebar } from './Sidebar';
|
||||||
|
import { AppHeader } from './AppHeader';
|
||||||
|
import { AppBreadcrumbs, type BreadcrumbItem } from './AppBreadcrumbs';
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppLayoutProps {
|
||||||
|
/** Page content */
|
||||||
|
children: ReactNode;
|
||||||
|
/** Current project (for project-specific pages) */
|
||||||
|
currentProject?: Project;
|
||||||
|
/** List of available projects */
|
||||||
|
projects?: Project[];
|
||||||
|
/** Callback when project is changed */
|
||||||
|
onProjectChange?: (projectSlug: string) => void;
|
||||||
|
/** Custom breadcrumb items (overrides auto-generation) */
|
||||||
|
breadcrumbs?: BreadcrumbItem[];
|
||||||
|
/** Hide breadcrumbs */
|
||||||
|
hideBreadcrumbs?: boolean;
|
||||||
|
/** Hide sidebar */
|
||||||
|
hideSidebar?: boolean;
|
||||||
|
/** Additional className for main content area */
|
||||||
|
className?: string;
|
||||||
|
/** Additional className for the outer wrapper */
|
||||||
|
wrapperClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppLayout({
|
||||||
|
children,
|
||||||
|
currentProject,
|
||||||
|
projects = [],
|
||||||
|
onProjectChange,
|
||||||
|
breadcrumbs,
|
||||||
|
hideBreadcrumbs = false,
|
||||||
|
hideSidebar = false,
|
||||||
|
className,
|
||||||
|
wrapperClassName,
|
||||||
|
}: AppLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('flex min-h-screen flex-col bg-background', wrapperClassName)}
|
||||||
|
data-testid="app-layout"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<AppHeader
|
||||||
|
currentProject={currentProject}
|
||||||
|
projects={projects}
|
||||||
|
onProjectChange={onProjectChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main content area with sidebar */}
|
||||||
|
<div className="flex flex-1">
|
||||||
|
{/* Sidebar */}
|
||||||
|
{!hideSidebar && <Sidebar projectSlug={currentProject?.slug} />}
|
||||||
|
|
||||||
|
{/* Content area */}
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
{/* Breadcrumbs */}
|
||||||
|
{!hideBreadcrumbs && <AppBreadcrumbs items={breadcrumbs} />}
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main
|
||||||
|
className={cn('flex-1', className)}
|
||||||
|
id="main-content"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page Container Component
|
||||||
|
* Standard container for page content with consistent padding and max-width
|
||||||
|
*/
|
||||||
|
interface PageContainerProps {
|
||||||
|
/** Page content */
|
||||||
|
children: ReactNode;
|
||||||
|
/** Maximum width constraint */
|
||||||
|
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '4xl' | '6xl' | 'full';
|
||||||
|
/** Additional className */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxWidthClasses: Record<string, string> = {
|
||||||
|
sm: 'max-w-sm',
|
||||||
|
md: 'max-w-md',
|
||||||
|
lg: 'max-w-lg',
|
||||||
|
xl: 'max-w-xl',
|
||||||
|
'2xl': 'max-w-2xl',
|
||||||
|
'4xl': 'max-w-4xl',
|
||||||
|
'6xl': 'max-w-6xl',
|
||||||
|
full: 'max-w-full',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PageContainer({
|
||||||
|
children,
|
||||||
|
maxWidth = '6xl',
|
||||||
|
className,
|
||||||
|
}: PageContainerProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'container mx-auto px-4 py-6 lg:px-6 lg:py-8',
|
||||||
|
maxWidthClasses[maxWidth],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-testid="page-container"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page Header Component
|
||||||
|
* Consistent header for page titles and actions
|
||||||
|
*/
|
||||||
|
interface PageHeaderProps {
|
||||||
|
/** Page title */
|
||||||
|
title: string;
|
||||||
|
/** Optional description */
|
||||||
|
description?: string;
|
||||||
|
/** Action buttons or other content */
|
||||||
|
actions?: ReactNode;
|
||||||
|
/** Additional className */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
className,
|
||||||
|
}: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-testid="page-header"
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">{title}</h1>
|
||||||
|
{description && (
|
||||||
|
<p className="text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
191
frontend/src/components/layout/ProjectSwitcher.tsx
Normal file
191
frontend/src/components/layout/ProjectSwitcher.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* Project Switcher Component
|
||||||
|
* Dropdown selector for switching between projects
|
||||||
|
* Displays current project and allows quick project navigation
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useRouter } from '@/lib/i18n/routing';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { FolderKanban, Plus, ChevronDown } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
/** Optional description */
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectSwitcherProps {
|
||||||
|
/** Currently selected project */
|
||||||
|
currentProject?: Project;
|
||||||
|
/** List of available projects */
|
||||||
|
projects: Project[];
|
||||||
|
/** Callback when project is changed */
|
||||||
|
onProjectChange?: (projectSlug: string) => void;
|
||||||
|
/** Additional className */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectSwitcher({
|
||||||
|
currentProject,
|
||||||
|
projects,
|
||||||
|
onProjectChange,
|
||||||
|
className,
|
||||||
|
}: ProjectSwitcherProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleProjectChange = useCallback(
|
||||||
|
(projectSlug: string) => {
|
||||||
|
if (onProjectChange) {
|
||||||
|
onProjectChange(projectSlug);
|
||||||
|
} else {
|
||||||
|
// Default behavior: navigate to project dashboard
|
||||||
|
router.push(`/projects/${projectSlug}`);
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
[onProjectChange, router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateProject = useCallback(() => {
|
||||||
|
router.push('/projects/new');
|
||||||
|
setOpen(false);
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
// If no projects, show create button
|
||||||
|
if (projects.length === 0) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCreateProject}
|
||||||
|
className={cn('gap-2', className)}
|
||||||
|
data-testid="create-project-button"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||||
|
<span>Create Project</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={cn('gap-2 min-w-[160px] justify-between', className)}
|
||||||
|
data-testid="project-switcher-trigger"
|
||||||
|
aria-label={
|
||||||
|
currentProject
|
||||||
|
? `Switch project, current: ${currentProject.name}`
|
||||||
|
: 'Select project'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FolderKanban className="h-4 w-4" aria-hidden="true" />
|
||||||
|
<span className="truncate max-w-[120px]">
|
||||||
|
{currentProject?.name ?? 'Select Project'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="start"
|
||||||
|
className="w-[200px]"
|
||||||
|
data-testid="project-switcher-menu"
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel>Projects</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{projects.map((project) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={project.id}
|
||||||
|
onSelect={() => handleProjectChange(project.slug)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
data-testid={`project-option-${project.slug}`}
|
||||||
|
>
|
||||||
|
<FolderKanban className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
|
<span className="truncate">{project.name}</span>
|
||||||
|
{currentProject?.id === project.id && (
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">Current</span>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={handleCreateProject}
|
||||||
|
className="cursor-pointer"
|
||||||
|
data-testid="create-project-option"
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
|
<span>Create New Project</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alternative version using Select component for simpler use cases
|
||||||
|
*/
|
||||||
|
interface ProjectSelectProps {
|
||||||
|
/** Currently selected project slug */
|
||||||
|
value?: string;
|
||||||
|
/** List of available projects */
|
||||||
|
projects: Project[];
|
||||||
|
/** Callback when project is selected */
|
||||||
|
onValueChange: (projectSlug: string) => void;
|
||||||
|
/** Placeholder text */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Additional className */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectSelect({
|
||||||
|
value,
|
||||||
|
projects,
|
||||||
|
onValueChange,
|
||||||
|
placeholder = 'Select a project',
|
||||||
|
className,
|
||||||
|
}: ProjectSelectProps) {
|
||||||
|
return (
|
||||||
|
<Select value={value} onValueChange={onValueChange}>
|
||||||
|
<SelectTrigger className={cn('w-[200px]', className)} data-testid="project-select">
|
||||||
|
<SelectValue placeholder={placeholder} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<SelectItem
|
||||||
|
key={project.id}
|
||||||
|
value={project.slug}
|
||||||
|
data-testid={`project-select-option-${project.slug}`}
|
||||||
|
>
|
||||||
|
{project.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
319
frontend/src/components/layout/Sidebar.tsx
Normal file
319
frontend/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
/**
|
||||||
|
* Main Navigation Sidebar
|
||||||
|
* Displays navigation links for the main application (projects, agents, etc.)
|
||||||
|
* Collapsible on mobile with responsive behavior
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { Link } from '@/lib/i18n/routing';
|
||||||
|
import { usePathname } from '@/lib/i18n/routing';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from '@/components/ui/sheet';
|
||||||
|
import {
|
||||||
|
FolderKanban,
|
||||||
|
Bot,
|
||||||
|
LayoutDashboard,
|
||||||
|
ListTodo,
|
||||||
|
IterationCw,
|
||||||
|
Settings,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Menu,
|
||||||
|
Shield,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
name: string;
|
||||||
|
href: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
/** If true, only matches exact path */
|
||||||
|
exact?: boolean;
|
||||||
|
/** If true, only visible to superusers */
|
||||||
|
adminOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainNavItems: NavItem[] = [
|
||||||
|
{
|
||||||
|
name: 'Projects',
|
||||||
|
href: '/projects',
|
||||||
|
icon: FolderKanban,
|
||||||
|
exact: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const projectNavItems: NavItem[] = [
|
||||||
|
{
|
||||||
|
name: 'Dashboard',
|
||||||
|
href: '/dashboard',
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
exact: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Issues',
|
||||||
|
href: '/issues',
|
||||||
|
icon: ListTodo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Sprints',
|
||||||
|
href: '/sprints',
|
||||||
|
icon: IterationCw,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Agents',
|
||||||
|
href: '/agents',
|
||||||
|
icon: Bot,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Settings',
|
||||||
|
href: '/settings',
|
||||||
|
icon: Settings,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const adminNavItems: NavItem[] = [
|
||||||
|
{
|
||||||
|
name: 'Agent Types',
|
||||||
|
href: '/admin/agent-types',
|
||||||
|
icon: Bot,
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Admin Panel',
|
||||||
|
href: '/admin',
|
||||||
|
icon: Shield,
|
||||||
|
adminOnly: true,
|
||||||
|
exact: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
/** Current project slug, if viewing a project */
|
||||||
|
projectSlug?: string;
|
||||||
|
/** Additional className */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavLinkProps {
|
||||||
|
item: NavItem;
|
||||||
|
collapsed: boolean;
|
||||||
|
basePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavLink({ item, collapsed, basePath = '' }: NavLinkProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const href = basePath ? `${basePath}${item.href}` : item.href;
|
||||||
|
|
||||||
|
const isActive = item.exact
|
||||||
|
? pathname === href
|
||||||
|
: pathname.startsWith(href);
|
||||||
|
|
||||||
|
const Icon = item.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground',
|
||||||
|
collapsed && 'justify-center'
|
||||||
|
)}
|
||||||
|
title={collapsed ? item.name : undefined}
|
||||||
|
data-testid={`nav-${item.name.toLowerCase().replace(/\s+/g, '-')}`}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5 flex-shrink-0" aria-hidden="true" />
|
||||||
|
{!collapsed && <span>{item.name}</span>}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarContent({
|
||||||
|
collapsed,
|
||||||
|
projectSlug,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
collapsed: boolean;
|
||||||
|
projectSlug?: string;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const isAdmin = user?.is_superuser ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* Sidebar Header */}
|
||||||
|
<div className="flex h-14 items-center justify-between border-b px-4">
|
||||||
|
{!collapsed && (
|
||||||
|
<span className="text-lg font-semibold text-foreground">Navigation</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onToggle}
|
||||||
|
className="h-8 w-8"
|
||||||
|
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
|
data-testid="sidebar-toggle"
|
||||||
|
>
|
||||||
|
{collapsed ? (
|
||||||
|
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Links */}
|
||||||
|
<nav className="flex-1 space-y-1 overflow-y-auto p-2" aria-label="Main navigation">
|
||||||
|
{/* Main navigation */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{!collapsed && (
|
||||||
|
<span className="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Main
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{mainNavItems.map((item) => (
|
||||||
|
<NavLink key={item.href} item={item} collapsed={collapsed} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project-specific navigation */}
|
||||||
|
{projectSlug && (
|
||||||
|
<div className="space-y-1 pt-4">
|
||||||
|
{!collapsed && (
|
||||||
|
<span className="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Project
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{projectNavItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.href}
|
||||||
|
item={item}
|
||||||
|
collapsed={collapsed}
|
||||||
|
basePath={`/projects/${projectSlug}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Admin navigation */}
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="space-y-1 pt-4">
|
||||||
|
{!collapsed && (
|
||||||
|
<span className="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Admin
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{adminNavItems
|
||||||
|
.filter((item) => !item.adminOnly || isAdmin)
|
||||||
|
.map((item) => (
|
||||||
|
<NavLink key={item.href} item={item} collapsed={collapsed} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User Info (only when expanded) */}
|
||||||
|
{!collapsed && user && (
|
||||||
|
<div className="border-t p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-sm font-medium">
|
||||||
|
{user.first_name?.[0]?.toUpperCase() || user.email[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<p className="text-sm font-medium truncate">
|
||||||
|
{user.first_name} {user.last_name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({ projectSlug, className }: SidebarProps) {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleToggle = useCallback(() => {
|
||||||
|
setCollapsed((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close mobile sidebar on route change
|
||||||
|
const pathname = usePathname();
|
||||||
|
useEffect(() => {
|
||||||
|
setMobileOpen(false);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
// Handle keyboard shortcut for sidebar toggle (Cmd/Ctrl + B)
|
||||||
|
useEffect(() => {
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if ((event.metaKey || event.ctrlKey) && event.key === 'b') {
|
||||||
|
event.preventDefault();
|
||||||
|
setCollapsed((prev) => !prev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mobile sidebar trigger */}
|
||||||
|
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="lg:hidden fixed left-4 top-4 z-40"
|
||||||
|
aria-label="Open navigation menu"
|
||||||
|
data-testid="mobile-menu-trigger"
|
||||||
|
>
|
||||||
|
<Menu className="h-5 w-5" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="left" className="w-64 p-0">
|
||||||
|
<SheetHeader className="sr-only">
|
||||||
|
<SheetTitle>Navigation Menu</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<SidebarContent
|
||||||
|
collapsed={false}
|
||||||
|
projectSlug={projectSlug}
|
||||||
|
onToggle={() => setMobileOpen(false)}
|
||||||
|
/>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
{/* Desktop sidebar */}
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
'hidden lg:flex flex-col border-r bg-muted/40 transition-all duration-300',
|
||||||
|
collapsed ? 'w-16' : 'w-64',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-testid="sidebar"
|
||||||
|
aria-label="Main navigation"
|
||||||
|
>
|
||||||
|
<SidebarContent
|
||||||
|
collapsed={collapsed}
|
||||||
|
projectSlug={projectSlug}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
frontend/src/components/layout/UserMenu.tsx
Normal file
172
frontend/src/components/layout/UserMenu.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* User Menu Component
|
||||||
|
* Dropdown menu with user profile, settings, and logout options
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Link } from '@/lib/i18n/routing';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
|
import { useLogout } from '@/lib/api/hooks/useAuth';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
|
import {
|
||||||
|
User,
|
||||||
|
LogOut,
|
||||||
|
Shield,
|
||||||
|
Lock,
|
||||||
|
Monitor,
|
||||||
|
UserCog,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface UserMenuProps {
|
||||||
|
/** Additional className */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user initials for avatar display
|
||||||
|
*/
|
||||||
|
function getUserInitials(firstName?: string | null, lastName?: string | null): string {
|
||||||
|
if (!firstName) return 'U';
|
||||||
|
|
||||||
|
const first = firstName.charAt(0).toUpperCase();
|
||||||
|
const last = lastName?.charAt(0).toUpperCase() || '';
|
||||||
|
|
||||||
|
return `${first}${last}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserMenu({ className }: UserMenuProps) {
|
||||||
|
const t = useTranslations('navigation');
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { mutate: logout, isPending: isLoggingOut } = useLogout();
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initials = getUserInitials(user.first_name, user.last_name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn('relative h-9 w-9 rounded-full', className)}
|
||||||
|
data-testid="user-menu-trigger"
|
||||||
|
aria-label={`User menu for ${user.first_name || user.email}`}
|
||||||
|
>
|
||||||
|
<Avatar className="h-9 w-9">
|
||||||
|
<AvatarFallback className="text-sm">{initials}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
className="w-56"
|
||||||
|
align="end"
|
||||||
|
data-testid="user-menu-content"
|
||||||
|
>
|
||||||
|
{/* User info header */}
|
||||||
|
<DropdownMenuLabel className="font-normal">
|
||||||
|
<div className="flex flex-col space-y-1">
|
||||||
|
<p className="text-sm font-medium leading-none">
|
||||||
|
{user.first_name} {user.last_name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs leading-none text-muted-foreground">
|
||||||
|
{user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
{/* Settings group */}
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
href="/settings/profile"
|
||||||
|
className="cursor-pointer"
|
||||||
|
data-testid="user-menu-profile"
|
||||||
|
>
|
||||||
|
<User className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
|
{t('profile')}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
href="/settings/password"
|
||||||
|
className="cursor-pointer"
|
||||||
|
data-testid="user-menu-password"
|
||||||
|
>
|
||||||
|
<Lock className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
|
{t('settings')}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
href="/settings/sessions"
|
||||||
|
className="cursor-pointer"
|
||||||
|
data-testid="user-menu-sessions"
|
||||||
|
>
|
||||||
|
<Monitor className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
|
Sessions
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
href="/settings/preferences"
|
||||||
|
className="cursor-pointer"
|
||||||
|
data-testid="user-menu-preferences"
|
||||||
|
>
|
||||||
|
<UserCog className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
|
Preferences
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
|
||||||
|
{/* Admin link (superusers only) */}
|
||||||
|
{user.is_superuser && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
href="/admin"
|
||||||
|
className="cursor-pointer"
|
||||||
|
data-testid="user-menu-admin"
|
||||||
|
>
|
||||||
|
<Shield className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
|
{t('adminPanel')}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Logout */}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer text-destructive focus:text-destructive focus:bg-destructive/10"
|
||||||
|
onClick={handleLogout}
|
||||||
|
disabled={isLoggingOut}
|
||||||
|
data-testid="user-menu-logout"
|
||||||
|
>
|
||||||
|
<LogOut className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
|
{isLoggingOut ? t('loggingOut') : t('logout')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,17 @@
|
|||||||
* Common layout elements for authenticated pages
|
* Common layout elements for authenticated pages
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Existing components
|
||||||
export { Header } from './Header';
|
export { Header } from './Header';
|
||||||
export { Footer } from './Footer';
|
export { Footer } from './Footer';
|
||||||
export { HeaderSkeleton } from './HeaderSkeleton';
|
export { HeaderSkeleton } from './HeaderSkeleton';
|
||||||
export { AuthLoadingSkeleton } from './AuthLoadingSkeleton';
|
export { AuthLoadingSkeleton } from './AuthLoadingSkeleton';
|
||||||
|
|
||||||
|
// Application layout components (Issue #44)
|
||||||
|
export { AppLayout, PageContainer, PageHeader } from './AppLayout';
|
||||||
|
export { AppHeader } from './AppHeader';
|
||||||
|
export { Sidebar } from './Sidebar';
|
||||||
|
export { AppBreadcrumbs } from './AppBreadcrumbs';
|
||||||
|
export type { BreadcrumbItem } from './AppBreadcrumbs';
|
||||||
|
export { ProjectSwitcher, ProjectSelect } from './ProjectSwitcher';
|
||||||
|
export { UserMenu } from './UserMenu';
|
||||||
|
|||||||
179
frontend/tests/components/layout/AppBreadcrumbs.test.tsx
Normal file
179
frontend/tests/components/layout/AppBreadcrumbs.test.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* Tests for AppBreadcrumbs Component
|
||||||
|
* Verifies breadcrumb generation, navigation, and accessibility
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { AppBreadcrumbs } from '@/components/layout/AppBreadcrumbs';
|
||||||
|
import { mockUsePathname } from 'next-intl/navigation';
|
||||||
|
|
||||||
|
describe('AppBreadcrumbs', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockUsePathname.mockReturnValue('/projects');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders breadcrumb navigation', () => {
|
||||||
|
render(<AppBreadcrumbs />);
|
||||||
|
expect(screen.getByTestId('breadcrumbs')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with correct aria-label', () => {
|
||||||
|
render(<AppBreadcrumbs />);
|
||||||
|
|
||||||
|
const nav = screen.getByTestId('breadcrumbs');
|
||||||
|
expect(nav).toHaveAttribute('aria-label', 'Breadcrumb');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders home icon by default', () => {
|
||||||
|
render(<AppBreadcrumbs />);
|
||||||
|
expect(screen.getByTestId('breadcrumb-home')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides home icon when showHome is false', () => {
|
||||||
|
render(<AppBreadcrumbs showHome={false} />);
|
||||||
|
expect(screen.queryByTestId('breadcrumb-home')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Auto-generated Breadcrumbs', () => {
|
||||||
|
it('generates breadcrumb from pathname', () => {
|
||||||
|
mockUsePathname.mockReturnValue('/projects');
|
||||||
|
|
||||||
|
render(<AppBreadcrumbs />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('breadcrumb-projects')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates nested breadcrumbs', () => {
|
||||||
|
mockUsePathname.mockReturnValue('/projects/my-project/issues');
|
||||||
|
|
||||||
|
render(<AppBreadcrumbs />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('breadcrumb-projects')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('breadcrumb-my-project')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('breadcrumb-issues')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses label mappings for known paths', () => {
|
||||||
|
mockUsePathname.mockReturnValue('/admin/agent-types');
|
||||||
|
|
||||||
|
render(<AppBreadcrumbs />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Admin')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Agent Types')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses segment as label for unknown paths', () => {
|
||||||
|
mockUsePathname.mockReturnValue('/custom-path');
|
||||||
|
|
||||||
|
render(<AppBreadcrumbs />);
|
||||||
|
|
||||||
|
expect(screen.getByText('custom-path')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Custom Breadcrumbs', () => {
|
||||||
|
it('uses provided items instead of auto-generation', () => {
|
||||||
|
mockUsePathname.mockReturnValue('/some/path');
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ label: 'Custom', href: '/custom', current: false },
|
||||||
|
{ label: 'Path', href: '/custom/path', current: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<AppBreadcrumbs items={items} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('breadcrumb-custom')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('breadcrumb-path')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('some')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Active State', () => {
|
||||||
|
it('marks last item as current page', () => {
|
||||||
|
mockUsePathname.mockReturnValue('/projects/issues');
|
||||||
|
|
||||||
|
render(<AppBreadcrumbs />);
|
||||||
|
|
||||||
|
const issuesItem = screen.getByTestId('breadcrumb-issues');
|
||||||
|
expect(issuesItem).toHaveAttribute('aria-current', 'page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders non-current items as links', () => {
|
||||||
|
mockUsePathname.mockReturnValue('/projects/issues');
|
||||||
|
|
||||||
|
render(<AppBreadcrumbs />);
|
||||||
|
|
||||||
|
const projectsLink = screen.getByTestId('breadcrumb-projects');
|
||||||
|
expect(projectsLink.tagName).toBe('A');
|
||||||
|
expect(projectsLink).toHaveAttribute('href', '/projects');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders current item as span (not a link)', () => {
|
||||||
|
mockUsePathname.mockReturnValue('/projects');
|
||||||
|
|
||||||
|
render(<AppBreadcrumbs />);
|
||||||
|
|
||||||
|
const projectsItem = screen.getByTestId('breadcrumb-projects');
|
||||||
|
expect(projectsItem.tagName).toBe('SPAN');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Separators', () => {
|
||||||
|
it('renders chevron separators between items', () => {
|
||||||
|
mockUsePathname.mockReturnValue('/projects/issues');
|
||||||
|
|
||||||
|
render(<AppBreadcrumbs />);
|
||||||
|
|
||||||
|
// There should be separators: home -> projects -> issues
|
||||||
|
const separators = screen.getAllByRole('listitem');
|
||||||
|
expect(separators.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Empty State', () => {
|
||||||
|
it('returns null when no breadcrumbs', () => {
|
||||||
|
mockUsePathname.mockReturnValue('/');
|
||||||
|
|
||||||
|
const { container } = render(<AppBreadcrumbs showHome={false} />);
|
||||||
|
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Accessibility', () => {
|
||||||
|
it('home link has accessible label', () => {
|
||||||
|
render(<AppBreadcrumbs />);
|
||||||
|
|
||||||
|
const homeLink = screen.getByTestId('breadcrumb-home');
|
||||||
|
expect(homeLink).toHaveAttribute('aria-label', 'Home');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links have focus-visible styling', () => {
|
||||||
|
mockUsePathname.mockReturnValue('/projects/issues');
|
||||||
|
|
||||||
|
render(<AppBreadcrumbs />);
|
||||||
|
|
||||||
|
const projectsLink = screen.getByTestId('breadcrumb-projects');
|
||||||
|
expect(projectsLink).toHaveClass('focus-visible:ring-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigation has proper landmark role', () => {
|
||||||
|
render(<AppBreadcrumbs />);
|
||||||
|
|
||||||
|
const nav = screen.getByRole('navigation');
|
||||||
|
expect(nav).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Custom className', () => {
|
||||||
|
it('applies custom className', () => {
|
||||||
|
render(<AppBreadcrumbs className="custom-class" />);
|
||||||
|
|
||||||
|
const nav = screen.getByTestId('breadcrumbs');
|
||||||
|
expect(nav).toHaveClass('custom-class');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
386
frontend/tests/components/layout/AppLayout.test.tsx
Normal file
386
frontend/tests/components/layout/AppLayout.test.tsx
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
/**
|
||||||
|
* Tests for AppLayout and related components
|
||||||
|
* Verifies layout structure, responsive behavior, and component integration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { AppLayout, PageContainer, PageHeader } from '@/components/layout/AppLayout';
|
||||||
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
|
import { useLogout } from '@/lib/api/hooks/useAuth';
|
||||||
|
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}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
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('AppLayout', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockUsePathname.mockReturnValue('/projects');
|
||||||
|
|
||||||
|
(useAuth as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: createMockUser(),
|
||||||
|
});
|
||||||
|
|
||||||
|
(useLogout as jest.Mock).mockReturnValue({
|
||||||
|
mutate: jest.fn(),
|
||||||
|
isPending: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders layout container', () => {
|
||||||
|
render(
|
||||||
|
<AppLayout>
|
||||||
|
<div>Content</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('app-layout')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders children', () => {
|
||||||
|
render(
|
||||||
|
<AppLayout>
|
||||||
|
<div data-testid="test-content">Test Content</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('test-content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders header', () => {
|
||||||
|
render(
|
||||||
|
<AppLayout>
|
||||||
|
<div>Content</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('app-header')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders sidebar', () => {
|
||||||
|
render(
|
||||||
|
<AppLayout>
|
||||||
|
<div>Content</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders breadcrumbs', () => {
|
||||||
|
render(
|
||||||
|
<AppLayout>
|
||||||
|
<div>Content</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('breadcrumbs')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders main content area', () => {
|
||||||
|
render(
|
||||||
|
<AppLayout>
|
||||||
|
<div>Content</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
const main = screen.getByRole('main');
|
||||||
|
expect(main).toBeInTheDocument();
|
||||||
|
expect(main).toHaveAttribute('id', 'main-content');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Configuration Options', () => {
|
||||||
|
it('hides sidebar when hideSidebar is true', () => {
|
||||||
|
render(
|
||||||
|
<AppLayout hideSidebar>
|
||||||
|
<div>Content</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('sidebar')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides breadcrumbs when hideBreadcrumbs is true', () => {
|
||||||
|
render(
|
||||||
|
<AppLayout hideBreadcrumbs>
|
||||||
|
<div>Content</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('breadcrumbs')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes custom breadcrumbs to AppBreadcrumbs', () => {
|
||||||
|
const customBreadcrumbs = [
|
||||||
|
{ label: 'Custom', href: '/custom', current: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AppLayout breadcrumbs={customBreadcrumbs}>
|
||||||
|
<div>Content</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('breadcrumb-custom')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes project slug to sidebar', () => {
|
||||||
|
const currentProject = { id: '1', slug: 'test-project', name: 'Test' };
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AppLayout currentProject={currentProject}>
|
||||||
|
<div>Content</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sidebar should show project-specific navigation
|
||||||
|
expect(screen.getByTestId('nav-dashboard')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Custom ClassNames', () => {
|
||||||
|
it('applies className to main content', () => {
|
||||||
|
render(
|
||||||
|
<AppLayout className="custom-main">
|
||||||
|
<div>Content</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
const main = screen.getByRole('main');
|
||||||
|
expect(main).toHaveClass('custom-main');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies wrapperClassName to outer container', () => {
|
||||||
|
render(
|
||||||
|
<AppLayout wrapperClassName="custom-wrapper">
|
||||||
|
<div>Content</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
const layout = screen.getByTestId('app-layout');
|
||||||
|
expect(layout).toHaveClass('custom-wrapper');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Accessibility', () => {
|
||||||
|
it('main content has tabIndex for skip link support', () => {
|
||||||
|
render(
|
||||||
|
<AppLayout>
|
||||||
|
<div>Content</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
const main = screen.getByRole('main');
|
||||||
|
expect(main).toHaveAttribute('tabIndex', '-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('layout has min-h-screen for full viewport', () => {
|
||||||
|
render(
|
||||||
|
<AppLayout>
|
||||||
|
<div>Content</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
const layout = screen.getByTestId('app-layout');
|
||||||
|
expect(layout).toHaveClass('min-h-screen');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PageContainer', () => {
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders container', () => {
|
||||||
|
render(
|
||||||
|
<PageContainer>
|
||||||
|
<div>Content</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('page-container')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders children', () => {
|
||||||
|
render(
|
||||||
|
<PageContainer>
|
||||||
|
<div data-testid="test-content">Test Content</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('test-content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Max Width', () => {
|
||||||
|
it('defaults to max-w-6xl', () => {
|
||||||
|
render(
|
||||||
|
<PageContainer>
|
||||||
|
<div>Content</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
const container = screen.getByTestId('page-container');
|
||||||
|
expect(container).toHaveClass('max-w-6xl');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies custom max width', () => {
|
||||||
|
render(
|
||||||
|
<PageContainer maxWidth="md">
|
||||||
|
<div>Content</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
const container = screen.getByTestId('page-container');
|
||||||
|
expect(container).toHaveClass('max-w-md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies full width', () => {
|
||||||
|
render(
|
||||||
|
<PageContainer maxWidth="full">
|
||||||
|
<div>Content</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
const container = screen.getByTestId('page-container');
|
||||||
|
expect(container).toHaveClass('max-w-full');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Styling', () => {
|
||||||
|
it('has container and centering classes', () => {
|
||||||
|
render(
|
||||||
|
<PageContainer>
|
||||||
|
<div>Content</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
const container = screen.getByTestId('page-container');
|
||||||
|
expect(container).toHaveClass('container', 'mx-auto');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has responsive padding', () => {
|
||||||
|
render(
|
||||||
|
<PageContainer>
|
||||||
|
<div>Content</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
const container = screen.getByTestId('page-container');
|
||||||
|
expect(container).toHaveClass('px-4', 'py-6', 'lg:px-6', 'lg:py-8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies custom className', () => {
|
||||||
|
render(
|
||||||
|
<PageContainer className="custom-class">
|
||||||
|
<div>Content</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
const container = screen.getByTestId('page-container');
|
||||||
|
expect(container).toHaveClass('custom-class');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PageHeader', () => {
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders page header', () => {
|
||||||
|
render(<PageHeader title="Test Title" />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('page-header')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders title', () => {
|
||||||
|
render(<PageHeader title="Test Title" />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Test Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders description when provided', () => {
|
||||||
|
render(<PageHeader title="Title" description="Test description" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Test description')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render description when not provided', () => {
|
||||||
|
render(<PageHeader title="Title" />);
|
||||||
|
|
||||||
|
const header = screen.getByTestId('page-header');
|
||||||
|
expect(header.querySelectorAll('p')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders actions when provided', () => {
|
||||||
|
render(
|
||||||
|
<PageHeader
|
||||||
|
title="Title"
|
||||||
|
actions={<button data-testid="action-button">Action</button>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('action-button')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Styling', () => {
|
||||||
|
it('has responsive flex layout', () => {
|
||||||
|
render(<PageHeader title="Title" />);
|
||||||
|
|
||||||
|
const header = screen.getByTestId('page-header');
|
||||||
|
expect(header).toHaveClass('flex', 'flex-col', 'sm:flex-row');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('title has responsive text size', () => {
|
||||||
|
render(<PageHeader title="Title" />);
|
||||||
|
|
||||||
|
const title = screen.getByRole('heading', { level: 1 });
|
||||||
|
expect(title).toHaveClass('text-2xl', 'sm:text-3xl');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('description has muted styling', () => {
|
||||||
|
render(<PageHeader title="Title" description="Description" />);
|
||||||
|
|
||||||
|
const description = screen.getByText('Description');
|
||||||
|
expect(description).toHaveClass('text-muted-foreground');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies custom className', () => {
|
||||||
|
render(<PageHeader title="Title" className="custom-class" />);
|
||||||
|
|
||||||
|
const header = screen.getByTestId('page-header');
|
||||||
|
expect(header).toHaveClass('custom-class');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
273
frontend/tests/components/layout/ProjectSwitcher.test.tsx
Normal file
273
frontend/tests/components/layout/ProjectSwitcher.test.tsx
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
/**
|
||||||
|
* Tests for ProjectSwitcher Component
|
||||||
|
* Verifies project selection, navigation, and accessibility
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { ProjectSwitcher, ProjectSelect } from '@/components/layout/ProjectSwitcher';
|
||||||
|
import { mockUseRouter } from 'next-intl/navigation';
|
||||||
|
|
||||||
|
// Mock useRouter
|
||||||
|
const mockPush = jest.fn();
|
||||||
|
|
||||||
|
describe('ProjectSwitcher', () => {
|
||||||
|
const mockProjects = [
|
||||||
|
{ id: '1', slug: 'project-one', name: 'Project One' },
|
||||||
|
{ id: '2', slug: 'project-two', name: 'Project Two' },
|
||||||
|
{ id: '3', slug: 'project-three', name: 'Project Three' },
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockUseRouter.mockReturnValue({
|
||||||
|
push: mockPush,
|
||||||
|
replace: jest.fn(),
|
||||||
|
prefetch: jest.fn(),
|
||||||
|
back: jest.fn(),
|
||||||
|
forward: jest.fn(),
|
||||||
|
refresh: jest.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders create project button when no projects', () => {
|
||||||
|
render(<ProjectSwitcher projects={[]} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('create-project-button')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Create Project')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders project switcher trigger when projects exist', () => {
|
||||||
|
render(<ProjectSwitcher projects={mockProjects} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('project-switcher-trigger')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays current project name', () => {
|
||||||
|
render(
|
||||||
|
<ProjectSwitcher
|
||||||
|
projects={mockProjects}
|
||||||
|
currentProject={mockProjects[0]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Project One')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays placeholder when no current project', () => {
|
||||||
|
render(<ProjectSwitcher projects={mockProjects} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Select Project')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Dropdown Menu', () => {
|
||||||
|
it('opens dropdown when trigger is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<ProjectSwitcher projects={mockProjects} />);
|
||||||
|
|
||||||
|
const trigger = screen.getByTestId('project-switcher-trigger');
|
||||||
|
await user.click(trigger);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('project-switcher-menu')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays all projects in dropdown', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<ProjectSwitcher projects={mockProjects} />);
|
||||||
|
|
||||||
|
const trigger = screen.getByTestId('project-switcher-trigger');
|
||||||
|
await user.click(trigger);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('project-option-project-one')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('project-option-project-two')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('project-option-project-three')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows current indicator on selected project', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ProjectSwitcher
|
||||||
|
projects={mockProjects}
|
||||||
|
currentProject={mockProjects[0]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const trigger = screen.getByTestId('project-switcher-trigger');
|
||||||
|
await user.click(trigger);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const currentOption = screen.getByTestId('project-option-project-one');
|
||||||
|
expect(currentOption).toHaveTextContent('Current');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes create new project option', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<ProjectSwitcher projects={mockProjects} />);
|
||||||
|
|
||||||
|
const trigger = screen.getByTestId('project-switcher-trigger');
|
||||||
|
await user.click(trigger);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('create-project-option')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Create New Project')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Navigation', () => {
|
||||||
|
it('navigates to project when option is selected', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<ProjectSwitcher projects={mockProjects} />);
|
||||||
|
|
||||||
|
const trigger = screen.getByTestId('project-switcher-trigger');
|
||||||
|
await user.click(trigger);
|
||||||
|
|
||||||
|
const projectOption = await screen.findByTestId('project-option-project-two');
|
||||||
|
await user.click(projectOption);
|
||||||
|
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/projects/project-two');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onProjectChange callback when provided', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const mockOnChange = jest.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ProjectSwitcher
|
||||||
|
projects={mockProjects}
|
||||||
|
onProjectChange={mockOnChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const trigger = screen.getByTestId('project-switcher-trigger');
|
||||||
|
await user.click(trigger);
|
||||||
|
|
||||||
|
const projectOption = await screen.findByTestId('project-option-project-two');
|
||||||
|
await user.click(projectOption);
|
||||||
|
|
||||||
|
expect(mockOnChange).toHaveBeenCalledWith('project-two');
|
||||||
|
expect(mockPush).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to create project page', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<ProjectSwitcher projects={mockProjects} />);
|
||||||
|
|
||||||
|
const trigger = screen.getByTestId('project-switcher-trigger');
|
||||||
|
await user.click(trigger);
|
||||||
|
|
||||||
|
const createOption = await screen.findByTestId('create-project-option');
|
||||||
|
await user.click(createOption);
|
||||||
|
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/projects/new');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates from empty state button', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<ProjectSwitcher projects={[]} />);
|
||||||
|
|
||||||
|
const createButton = screen.getByTestId('create-project-button');
|
||||||
|
await user.click(createButton);
|
||||||
|
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/projects/new');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Accessibility', () => {
|
||||||
|
it('has accessible label on trigger', () => {
|
||||||
|
render(
|
||||||
|
<ProjectSwitcher
|
||||||
|
projects={mockProjects}
|
||||||
|
currentProject={mockProjects[0]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const trigger = screen.getByTestId('project-switcher-trigger');
|
||||||
|
expect(trigger).toHaveAttribute(
|
||||||
|
'aria-label',
|
||||||
|
'Switch project, current: Project One'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has accessible label when no current project', () => {
|
||||||
|
render(<ProjectSwitcher projects={mockProjects} />);
|
||||||
|
|
||||||
|
const trigger = screen.getByTestId('project-switcher-trigger');
|
||||||
|
expect(trigger).toHaveAttribute('aria-label', 'Select project');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ProjectSelect', () => {
|
||||||
|
const mockProjects = [
|
||||||
|
{ id: '1', slug: 'project-one', name: 'Project One' },
|
||||||
|
{ id: '2', slug: 'project-two', name: 'Project Two' },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders select component', () => {
|
||||||
|
render(
|
||||||
|
<ProjectSelect
|
||||||
|
projects={mockProjects}
|
||||||
|
onValueChange={jest.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('project-select')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays placeholder', () => {
|
||||||
|
render(
|
||||||
|
<ProjectSelect
|
||||||
|
projects={mockProjects}
|
||||||
|
onValueChange={jest.fn()}
|
||||||
|
placeholder="Choose a project"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Choose a project')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has combobox role', () => {
|
||||||
|
render(
|
||||||
|
<ProjectSelect
|
||||||
|
projects={mockProjects}
|
||||||
|
onValueChange={jest.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies custom className', () => {
|
||||||
|
render(
|
||||||
|
<ProjectSelect
|
||||||
|
projects={mockProjects}
|
||||||
|
onValueChange={jest.fn()}
|
||||||
|
className="custom-class"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const select = screen.getByTestId('project-select');
|
||||||
|
expect(select).toHaveClass('custom-class');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: Selection interaction tests are skipped because Radix UI Select
|
||||||
|
// doesn't properly open in JSDOM environment. The component is tested
|
||||||
|
// through E2E tests instead.
|
||||||
|
});
|
||||||
322
frontend/tests/components/layout/Sidebar.test.tsx
Normal file
322
frontend/tests/components/layout/Sidebar.test.tsx
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
323
frontend/tests/components/layout/UserMenu.test.tsx
Normal file
323
frontend/tests/components/layout/UserMenu.test.tsx
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
/**
|
||||||
|
* Tests for UserMenu Component
|
||||||
|
* Verifies user menu rendering, navigation, and logout functionality
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { UserMenu } from '@/components/layout/UserMenu';
|
||||||
|
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(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 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('UserMenu', () => {
|
||||||
|
const mockLogout = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
(useLogout as jest.Mock).mockReturnValue({
|
||||||
|
mutate: mockLogout,
|
||||||
|
isPending: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
(useAuth as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: createMockUser(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders user menu trigger', () => {
|
||||||
|
render(<UserMenu />);
|
||||||
|
expect(screen.getByTestId('user-menu-trigger')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays user initials in avatar', () => {
|
||||||
|
(useAuth as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: createMockUser({
|
||||||
|
first_name: 'John',
|
||||||
|
last_name: 'Doe',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<UserMenu />);
|
||||||
|
|
||||||
|
expect(screen.getByText('JD')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays single initial when no last name', () => {
|
||||||
|
(useAuth as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: createMockUser({
|
||||||
|
first_name: 'John',
|
||||||
|
last_name: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<UserMenu />);
|
||||||
|
|
||||||
|
expect(screen.getByText('J')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays default initial when no first name', () => {
|
||||||
|
(useAuth as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: createMockUser({
|
||||||
|
first_name: '',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<UserMenu />);
|
||||||
|
|
||||||
|
expect(screen.getByText('U')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no user', () => {
|
||||||
|
(useAuth as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(<UserMenu />);
|
||||||
|
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Dropdown Menu', () => {
|
||||||
|
it('opens menu when trigger is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<UserMenu />);
|
||||||
|
|
||||||
|
const trigger = screen.getByTestId('user-menu-trigger');
|
||||||
|
await user.click(trigger);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('user-menu-content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays user info in menu header', 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(<UserMenu />);
|
||||||
|
|
||||||
|
const trigger = screen.getByTestId('user-menu-trigger');
|
||||||
|
await user.click(trigger);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('john@example.com')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Navigation Links', () => {
|
||||||
|
it('includes profile link', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<UserMenu />);
|
||||||
|
|
||||||
|
const trigger = screen.getByTestId('user-menu-trigger');
|
||||||
|
await user.click(trigger);
|
||||||
|
|
||||||
|
const profileLink = await screen.findByTestId('user-menu-profile');
|
||||||
|
expect(profileLink).toHaveAttribute('href', '/settings/profile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes password/settings link', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<UserMenu />);
|
||||||
|
|
||||||
|
const trigger = screen.getByTestId('user-menu-trigger');
|
||||||
|
await user.click(trigger);
|
||||||
|
|
||||||
|
const passwordLink = await screen.findByTestId('user-menu-password');
|
||||||
|
expect(passwordLink).toHaveAttribute('href', '/settings/password');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes sessions link', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<UserMenu />);
|
||||||
|
|
||||||
|
const trigger = screen.getByTestId('user-menu-trigger');
|
||||||
|
await user.click(trigger);
|
||||||
|
|
||||||
|
const sessionsLink = await screen.findByTestId('user-menu-sessions');
|
||||||
|
expect(sessionsLink).toHaveAttribute('href', '/settings/sessions');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes preferences link', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<UserMenu />);
|
||||||
|
|
||||||
|
const trigger = screen.getByTestId('user-menu-trigger');
|
||||||
|
await user.click(trigger);
|
||||||
|
|
||||||
|
const preferencesLink = await screen.findByTestId('user-menu-preferences');
|
||||||
|
expect(preferencesLink).toHaveAttribute('href', '/settings/preferences');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Admin Link', () => {
|
||||||
|
it('shows admin link for superusers', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
(useAuth as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: createMockUser({ is_superuser: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<UserMenu />);
|
||||||
|
|
||||||
|
const trigger = screen.getByTestId('user-menu-trigger');
|
||||||
|
await user.click(trigger);
|
||||||
|
|
||||||
|
const adminLink = await screen.findByTestId('user-menu-admin');
|
||||||
|
expect(adminLink).toHaveAttribute('href', '/admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show admin link for regular users', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
(useAuth as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: createMockUser({ is_superuser: false }),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<UserMenu />);
|
||||||
|
|
||||||
|
const trigger = screen.getByTestId('user-menu-trigger');
|
||||||
|
await user.click(trigger);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId('user-menu-admin')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Logout Functionality', () => {
|
||||||
|
it('calls logout when logout button is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<UserMenu />);
|
||||||
|
|
||||||
|
const trigger = screen.getByTestId('user-menu-trigger');
|
||||||
|
await user.click(trigger);
|
||||||
|
|
||||||
|
const logoutButton = await screen.findByTestId('user-menu-logout');
|
||||||
|
await user.click(logoutButton);
|
||||||
|
|
||||||
|
expect(mockLogout).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading state when logging out', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
(useLogout as jest.Mock).mockReturnValue({
|
||||||
|
mutate: mockLogout,
|
||||||
|
isPending: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<UserMenu />);
|
||||||
|
|
||||||
|
const trigger = screen.getByTestId('user-menu-trigger');
|
||||||
|
await user.click(trigger);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('loggingOut')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables logout button when logging out', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
(useLogout as jest.Mock).mockReturnValue({
|
||||||
|
mutate: mockLogout,
|
||||||
|
isPending: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<UserMenu />);
|
||||||
|
|
||||||
|
const trigger = screen.getByTestId('user-menu-trigger');
|
||||||
|
await user.click(trigger);
|
||||||
|
|
||||||
|
const logoutButton = await screen.findByTestId('user-menu-logout');
|
||||||
|
expect(logoutButton).toHaveAttribute('data-disabled');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Accessibility', () => {
|
||||||
|
it('has accessible label on trigger', () => {
|
||||||
|
(useAuth as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: createMockUser({
|
||||||
|
first_name: 'John',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<UserMenu />);
|
||||||
|
|
||||||
|
const trigger = screen.getByTestId('user-menu-trigger');
|
||||||
|
expect(trigger).toHaveAttribute('aria-label', 'User menu for John');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses email when no first name', () => {
|
||||||
|
(useAuth as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: createMockUser({
|
||||||
|
first_name: '',
|
||||||
|
email: 'test@example.com',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<UserMenu />);
|
||||||
|
|
||||||
|
const trigger = screen.getByTestId('user-menu-trigger');
|
||||||
|
expect(trigger).toHaveAttribute('aria-label', 'User menu for test@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logout button has destructive styling', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<UserMenu />);
|
||||||
|
|
||||||
|
const trigger = screen.getByTestId('user-menu-trigger');
|
||||||
|
await user.click(trigger);
|
||||||
|
|
||||||
|
const logoutButton = await screen.findByTestId('user-menu-logout');
|
||||||
|
expect(logoutButton).toHaveClass('text-destructive');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user