Add Layout components for web application
Implemented responsive layout system with: - Header: Top navigation with profile menu and mobile hamburger - Sidebar: Desktop-only navigation sidebar (lg and above) - Breadcrumbs: Auto-generated navigation breadcrumbs - Layout: Main wrapper component with configurable options Features: - Responsive design (mobile, tablet, desktop) - Active route highlighting - User profile integration via auth store - Click-outside dropdown closing - Comprehensive test coverage (49 tests passing) Updated (main) layout to use new Layout component system. Updated dashboard page to work with new layout structure. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
4b60a92777
commit
1628501e75
@ -1,11 +1,22 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard Page
|
||||||
|
*
|
||||||
|
* Main dashboard showing overview of beneficiaries and sensor status.
|
||||||
|
*/
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
<div>
|
||||||
<div className="text-center">
|
<div className="mb-6">
|
||||||
<h1 className="text-4xl font-bold text-gray-900">Dashboard</h1>
|
<h1 className="text-3xl font-bold text-slate-900">Dashboard</h1>
|
||||||
<p className="mt-2 text-gray-600">Coming soon...</p>
|
<p className="mt-2 text-slate-600">
|
||||||
|
Monitor your loved ones and manage their health sensors
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white p-8 text-center">
|
||||||
|
<p className="text-slate-600">Dashboard content coming soon...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
16
web/app/(main)/layout.tsx
Normal file
16
web/app/(main)/layout.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { Layout } from '@/components/Layout';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main App Layout
|
||||||
|
*
|
||||||
|
* Wraps all authenticated pages with the Layout component.
|
||||||
|
* Provides consistent navigation, header, and sidebar.
|
||||||
|
*
|
||||||
|
* This layout applies to all routes under (main) route group.
|
||||||
|
*/
|
||||||
|
export default function MainLayout({ children }: { children: ReactNode }) {
|
||||||
|
return <Layout>{children}</Layout>;
|
||||||
|
}
|
||||||
144
web/components/Layout/Breadcrumbs.tsx
Normal file
144
web/components/Layout/Breadcrumbs.tsx
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Breadcrumb item type
|
||||||
|
*/
|
||||||
|
interface BreadcrumbItem {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route label mappings
|
||||||
|
* Maps route segments to human-readable labels
|
||||||
|
*/
|
||||||
|
const routeLabels: Record<string, string> = {
|
||||||
|
dashboard: 'Dashboard',
|
||||||
|
beneficiaries: 'Loved Ones',
|
||||||
|
sensors: 'Sensors',
|
||||||
|
settings: 'Settings',
|
||||||
|
profile: 'Profile',
|
||||||
|
'add-sensor': 'Add Sensor',
|
||||||
|
subscription: 'Subscription',
|
||||||
|
equipment: 'Equipment',
|
||||||
|
share: 'Share Access',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Breadcrumbs Component
|
||||||
|
*
|
||||||
|
* Displays navigation breadcrumbs based on current route.
|
||||||
|
* Automatically generates breadcrumb trail from pathname.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Auto-generated from URL path
|
||||||
|
* - Clickable navigation links
|
||||||
|
* - Current page highlighted
|
||||||
|
* - Responsive (hidden on mobile)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <Breadcrumbs />
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Route examples:
|
||||||
|
* - /dashboard -> Dashboard
|
||||||
|
* - /beneficiaries/123 -> Loved Ones / John Doe
|
||||||
|
* - /beneficiaries/123/sensors -> Loved Ones / John Doe / Sensors
|
||||||
|
*/
|
||||||
|
export function Breadcrumbs() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const breadcrumbs = useMemo(() => {
|
||||||
|
// Don't show breadcrumbs on auth pages
|
||||||
|
if (pathname.startsWith('/login') || pathname.startsWith('/verify-otp')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = pathname.split('/').filter(Boolean);
|
||||||
|
const items: BreadcrumbItem[] = [];
|
||||||
|
|
||||||
|
// Build breadcrumb trail
|
||||||
|
let currentPath = '';
|
||||||
|
segments.forEach((segment, index) => {
|
||||||
|
currentPath += `/${segment}`;
|
||||||
|
|
||||||
|
// Check if segment is a dynamic ID (numeric)
|
||||||
|
const isId = /^\d+$/.test(segment);
|
||||||
|
|
||||||
|
if (isId) {
|
||||||
|
// For IDs, use a placeholder or fetch the actual name
|
||||||
|
// In production, you would fetch the beneficiary name here
|
||||||
|
items.push({
|
||||||
|
label: `#${segment}`,
|
||||||
|
href: currentPath,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Use mapped label or capitalize segment
|
||||||
|
const label = routeLabels[segment] || segment.charAt(0).toUpperCase() + segment.slice(1);
|
||||||
|
items.push({
|
||||||
|
label,
|
||||||
|
href: currentPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
// Don't render if no breadcrumbs
|
||||||
|
if (breadcrumbs.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label="Breadcrumb" className="hidden md:block">
|
||||||
|
<ol className="flex items-center gap-2 text-sm">
|
||||||
|
{/* Home icon */}
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="text-slate-500 hover:text-slate-700"
|
||||||
|
aria-label="Home"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{/* Breadcrumb items */}
|
||||||
|
{breadcrumbs.map((item, index) => {
|
||||||
|
const isLast = index === breadcrumbs.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={item.href} className="flex items-center gap-2">
|
||||||
|
{/* Separator */}
|
||||||
|
<svg className="h-4 w-4 text-slate-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Link or current page */}
|
||||||
|
{isLast ? (
|
||||||
|
<span className="font-medium text-slate-900" aria-current="page">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={item.href || '#'}
|
||||||
|
className="text-slate-500 hover:text-slate-700"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
238
web/components/Layout/Header.tsx
Normal file
238
web/components/Layout/Header.tsx
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation items for the header
|
||||||
|
*/
|
||||||
|
const navItems = [
|
||||||
|
{ href: '/dashboard', label: 'Dashboard' },
|
||||||
|
{ href: '/beneficiaries', label: 'Loved Ones' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Header Component
|
||||||
|
*
|
||||||
|
* Responsive header with navigation and profile menu.
|
||||||
|
* - Desktop: Full navigation with profile dropdown
|
||||||
|
* - Mobile: Hamburger menu with slide-in navigation
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <Header />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function Header() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
|
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
|
||||||
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
const profileMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close profile menu when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (profileMenuRef.current && !profileMenuRef.current.contains(event.target as Node)) {
|
||||||
|
setIsProfileMenuOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isProfileMenuOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
}, [isProfileMenuOpen]);
|
||||||
|
|
||||||
|
// Close mobile menu when route changes
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
setIsProfileMenuOpen(false);
|
||||||
|
await logout();
|
||||||
|
};
|
||||||
|
|
||||||
|
const userInitials = user?.firstName && user?.lastName
|
||||||
|
? `${user.firstName[0]}${user.lastName[0]}`.toUpperCase()
|
||||||
|
: user?.email?.[0]?.toUpperCase() || '?';
|
||||||
|
|
||||||
|
const userName = user?.firstName && user?.lastName
|
||||||
|
? `${user.firstName} ${user.lastName}`
|
||||||
|
: user?.email || 'User';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 w-full border-b border-slate-200 bg-white/80 backdrop-blur-md">
|
||||||
|
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex h-16 items-center justify-between">
|
||||||
|
{/* Logo and brand */}
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
<Link href="/dashboard" className="flex items-center gap-2">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-600 to-blue-500">
|
||||||
|
<span className="text-lg font-bold text-white">W</span>
|
||||||
|
</div>
|
||||||
|
<span className="hidden text-xl font-semibold text-slate-900 sm:block">
|
||||||
|
WellNuo
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Desktop navigation */}
|
||||||
|
<nav className="hidden md:flex md:gap-6">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`relative px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'text-blue-600'
|
||||||
|
: 'text-slate-600 hover:text-slate-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
{isActive && (
|
||||||
|
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600" />
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side: Profile menu */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Desktop profile dropdown */}
|
||||||
|
<div className="relative hidden md:block" ref={profileMenuRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsProfileMenuOpen(!isProfileMenuOpen)}
|
||||||
|
className="flex items-center gap-3 rounded-lg px-3 py-2 transition-colors hover:bg-slate-50"
|
||||||
|
aria-expanded={isProfileMenuOpen}
|
||||||
|
aria-haspopup="true"
|
||||||
|
>
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100 text-sm font-semibold text-blue-700">
|
||||||
|
{userInitials}
|
||||||
|
</div>
|
||||||
|
<span className="hidden text-sm font-medium text-slate-700 lg:block">
|
||||||
|
{userName}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className={`h-4 w-4 text-slate-400 transition-transform ${
|
||||||
|
isProfileMenuOpen ? 'rotate-180' : ''
|
||||||
|
}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Profile dropdown menu */}
|
||||||
|
{isProfileMenuOpen && (
|
||||||
|
<div className="absolute right-0 mt-2 w-56 origin-top-right rounded-lg border border-slate-200 bg-white shadow-lg ring-1 ring-black ring-opacity-5">
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="mb-1 text-sm font-medium text-slate-900">{userName}</div>
|
||||||
|
{user?.email && (
|
||||||
|
<div className="text-xs text-slate-500">{user.email}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-slate-100">
|
||||||
|
<Link
|
||||||
|
href="/profile"
|
||||||
|
className="block px-4 py-2 text-sm text-slate-700 hover:bg-slate-50"
|
||||||
|
onClick={() => setIsProfileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
Profile Settings
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="block w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile menu button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||||
|
className="md:hidden rounded-lg p-2 text-slate-600 hover:bg-slate-50"
|
||||||
|
aria-label="Toggle menu"
|
||||||
|
aria-expanded={isMobileMenuOpen}
|
||||||
|
>
|
||||||
|
{isMobileMenuOpen ? (
|
||||||
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile menu */}
|
||||||
|
{isMobileMenuOpen && (
|
||||||
|
<div className="border-t border-slate-200 bg-white md:hidden">
|
||||||
|
<div className="space-y-1 px-4 pb-3 pt-2">
|
||||||
|
{/* User info */}
|
||||||
|
<div className="mb-4 flex items-center gap-3 rounded-lg bg-slate-50 p-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 text-sm font-semibold text-blue-700">
|
||||||
|
{userInitials}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium text-slate-900">{userName}</div>
|
||||||
|
{user?.email && (
|
||||||
|
<div className="truncate text-xs text-slate-500">{user.email}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation links */}
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`block rounded-lg px-3 py-2 text-base font-medium ${
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-50 text-blue-700'
|
||||||
|
: 'text-slate-700 hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Profile link */}
|
||||||
|
<Link
|
||||||
|
href="/profile"
|
||||||
|
className="block rounded-lg px-3 py-2 text-base font-medium text-slate-700 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Profile Settings
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Sign out */}
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="block w-full rounded-lg px-3 py-2 text-left text-base font-medium text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
web/components/Layout/Layout.tsx
Normal file
137
web/components/Layout/Layout.tsx
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { Header } from './Header';
|
||||||
|
import { Sidebar } from './Sidebar';
|
||||||
|
import { Breadcrumbs } from './Breadcrumbs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout Props
|
||||||
|
*/
|
||||||
|
interface LayoutProps {
|
||||||
|
/**
|
||||||
|
* Page content
|
||||||
|
*/
|
||||||
|
children: ReactNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show sidebar (desktop only)
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
showSidebar?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show header
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
showHeader?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show breadcrumbs
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
showBreadcrumbs?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum content width
|
||||||
|
* @default '7xl' (1280px)
|
||||||
|
*/
|
||||||
|
maxWidth?: 'full' | '7xl' | '6xl' | '5xl' | '4xl';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom page title (shown in breadcrumbs if provided)
|
||||||
|
*/
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Max width classes mapping
|
||||||
|
*/
|
||||||
|
const maxWidthClasses = {
|
||||||
|
full: '',
|
||||||
|
'7xl': 'max-w-7xl',
|
||||||
|
'6xl': 'max-w-6xl',
|
||||||
|
'5xl': 'max-w-5xl',
|
||||||
|
'4xl': 'max-w-4xl',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main Layout Component
|
||||||
|
*
|
||||||
|
* Provides consistent layout structure for authenticated pages.
|
||||||
|
*
|
||||||
|
* Layout structure:
|
||||||
|
* - Desktop (lg+): Sidebar + Header + Main content
|
||||||
|
* - Mobile/Tablet: Header + Main content
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Responsive sidebar (desktop only)
|
||||||
|
* - Sticky header
|
||||||
|
* - Breadcrumb navigation
|
||||||
|
* - Configurable max-width
|
||||||
|
* - Proper spacing and padding
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // Default layout (all features enabled)
|
||||||
|
* <Layout>
|
||||||
|
* <h1>Dashboard</h1>
|
||||||
|
* <p>Content here</p>
|
||||||
|
* </Layout>
|
||||||
|
*
|
||||||
|
* // Custom layout without sidebar
|
||||||
|
* <Layout showSidebar={false} maxWidth="4xl">
|
||||||
|
* <h1>Profile</h1>
|
||||||
|
* </Layout>
|
||||||
|
*
|
||||||
|
* // Full-width layout
|
||||||
|
* <Layout maxWidth="full">
|
||||||
|
* <div>Full width content</div>
|
||||||
|
* </Layout>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function Layout({
|
||||||
|
children,
|
||||||
|
showSidebar = true,
|
||||||
|
showHeader = true,
|
||||||
|
showBreadcrumbs = true,
|
||||||
|
maxWidth = '7xl',
|
||||||
|
title,
|
||||||
|
}: LayoutProps) {
|
||||||
|
const maxWidthClass = maxWidthClasses[maxWidth];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-slate-50">
|
||||||
|
{/* Sidebar - Desktop only */}
|
||||||
|
{showSidebar && <Sidebar />}
|
||||||
|
|
||||||
|
{/* Main content area */}
|
||||||
|
<div className={`flex flex-1 flex-col ${showSidebar ? 'lg:pl-64' : ''}`}>
|
||||||
|
{/* Header */}
|
||||||
|
{showHeader && <Header />}
|
||||||
|
|
||||||
|
{/* Page content */}
|
||||||
|
<main className="flex-1">
|
||||||
|
<div className={`mx-auto px-4 py-6 sm:px-6 lg:px-8 ${maxWidthClass}`}>
|
||||||
|
{/* Breadcrumbs */}
|
||||||
|
{showBreadcrumbs && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<Breadcrumbs />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Page title (if provided) */}
|
||||||
|
{title && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-semibold text-slate-900">{title}</h1>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Page content */}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
web/components/Layout/Sidebar.tsx
Normal file
145
web/components/Layout/Sidebar.tsx
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sidebar navigation items with icons
|
||||||
|
*/
|
||||||
|
const sidebarItems = [
|
||||||
|
{
|
||||||
|
href: '/dashboard',
|
||||||
|
label: 'Dashboard',
|
||||||
|
icon: (
|
||||||
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/beneficiaries',
|
||||||
|
label: 'Loved Ones',
|
||||||
|
icon: (
|
||||||
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/sensors',
|
||||||
|
label: 'Sensors',
|
||||||
|
icon: (
|
||||||
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/settings',
|
||||||
|
label: 'Settings',
|
||||||
|
icon: (
|
||||||
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sidebar Component
|
||||||
|
*
|
||||||
|
* Desktop-only sidebar navigation.
|
||||||
|
* Hidden on mobile/tablet, shown on desktop (lg and above).
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Active state highlighting
|
||||||
|
* - Icon + label navigation
|
||||||
|
* - User profile section at bottom
|
||||||
|
* - Logout button
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <Sidebar />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function Sidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
|
|
||||||
|
const userInitials = user?.firstName && user?.lastName
|
||||||
|
? `${user.firstName[0]}${user.lastName[0]}`.toUpperCase()
|
||||||
|
: user?.email?.[0]?.toUpperCase() || '?';
|
||||||
|
|
||||||
|
const userName = user?.firstName && user?.lastName
|
||||||
|
? `${user.firstName} ${user.lastName}`
|
||||||
|
: user?.email || 'User';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col">
|
||||||
|
{/* Sidebar container */}
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col border-r border-slate-200 bg-white">
|
||||||
|
{/* Logo section */}
|
||||||
|
<div className="flex h-16 shrink-0 items-center gap-2 border-b border-slate-200 px-6">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-600 to-blue-500">
|
||||||
|
<span className="text-lg font-bold text-white">W</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-semibold text-slate-900">WellNuo</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 space-y-1 px-3 py-4">
|
||||||
|
{sidebarItems.map((item) => {
|
||||||
|
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`group flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-50 text-blue-700'
|
||||||
|
: 'text-slate-700 hover:bg-slate-50 hover:text-slate-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={isActive ? 'text-blue-700' : 'text-slate-400 group-hover:text-slate-600'}>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User profile section at bottom */}
|
||||||
|
<div className="border-t border-slate-200 p-4">
|
||||||
|
<Link
|
||||||
|
href="/profile"
|
||||||
|
className="group flex items-center gap-3 rounded-lg px-3 py-2 transition-colors hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 text-sm font-semibold text-blue-700">
|
||||||
|
{userInitials}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium text-slate-900">{userName}</div>
|
||||||
|
{user?.email && (
|
||||||
|
<div className="truncate text-xs text-slate-500">{user.email}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Logout button */}
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="mt-2 flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-red-50 hover:text-red-600"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||||
|
</svg>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
web/components/Layout/__tests__/Breadcrumbs.test.tsx
Normal file
107
web/components/Layout/__tests__/Breadcrumbs.test.tsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { Breadcrumbs } from '../Breadcrumbs';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
// Mock Next.js navigation
|
||||||
|
jest.mock('next/navigation', () => ({
|
||||||
|
usePathname: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Breadcrumbs Component', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render on login page', () => {
|
||||||
|
(usePathname as jest.Mock).mockReturnValue('/login');
|
||||||
|
const { container } = render(<Breadcrumbs />);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render on verify-otp page', () => {
|
||||||
|
(usePathname as jest.Mock).mockReturnValue('/verify-otp');
|
||||||
|
const { container } = render(<Breadcrumbs />);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders home icon link', () => {
|
||||||
|
(usePathname as jest.Mock).mockReturnValue('/dashboard');
|
||||||
|
render(<Breadcrumbs />);
|
||||||
|
|
||||||
|
const homeLink = screen.getByLabelText('Home');
|
||||||
|
expect(homeLink).toHaveAttribute('href', '/dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders single breadcrumb for dashboard', () => {
|
||||||
|
(usePathname as jest.Mock).mockReturnValue('/dashboard');
|
||||||
|
render(<Breadcrumbs />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Dashboard')).toHaveAttribute('aria-current', 'page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders breadcrumbs for nested route', () => {
|
||||||
|
(usePathname as jest.Mock).mockReturnValue('/beneficiaries/123');
|
||||||
|
render(<Breadcrumbs />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Loved Ones')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('#123')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders breadcrumbs for deep nested route', () => {
|
||||||
|
(usePathname as jest.Mock).mockReturnValue('/beneficiaries/123/sensors');
|
||||||
|
render(<Breadcrumbs />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Loved Ones')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('#123')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Sensors')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders proper mapped labels', () => {
|
||||||
|
(usePathname as jest.Mock).mockReturnValue('/beneficiaries/123/subscription');
|
||||||
|
render(<Breadcrumbs />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Loved Ones')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Subscription')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('capitalizes unknown segments', () => {
|
||||||
|
(usePathname as jest.Mock).mockReturnValue('/unknown-route');
|
||||||
|
render(<Breadcrumbs />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Unknown-route')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks last item as current page', () => {
|
||||||
|
(usePathname as jest.Mock).mockReturnValue('/beneficiaries/123/sensors');
|
||||||
|
render(<Breadcrumbs />);
|
||||||
|
|
||||||
|
const currentPage = screen.getByText('Sensors');
|
||||||
|
expect(currentPage).toHaveAttribute('aria-current', 'page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes intermediate items clickable', () => {
|
||||||
|
(usePathname as jest.Mock).mockReturnValue('/beneficiaries/123/sensors');
|
||||||
|
render(<Breadcrumbs />);
|
||||||
|
|
||||||
|
const lovedOnesLink = screen.getByText('Loved Ones').closest('a');
|
||||||
|
expect(lovedOnesLink).toHaveAttribute('href', '/beneficiaries');
|
||||||
|
|
||||||
|
const idLink = screen.getByText('#123').closest('a');
|
||||||
|
expect(idLink).toHaveAttribute('href', '/beneficiaries/123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles settings route', () => {
|
||||||
|
(usePathname as jest.Mock).mockReturnValue('/settings');
|
||||||
|
render(<Breadcrumbs />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Settings')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles profile route', () => {
|
||||||
|
(usePathname as jest.Mock).mockReturnValue('/profile');
|
||||||
|
render(<Breadcrumbs />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Profile')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
155
web/components/Layout/__tests__/Header.test.tsx
Normal file
155
web/components/Layout/__tests__/Header.test.tsx
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { Header } from '../Header';
|
||||||
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
// Mock Next.js navigation
|
||||||
|
jest.mock('next/navigation', () => ({
|
||||||
|
usePathname: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock auth store
|
||||||
|
jest.mock('@/stores/authStore', () => ({
|
||||||
|
useAuthStore: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Header Component', () => {
|
||||||
|
const mockLogout = jest.fn();
|
||||||
|
const mockUser = {
|
||||||
|
user_id: 1,
|
||||||
|
email: 'test@example.com',
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
(usePathname as jest.Mock).mockReturnValue('/dashboard');
|
||||||
|
(useAuthStore as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: mockUser,
|
||||||
|
logout: mockLogout,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the WellNuo logo and brand', () => {
|
||||||
|
render(<Header />);
|
||||||
|
expect(screen.getByText('WellNuo')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders navigation items', () => {
|
||||||
|
render(<Header />);
|
||||||
|
expect(screen.getAllByText('Dashboard')).toHaveLength(1);
|
||||||
|
expect(screen.getAllByText('Loved Ones')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('highlights active navigation item', () => {
|
||||||
|
render(<Header />);
|
||||||
|
const dashboardLink = screen.getAllByText('Dashboard')[0].closest('a');
|
||||||
|
expect(dashboardLink).toHaveClass('text-blue-600');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays user initials in profile button', () => {
|
||||||
|
render(<Header />);
|
||||||
|
// Desktop profile button shows initials
|
||||||
|
expect(screen.getByText('JD')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays user full name on desktop', () => {
|
||||||
|
render(<Header />);
|
||||||
|
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens profile dropdown when clicked', async () => {
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
// Get profile button (not the mobile menu button)
|
||||||
|
const buttons = screen.getAllByRole('button', { expanded: false });
|
||||||
|
const profileButton = buttons.find(btn => btn.getAttribute('aria-haspopup') === 'true');
|
||||||
|
|
||||||
|
if (!profileButton) throw new Error('Profile button not found');
|
||||||
|
|
||||||
|
fireEvent.click(profileButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Profile Settings')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Sign Out')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls logout when Sign Out is clicked in dropdown', async () => {
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
// Open dropdown
|
||||||
|
const buttons = screen.getAllByRole('button', { expanded: false });
|
||||||
|
const profileButton = buttons.find(btn => btn.getAttribute('aria-haspopup') === 'true');
|
||||||
|
if (!profileButton) throw new Error('Profile button not found');
|
||||||
|
|
||||||
|
fireEvent.click(profileButton);
|
||||||
|
|
||||||
|
// Click Sign Out in dropdown (not mobile menu)
|
||||||
|
await waitFor(() => {
|
||||||
|
const signOutButtons = screen.getAllByRole('button', { name: /sign out/i });
|
||||||
|
fireEvent.click(signOutButtons[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockLogout).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles mobile menu', async () => {
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
const menuButton = screen.getByLabelText('Toggle menu');
|
||||||
|
fireEvent.click(menuButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Mobile menu should show navigation items
|
||||||
|
expect(screen.getByText('Profile Settings')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close menu
|
||||||
|
fireEvent.click(menuButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays user email when no first/last name', () => {
|
||||||
|
(useAuthStore as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: { user_id: 1, email: 'test@example.com' },
|
||||||
|
logout: mockLogout,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Header />);
|
||||||
|
// Email displayed in profile section
|
||||||
|
expect(screen.getByText('test@example.com')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays fallback initials when no user data', () => {
|
||||||
|
(useAuthStore as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: { user_id: 1 },
|
||||||
|
logout: mockLogout,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Header />);
|
||||||
|
expect(screen.getByText('?')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes dropdown when clicking outside', async () => {
|
||||||
|
render(<Header />);
|
||||||
|
|
||||||
|
// Open dropdown
|
||||||
|
const buttons = screen.getAllByRole('button', { expanded: false });
|
||||||
|
const profileButton = buttons.find(btn => btn.getAttribute('aria-haspopup') === 'true');
|
||||||
|
if (!profileButton) throw new Error('Profile button not found');
|
||||||
|
|
||||||
|
fireEvent.click(profileButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Profile Settings')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click outside
|
||||||
|
fireEvent.mouseDown(document.body);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Profile Settings')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
221
web/components/Layout/__tests__/Layout.test.tsx
Normal file
221
web/components/Layout/__tests__/Layout.test.tsx
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { Layout } from '../Layout';
|
||||||
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
// Mock child components
|
||||||
|
jest.mock('../Header', () => ({
|
||||||
|
Header: () => <div data-testid="header">Header</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../Sidebar', () => ({
|
||||||
|
Sidebar: () => <div data-testid="sidebar">Sidebar</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../Breadcrumbs', () => ({
|
||||||
|
Breadcrumbs: () => <div data-testid="breadcrumbs">Breadcrumbs</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock Next.js navigation
|
||||||
|
jest.mock('next/navigation', () => ({
|
||||||
|
usePathname: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock auth store
|
||||||
|
jest.mock('@/stores/authStore', () => ({
|
||||||
|
useAuthStore: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Layout Component', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
(usePathname as jest.Mock).mockReturnValue('/dashboard');
|
||||||
|
(useAuthStore as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: { user_id: 1, email: 'test@example.com' },
|
||||||
|
logout: jest.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders children content', () => {
|
||||||
|
render(
|
||||||
|
<Layout>
|
||||||
|
<div>Test Content</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Test Content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders header by default', () => {
|
||||||
|
render(
|
||||||
|
<Layout>
|
||||||
|
<div>Content</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('header')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders sidebar by default', () => {
|
||||||
|
render(
|
||||||
|
<Layout>
|
||||||
|
<div>Content</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders breadcrumbs by default', () => {
|
||||||
|
render(
|
||||||
|
<Layout>
|
||||||
|
<div>Content</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('breadcrumbs')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides header when showHeader is false', () => {
|
||||||
|
render(
|
||||||
|
<Layout showHeader={false}>
|
||||||
|
<div>Content</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('header')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides sidebar when showSidebar is false', () => {
|
||||||
|
render(
|
||||||
|
<Layout showSidebar={false}>
|
||||||
|
<div>Content</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('sidebar')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides breadcrumbs when showBreadcrumbs is false', () => {
|
||||||
|
render(
|
||||||
|
<Layout showBreadcrumbs={false}>
|
||||||
|
<div>Content</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('breadcrumbs')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders page title when provided', () => {
|
||||||
|
render(
|
||||||
|
<Layout title="Dashboard">
|
||||||
|
<div>Content</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies correct max-width class (7xl by default)', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Layout>
|
||||||
|
<div>Content</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
|
||||||
|
const main = container.querySelector('main div');
|
||||||
|
expect(main).toHaveClass('max-w-7xl');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies custom max-width class', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Layout maxWidth="4xl">
|
||||||
|
<div>Content</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
|
||||||
|
const main = container.querySelector('main div');
|
||||||
|
expect(main).toHaveClass('max-w-4xl');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies full-width when maxWidth is full', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Layout maxWidth="full">
|
||||||
|
<div>Content</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
|
||||||
|
const main = container.querySelector('main div');
|
||||||
|
expect(main).not.toHaveClass('max-w-7xl');
|
||||||
|
expect(main).not.toHaveClass('max-w-4xl');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies sidebar padding class to main area', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Layout showSidebar={true}>
|
||||||
|
<div>Content</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
|
||||||
|
const mainWrapper = container.querySelector('.lg\\:pl-64');
|
||||||
|
expect(mainWrapper).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not apply sidebar padding when sidebar is hidden', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Layout showSidebar={false}>
|
||||||
|
<div>Content</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
|
||||||
|
const mainWrapper = container.querySelector('.lg\\:pl-64');
|
||||||
|
expect(mainWrapper).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has proper background color', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Layout>
|
||||||
|
<div>Content</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
|
||||||
|
const wrapper = container.firstChild;
|
||||||
|
expect(wrapper).toHaveClass('bg-slate-50');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with all features enabled', () => {
|
||||||
|
render(
|
||||||
|
<Layout
|
||||||
|
showHeader={true}
|
||||||
|
showSidebar={true}
|
||||||
|
showBreadcrumbs={true}
|
||||||
|
title="Test Page"
|
||||||
|
>
|
||||||
|
<div>Content</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('header')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('breadcrumbs')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Test Page')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders minimal layout without any chrome', () => {
|
||||||
|
render(
|
||||||
|
<Layout
|
||||||
|
showHeader={false}
|
||||||
|
showSidebar={false}
|
||||||
|
showBreadcrumbs={false}
|
||||||
|
>
|
||||||
|
<div>Content</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('header')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('sidebar')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('breadcrumbs')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
96
web/components/Layout/__tests__/Sidebar.test.tsx
Normal file
96
web/components/Layout/__tests__/Sidebar.test.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { Sidebar } from '../Sidebar';
|
||||||
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
// Mock Next.js navigation
|
||||||
|
jest.mock('next/navigation', () => ({
|
||||||
|
usePathname: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock auth store
|
||||||
|
jest.mock('@/stores/authStore', () => ({
|
||||||
|
useAuthStore: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Sidebar Component', () => {
|
||||||
|
const mockLogout = jest.fn();
|
||||||
|
const mockUser = {
|
||||||
|
user_id: 1,
|
||||||
|
email: 'test@example.com',
|
||||||
|
firstName: 'Jane',
|
||||||
|
lastName: 'Smith',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
(usePathname as jest.Mock).mockReturnValue('/dashboard');
|
||||||
|
(useAuthStore as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: mockUser,
|
||||||
|
logout: mockLogout,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the WellNuo logo', () => {
|
||||||
|
render(<Sidebar />);
|
||||||
|
expect(screen.getByText('WellNuo')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all navigation items', () => {
|
||||||
|
render(<Sidebar />);
|
||||||
|
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Loved Ones')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Sensors')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Settings')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('highlights active navigation item', () => {
|
||||||
|
render(<Sidebar />);
|
||||||
|
const dashboardLink = screen.getByText('Dashboard').closest('a');
|
||||||
|
expect(dashboardLink).toHaveClass('bg-blue-50', 'text-blue-700');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('highlights active item for nested routes', () => {
|
||||||
|
(usePathname as jest.Mock).mockReturnValue('/beneficiaries/123');
|
||||||
|
render(<Sidebar />);
|
||||||
|
|
||||||
|
const beneficiariesLink = screen.getByText('Loved Ones').closest('a');
|
||||||
|
expect(beneficiariesLink).toHaveClass('bg-blue-50', 'text-blue-700');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays user profile with initials', () => {
|
||||||
|
render(<Sidebar />);
|
||||||
|
expect(screen.getByText('JS')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays user full name', () => {
|
||||||
|
render(<Sidebar />);
|
||||||
|
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays user email', () => {
|
||||||
|
render(<Sidebar />);
|
||||||
|
expect(screen.getByText('test@example.com')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has a link to profile page', () => {
|
||||||
|
render(<Sidebar />);
|
||||||
|
const profileLink = screen.getByText('Jane Smith').closest('a');
|
||||||
|
expect(profileLink).toHaveAttribute('href', '/profile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders sign out button', () => {
|
||||||
|
render(<Sidebar />);
|
||||||
|
expect(screen.getByText('Sign Out')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays email when no first/last name', () => {
|
||||||
|
(useAuthStore as unknown as jest.Mock).mockReturnValue({
|
||||||
|
user: { user_id: 1, email: 'user@example.com' },
|
||||||
|
logout: mockLogout,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Sidebar />);
|
||||||
|
expect(screen.getAllByText('user@example.com')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
29
web/components/Layout/index.ts
Normal file
29
web/components/Layout/index.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Layout Components
|
||||||
|
*
|
||||||
|
* Provides consistent layout structure for the web application.
|
||||||
|
*
|
||||||
|
* Components:
|
||||||
|
* - Layout: Main layout wrapper with sidebar, header, and breadcrumbs
|
||||||
|
* - Header: Top navigation bar with profile menu
|
||||||
|
* - Sidebar: Desktop navigation sidebar
|
||||||
|
* - Breadcrumbs: Contextual navigation breadcrumbs
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* import { Layout } from '@/components/Layout';
|
||||||
|
*
|
||||||
|
* export default function DashboardPage() {
|
||||||
|
* return (
|
||||||
|
* <Layout title="Dashboard">
|
||||||
|
* <div>Content here</div>
|
||||||
|
* </Layout>
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { Layout } from './Layout';
|
||||||
|
export { Header } from './Header';
|
||||||
|
export { Sidebar } from './Sidebar';
|
||||||
|
export { Breadcrumbs } from './Breadcrumbs';
|
||||||
Loading…
x
Reference in New Issue
Block a user