diff --git a/docs/design/m5-iot-energy.md b/docs/design/m5-iot-energy.md index 85467a6..c0e67a4 100644 --- a/docs/design/m5-iot-energy.md +++ b/docs/design/m5-iot-energy.md @@ -258,7 +258,7 @@ Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口) > 后端任务沿用校验闸门(`pytest` / `ruff` / 改路由或 schema 则 `export_openapi` 重导出入库)。前端任务闸门见 §8。新增依赖(`pymodbus`、`PyYAML`、`paho-mqtt`、`recharts`)须在对应任务里同步 `requirements.in/.txt` 或 `frontend/package.json` 并重新锁定。 ### M5-T01 — 侧边栏布局重构 `[structural]` -- **Status**: `todo` · **Depends**: none +- **Status**: `done` · **Depends**: none - **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`(导航断言) - **Steps**: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 16e195f..c2aa217 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,24 +6,19 @@ * * Route tree: * /login → LoginPage (public) - * /change-password → ProtectedRoute → ChangePasswordPage (T07: forced password change gate) - * / → ProtectedRoute → AppLayout → HomePage (T09) - * /config → ProtectedRoute → AppLayout → ConfigPage (T08) + * /change-password → ProtectedRoute → ChangePasswordPage (forced password change gate) + * / → ProtectedRoute → AppLayout → HomePage + * /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 on the right. + * /login and /change-password have no sidebar layout. */ +import { useState } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { BrowserRouter, Routes, Route, Link, Outlet, useNavigate } from 'react-router-dom' -import { - MantineProvider, - Group, - ActionIcon, - Tooltip, - useMantineColorScheme, - useComputedColorScheme, -} from '@mantine/core' -import { List, Settings, Sun, Moon, LogOut } from 'react-feather' +import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom' +import { MantineProvider, AppShell, Burger } from '@mantine/core' // Mantine requires its CSS to be imported once. import '@mantine/core/styles.css' @@ -35,8 +30,7 @@ import { HomePage } from './pages/HomePage' import { ConfigPage } from './pages/ConfigPage' import { RecordsPage } from './pages/RecordsPage' import { ChangePasswordPage } from './pages/ChangePasswordPage' -import apiClient from './api/client' -import { useQueryClient } from '@tanstack/react-query' +import { AppSidebar } from './components/AppSidebar' // --------------------------------------------------------------------------- // 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 ( - - - - - - ) -} - -// --------------------------------------------------------------------------- -// 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 ( - - setColorScheme(isDark ? 'light' : 'dark')} - data-testid="color-scheme-toggle" - > - {isDark ? : } - - - ) -} - // --------------------------------------------------------------------------- // App shell layout (used by all protected pages) // --------------------------------------------------------------------------- function AppLayout() { + // Mobile navbar open/close state — controlled here so the Burger and + // AppShell share the same state. + const [navbarOpen, setNavbarOpen] = useState(false) + return ( -
- {/* Top nav */} - + setNavbarOpen(false)} /> - {/* Page content */} -
+ -
-
+ + ) } @@ -198,7 +114,7 @@ export default function App() { } /> - {/* Protected routes — all nested under AppLayout */} + {/* Protected routes — all nested under AppLayout (sidebar) */} diff --git a/frontend/src/components/AppSidebar.test.tsx b/frontend/src/components/AppSidebar.test.tsx new file mode 100644 index 0000000..88aef44 --- /dev/null +++ b/frontend/src/components/AppSidebar.test.tsx @@ -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( + + + + + + + + + , + ) +} + +// --------------------------------------------------------------------------- +// 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') + }) +}) diff --git a/frontend/src/components/AppSidebar.tsx b/frontend/src/components/AppSidebar.tsx new file mode 100644 index 0000000..c2e0f94 --- /dev/null +++ b/frontend/src/components/AppSidebar.tsx @@ -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: , + testId: 'nav-home', + }, + { + to: '/records', + label: 'Records', + icon: , + testId: 'nav-records', + }, + { + to: '/config', + label: 'Config', + icon: , + testId: 'nav-config', + }, +] + +// --------------------------------------------------------------------------- +// Colour-scheme toggle +// --------------------------------------------------------------------------- + +function ColorSchemeToggle() { + const { setColorScheme } = useMantineColorScheme() + const computed = useComputedColorScheme('light', { getInitialValueInEffect: true }) + const isDark = computed === 'dark' + return ( + + setColorScheme(isDark ? 'light' : 'dark')} + data-testid="color-scheme-toggle" + > + {isDark ? : } + + + ) +} + +// --------------------------------------------------------------------------- +// 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 ( + + + + + + ) +} + +// --------------------------------------------------------------------------- +// 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 ( + + {/* App title */} + + + + Home Automation + + + + + + {/* Navigation links */} + + + {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 ( + + ) + })} + + + + {/* Bottom utilities: theme toggle + logout */} + + + + + + + + + ) +}