M5-T01: refactor AppLayout from top bar to sidebar navigation
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Tests for AppSidebar (M5-T01).
|
||||
*
|
||||
* Strategy: render AppSidebar inside a minimal AppShell (required by
|
||||
* AppShell.Navbar) + MemoryRouter so useLocation() works.
|
||||
*
|
||||
* Coverage:
|
||||
* 1. All three nav items render (Home, Records, Config).
|
||||
* 2. Home nav item is active when pathname is '/'.
|
||||
* 3. Records nav item is active when pathname is '/records'.
|
||||
* 4. Config nav item is active when pathname is '/config'.
|
||||
* 5. Home is NOT active when on '/records'.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { render } from '@testing-library/react'
|
||||
import { MantineProvider, AppShell } from '@mantine/core'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { AppSidebar } from './AppSidebar'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock apiClient (LogoutButton calls POST /api/auth/logout)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock('../api/client', () => ({
|
||||
default: {
|
||||
POST: vi.fn(),
|
||||
GET: vi.fn(),
|
||||
},
|
||||
ApiError: class ApiError extends Error {
|
||||
status: number
|
||||
body: unknown
|
||||
constructor(status: number, body: unknown) {
|
||||
super(`API error ${status}`)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
},
|
||||
registerLoginRedirect: vi.fn(),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: render AppSidebar inside the required AppShell + router providers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderSidebar(initialPath = '/') {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<AppShell
|
||||
navbar={{ width: 220, breakpoint: 'sm', collapsed: { mobile: false } }}
|
||||
header={{ height: { base: 48, sm: 0 } }}
|
||||
>
|
||||
<AppSidebar />
|
||||
</AppShell>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</MantineProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('AppSidebar', () => {
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. All nav items render
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('renders Home, Records, and Config nav items', () => {
|
||||
renderSidebar('/')
|
||||
expect(screen.getByTestId('nav-home')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('nav-records')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('nav-config')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. Home active on '/'
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('marks Home nav item as active when on "/"', () => {
|
||||
renderSidebar('/')
|
||||
// Mantine NavLink sets data-active="true" on the active item
|
||||
const homeLink = screen.getByTestId('nav-home')
|
||||
expect(homeLink).toHaveAttribute('data-active', 'true')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3. Records active on '/records'
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('marks Records nav item as active when on "/records"', () => {
|
||||
renderSidebar('/records')
|
||||
const recordsLink = screen.getByTestId('nav-records')
|
||||
expect(recordsLink).toHaveAttribute('data-active', 'true')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4. Config active on '/config'
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('marks Config nav item as active when on "/config"', () => {
|
||||
renderSidebar('/config')
|
||||
const configLink = screen.getByTestId('nav-config')
|
||||
expect(configLink).toHaveAttribute('data-active', 'true')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 5. Home NOT active on '/records' (exact match guard)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('does NOT mark Home as active when on "/records"', () => {
|
||||
renderSidebar('/records')
|
||||
const homeLink = screen.getByTestId('nav-home')
|
||||
expect(homeLink).not.toHaveAttribute('data-active', 'true')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* AppSidebar — vertical sidebar navigation for all protected pages.
|
||||
*
|
||||
* Nav items: Home / Records / Config
|
||||
* Utilities: ColorSchemeToggle + LogoutButton (at bottom)
|
||||
*
|
||||
* Current route is highlighted via useLocation().
|
||||
* Mobile: burger button toggles the navbar open/closed (handled by AppShell context).
|
||||
*
|
||||
* NOTE: Energy nav item is intentionally absent — added in M5-T06 once /energy exists.
|
||||
*/
|
||||
|
||||
import { NavLink, Stack, Divider, Tooltip, ActionIcon, useMantineColorScheme, useComputedColorScheme, AppShell, Text, Group } from '@mantine/core'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Home, List, Settings, Sun, Moon, LogOut } from 'react-feather'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import apiClient from '../api/client'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Individual nav entries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface NavEntry {
|
||||
to: string
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
testId: string
|
||||
}
|
||||
|
||||
const NAV_ENTRIES: NavEntry[] = [
|
||||
{
|
||||
to: '/',
|
||||
label: 'Home',
|
||||
icon: <Home size={18} />,
|
||||
testId: 'nav-home',
|
||||
},
|
||||
{
|
||||
to: '/records',
|
||||
label: 'Records',
|
||||
icon: <List size={18} />,
|
||||
testId: 'nav-records',
|
||||
},
|
||||
{
|
||||
to: '/config',
|
||||
label: 'Config',
|
||||
icon: <Settings size={18} />,
|
||||
testId: 'nav-config',
|
||||
},
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Colour-scheme toggle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ColorSchemeToggle() {
|
||||
const { setColorScheme } = useMantineColorScheme()
|
||||
const computed = useComputedColorScheme('light', { getInitialValueInEffect: true })
|
||||
const isDark = computed === 'dark'
|
||||
return (
|
||||
<Tooltip label={isDark ? 'Light mode' : 'Dark mode'} position="right">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
aria-label="Toggle color scheme"
|
||||
onClick={() => setColorScheme(isDark ? 'light' : 'dark')}
|
||||
data-testid="color-scheme-toggle"
|
||||
>
|
||||
{isDark ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logout button
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function LogoutButton() {
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await apiClient.POST('/api/auth/logout')
|
||||
} catch {
|
||||
// Ignore errors — clear session regardless.
|
||||
}
|
||||
await qc.invalidateQueries({ queryKey: ['session'] })
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip label="Log out" position="right">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
onClick={handleLogout}
|
||||
aria-label="Log out"
|
||||
data-testid="logout-button"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AppSidebarProps {
|
||||
/** Called when a nav link is clicked on mobile (to close the navbar). */
|
||||
onNavClick?: () => void
|
||||
}
|
||||
|
||||
export function AppSidebar({ onNavClick }: AppSidebarProps) {
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<AppShell.Navbar p="xs">
|
||||
{/* App title */}
|
||||
<AppShell.Section>
|
||||
<Group px="xs" py="sm">
|
||||
<Text fw={700} size="sm" component={Link} to="/" style={{ textDecoration: 'none' }}>
|
||||
Home Automation
|
||||
</Text>
|
||||
</Group>
|
||||
<Divider />
|
||||
</AppShell.Section>
|
||||
|
||||
{/* Navigation links */}
|
||||
<AppShell.Section grow mt="sm">
|
||||
<Stack gap={4}>
|
||||
{NAV_ENTRIES.map(({ to, label, icon, testId }) => {
|
||||
// For the home route "/" we need an exact match; others prefix-match is fine.
|
||||
const isActive =
|
||||
to === '/'
|
||||
? location.pathname === '/'
|
||||
: location.pathname.startsWith(to)
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={to}
|
||||
component={Link}
|
||||
to={to}
|
||||
label={label}
|
||||
leftSection={icon}
|
||||
active={isActive}
|
||||
data-testid={testId}
|
||||
onClick={onNavClick}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Stack>
|
||||
</AppShell.Section>
|
||||
|
||||
{/* Bottom utilities: theme toggle + logout */}
|
||||
<AppShell.Section>
|
||||
<Divider mb="xs" />
|
||||
<Group gap="xs" px="xs" pb="xs">
|
||||
<ColorSchemeToggle />
|
||||
<LogoutButton />
|
||||
</Group>
|
||||
</AppShell.Section>
|
||||
</AppShell.Navbar>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user