From 1628501e7506e33901ee492faa23c23debad753c Mon Sep 17 00:00:00 2001 From: Sergei Date: Sat, 31 Jan 2026 18:20:13 -0800 Subject: [PATCH] Add Layout components for web application MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/app/(main)/dashboard/page.tsx | 19 +- web/app/(main)/layout.tsx | 16 ++ web/components/Layout/Breadcrumbs.tsx | 144 +++++++++++ web/components/Layout/Header.tsx | 238 ++++++++++++++++++ web/components/Layout/Layout.tsx | 137 ++++++++++ web/components/Layout/Sidebar.tsx | 145 +++++++++++ .../Layout/__tests__/Breadcrumbs.test.tsx | 107 ++++++++ .../Layout/__tests__/Header.test.tsx | 155 ++++++++++++ .../Layout/__tests__/Layout.test.tsx | 221 ++++++++++++++++ .../Layout/__tests__/Sidebar.test.tsx | 96 +++++++ web/components/Layout/index.ts | 29 +++ 11 files changed, 1303 insertions(+), 4 deletions(-) create mode 100644 web/app/(main)/layout.tsx create mode 100644 web/components/Layout/Breadcrumbs.tsx create mode 100644 web/components/Layout/Header.tsx create mode 100644 web/components/Layout/Layout.tsx create mode 100644 web/components/Layout/Sidebar.tsx create mode 100644 web/components/Layout/__tests__/Breadcrumbs.test.tsx create mode 100644 web/components/Layout/__tests__/Header.test.tsx create mode 100644 web/components/Layout/__tests__/Layout.test.tsx create mode 100644 web/components/Layout/__tests__/Sidebar.test.tsx create mode 100644 web/components/Layout/index.ts diff --git a/web/app/(main)/dashboard/page.tsx b/web/app/(main)/dashboard/page.tsx index 89f36f5..b483935 100644 --- a/web/app/(main)/dashboard/page.tsx +++ b/web/app/(main)/dashboard/page.tsx @@ -1,11 +1,22 @@ 'use client'; +/** + * Dashboard Page + * + * Main dashboard showing overview of beneficiaries and sensor status. + */ export default function DashboardPage() { return ( -
-
-

Dashboard

-

Coming soon...

+
+
+

Dashboard

+

+ Monitor your loved ones and manage their health sensors +

+
+ +
+

Dashboard content coming soon...

); diff --git a/web/app/(main)/layout.tsx b/web/app/(main)/layout.tsx new file mode 100644 index 0000000..ee94199 --- /dev/null +++ b/web/app/(main)/layout.tsx @@ -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 {children}; +} diff --git a/web/components/Layout/Breadcrumbs.tsx b/web/components/Layout/Breadcrumbs.tsx new file mode 100644 index 0000000..fa639bd --- /dev/null +++ b/web/components/Layout/Breadcrumbs.tsx @@ -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 = { + 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 + * + * ``` + * + * 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 ( + + ); +} diff --git a/web/components/Layout/Header.tsx b/web/components/Layout/Header.tsx new file mode 100644 index 0000000..7f60ef4 --- /dev/null +++ b/web/components/Layout/Header.tsx @@ -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 + *
+ * ``` + */ +export function Header() { + const pathname = usePathname(); + const { user, logout } = useAuthStore(); + const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false); + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const profileMenuRef = useRef(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 ( +
+
+
+ {/* Logo and brand */} +
+ +
+ W +
+ + WellNuo + + + + {/* Desktop navigation */} + +
+ + {/* Right side: Profile menu */} +
+ {/* Desktop profile dropdown */} +
+ + + {/* Profile dropdown menu */} + {isProfileMenuOpen && ( +
+
+
{userName}
+ {user?.email && ( +
{user.email}
+ )} +
+
+ setIsProfileMenuOpen(false)} + > + Profile Settings + + +
+
+ )} +
+ + {/* Mobile menu button */} + +
+
+
+ + {/* Mobile menu */} + {isMobileMenuOpen && ( +
+
+ {/* User info */} +
+
+ {userInitials} +
+
+
{userName}
+ {user?.email && ( +
{user.email}
+ )} +
+
+ + {/* Navigation links */} + {navItems.map((item) => { + const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`); + return ( + + {item.label} + + ); + })} + + {/* Profile link */} + + Profile Settings + + + {/* Sign out */} + +
+
+ )} +
+ ); +} diff --git a/web/components/Layout/Layout.tsx b/web/components/Layout/Layout.tsx new file mode 100644 index 0000000..0980b9c --- /dev/null +++ b/web/components/Layout/Layout.tsx @@ -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) + * + *

Dashboard

+ *

Content here

+ *
+ * + * // Custom layout without sidebar + * + *

Profile

+ *
+ * + * // Full-width layout + * + *
Full width content
+ *
+ * ``` + */ +export function Layout({ + children, + showSidebar = true, + showHeader = true, + showBreadcrumbs = true, + maxWidth = '7xl', + title, +}: LayoutProps) { + const maxWidthClass = maxWidthClasses[maxWidth]; + + return ( +
+ {/* Sidebar - Desktop only */} + {showSidebar && } + + {/* Main content area */} +
+ {/* Header */} + {showHeader &&
} + + {/* Page content */} +
+
+ {/* Breadcrumbs */} + {showBreadcrumbs && ( +
+ +
+ )} + + {/* Page title (if provided) */} + {title && ( +
+

{title}

+
+ )} + + {/* Page content */} + {children} +
+
+
+
+ ); +} diff --git a/web/components/Layout/Sidebar.tsx b/web/components/Layout/Sidebar.tsx new file mode 100644 index 0000000..b95b5dc --- /dev/null +++ b/web/components/Layout/Sidebar.tsx @@ -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: ( + + + + ), + }, + { + href: '/beneficiaries', + label: 'Loved Ones', + icon: ( + + + + ), + }, + { + href: '/sensors', + label: 'Sensors', + icon: ( + + + + ), + }, + { + href: '/settings', + label: 'Settings', + icon: ( + + + + + ), + }, +]; + +/** + * 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 + * + * ``` + */ +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 ( + + ); +} diff --git a/web/components/Layout/__tests__/Breadcrumbs.test.tsx b/web/components/Layout/__tests__/Breadcrumbs.test.tsx new file mode 100644 index 0000000..cc20eaf --- /dev/null +++ b/web/components/Layout/__tests__/Breadcrumbs.test.tsx @@ -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(); + expect(container.firstChild).toBeNull(); + }); + + it('does not render on verify-otp page', () => { + (usePathname as jest.Mock).mockReturnValue('/verify-otp'); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders home icon link', () => { + (usePathname as jest.Mock).mockReturnValue('/dashboard'); + render(); + + const homeLink = screen.getByLabelText('Home'); + expect(homeLink).toHaveAttribute('href', '/dashboard'); + }); + + it('renders single breadcrumb for dashboard', () => { + (usePathname as jest.Mock).mockReturnValue('/dashboard'); + render(); + + 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(); + + 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(); + + 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(); + + expect(screen.getByText('Loved Ones')).toBeInTheDocument(); + expect(screen.getByText('Subscription')).toBeInTheDocument(); + }); + + it('capitalizes unknown segments', () => { + (usePathname as jest.Mock).mockReturnValue('/unknown-route'); + render(); + + expect(screen.getByText('Unknown-route')).toBeInTheDocument(); + }); + + it('marks last item as current page', () => { + (usePathname as jest.Mock).mockReturnValue('/beneficiaries/123/sensors'); + render(); + + 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(); + + 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(); + + expect(screen.getByText('Settings')).toBeInTheDocument(); + }); + + it('handles profile route', () => { + (usePathname as jest.Mock).mockReturnValue('/profile'); + render(); + + expect(screen.getByText('Profile')).toBeInTheDocument(); + }); +}); diff --git a/web/components/Layout/__tests__/Header.test.tsx b/web/components/Layout/__tests__/Header.test.tsx new file mode 100644 index 0000000..a33d539 --- /dev/null +++ b/web/components/Layout/__tests__/Header.test.tsx @@ -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(
); + expect(screen.getByText('WellNuo')).toBeInTheDocument(); + }); + + it('renders navigation items', () => { + render(
); + expect(screen.getAllByText('Dashboard')).toHaveLength(1); + expect(screen.getAllByText('Loved Ones')).toHaveLength(1); + }); + + it('highlights active navigation item', () => { + render(
); + const dashboardLink = screen.getAllByText('Dashboard')[0].closest('a'); + expect(dashboardLink).toHaveClass('text-blue-600'); + }); + + it('displays user initials in profile button', () => { + render(
); + // Desktop profile button shows initials + expect(screen.getByText('JD')).toBeInTheDocument(); + }); + + it('displays user full name on desktop', () => { + render(
); + expect(screen.getByText('John Doe')).toBeInTheDocument(); + }); + + it('opens profile dropdown when clicked', async () => { + render(
); + + // 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(
); + + // 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(
); + + 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(
); + // 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(
); + expect(screen.getByText('?')).toBeInTheDocument(); + }); + + it('closes dropdown when clicking outside', async () => { + render(
); + + // 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(); + }); + }); +}); diff --git a/web/components/Layout/__tests__/Layout.test.tsx b/web/components/Layout/__tests__/Layout.test.tsx new file mode 100644 index 0000000..0446a39 --- /dev/null +++ b/web/components/Layout/__tests__/Layout.test.tsx @@ -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: () =>
Header
, +})); + +jest.mock('../Sidebar', () => ({ + Sidebar: () =>
Sidebar
, +})); + +jest.mock('../Breadcrumbs', () => ({ + Breadcrumbs: () =>
Breadcrumbs
, +})); + +// 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( + +
Test Content
+
+ ); + + expect(screen.getByText('Test Content')).toBeInTheDocument(); + }); + + it('renders header by default', () => { + render( + +
Content
+
+ ); + + expect(screen.getByTestId('header')).toBeInTheDocument(); + }); + + it('renders sidebar by default', () => { + render( + +
Content
+
+ ); + + expect(screen.getByTestId('sidebar')).toBeInTheDocument(); + }); + + it('renders breadcrumbs by default', () => { + render( + +
Content
+
+ ); + + expect(screen.getByTestId('breadcrumbs')).toBeInTheDocument(); + }); + + it('hides header when showHeader is false', () => { + render( + +
Content
+
+ ); + + expect(screen.queryByTestId('header')).not.toBeInTheDocument(); + }); + + it('hides sidebar when showSidebar is false', () => { + render( + +
Content
+
+ ); + + expect(screen.queryByTestId('sidebar')).not.toBeInTheDocument(); + }); + + it('hides breadcrumbs when showBreadcrumbs is false', () => { + render( + +
Content
+
+ ); + + expect(screen.queryByTestId('breadcrumbs')).not.toBeInTheDocument(); + }); + + it('renders page title when provided', () => { + render( + +
Content
+
+ ); + + expect(screen.getByText('Dashboard')).toBeInTheDocument(); + }); + + it('applies correct max-width class (7xl by default)', () => { + const { container } = render( + +
Content
+
+ ); + + const main = container.querySelector('main div'); + expect(main).toHaveClass('max-w-7xl'); + }); + + it('applies custom max-width class', () => { + const { container } = render( + +
Content
+
+ ); + + const main = container.querySelector('main div'); + expect(main).toHaveClass('max-w-4xl'); + }); + + it('applies full-width when maxWidth is full', () => { + const { container } = render( + +
Content
+
+ ); + + 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( + +
Content
+
+ ); + + const mainWrapper = container.querySelector('.lg\\:pl-64'); + expect(mainWrapper).toBeInTheDocument(); + }); + + it('does not apply sidebar padding when sidebar is hidden', () => { + const { container } = render( + +
Content
+
+ ); + + const mainWrapper = container.querySelector('.lg\\:pl-64'); + expect(mainWrapper).not.toBeInTheDocument(); + }); + + it('has proper background color', () => { + const { container } = render( + +
Content
+
+ ); + + const wrapper = container.firstChild; + expect(wrapper).toHaveClass('bg-slate-50'); + }); + + it('renders with all features enabled', () => { + render( + +
Content
+
+ ); + + 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( + +
Content
+
+ ); + + expect(screen.queryByTestId('header')).not.toBeInTheDocument(); + expect(screen.queryByTestId('sidebar')).not.toBeInTheDocument(); + expect(screen.queryByTestId('breadcrumbs')).not.toBeInTheDocument(); + expect(screen.getByText('Content')).toBeInTheDocument(); + }); +}); diff --git a/web/components/Layout/__tests__/Sidebar.test.tsx b/web/components/Layout/__tests__/Sidebar.test.tsx new file mode 100644 index 0000000..f273730 --- /dev/null +++ b/web/components/Layout/__tests__/Sidebar.test.tsx @@ -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(); + expect(screen.getByText('WellNuo')).toBeInTheDocument(); + }); + + it('renders all navigation items', () => { + render(); + 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(); + 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(); + + const beneficiariesLink = screen.getByText('Loved Ones').closest('a'); + expect(beneficiariesLink).toHaveClass('bg-blue-50', 'text-blue-700'); + }); + + it('displays user profile with initials', () => { + render(); + expect(screen.getByText('JS')).toBeInTheDocument(); + }); + + it('displays user full name', () => { + render(); + expect(screen.getByText('Jane Smith')).toBeInTheDocument(); + }); + + it('displays user email', () => { + render(); + expect(screen.getByText('test@example.com')).toBeInTheDocument(); + }); + + it('has a link to profile page', () => { + render(); + const profileLink = screen.getByText('Jane Smith').closest('a'); + expect(profileLink).toHaveAttribute('href', '/profile'); + }); + + it('renders sign out button', () => { + render(); + 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(); + expect(screen.getAllByText('user@example.com')).toHaveLength(2); + }); +}); diff --git a/web/components/Layout/index.ts b/web/components/Layout/index.ts new file mode 100644 index 0000000..1146e84 --- /dev/null +++ b/web/components/Layout/index.ts @@ -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 ( + * + *
Content here
+ *
+ * ); + * } + * ``` + */ + +export { Layout } from './Layout'; +export { Header } from './Header'; +export { Sidebar } from './Sidebar'; +export { Breadcrumbs } from './Breadcrumbs';