M5-T01: refactor AppLayout from top bar to sidebar navigation

This commit is contained in:
2026-06-22 12:26:52 +02:00
parent e49a6ad963
commit da7d0f9d62
4 changed files with 332 additions and 123 deletions
+126
View File
@@ -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')
})
})
+167
View File
@@ -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>
)
}