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
+1 -1
View File
@@ -258,7 +258,7 @@ Phase CMQTT / Discovery,依赖 B 的设备数据与 provider 接口)
> 后端任务沿用校验闸门(`pytest` / `ruff` / 改路由或 schema 则 `export_openapi` 重导出入库)。前端任务闸门见 §8。新增依赖(`pymodbus`、`PyYAML`、`paho-mqtt`、`recharts`)须在对应任务里同步 `requirements.in/.txt` 或 `frontend/package.json` 并重新锁定。 > 后端任务沿用校验闸门(`pytest` / `ruff` / 改路由或 schema 则 `export_openapi` 重导出入库)。前端任务闸门见 §8。新增依赖(`pymodbus`、`PyYAML`、`paho-mqtt`、`recharts`)须在对应任务里同步 `requirements.in/.txt` 或 `frontend/package.json` 并重新锁定。
### M5-T01 — 侧边栏布局重构 `[structural]` ### M5-T01 — 侧边栏布局重构 `[structural]`
- **Status**: `todo` · **Depends**: none - **Status**: `done` · **Depends**: none
- **Context**: 把 `AppLayout` 从顶栏改为侧边导航,给后续 Energy 等视图腾入口。纯前端,不碰各页主体。 - **Context**: 把 `AppLayout` 从顶栏改为侧边导航,给后续 Energy 等视图腾入口。纯前端,不碰各页主体。
- **Files**: `modify frontend/src/App.tsx``create frontend/src/components/AppSidebar.tsx``frontend/src/components/NavItem.tsx`(可选);`modify` 受影响的 `frontend/src/pages/*.test.tsx`(导航断言) - **Files**: `modify frontend/src/App.tsx``create frontend/src/components/AppSidebar.tsx``frontend/src/components/NavItem.tsx`(可选);`modify` 受影响的 `frontend/src/pages/*.test.tsx`(导航断言)
- **Steps**: - **Steps**:
+38 -122
View File
@@ -6,24 +6,19 @@
* *
* Route tree: * Route tree:
* /login → LoginPage (public) * /login → LoginPage (public)
* /change-password → ProtectedRoute → ChangePasswordPage (T07: forced password change gate) * /change-password → ProtectedRoute → ChangePasswordPage (forced password change gate)
* / → ProtectedRoute → AppLayout → HomePage (T09) * / → ProtectedRoute → AppLayout → HomePage
* /config → ProtectedRoute → AppLayout → ConfigPage (T08) * /config → ProtectedRoute → AppLayout → ConfigPage
* /records → ProtectedRoute → AppLayout → RecordsPage
* *
* AppLayout renders a nav with a gear-icon entry for /config and a logout button (T07). * AppLayout renders a sidebar (AppSidebar) on the left; page content via <Outlet/> on the right.
* /login and /change-password have no sidebar layout.
*/ */
import { useState } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter, Routes, Route, Link, Outlet, useNavigate } from 'react-router-dom' import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'
import { import { MantineProvider, AppShell, Burger } from '@mantine/core'
MantineProvider,
Group,
ActionIcon,
Tooltip,
useMantineColorScheme,
useComputedColorScheme,
} from '@mantine/core'
import { List, Settings, Sun, Moon, LogOut } from 'react-feather'
// Mantine requires its CSS to be imported once. // Mantine requires its CSS to be imported once.
import '@mantine/core/styles.css' import '@mantine/core/styles.css'
@@ -35,8 +30,7 @@ import { HomePage } from './pages/HomePage'
import { ConfigPage } from './pages/ConfigPage' import { ConfigPage } from './pages/ConfigPage'
import { RecordsPage } from './pages/RecordsPage' import { RecordsPage } from './pages/RecordsPage'
import { ChangePasswordPage } from './pages/ChangePasswordPage' import { ChangePasswordPage } from './pages/ChangePasswordPage'
import apiClient from './api/client' import { AppSidebar } from './components/AppSidebar'
import { useQueryClient } from '@tanstack/react-query'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// TanStack Query client (singleton, created outside render to avoid re-creation) // TanStack Query client (singleton, created outside render to avoid re-creation)
@@ -57,120 +51,42 @@ const queryClient = new QueryClient({
}, },
}) })
// ---------------------------------------------------------------------------
// Logout button component (needs navigate + queryClient hooks, so it's a component)
// ---------------------------------------------------------------------------
function LogoutButton() {
const navigate = useNavigate()
const qc = useQueryClient()
async function handleLogout() {
try {
await apiClient.POST('/api/auth/logout')
} catch {
// Ignore errors on logout — we clear the session regardless.
}
// Invalidate session so SessionProvider becomes unauthenticated.
await qc.invalidateQueries({ queryKey: ['session'] })
navigate('/login', { replace: true })
}
return (
<Tooltip label="Log out">
<ActionIcon
variant="default"
size="lg"
onClick={handleLogout}
aria-label="Log out"
data-testid="logout-button"
>
<LogOut size={18} />
</ActionIcon>
</Tooltip>
)
}
// ---------------------------------------------------------------------------
// Dark-mode toggle (sits next to the gear / settings icon)
// ---------------------------------------------------------------------------
function ColorSchemeToggle() {
const { setColorScheme } = useMantineColorScheme()
const computed = useComputedColorScheme('light', { getInitialValueInEffect: true })
const isDark = computed === 'dark'
return (
<Tooltip label={isDark ? 'Light mode' : 'Dark mode'}>
<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>
)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// App shell layout (used by all protected pages) // App shell layout (used by all protected pages)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function AppLayout() { function AppLayout() {
// Mobile navbar open/close state — controlled here so the Burger and
// AppShell share the same state.
const [navbarOpen, setNavbarOpen] = useState(false)
return ( return (
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}> <AppShell
{/* Top nav */} navbar={{
<nav width: 220,
style={{ breakpoint: 'sm',
display: 'flex', collapsed: { mobile: !navbarOpen },
alignItems: 'center', }}
justifyContent: 'space-between', header={{ height: { base: 48, sm: 0 } }}
padding: '0.5rem 1rem', padding="md"
borderBottom: '1px solid var(--mantine-color-default-border)', >
}} {/* Burger shown on mobile only (hidden on sm+) */}
> <AppShell.Header hiddenFrom="sm" p="xs">
<Link to="/" style={{ fontWeight: 600, textDecoration: 'none' }}> <Burger
Home Automation opened={navbarOpen}
</Link> onClick={() => setNavbarOpen((o) => !o)}
size="sm"
aria-label="Toggle navigation"
data-testid="nav-burger"
/>
</AppShell.Header>
<Group gap="xs"> <AppSidebar onNavClick={() => setNavbarOpen(false)} />
{/* Records nav link */}
<Tooltip label="Records">
<ActionIcon
component={Link}
to="/records"
variant="default"
size="lg"
aria-label="Records"
>
<List size={18} />
</ActionIcon>
</Tooltip>
{/* Dark-mode toggle — directly beside the settings gear */}
<ColorSchemeToggle />
{/* Settings — links to config page (§5#10) */}
<Tooltip label="Settings">
<ActionIcon
component={Link}
to="/config"
variant="default"
size="lg"
aria-label="Settings"
>
<Settings size={18} />
</ActionIcon>
</Tooltip>
<LogoutButton />
</Group>
</nav>
{/* Page content */} <AppShell.Main>
<main style={{ flex: 1 }}>
<Outlet /> <Outlet />
</main> </AppShell.Main>
</div> </AppShell>
) )
} }
@@ -198,7 +114,7 @@ export default function App() {
} }
/> />
{/* Protected routes — all nested under AppLayout */} {/* Protected routes — all nested under AppLayout (sidebar) */}
<Route <Route
element={ element={
<ProtectedRoute> <ProtectedRoute>
+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>
)
}