feat(api,web): eigene Module — Tabelle, API, Seitenleiste, Rahmen-Seite
- Tabelle CustomModule mit Zeilenschutz (tenant_isolation_policy), Migration 20260929120000 - API /custom-modules: Lesen für jeden Angemeldeten, Schreiben nur Administrator, nur https ohne Zugangsdaten - Seitenleiste zeigt eigene Module unter ihrer Kategorie, Rahmen-Seite mit Sandbox und „In neuem Tab öffnen“ - MODULE_CATEGORIES als gemeinsame Liste, Zugriffsklassifikation nachgemessen fortgeschrieben Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -74,6 +74,20 @@ vi.mock('@/components/layout/app-version-badge', () => ({
|
||||
AppVersionBadge: () => <div data-testid="app-version-badge" />,
|
||||
}));
|
||||
|
||||
// Eigene Module (quick-260929-9wc): eigener Client, Standard = leere Liste,
|
||||
// damit die Abruf-Zaehler-Tests auf `fetch` unveraendert gelten.
|
||||
let mockCustomModules: {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
category: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}[] = [];
|
||||
vi.mock('@/lib/custom-modules-api', () => ({
|
||||
listCustomModules: vi.fn(() => Promise.resolve(mockCustomModules)),
|
||||
}));
|
||||
|
||||
const mockActiveModules = [
|
||||
{ id: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'Domain-Tools' },
|
||||
{ id: 'm2', slug: 'converter', name: 'Converter', category: 'Utilities' },
|
||||
@@ -93,8 +107,21 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockPathname = '/';
|
||||
mockRefreshKey = 0;
|
||||
mockCustomModules = [];
|
||||
});
|
||||
|
||||
function customModule(overrides: Partial<(typeof mockCustomModules)[number]> = {}) {
|
||||
return {
|
||||
id: 'cm-1',
|
||||
name: 'Wiki',
|
||||
url: 'https://wiki.example.com',
|
||||
category: 'infrastructure',
|
||||
createdAt: '2026-09-29T00:00:00.000Z',
|
||||
updatedAt: '2026-09-29T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function importSidebar() {
|
||||
const mod = await import('./sidebar');
|
||||
return mod.Sidebar;
|
||||
@@ -243,6 +270,85 @@ describe('Sidebar', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('zeigt ein eigenes Modul unter seiner Kategorie als Link auf /modules/custom/<id>', async () => {
|
||||
mockCustomModules = [customModule()];
|
||||
const Sidebar = await importSidebar();
|
||||
render(<Sidebar />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Wiki')).toBeInTheDocument();
|
||||
});
|
||||
const link = screen.getByText('Wiki').closest('a');
|
||||
expect(link).toHaveAttribute('href', '/modules/custom/cm-1');
|
||||
expect(link?.querySelector('[data-module-tile="custom"]')).not.toBeNull();
|
||||
// Kategorie-Kopf "infrastructure" (Test-Attrappe ohne Uebersetzung -> Kennung)
|
||||
const header = screen.getByText('infrastructure').closest('li');
|
||||
expect(header?.contains(link)).toBe(true);
|
||||
});
|
||||
|
||||
it('zeigt eine Kategorie, die nur eigene Module hat', async () => {
|
||||
mockCustomModules = [customModule({ category: 'fleet' })];
|
||||
const Sidebar = await importSidebar();
|
||||
render(<Sidebar />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('fleet')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Wiki')).toBeInTheDocument();
|
||||
// Die eingebauten Kategorien bleiben daneben stehen.
|
||||
expect(screen.getByText('Domain-Tools')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('eingebaute Module stehen innerhalb einer Kategorie vor eigenen', async () => {
|
||||
mockCustomModules = [customModule({ category: 'Domain-Tools', name: 'Aaa Eigenes' })];
|
||||
const Sidebar = await importSidebar();
|
||||
render(<Sidebar />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Aaa Eigenes')).toBeInTheDocument();
|
||||
});
|
||||
const items = screen
|
||||
.getByText('Domain-Tools')
|
||||
.closest('li')
|
||||
?.querySelectorAll('a span.truncate');
|
||||
expect(Array.from(items ?? []).map((n) => n.textContent)).toEqual(['Domaincheck', 'Aaa Eigenes']);
|
||||
});
|
||||
|
||||
it('auf /modules/custom/<id> traegt genau dieser Eintrag die Auswahlmarke', async () => {
|
||||
mockCustomModules = [customModule(), customModule({ id: 'cm-2', name: 'Anderes' })];
|
||||
mockPathname = '/modules/custom/cm-1';
|
||||
const Sidebar = await importSidebar();
|
||||
render(<Sidebar />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Wiki')).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.getByText('Wiki').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
screen.getByText('Anderes').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.getByText('Domaincheck').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('die Suche findet ein eigenes Modul', async () => {
|
||||
mockCustomModules = [customModule()];
|
||||
const Sidebar = await importSidebar();
|
||||
render(<Sidebar />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Wiki')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText('Module suchen...'), 'wik');
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Domaincheck')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Wiki')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the version badge below the navigation', async () => {
|
||||
const Sidebar = await importSidebar();
|
||||
render(<Sidebar />);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ModuleTile } from '@/components/modules/module-tile';
|
||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||
import { useNavStore } from '@/lib/stores/nav-store';
|
||||
import { DashboardGreeting } from '@/components/dashboard/dashboard-greeting';
|
||||
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
|
||||
|
||||
/** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */
|
||||
function SelectionPill() {
|
||||
@@ -34,11 +35,50 @@ interface SidebarModule {
|
||||
category: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Eintrag der Seitenleiste: eingebautes Modul ODER eigenes Modul
|
||||
* (quick-260929-9wc). Beide laufen durch dieselbe Gruppierung, Suche und
|
||||
* Kachelliste; sie unterscheiden sich nur in Ziel und Kachelsymbol.
|
||||
*/
|
||||
interface SidebarEntry {
|
||||
key: string;
|
||||
name: string;
|
||||
category: string;
|
||||
href: string;
|
||||
/** Kennung fuer das Kachelsymbol; `custom` hat kein eigenes Symbol (allgemeine Kachel). */
|
||||
tileSlug: string;
|
||||
/** Weitere Pfade, unter denen der Eintrag als ausgewaehlt gilt. */
|
||||
extraActiveHref?: string;
|
||||
}
|
||||
|
||||
function moduleEntry(mod: SidebarModule): SidebarEntry {
|
||||
const category = mod.category || 'other';
|
||||
return {
|
||||
key: mod.id,
|
||||
name: mod.name,
|
||||
category,
|
||||
href: `/modules/${category}/${mod.slug}`,
|
||||
tileSlug: mod.slug,
|
||||
extraActiveHref: `/modules/${mod.slug}`,
|
||||
};
|
||||
}
|
||||
|
||||
function customEntry(mod: CustomModule): SidebarEntry {
|
||||
return {
|
||||
key: `custom-${mod.id}`,
|
||||
name: mod.name,
|
||||
category: mod.category || 'other',
|
||||
href: `/modules/custom/${mod.id}`,
|
||||
tileSlug: 'custom',
|
||||
};
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const t = useTranslations('sidebar');
|
||||
const tCommon = useTranslations('common');
|
||||
const { isCollapsed, isMobileOpen, toggle, setMobileOpen } = useSidebarStore();
|
||||
const [activeModules, setActiveModules] = useState<SidebarModule[]>([]);
|
||||
const [customModules, setCustomModules] = useState<CustomModule[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
// Kategorien sind anfangs aufgeklappt (Design „Mosaik“): die Modul-Kacheln
|
||||
// sind der wichtigste Wegweiser. Gemerkt werden die ZUGEKLAPPTEN.
|
||||
@@ -67,6 +107,9 @@ export function Sidebar() {
|
||||
|
||||
const linkClass = (href: string) => itemClass(isActive(href));
|
||||
|
||||
const isEntryActive = (entry: SidebarEntry) =>
|
||||
isActive(entry.href) || (entry.extraActiveHref !== undefined && isActive(entry.extraActiveHref));
|
||||
|
||||
// Moduleintraege unter einer Kategorie eine Stufe kleiner als die
|
||||
// Hauptpunkte (quick-260928-wtt): 32 statt 36 px hoch, 13 statt 14 px Schrift;
|
||||
// die 20-px-Modulkachel passt weiter hinein.
|
||||
@@ -74,19 +117,50 @@ export function Sidebar() {
|
||||
itemClass(active).replace('h-9', 'h-8').replace('gap-3', 'gap-2.5').replace('text-sm', 'text-[13px]');
|
||||
|
||||
const fetchActiveModules = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/modules/active`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (res.ok) {
|
||||
const modules: SidebarModule[] = await res.json();
|
||||
setActiveModules(modules);
|
||||
setNavModules(modules);
|
||||
const loadBuiltIn = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/modules/active`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (res.ok) {
|
||||
setActiveModules(await res.json());
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}, [setNavModules]);
|
||||
};
|
||||
// Eigene Module (quick-260929-9wc) im selben Auffrisch-Takt; ein Fehler
|
||||
// laesst die Leiste wie beim Modulabruf still beim bisherigen Stand.
|
||||
const loadCustom = async () => {
|
||||
try {
|
||||
setCustomModules(await listCustomModules());
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
};
|
||||
await Promise.all([loadBuiltIn(), loadCustom()]);
|
||||
}, []);
|
||||
|
||||
// Die Kopfzeile liest den Seitentitel aus dem geteilten Stand; eigene
|
||||
// Module gehen mit slug = id hinein, damit `resolvePageTitle` das
|
||||
// Pfadsegment /modules/custom/<id> ohne Aenderung findet.
|
||||
useEffect(() => {
|
||||
setNavModules([
|
||||
...activeModules,
|
||||
...customModules.map((m) => ({
|
||||
id: m.id,
|
||||
slug: m.id,
|
||||
name: m.name,
|
||||
category: m.category,
|
||||
})),
|
||||
]);
|
||||
}, [activeModules, customModules, setNavModules]);
|
||||
|
||||
// Eingebaute Module stehen innerhalb einer Kategorie vor eigenen.
|
||||
const entries = useMemo<SidebarEntry[]>(
|
||||
() => [...activeModules.map(moduleEntry), ...customModules.map(customEntry)],
|
||||
[activeModules, customModules],
|
||||
);
|
||||
|
||||
// Befund 17 (quick-260921-gof): sidebarRefreshKey ist der Auffrisch-
|
||||
// Ausloeser aus dem Marketplace-Speicher — ohne ihn erscheint ein frisch
|
||||
@@ -107,17 +181,16 @@ export function Sidebar() {
|
||||
};
|
||||
|
||||
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);
|
||||
const categories = new Map<string, SidebarEntry[]>();
|
||||
for (const entry of entries) {
|
||||
if (!categories.has(entry.category)) categories.set(entry.category, []);
|
||||
categories.get(entry.category)!.push(entry);
|
||||
}
|
||||
|
||||
if (!searchQuery) return Array.from(categories.entries());
|
||||
|
||||
const q = searchQuery.toLowerCase();
|
||||
const result: [string, SidebarModule[]][] = [];
|
||||
const result: [string, SidebarEntry[]][] = [];
|
||||
for (const [cat, mods] of categories.entries()) {
|
||||
if (cat.toLowerCase().includes(q) || categoryLabel(cat).toLowerCase().includes(q)) {
|
||||
result.push([cat, mods]);
|
||||
@@ -127,7 +200,7 @@ export function Sidebar() {
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [activeModules, searchQuery, categoryLabel]);
|
||||
}, [entries, searchQuery, categoryLabel]);
|
||||
|
||||
const navIcon = (children: React.ReactNode) => (
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
@@ -168,16 +241,15 @@ export function Sidebar() {
|
||||
</ul>
|
||||
|
||||
{/* Eingeklappt: nur die Modul-Kacheln untereinander (Name als Tooltip). */}
|
||||
{isCollapsed && activeModules.length > 0 && (
|
||||
{isCollapsed && entries.length > 0 && (
|
||||
<ul className="mt-3 flex flex-col gap-0.5 border-t border-sidebar-border pt-3">
|
||||
{activeModules.map((mod) => {
|
||||
const href = `/modules/${mod.category || 'other'}/${mod.slug}`;
|
||||
const active = isActive(href) || isActive(`/modules/${mod.slug}`);
|
||||
{entries.map((entry) => {
|
||||
const active = isEntryActive(entry);
|
||||
return (
|
||||
<li key={mod.id}>
|
||||
<Link href={href} className={itemClass(active)} aria-label={mod.name} title={mod.name}>
|
||||
<li key={entry.key}>
|
||||
<Link href={entry.href} className={itemClass(active)} aria-label={entry.name} title={entry.name}>
|
||||
{active && <SelectionPill />}
|
||||
<ModuleTile slug={mod.slug} size={20} tone={active ? 'accent' : 'neutral'} />
|
||||
<ModuleTile slug={entry.tileSlug} size={20} tone={active ? 'accent' : 'neutral'} />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
@@ -192,7 +264,7 @@ export function Sidebar() {
|
||||
</div>
|
||||
|
||||
<div className="mt-1" aria-label={t('categories.label')} role="group">
|
||||
{activeModules.length === 0 ? (
|
||||
{entries.length === 0 ? (
|
||||
<div className="px-2.5 py-2 text-sm text-muted-foreground">
|
||||
{t('categories.empty')}
|
||||
</div>
|
||||
@@ -202,7 +274,7 @@ export function Sidebar() {
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{filteredCategories.map(([category, mods]) => {
|
||||
{filteredCategories.map(([category, categoryEntries]) => {
|
||||
// Bei aktiver Suche immer aufgeklappt, sonst nach Wahl.
|
||||
const open = searchQuery !== '' || !closedCategories.has(category);
|
||||
return (
|
||||
@@ -220,15 +292,14 @@ export function Sidebar() {
|
||||
</button>
|
||||
{open && (
|
||||
<ul className="mt-0.5 flex flex-col gap-0.5">
|
||||
{mods.map((mod) => {
|
||||
const href = `/modules/${category}/${mod.slug}`;
|
||||
const active = isActive(href) || isActive(`/modules/${mod.slug}`);
|
||||
{categoryEntries.map((entry) => {
|
||||
const active = isEntryActive(entry);
|
||||
return (
|
||||
<li key={mod.id}>
|
||||
<Link href={href} className={moduleItemClass(active)}>
|
||||
<li key={entry.key}>
|
||||
<Link href={entry.href} className={moduleItemClass(active)}>
|
||||
{active && <SelectionPill />}
|
||||
<ModuleTile slug={mod.slug} size={20} tone={active ? 'accent' : 'neutral'} />
|
||||
<span className="truncate">{mod.name}</span>
|
||||
<ModuleTile slug={entry.tileSlug} size={20} tone={active ? 'accent' : 'neutral'} />
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user