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:
@@ -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 />);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user