From e967ea966091a0171b88bba1f652675f94fda074 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 23 Jun 2026 10:21:27 +0200 Subject: [PATCH] feat(04-03): migrate sidebar to Link/usePathname, add search and refresh signal Replace all raw with Next.js Link. Add usePathname-based active highlighting, SidebarSearch with category/module filtering, and sidebarRefreshKey subscription for live activation updates. 26 tests pass. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/layout/sidebar-search.tsx | 25 ++ .../src/components/layout/sidebar.test.tsx | 173 ++++++++++ apps/web/src/components/layout/sidebar.tsx | 325 +++++++----------- 3 files changed, 314 insertions(+), 209 deletions(-) create mode 100644 apps/web/src/components/layout/sidebar-search.tsx create mode 100644 apps/web/src/components/layout/sidebar.test.tsx diff --git a/apps/web/src/components/layout/sidebar-search.tsx b/apps/web/src/components/layout/sidebar-search.tsx new file mode 100644 index 0000000..0582e9f --- /dev/null +++ b/apps/web/src/components/layout/sidebar-search.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { useTranslations } from 'next-intl'; + +interface SidebarSearchProps { + value: string; + onChange: (value: string) => void; +} + +export function SidebarSearch({ value, onChange }: SidebarSearchProps) { + const t = useTranslations('sidebar'); + + return ( +
+ onChange(e.target.value)} + placeholder={t('search')} + aria-label={t('search')} + className="flex h-9 w-full rounded-md border border-border bg-background px-3 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent" + /> +
+ ); +} diff --git a/apps/web/src/components/layout/sidebar.test.tsx b/apps/web/src/components/layout/sidebar.test.tsx new file mode 100644 index 0000000..a04d556 --- /dev/null +++ b/apps/web/src/components/layout/sidebar.test.tsx @@ -0,0 +1,173 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +let mockPathname = '/'; +vi.mock('next/navigation', () => ({ + usePathname: () => mockPathname, +})); + +vi.mock('next/link', () => ({ + default: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => ( +
{children} + ), +})); + +vi.mock('next-intl', () => ({ + useTranslations: (ns: string) => (key: string) => { + const map: Record> = { + sidebar: { + dashboard: 'Dashboard', + marketplace: 'Marktplatz', + collapse: 'Einklappen', + expand: 'Ausklappen', + search: 'Module suchen...', + noResults: 'Keine Module gefunden', + 'categories.label': 'Kategorien', + 'categories.empty': 'Keine Module', + admin: 'Verwaltung', + users: 'Benutzer', + tenants: 'Mandanten', + modules: 'Module', + ldap: 'LDAP', + }, + common: { + appName: 'Tessera', + }, + }; + return map[ns]?.[key] ?? key; + }, +})); + +vi.mock('@/lib/stores/sidebar-store', () => ({ + useSidebarStore: () => ({ + isCollapsed: false, + isMobileOpen: false, + toggle: vi.fn(), + setMobileOpen: vi.fn(), + }), +})); + +const mockAuthStore = vi.fn(); +vi.mock('@/lib/stores/auth-store', () => ({ + useAuthStore: (selector: (state: unknown) => unknown) => mockAuthStore(selector), +})); + +let mockRefreshKey = 0; +vi.mock('@/lib/stores/marketplace-store', () => ({ + useMarketplaceStore: (selector: (state: { sidebarRefreshKey: number }) => unknown) => + selector({ sidebarRefreshKey: mockRefreshKey }), +})); + +vi.mock('@/components/layout/sidebar-footer', () => ({ + SidebarFooter: () =>
, +})); + +const mockActiveModules = [ + { id: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'Domain-Tools' }, + { id: 'm2', slug: 'converter', name: 'Converter', category: 'Utilities' }, +]; + +function stubFetch() { + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) }), + ), + ); +} + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + mockPathname = '/'; + mockRefreshKey = 0; +}); + +async function importSidebar() { + const mod = await import('./sidebar'); + return mod.Sidebar; +} + +describe('Sidebar', () => { + beforeEach(() => { + mockAuthStore.mockImplementation( + (selector: (state: { user: { id: string; role: string; tenantId: string } }) => unknown) => + selector({ user: { id: 'u1', role: 'ADMIN', tenantId: 't1' } }), + ); + stubFetch(); + }); + + it('renders Dashboard and Marketplace as Link elements with correct hrefs', async () => { + const Sidebar = await importSidebar(); + render(); + + await waitFor(() => { + expect(screen.getByText('Dashboard')).toBeInTheDocument(); + }); + + const dashLink = screen.getByText('Dashboard').closest('a'); + const marketLink = screen.getByText('Marktplatz').closest('a'); + expect(dashLink).toHaveAttribute('href', '/'); + expect(marketLink).toHaveAttribute('href', '/marketplace'); + }); + + it('highlights Marketplace link when pathname is /marketplace', async () => { + mockPathname = '/marketplace'; + const Sidebar = await importSidebar(); + render(); + + await waitFor(() => { + expect(screen.getByText('Dashboard')).toBeInTheDocument(); + }); + + const dashLink = screen.getByText('Dashboard').closest('a'); + const marketLink = screen.getByText('Marktplatz').closest('a'); + expect(marketLink?.className).toContain('bg-sidebar-accent'); + expect(dashLink?.className).not.toContain('bg-sidebar-accent'); + }); + + it('renders search input with correct placeholder', async () => { + const Sidebar = await importSidebar(); + render(); + + await waitFor(() => { + expect(screen.getByPlaceholderText('Module suchen...')).toBeInTheDocument(); + }); + }); + + it('shows noResults text when search matches no modules', async () => { + const Sidebar = await importSidebar(); + render(); + + await waitFor(() => { + expect(screen.getByText('Domaincheck')).toBeInTheDocument(); + }); + + const searchInput = screen.getByPlaceholderText('Module suchen...'); + await userEvent.type(searchInput, 'zzz-nothing'); + + await waitFor(() => { + expect(screen.getByText('Keine Module gefunden')).toBeInTheDocument(); + }); + }); + + it('re-fetches modules when sidebarRefreshKey changes', async () => { + const Sidebar = await importSidebar(); + const { rerender } = render(); + + await waitFor(() => { + expect(screen.getByText('Domaincheck')).toBeInTheDocument(); + }); + + const initialCallCount = (globalThis.fetch as ReturnType).mock.calls.length; + + mockRefreshKey = 1; + rerender(); + + await waitFor(() => { + const newCallCount = (globalThis.fetch as ReturnType).mock.calls.length; + expect(newCallCount).toBeGreaterThan(initialCallCount); + }); + }); +}); diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index 8e6aea4..da26556 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -1,10 +1,14 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { useSidebarStore } from '@/lib/stores/sidebar-store'; import { useAuthStore } from '@/lib/stores/auth-store'; +import { useMarketplaceStore } from '@/lib/stores/marketplace-store'; import { SidebarFooter } from '@/components/layout/sidebar-footer'; +import { SidebarSearch } from '@/components/layout/sidebar-search'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; @@ -21,7 +25,22 @@ export function Sidebar() { const { isCollapsed, isMobileOpen, toggle, setMobileOpen } = useSidebarStore(); const [categoriesOpen, setCategoriesOpen] = useState(true); const [activeModules, setActiveModules] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); const user = useAuthStore((s) => s.user); + const pathname = usePathname(); + const sidebarRefreshKey = useMarketplaceStore((s) => s.sidebarRefreshKey); + + const isActive = (href: string) => { + if (href === '/') return pathname === '/'; + return pathname.startsWith(href); + }; + + const linkClass = (href: string) => + `flex items-center gap-3 rounded-md px-2 py-2 text-sm transition-colors ${ + isActive(href) + ? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium' + : 'text-sidebar-foreground hover:bg-muted' + }`; const fetchActiveModules = useCallback(async () => { try { @@ -38,144 +57,131 @@ export function Sidebar() { useEffect(() => { fetchActiveModules(); - }, [fetchActiveModules]); + }, [fetchActiveModules, sidebarRefreshKey]); - // Admin section visibility based on role const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN'; const isSuperAdmin = user?.role === 'SUPER_ADMIN'; + const filteredCategories = useMemo(() => { + const categories = new Map(); + for (const mod of activeModules) { + const cat = mod.category || 'other'; + if (!categories.has(cat)) categories.set(cat, []); + categories.get(cat)!.push(mod); + } + + if (!searchQuery) return Array.from(categories.entries()); + + const q = searchQuery.toLowerCase(); + const result: [string, SidebarModule[]][] = []; + for (const [cat, mods] of categories.entries()) { + if (cat.toLowerCase().includes(q)) { + result.push([cat, mods]); + } else { + const filtered = mods.filter((m) => m.name.toLowerCase().includes(q)); + if (filtered.length > 0) result.push([cat, filtered]); + } + } + return result; + }, [activeModules, searchQuery]); + const sidebarContent = (
- {/* Navigation items */}
+ )} - {/* Admin section -- Verwaltung (D-12) */} {isAdmin && (
{!isCollapsed && ( @@ -184,157 +190,72 @@ export function Sidebar() {
)}
)} - {/* Collapse/Expand toggle */}
- {/* Sidebar footer (D-06) */} ); return ( <> - {/* Desktop sidebar */}