feat(quick-261003-387): Modulkategorien je Organisation - Tabellen, Grundbestand, Lese-Endpunkt bis in die Seitenleiste

- Tabellen ModuleCategory und ModuleCategoryPlacement mit Zeilenschutz, Spalte CustomModule.sortOrder
- Dienst legt den Grundbestand beim ersten Lesen an, legt die wirksame Kategorie ueber Modullisten
- GET /module-categories, PATCH :key (nur Administratoren), /modules/active liefert wirksame Kategorie
- Seitenleiste ordnet Gruppen und Eintraege nach Kategorienspeicher, zeigt umbenannte Namen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-03 02:33:59 +02:00
parent 53a49a109f
commit 8ec116c75a
21 changed files with 1052 additions and 16 deletions
@@ -1,6 +1,7 @@
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';
import { useModuleCategoryStore } from '@/lib/stores/module-category-store';
let mockPathname = '/';
vi.mock('next/navigation', () => ({
@@ -95,6 +96,7 @@ let mockCustomModules: {
name: string;
url: string;
category: string;
sortOrder?: number | null;
createdAt: string;
updatedAt: string;
}[] = [];
@@ -102,10 +104,41 @@ vi.mock('@/lib/custom-modules-api', () => ({
listCustomModules: vi.fn(() => Promise.resolve(mockCustomModules)),
}));
const mockActiveModules = [
// Kategorien (quick-261003-387): eigener Client; Standard = die Kategorien der
// Testdaten, „Eigene Module“ zuletzt. Der Kategorienspeicher ist echt.
interface MockCategory {
id: string;
key: string;
name: string | null;
sortOrder: number;
isSystem: boolean;
}
function categoriesFor(keys: string[], names: Record<string, string> = {}): MockCategory[] {
return keys.map((key, index) => ({
id: `c-${key}`,
key,
name: names[key] ?? null,
sortOrder: index,
isSystem: key === 'custom-modules',
}));
}
const DEFAULT_CATEGORY_KEYS = ['Domain-Tools', 'Utilities', 'infrastructure', 'fleet', 'custom-modules'];
let mockCategories: MockCategory[] = categoriesFor(DEFAULT_CATEGORY_KEYS);
vi.mock('@/lib/module-categories-api', () => ({
listModuleCategories: vi.fn(() => Promise.resolve(mockCategories)),
}));
let mockActiveModules: {
id: string;
slug: string;
name: string;
category: string;
sortOrder?: number | null;
}[] = [
{ id: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'Domain-Tools' },
{ id: 'm2', slug: 'converter', name: 'Converter', category: 'Utilities' },
];
const DEFAULT_ACTIVE_MODULES = [...mockActiveModules];
function stubFetch() {
vi.stubGlobal(
@@ -121,6 +154,9 @@ afterEach(() => {
mockRefreshKey = 0;
mockCustomModules = [];
mockCollapsed = false;
mockCategories = categoriesFor(DEFAULT_CATEGORY_KEYS);
mockActiveModules = [...DEFAULT_ACTIVE_MODULES];
useModuleCategoryStore.setState({ categories: [], loaded: false });
});
function customModule(overrides: Partial<(typeof mockCustomModules)[number]> = {}) {
@@ -442,6 +478,67 @@ describe('Sidebar', () => {
expect(screen.getByText('Wiki')).toBeInTheDocument();
});
it('Gruppen folgen der Reihenfolge aus dem Kategorienspeicher', async () => {
mockCategories = categoriesFor(['Utilities', 'Domain-Tools', 'custom-modules']);
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
});
const groups = () =>
Array.from(
screen
.getByRole('group', { name: 'Kategorien' })
.querySelectorAll(':scope > ul > li > button span.truncate'),
).map((n) => n.textContent);
await waitFor(() => {
expect(groups()).toEqual(['Utilities', 'Domain-Tools']);
});
});
it('eine umbenannte Kategorie zeigt den gespeicherten Namen', async () => {
mockCategories = categoriesFor(DEFAULT_CATEGORY_KEYS, { 'Domain-Tools': 'Meine Domains' });
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Meine Domains')).toBeInTheDocument();
});
expect(screen.queryByText('Domain-Tools')).not.toBeInTheDocument();
// Eine nicht umbenannte Kategorie behaelt ihre Beschriftung.
expect(screen.getByText('Utilities')).toBeInTheDocument();
});
it('Eintraege innerhalb einer Gruppe folgen sortOrder, null zuletzt, eingebaut vor eigenem', async () => {
mockActiveModules = [
{ id: 'a', slug: 'aaa', name: 'Aaa', category: 'fleet', sortOrder: null },
{ id: 'b', slug: 'bbb', name: 'Bbb', category: 'fleet', sortOrder: 2 },
{ id: 'c', slug: 'ccc', name: 'Ccc', category: 'fleet', sortOrder: 1 },
];
mockCustomModules = [
customModule({ id: 'cm-1', category: 'fleet', name: 'Eigenes A', sortOrder: 2 }),
customModule({ id: 'cm-2', category: 'fleet', name: 'Eigenes B', sortOrder: null }),
];
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Eigenes B')).toBeInTheDocument();
});
const items = screen
.getByText('fleet')
.closest('li')
?.querySelectorAll('a span.truncate');
expect(Array.from(items ?? []).map((n) => n.textContent)).toEqual([
'Ccc',
'Bbb',
'Eigenes A',
'Aaa',
'Eigenes B',
]);
});
it('renders the version badge below the navigation', async () => {
const Sidebar = await importSidebar();
render(<Sidebar />);
+29 -9
View File
@@ -1,6 +1,5 @@
'use client';
import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl';
@@ -11,7 +10,9 @@ import { AppVersionBadge } from '@/components/layout/app-version-badge';
import { SidebarSearch } from '@/components/layout/sidebar-search';
import { ModuleTile } from '@/components/modules/module-tile';
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
import { categoryRank, compareSidebarEntries } from '@/lib/module-category-order';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { useModuleCategoryStore } from '@/lib/stores/module-category-store';
import { useNavStore } from '@/lib/stores/nav-store';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { useCategoryLabel } from '@/lib/use-category-label';
@@ -34,6 +35,8 @@ interface SidebarModule {
slug: string;
name: string;
category: string;
/** Reihenfolge innerhalb der Kategorie (Administrator); `null`/fehlt = nicht sortiert. */
sortOrder?: number | null;
}
/**
@@ -48,6 +51,10 @@ interface SidebarEntry {
href: string;
/** Kennung fuer das Kachelsymbol; `custom` hat kein eigenes Symbol (allgemeine Kachel). */
tileSlug: string;
/** Reihenfolge innerhalb der Kategorie; `null` = nicht sortiert, steht hinten. */
sortOrder: number | null;
/** `true` = eigenes Modul (steht bei Gleichstand hinter eingebauten). */
custom: boolean;
/** Weitere Pfade, unter denen der Eintrag als ausgewaehlt gilt. */
extraActiveHref?: string;
}
@@ -60,6 +67,8 @@ function moduleEntry(mod: SidebarModule): SidebarEntry {
category,
href: `/modules/${category}/${mod.slug}`,
tileSlug: mod.slug,
sortOrder: mod.sortOrder ?? null,
custom: false,
extraActiveHref: `/modules/${mod.slug}`,
};
}
@@ -71,6 +80,8 @@ function customEntry(mod: CustomModule): SidebarEntry {
category: mod.category || 'other',
href: `/modules/custom/${mod.id}`,
tileSlug: 'custom',
sortOrder: mod.sortOrder ?? null,
custom: true,
};
}
@@ -85,6 +96,8 @@ export function Sidebar() {
// sind der wichtigste Wegweiser. Gemerkt werden die ZUGEKLAPPTEN.
const [closedCategories, setClosedCategories] = useState<Set<string>>(new Set());
const categoryLabel = useCategoryLabel();
const orderedCategoryList = useModuleCategoryStore((s) => s.categories);
const loadCategories = useModuleCategoryStore((s) => s.load);
const setNavModules = useNavStore((s) => s.setModules);
const pathname = usePathname();
const sidebarRefreshKey = useMarketplaceStore((s) => s.sidebarRefreshKey);
@@ -148,8 +161,10 @@ export function Sidebar() {
// silently fail
}
};
await Promise.all([loadBuiltIn(), loadCustom()]);
}, []);
// Kategorien (quick-261003-387) ebenfalls im selben Takt: Reihenfolge und
// Namen stammen aus dem geteilten Kategorienspeicher.
await Promise.all([loadBuiltIn(), loadCustom(), loadCategories()]);
}, [loadCategories]);
// Die Kopfzeile liest den Seitentitel aus dem geteilten Stand; eigene
// Module gehen mit slug = id hinein, damit `resolvePageTitle` das
@@ -166,9 +181,13 @@ export function Sidebar() {
]);
}, [activeModules, customModules, setNavModules]);
// Eingebaute Module stehen innerhalb einer Kategorie vor eigenen.
// Reihenfolge innerhalb einer Kategorie: sortOrder aufsteigend (null zuletzt),
// bei Gleichstand eingebaute Module vor eigenen, dann nach Name.
const entries = useMemo<SidebarEntry[]>(
() => [...activeModules.map(moduleEntry), ...customModules.map(customEntry)],
() =>
[...activeModules.map(moduleEntry), ...customModules.map(customEntry)].sort(
compareSidebarEntries,
),
[activeModules, customModules],
);
@@ -190,8 +209,9 @@ export function Sidebar() {
});
};
// Eintraege nach Kategorie gruppiert: „Eigene Module“ steht immer zuletzt,
// die uebrigen in Fundreihenfolge. Grundlage fuer BEIDE Ansichten.
// Eintraege nach Kategorie gruppiert, Gruppen in der von Administratoren
// eingestellten Reihenfolge (Standard: „Eigene Module“ zuletzt); unbekannte
// Kennungen stehen zuletzt in Fundreihenfolge. Grundlage fuer BEIDE Ansichten.
const orderedCategories = useMemo(() => {
const categories = new Map<string, SidebarEntry[]>();
for (const entry of entries) {
@@ -200,9 +220,9 @@ export function Sidebar() {
else categories.set(entry.category, [entry]);
}
return Array.from(categories.entries()).sort(
([a], [b]) => Number(a === CUSTOM_MODULE_CATEGORY) - Number(b === CUSTOM_MODULE_CATEGORY),
([a], [b]) => categoryRank(orderedCategoryList, a) - categoryRank(orderedCategoryList, b),
);
}, [entries]);
}, [entries, orderedCategoryList]);
// Eingeklappt in derselben Gruppenfolge wie ausgeklappt — die Eintraege
// stehen gruppenweise hintereinander, „Eigene Module“ also auch hier zuletzt.
+2
View File
@@ -13,6 +13,8 @@ export interface CustomModule {
name: string;
url: string;
category: string;
/** Reihenfolge innerhalb der Kategorie (nur gemeinsame Eintraege); `null` = nicht sortiert. */
sortOrder?: number | null;
/** `true` = persoenlicher Eintrag des angemeldeten Benutzers, `false` = gemeinsam (vom Administrator). */
personal: boolean;
createdAt: string;
+25
View File
@@ -0,0 +1,25 @@
/**
* Modulkategorien — API-Client (quick-261003-387). Konsumiert
* `/module-categories`. Muster `custom-modules-api.ts`: `credentials:
* 'include'` fuer Cookie-Auth, `NEXT_PUBLIC_API_URL` als Basis.
*/
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
export interface ModuleCategoryInfo {
id: string;
/** Unveraenderliche Kennung (URL-Segment /modules/<key>/<slug>). */
key: string;
/** Eigener Name; `null` = Uebersetzung `moduleCategories.<key>`. */
name: string | null;
sortOrder: number;
/** `true` nur fuer „Eigene Module“: umbenennbar, nicht loeschbar. */
isSystem: boolean;
}
/** Alle Kategorien der Organisation in ihrer Reihenfolge (jeder Angemeldete). */
export async function listModuleCategories(): Promise<ModuleCategoryInfo[]> {
const res = await fetch(`${API_URL}/module-categories`, { credentials: 'include' });
if (!res.ok) throw new Error(`Request failed (${res.status})`);
return res.json();
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { categoryRank, compareSidebarEntries } from './module-category-order';
describe('categoryRank', () => {
const cats = [{ key: 'b' }, { key: 'a' }];
it('liefert den Index in der eingestellten Reihenfolge', () => {
expect(categoryRank(cats, 'b')).toBe(0);
expect(categoryRank(cats, 'a')).toBe(1);
});
it('stellt unbekannte Kennungen hinter alle bekannten', () => {
expect(categoryRank(cats, 'x')).toBe(2);
expect(categoryRank([], 'x')).toBe(0);
});
});
describe('compareSidebarEntries', () => {
const e = (name: string, sortOrder: number | null, custom = false) => ({
name,
sortOrder,
custom,
});
const sorted = (list: ReturnType<typeof e>[]) =>
[...list].sort(compareSidebarEntries).map((x) => x.name);
it('sortOrder aufsteigend, null zuletzt', () => {
expect(sorted([e('c', null), e('b', 2), e('a', 1)])).toEqual(['a', 'b', 'c']);
});
it('bei Gleichstand stehen eingebaute Module vor eigenen', () => {
expect(sorted([e('Aaa', null, true), e('Zzz', null, false)])).toEqual(['Zzz', 'Aaa']);
expect(sorted([e('Aaa', 1, true), e('Zzz', 1, false)])).toEqual(['Zzz', 'Aaa']);
});
it('danach nach Name', () => {
expect(sorted([e('b', null), e('a', null)])).toEqual(['a', 'b']);
});
it('ein sortierter Eintrag steht vor einem unsortierten, auch wenn der unsortierte eingebaut ist', () => {
expect(sorted([e('Eingebaut', null, false), e('Eigenes', 0, true)])).toEqual([
'Eigenes',
'Eingebaut',
]);
});
});
+31
View File
@@ -0,0 +1,31 @@
/**
* Reihenfolge von Kategorien und Seitenleisten-Eintraegen (quick-261003-387,
* E-08). Reine Funktionen — der Stand kommt aus dem Kategorienspeicher.
*/
/** Rang einer Kategorie in der eingestellten Reihenfolge; unbekannte zuletzt (alle gleich). */
export function categoryRank(categories: ReadonlyArray<{ key: string }>, key: string): number {
const index = categories.findIndex((c) => c.key === key);
return index === -1 ? categories.length : index;
}
export interface SortableEntry {
name: string;
sortOrder: number | null;
/** `true` = eigenes Modul. */
custom: boolean;
}
/**
* Reihenfolge innerhalb einer Kategorie: `sortOrder` aufsteigend, `null`
* zuletzt; bei Gleichstand eingebaute Module vor eigenen, dann nach Name.
*/
export function compareSidebarEntries(a: SortableEntry, b: SortableEntry): number {
if (a.sortOrder !== b.sortOrder) {
if (a.sortOrder === null) return 1;
if (b.sortOrder === null) return -1;
return a.sortOrder - b.sortOrder;
}
if (a.custom !== b.custom) return a.custom ? 1 : -1;
return a.name.localeCompare(b.name);
}
@@ -0,0 +1,32 @@
import { create } from 'zustand';
import { listModuleCategories, type ModuleCategoryInfo } from '@/lib/module-categories-api';
/**
* Geteilter Kategorienstand (quick-261003-387): Beschriftung, Reihenfolge und
* Auswahlfelder lesen alle denselben Stand. Geladen wird von der Seitenleiste
* (sie ist im Portal immer da) und von Seiten, die die Reihenfolge brauchen
* (`ensureLoaded`). Ein Fehler laesst den bisherigen Stand stehen — wie beim
* Modulabruf der Seitenleiste.
*/
interface ModuleCategoryState {
categories: ModuleCategoryInfo[];
loaded: boolean;
load: () => Promise<void>;
ensureLoaded: () => Promise<void>;
}
export const useModuleCategoryStore = create<ModuleCategoryState>()((set, get) => ({
categories: [],
loaded: false,
load: async () => {
try {
const categories = await listModuleCategories();
set({ categories, loaded: true });
} catch {
// silently fail
}
},
ensureLoaded: async () => {
if (!get().loaded) await get().load();
},
}));
+13 -4
View File
@@ -2,16 +2,25 @@
import { useTranslations } from 'next-intl';
import { useCallback } from 'react';
import { useModuleCategoryStore } from '@/lib/stores/module-category-store';
/**
* Liefert den Anzeigenamen einer Modulkategorie in Alltagssprache
* (`domain-tools` → „Domains“). Unbekannte Kategorien behalten ihre Kennung.
* (`domain-tools` → „Domains“). Reihenfolge der Quellen (quick-261003-387):
* ein von Administratoren gespeicherter Name, dann die Uebersetzung, dann
* die Kennung selbst. Der Hook abonniert den Kategorienspeicher, damit
* Beschriftungen nach dem Laden oder Umbenennen neu gezeichnet werden.
*/
export function useCategoryLabel(): (slug: string) => string {
const t = useTranslations('moduleCategories');
const categories = useModuleCategoryStore((s) => s.categories);
return useCallback(
// `t.has` fehlt in einfachen Test-Attrappen von next-intl — dann Kennung.
(slug: string) => (typeof t.has === 'function' && t.has(slug) ? t(slug) : slug),
[t],
(slug: string) => {
const stored = categories.find((c) => c.key === slug)?.name;
if (stored) return stored;
// `t.has` fehlt in einfachen Test-Attrappen von next-intl — dann Kennung.
return typeof t.has === 'function' && t.has(slug) ? t(slug) : slug;
},
[t, categories],
);
}