feat(04-03): migrate sidebar to Link/usePathname, add search and refresh signal

Replace all raw <a> 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 10:21:27 +02:00
parent 9e705ab13a
commit e967ea9660
3 changed files with 314 additions and 209 deletions
@@ -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 (
<div className="px-3 pb-2">
<input
type="text"
value={value}
onChange={(e) => 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"
/>
</div>
);
}
@@ -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 }) => (
<a href={href} className={className}>{children}</a>
),
}));
vi.mock('next-intl', () => ({
useTranslations: (ns: string) => (key: string) => {
const map: Record<string, Record<string, string>> = {
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: () => <div data-testid="sidebar-footer" />,
}));
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(<Sidebar />);
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(<Sidebar />);
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(<Sidebar />);
await waitFor(() => {
expect(screen.getByPlaceholderText('Module suchen...')).toBeInTheDocument();
});
});
it('shows noResults text when search matches no modules', async () => {
const Sidebar = await importSidebar();
render(<Sidebar />);
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(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
});
const initialCallCount = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length;
mockRefreshKey = 1;
rerender(<Sidebar />);
await waitFor(() => {
const newCallCount = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length;
expect(newCallCount).toBeGreaterThan(initialCallCount);
});
});
});
+96 -189
View File
@@ -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<SidebarModule[]>([]);
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,91 +57,74 @@ 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<string, SidebarModule[]>();
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 = (
<div className="flex h-full flex-col bg-sidebar">
{/* Navigation items */}
<nav className="flex-1 overflow-y-auto p-3">
<ul className="flex flex-col gap-1">
{/* Dashboard - active by default (D-15) */}
<li>
<a
href="/"
className="flex items-center gap-3 rounded-md px-2 py-2 text-sm font-medium bg-sidebar-accent text-sidebar-accent-foreground transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0"
>
<Link href="/" className={linkClass('/')}>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
{!isCollapsed && <span className="truncate">{t('dashboard')}</span>}
</a>
</Link>
</li>
{/* Marketplace */}
<li>
<a
href="/marketplace"
className="flex items-center gap-3 rounded-md px-2 py-2 text-sm text-sidebar-foreground hover:bg-muted transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0"
>
<Link href="/marketplace" className={linkClass('/marketplace')}>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<circle cx="9" cy="21" r="1" />
<circle cx="20" cy="21" r="1" />
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
</svg>
{!isCollapsed && <span className="truncate">{t('marketplace')}</span>}
</a>
</Link>
</li>
</ul>
{/* Categories accordion with active modules (D-05) */}
{!isCollapsed && (
<div className="mt-6">
<>
<div className="mt-4">
<SidebarSearch value={searchQuery} onChange={setSearchQuery} />
</div>
<div className="mt-2">
<button
onClick={() => setCategoriesOpen(!categoriesOpen)}
className="flex w-full items-center justify-between rounded-md px-2 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors"
>
<span>{t('categories.label')}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`transition-transform duration-200 ${categoriesOpen ? 'rotate-180' : ''}`}
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={`transition-transform duration-200 ${categoriesOpen ? 'rotate-180' : ''}`}>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
@@ -132,50 +134,54 @@ export function Sidebar() {
<div className="px-2 py-2 text-sm text-muted-foreground">
{t('categories.empty')}
</div>
) : filteredCategories.length === 0 ? (
<div className="px-2 py-2 text-sm text-muted-foreground">
{t('noResults')}
</div>
) : (
<ul className="flex flex-col gap-1">
{(() => {
const categories = new Map<string, SidebarModule[]>();
for (const mod of activeModules) {
const cat = mod.category || 'other';
if (!categories.has(cat)) categories.set(cat, []);
categories.get(cat)!.push(mod);
}
return Array.from(categories.entries()).map(([category, mods]) => (
{filteredCategories.map(([category, mods]) => (
<li key={category}>
<a
<Link
href={`/modules/${category}`}
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-xs font-medium transition-colors ${
isActive(`/modules/${category}`)
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
<span className="capitalize">{category.replace(/-/g, ' ')}</span>
<span className="ml-auto text-[10px] text-muted-foreground/60">{mods.length}</span>
</a>
</Link>
<ul className="ml-5 flex flex-col gap-0.5">
{mods.map((mod) => (
<li key={mod.id}>
<a
<Link
href={`/modules/${category}/${mod.slug}`}
className="block rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
className={`block rounded-md px-2 py-1 text-xs transition-colors ${
isActive(`/modules/${category}/${mod.slug}`)
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
{mod.name}
</a>
</Link>
</li>
))}
</ul>
</li>
));
})()}
))}
</ul>
)}
</div>
)}
</div>
</>
)}
{/* Admin section -- Verwaltung (D-12) */}
{isAdmin && (
<div className="mt-6">
{!isCollapsed && (
@@ -184,157 +190,72 @@ export function Sidebar() {
</div>
)}
<ul className="flex flex-col gap-1">
{/* Users link -- ADMIN and SUPER_ADMIN */}
<li>
<a
href="/admin/users"
className="flex items-center gap-3 rounded-md px-2 py-2 text-sm text-sidebar-foreground hover:bg-muted transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0"
>
<Link href="/admin/users" className={linkClass('/admin/users')}>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
{!isCollapsed && <span className="truncate">{t('users')}</span>}
</a>
</Link>
</li>
{/* Tenants link -- SUPER_ADMIN only (D-10) */}
{isSuperAdmin && (
<li>
<a
href="/admin/tenants"
className="flex items-center gap-3 rounded-md px-2 py-2 text-sm text-sidebar-foreground hover:bg-muted transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0"
>
<Link href="/admin/tenants" className={linkClass('/admin/tenants')}>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
{!isCollapsed && (
<span className="truncate">{t('tenants')}</span>
)}
</a>
{!isCollapsed && <span className="truncate">{t('tenants')}</span>}
</Link>
</li>
)}
{/* Modules link -- ADMIN and SUPER_ADMIN */}
<li>
<a
href="/admin/modules"
className="flex items-center gap-3 rounded-md px-2 py-2 text-sm text-sidebar-foreground hover:bg-muted transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0"
>
<Link href="/admin/modules" className={linkClass('/admin/modules')}>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
</svg>
{!isCollapsed && (
<span className="truncate">{t('modules')}</span>
)}
</a>
{!isCollapsed && <span className="truncate">{t('modules')}</span>}
</Link>
</li>
{/* LDAP link -- ADMIN and SUPER_ADMIN (D-18) */}
<li>
<a
href="/admin/ldap"
className="flex items-center gap-3 rounded-md px-2 py-2 text-sm text-sidebar-foreground hover:bg-muted transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0"
>
<Link href="/admin/ldap" className={linkClass('/admin/ldap')}>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
{!isCollapsed && (
<span className="truncate">{t('ldap')}</span>
)}
</a>
{!isCollapsed && <span className="truncate">{t('ldap')}</span>}
</Link>
</li>
</ul>
</div>
)}
</nav>
{/* Collapse/Expand toggle */}
<div className="hidden md:block border-t border-sidebar-border p-2">
<button
onClick={toggle}
className="flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-sm text-sidebar-foreground hover:bg-muted transition-colors"
aria-label={isCollapsed ? t('expand') : t('collapse')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}>
<polyline points="15 18 9 12 15 6" />
</svg>
{!isCollapsed && (
<span className="truncate">{t('collapse')}</span>
)}
{!isCollapsed && <span className="truncate">{t('collapse')}</span>}
</button>
</div>
{/* Sidebar footer (D-06) */}
<SidebarFooter />
</div>
);
return (
<>
{/* Desktop sidebar */}
<aside
className="fixed left-0 top-[var(--header-height)] z-30 hidden h-[calc(100vh-var(--header-height))] border-r border-sidebar-border transition-all duration-200 md:block"
style={{
@@ -346,18 +267,14 @@ export function Sidebar() {
{sidebarContent}
</aside>
{/* Mobile sidebar overlay (D-03) */}
{isMobileOpen && (
<div className="fixed inset-0 z-40 md:hidden">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50"
onClick={() => setMobileOpen(false)}
aria-hidden="true"
/>
{/* Sidebar panel */}
<aside className="fixed left-0 top-0 z-50 h-full w-[var(--sidebar-width)] bg-sidebar shadow-lg">
{/* Mobile close button */}
<div className="flex h-[var(--header-height)] items-center justify-between border-b border-sidebar-border px-4">
<span className="text-lg font-bold text-primary">
{tCommon('appName')}
@@ -366,17 +283,7 @@ export function Sidebar() {
onClick={() => setMobileOpen(false)}
className="rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>