From be1e0035e02cc53a469fa95491c87bc79d3c1e11 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 30 Sep 2026 03:20:16 +0200 Subject: [PATCH] fix(custom-modules): null beim Aendern ablehnen, Ladefehler statt 404, Fehlertexte, Seitenleiste eingeklappt - PATCH mit null fuer name/url/category ergibt 400 statt 500 - Modulansicht unterscheidet Ladefehler von "nicht gefunden" - Formular/Loeschdialog nennen 403 und 400 eigens - eingeklappte Seitenleiste folgt der Gruppenreihenfolge der ausgeklappten - neue Eintraege sind mit "Eigene Module" vorbelegt - Verwaltung zeigt bei Ladefehler nicht zusaetzlich "keine Eintraege" Co-Authored-By: Claude Opus 5.5 (1M context) --- .../dto/custom-module.dto.spec.ts | 8 + .../custom-modules/dto/custom-module.dto.ts | 4 + .../custom-modules-page.test.tsx | 149 +++++++++++++--- .../custom-modules-settings.test.tsx | 12 +- .../custom-module-form-modal.tsx | 30 +++- .../custom-modules/custom-module-manager.tsx | 22 ++- .../delete-custom-module-dialog.tsx | 13 +- .../src/components/layout/sidebar.test.tsx | 64 +++++-- apps/web/src/components/layout/sidebar.tsx | 165 +++++++++++++----- .../modules/custom-module-view.test.tsx | 17 ++ .../components/modules/custom-module-view.tsx | 21 ++- 11 files changed, 401 insertions(+), 104 deletions(-) diff --git a/apps/api/src/custom-modules/dto/custom-module.dto.spec.ts b/apps/api/src/custom-modules/dto/custom-module.dto.spec.ts index 04a1ab1..c22416b 100644 --- a/apps/api/src/custom-modules/dto/custom-module.dto.spec.ts +++ b/apps/api/src/custom-modules/dto/custom-module.dto.spec.ts @@ -64,4 +64,12 @@ describe('UpdateCustomModuleDto', () => { expect(await errorsFor(UpdateCustomModuleDto, { category: 'other' })).toContain('category'); expect(await errorsFor(UpdateCustomModuleDto, { name: ' ' })).toContain('name'); }); + + it.each([ + 'name', + 'url', + 'category', + ])('lehnt %s: null ab statt es durchzulassen', async (field) => { + expect(await errorsFor(UpdateCustomModuleDto, { [field]: null })).toContain(field); + }); }); diff --git a/apps/api/src/custom-modules/dto/custom-module.dto.ts b/apps/api/src/custom-modules/dto/custom-module.dto.ts index 1c1960a..292c612 100644 --- a/apps/api/src/custom-modules/dto/custom-module.dto.ts +++ b/apps/api/src/custom-modules/dto/custom-module.dto.ts @@ -78,7 +78,11 @@ export class CreateCustomModuleDto { * `shared` ist ausgenommen — ob ein Eintrag gemeinsam oder persoenlich ist, * aendert sich nach dem Anlegen nicht (die globale Pipe verwirft das Feld * dank `whitelist: true`). + * `skipNullProperties: false`: fehlende Felder bleiben unveraendert, ein + * ausdrueckliches `null` wird aber geprueft und damit abgelehnt (400) — sonst + * liefe `{"name": null}` bis in die Datenbank und endete als 500. */ export class UpdateCustomModuleDto extends PartialType( OmitType(CreateCustomModuleDto, ['shared'] as const), + { skipNullProperties: false }, ) {} diff --git a/apps/web/src/app/(portal)/admin/custom-modules/custom-modules-page.test.tsx b/apps/web/src/app/(portal)/admin/custom-modules/custom-modules-page.test.tsx index 6343f32..81e9ca3 100644 --- a/apps/web/src/app/(portal)/admin/custom-modules/custom-modules-page.test.tsx +++ b/apps/web/src/app/(portal)/admin/custom-modules/custom-modules-page.test.tsx @@ -28,6 +28,8 @@ const messages: Record> = { urlCredentials: 'Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.', saveError: 'Das Modul konnte nicht gespeichert werden.', deleteError: 'Das Modul konnte nicht gelöscht werden.', + forbidden: 'Sie haben keine Berechtigung für diese Aktion.', + invalidInput: 'Die Angaben wurden nicht angenommen. Bitte prüfen Sie Name, Adresse und Kategorie.', deleteConfirm: { title: 'Eigenes Modul löschen', bodyShared: 'Möchten Sie „{name}“ wirklich löschen? Für alle Benutzer.', @@ -58,8 +60,18 @@ vi.mock('next-intl', () => ({ })); vi.mock('next/link', () => ({ - default: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => ( - {children} + default: ({ + children, + href, + className, + }: { + children: React.ReactNode; + href: string; + className?: string; + }) => ( + + {children} + ), })); @@ -68,13 +80,7 @@ vi.mock('@/lib/use-category-label', () => ({ ({ infrastructure: 'Infrastruktur', 'security-tools': 'Sicherheit' })[slug] ?? slug, })); -const { - mockList, - mockCreate, - mockUpdate, - mockDelete, - mockBump, -} = vi.hoisted(() => ({ +const { mockList, mockCreate, mockUpdate, mockDelete, mockBump } = vi.hoisted(() => ({ mockList: vi.fn(), mockCreate: vi.fn(), mockUpdate: vi.fn(), @@ -106,6 +112,7 @@ vi.mock('@/lib/stores/auth-store', () => ({ selector({ user: { role: mockRole } }), })); +import { CustomModuleRequestError } from '@/lib/custom-modules-api'; import AdminCustomModulesPage from './page'; const wiki = { @@ -136,7 +143,9 @@ describe('AdminCustomModulesPage', () => { render(); expect(await screen.findByText('Noch keine gemeinsamen Einträge')).toBeInTheDocument(); expect(screen.getByText('Legen Sie Ihren ersten gemeinsamen Eintrag an.')).toBeInTheDocument(); - expect(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' }).length).toBeGreaterThan(0); + expect( + screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' }).length, + ).toBeGreaterThan(0); }); it('zeigt mit Einträgen die Tabelle mit Link, Adresse und Kategorie-Anzeigename', async () => { @@ -154,13 +163,17 @@ describe('AdminCustomModulesPage', () => { it('Anlegen: http-Adresse zeigt die Meldung und ruft createCustomModule nicht auf', async () => { render(); await screen.findByText('Noch keine gemeinsamen Einträge'); - await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]); + await userEvent.click( + screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0], + ); await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite'); await userEvent.type(screen.getByLabelText('Adresse'), 'http://example.com'); await userEvent.click(screen.getByRole('button', { name: 'Speichern' })); - expect(await screen.findByText('Bitte geben Sie eine Adresse ein, die mit https:// beginnt.')).toBeInTheDocument(); + expect( + await screen.findByText('Bitte geben Sie eine Adresse ein, die mit https:// beginnt.'), + ).toBeInTheDocument(); expect(mockCreate).not.toHaveBeenCalled(); expect(mockBump).not.toHaveBeenCalled(); }); @@ -168,7 +181,9 @@ describe('AdminCustomModulesPage', () => { it('Anlegen: Adresse mit Zugangsdaten zeigt die passende Meldung', async () => { render(); await screen.findByText('Noch keine gemeinsamen Einträge'); - await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]); + await userEvent.click( + screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0], + ); await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite'); await userEvent.type(screen.getByLabelText('Adresse'), 'https://user:pw@example.com'); @@ -183,7 +198,9 @@ describe('AdminCustomModulesPage', () => { it('Anlegen: leerer Name zeigt die Meldung', async () => { render(); await screen.findByText('Noch keine gemeinsamen Einträge'); - await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]); + await userEvent.click( + screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0], + ); await userEvent.type(screen.getByLabelText('Name'), ' '); await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com'); @@ -196,7 +213,9 @@ describe('AdminCustomModulesPage', () => { it('Anlegen: gültige Eingabe ruft create mit getrimmtem Namen, lädt neu und aktualisiert die Seitenleiste genau einmal', async () => { render(); await screen.findByText('Noch keine gemeinsamen Einträge'); - await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]); + await userEvent.click( + screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0], + ); await userEvent.type(screen.getByLabelText('Name'), ' Beispielseite '); await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com'); @@ -217,11 +236,62 @@ describe('AdminCustomModulesPage', () => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); - it('Anlegen: die Kategorie ist vorbelegt mit Infrastruktur', async () => { + it('Anlegen: die Kategorie ist vorbelegt mit „Eigene Module“', async () => { render(); await screen.findByText('Noch keine gemeinsamen Einträge'); - await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]); - expect(screen.getByLabelText('Kategorie')).toHaveValue('infrastructure'); + await userEvent.click( + screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0], + ); + expect(screen.getByLabelText('Kategorie')).toHaveValue('custom-modules'); + }); + + it('Anlegen ohne Kategoriewahl sendet die Kategorie „Eigene Module“', async () => { + render(); + await screen.findByText('Noch keine gemeinsamen Einträge'); + await userEvent.click( + screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0], + ); + await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite'); + await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com'); + await userEvent.click(screen.getByRole('button', { name: 'Speichern' })); + + await waitFor(() => { + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ category: 'custom-modules' }), + ); + }); + }); + + it('Speichern mit 403 zeigt „keine Berechtigung“, nicht den allgemeinen Fehler', async () => { + mockCreate.mockRejectedValue(new CustomModuleRequestError(403, 'Forbidden')); + render(); + await screen.findByText('Noch keine gemeinsamen Einträge'); + await userEvent.click( + screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0], + ); + await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite'); + await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com'); + await userEvent.click(screen.getByRole('button', { name: 'Speichern' })); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Sie haben keine Berechtigung für diese Aktion.', + ); + expect(screen.queryByText('Das Modul konnte nicht gespeichert werden.')).toBeNull(); + }); + + it('Speichern mit 400 zeigt die Meldung des Servers', async () => { + mockList.mockResolvedValue([wiki]); + mockUpdate.mockRejectedValue( + new CustomModuleRequestError(400, 'Nur https-Adressen ohne Zugangsdaten sind erlaubt.'), + ); + render(); + await userEvent.click(await screen.findByRole('button', { name: 'Bearbeiten' })); + const dialog = screen.getByRole('dialog'); + await userEvent.click(within(dialog).getByRole('button', { name: 'Speichern' })); + + expect(await within(dialog).findByRole('alert')).toHaveTextContent( + 'Die Angaben wurden nicht angenommen. Bitte prüfen Sie Name, Adresse und Kategorie.', + ); }); it('Bearbeiten: Formular ist vorbelegt, Speichern ruft update und aktualisiert die Seitenleiste', async () => { @@ -254,13 +324,17 @@ describe('AdminCustomModulesPage', () => { mockCreate.mockRejectedValue(new Error('500')); render(); await screen.findByText('Noch keine gemeinsamen Einträge'); - await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]); + await userEvent.click( + screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0], + ); await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite'); await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com'); await userEvent.click(screen.getByRole('button', { name: 'Speichern' })); - expect(await screen.findByText('Das Modul konnte nicht gespeichert werden.')).toBeInTheDocument(); + expect( + await screen.findByText('Das Modul konnte nicht gespeichert werden.'), + ).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(mockBump).not.toHaveBeenCalled(); }); @@ -271,7 +345,9 @@ describe('AdminCustomModulesPage', () => { await userEvent.click(await screen.findByRole('button', { name: 'Löschen' })); const dialog = screen.getByRole('dialog'); - expect(within(dialog).getByText('Möchten Sie „Wiki“ wirklich löschen? Für alle Benutzer.')).toBeInTheDocument(); + expect( + within(dialog).getByText('Möchten Sie „Wiki“ wirklich löschen? Für alle Benutzer.'), + ).toBeInTheDocument(); await userEvent.click(within(dialog).getByRole('button', { name: 'Löschen' })); await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('cm-1')); @@ -283,7 +359,9 @@ describe('AdminCustomModulesPage', () => { mockList.mockResolvedValue([wiki]); render(); await userEvent.click(await screen.findByRole('button', { name: 'Löschen' })); - await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Abbrechen' })); + await userEvent.click( + within(screen.getByRole('dialog')).getByRole('button', { name: 'Abbrechen' }), + ); expect(mockDelete).not.toHaveBeenCalled(); expect(mockBump).not.toHaveBeenCalled(); @@ -295,13 +373,38 @@ describe('AdminCustomModulesPage', () => { mockDelete.mockRejectedValue(new Error('500')); render(); await userEvent.click(await screen.findByRole('button', { name: 'Löschen' })); - await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Löschen' })); + await userEvent.click( + within(screen.getByRole('dialog')).getByRole('button', { name: 'Löschen' }), + ); expect(await screen.findByText('Das Modul konnte nicht gelöscht werden.')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(mockBump).not.toHaveBeenCalled(); }); + it('Löschen mit 403 zeigt „keine Berechtigung“', async () => { + mockList.mockResolvedValue([wiki]); + mockDelete.mockRejectedValue(new CustomModuleRequestError(403, 'Forbidden')); + render(); + await userEvent.click(await screen.findByRole('button', { name: 'Löschen' })); + await userEvent.click( + within(screen.getByRole('dialog')).getByRole('button', { name: 'Löschen' }), + ); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Sie haben keine Berechtigung für diese Aktion.', + ); + expect(screen.queryByText('Das Modul konnte nicht gelöscht werden.')).toBeNull(); + }); + + it('Ladefehler zeigt nur die Fehlermeldung, nicht zusätzlich den Leer-Zustand', async () => { + mockList.mockRejectedValue(new CustomModuleRequestError(500, 'Internal server error')); + render(); + + expect(await screen.findByText('Laden fehlgeschlagen.')).toBeInTheDocument(); + expect(screen.queryByText('Noch keine gemeinsamen Einträge')).toBeNull(); + }); + it('zeigt nur gemeinsame Einträge, nie persönliche (Filter über personal)', async () => { mockList.mockResolvedValue([ wiki, diff --git a/apps/web/src/app/(portal)/settings/custom-modules/custom-modules-settings.test.tsx b/apps/web/src/app/(portal)/settings/custom-modules/custom-modules-settings.test.tsx index 3c9ede0..93980c2 100644 --- a/apps/web/src/app/(portal)/settings/custom-modules/custom-modules-settings.test.tsx +++ b/apps/web/src/app/(portal)/settings/custom-modules/custom-modules-settings.test.tsx @@ -129,9 +129,9 @@ describe('CustomModulesSettingsPage', () => { render(); expect(await screen.findByText('Noch keine eigenen Module')).toBeInTheDocument(); expect(screen.getByText('Diese Einträge sehen nur Sie.')).toBeInTheDocument(); - expect( - screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' }).length, - ).toBeGreaterThan(0); + expect(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' }).length).toBeGreaterThan( + 0, + ); }); it('listet nur die persönlichen Einträge, gemeinsame der Verwaltung nicht', async () => { @@ -158,7 +158,7 @@ describe('CustomModulesSettingsPage', () => { expect(mockCreate).toHaveBeenCalledWith({ name: 'Mein Wiki', url: 'https://example.com', - category: 'infrastructure', + category: 'custom-modules', }); }); expect(mockCreate.mock.calls[0][0]).not.toHaveProperty('shared'); @@ -228,7 +228,9 @@ describe('CustomModulesSettingsPage', () => { await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com'); await userEvent.click(screen.getByRole('button', { name: 'Speichern' })); - expect(await screen.findByText('Das Modul konnte nicht gespeichert werden.')).toBeInTheDocument(); + expect( + await screen.findByText('Das Modul konnte nicht gespeichert werden.'), + ).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(mockBump).not.toHaveBeenCalled(); }); diff --git a/apps/web/src/components/custom-modules/custom-module-form-modal.tsx b/apps/web/src/components/custom-modules/custom-module-form-modal.tsx index f671264..9eff9d9 100644 --- a/apps/web/src/components/custom-modules/custom-module-form-modal.tsx +++ b/apps/web/src/components/custom-modules/custom-module-form-modal.tsx @@ -1,10 +1,11 @@ 'use client'; -import { useState } from 'react'; +import { CUSTOM_MODULE_CATEGORIES, CUSTOM_MODULE_CATEGORY } from '@tessera/shared'; import { useTranslations } from 'next-intl'; -import { CUSTOM_MODULE_CATEGORIES } from '@tessera/shared'; +import { useState } from 'react'; import { type CustomModule, + CustomModuleRequestError, checkCustomModuleUrl, createCustomModule, updateCustomModule, @@ -27,6 +28,23 @@ interface CustomModuleFormModalProps { const NAME_MAX = 100; const URL_MAX = 2048; +/** + * Fehlertext fuer Speichern und Loeschen: 403 = keine Berechtigung, 400 = die + * API hat die Angaben abgelehnt (mit ihrer Meldung), alles andere (500, + * Netzwerk) = der allgemeine „bitte erneut versuchen“-Text `fallbackKey`. + */ +export function customModuleErrorText( + err: unknown, + t: (key: string, values?: Record) => string, + fallbackKey: 'saveError' | 'deleteError', +): string { + if (err instanceof CustomModuleRequestError) { + if (err.status === 403) return t('forbidden'); + if (err.status === 400) return t('invalidInput'); + } + return t(fallbackKey); +} + /** * Formular „Eigenes Modul“ (quick-260929-9wc, Vorbild `GroupFormModal`; * quick-260929-dzu: ein Formular, zwei Aufrufer — Verwaltung legt gemeinsame, @@ -47,7 +65,9 @@ export function CustomModuleFormModal({ const [name, setName] = useState(mod?.name ?? ''); const [url, setUrl] = useState(mod?.url ?? ''); - const [category, setCategory] = useState(mod?.category ?? 'infrastructure'); + // Neue Eintraege landen vorbelegt in der Gruppe „Eigene Module“; beim + // Bearbeiten bleibt die gespeicherte Kategorie. + const [category, setCategory] = useState(mod?.category ?? CUSTOM_MODULE_CATEGORY); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -81,8 +101,8 @@ export function CustomModuleFormModal({ } onSaved(); onClose(); - } catch { - setError(t('saveError')); + } catch (err) { + setError(customModuleErrorText(err, t, 'saveError')); } finally { setSaving(false); } diff --git a/apps/web/src/components/custom-modules/custom-module-manager.tsx b/apps/web/src/components/custom-modules/custom-module-manager.tsx index bc1a42c..8af93c0 100644 --- a/apps/web/src/components/custom-modules/custom-module-manager.tsx +++ b/apps/web/src/components/custom-modules/custom-module-manager.tsx @@ -1,8 +1,8 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; import Link from 'next/link'; import { useTranslations } from 'next-intl'; +import { useCallback, useEffect, useState } from 'react'; import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api'; import { useMarketplaceStore } from '@/lib/stores/marketplace-store'; import { useCategoryLabel } from '@/lib/use-category-label'; @@ -86,9 +86,7 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps) return (
-

+

{texts.title}

)} + {/* Bei einem Ladefehler ohne Eintraege steht nur die Fehlermeldung da — + „keine Eintraege“ waere dann falsch. */} {loading ? (

{tCommon('loading')}

- ) : modules.length === 0 ? ( + ) : modules.length === 0 && loadFailed ? null : modules.length === 0 ? (

{texts.noModules}

{texts.noModulesBody}

@@ -119,9 +119,15 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps) - - - + + + diff --git a/apps/web/src/components/custom-modules/delete-custom-module-dialog.tsx b/apps/web/src/components/custom-modules/delete-custom-module-dialog.tsx index 05cc1b5..aa20271 100644 --- a/apps/web/src/components/custom-modules/delete-custom-module-dialog.tsx +++ b/apps/web/src/components/custom-modules/delete-custom-module-dialog.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useState } from 'react'; import { useTranslations } from 'next-intl'; +import { useState } from 'react'; import { type CustomModule, deleteCustomModule } from '@/lib/custom-modules-api'; +import { customModuleErrorText } from './custom-module-form-modal'; interface DeleteCustomModuleDialogProps { mod: CustomModule; @@ -37,8 +38,8 @@ export function DeleteCustomModuleDialog({ await deleteCustomModule(mod.id); onDeleted(); onClose(); - } catch { - setError(t('deleteError')); + } catch (err) { + setError(customModuleErrorText(err, t, 'deleteError')); } finally { setDeleting(false); } @@ -55,7 +56,11 @@ export function DeleteCustomModuleDialog({

{t('deleteConfirm.title')}

-

{t(shared ? 'deleteConfirm.bodyShared' : 'deleteConfirm.bodyPersonal', { name: mod.name })}

+

+ {t(shared ? 'deleteConfirm.bodyShared' : 'deleteConfirm.bodyPersonal', { + name: mod.name, + })} +

{error && (
({ })); vi.mock('next/link', () => ({ - default: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => ( - {children} + default: ({ + children, + href, + className, + }: { + children: React.ReactNode; + href: string; + className?: string; + }) => ( + + {children} + ), })); @@ -100,9 +110,7 @@ const mockActiveModules = [ function stubFetch() { vi.stubGlobal( 'fetch', - vi.fn(() => - Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) }), - ), + vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) })), ); } @@ -316,7 +324,9 @@ describe('Sidebar', () => { expect(screen.getByText('Mein Wiki')).toBeInTheDocument(); }); const groups = Array.from( - screen.getByRole('group', { name: 'Kategorien' }).querySelectorAll(':scope > ul > li > button span.truncate'), + screen + .getByRole('group', { name: 'Kategorien' }) + .querySelectorAll(':scope > ul > li > button span.truncate'), ).map((n) => n.textContent); expect(groups.at(-1)).toBe('custom-modules'); expect(groups).toContain('fleet'); @@ -340,12 +350,40 @@ describe('Sidebar', () => { render(); const hrefs = () => - Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) => a.getAttribute('href')); + Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) => + a.getAttribute('href'), + ); await waitFor(() => { expect(hrefs()).toContain('/modules/custom/cm-1'); }); expect(hrefs().at(-1)).toBe('/modules/custom/cm-1'); - expect(hrefs().indexOf('/modules/custom/cm-2')).toBeLessThan(hrefs().indexOf('/modules/custom/cm-1')); + expect(hrefs().indexOf('/modules/custom/cm-2')).toBeLessThan( + hrefs().indexOf('/modules/custom/cm-1'), + ); + }); + + it('eingeklappt dieselbe Gruppenfolge wie ausgeklappt (eigenes Modul bei seiner Kategorie)', async () => { + mockCustomModules = [ + customModule({ id: 'cm-1', category: 'Domain-Tools', name: 'Mein Tool' }), + customModule({ id: 'cm-2', category: 'custom-modules', name: 'Mein Wiki' }), + ]; + mockSidebarCollapsed(true); + const Sidebar = await importSidebar(); + render(); + + const hrefs = () => + Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) => + a.getAttribute('href'), + ); + await waitFor(() => { + expect(hrefs()).toContain('/modules/custom/cm-2'); + }); + expect(hrefs()).toEqual([ + '/modules/Domain-Tools/domaincheck', + '/modules/custom/cm-1', + '/modules/Utilities/converter', + '/modules/custom/cm-2', + ]); }); it('eingebaute Module stehen innerhalb einer Kategorie vor eigenen', async () => { @@ -360,7 +398,10 @@ describe('Sidebar', () => { .getByText('Domain-Tools') .closest('li') ?.querySelectorAll('a span.truncate'); - expect(Array.from(items ?? []).map((n) => n.textContent)).toEqual(['Domaincheck', 'Aaa Eigenes']); + expect(Array.from(items ?? []).map((n) => n.textContent)).toEqual([ + 'Domaincheck', + 'Aaa Eigenes', + ]); }); it('auf /modules/custom/ traegt genau dieser Eintrag die Auswahlmarke', async () => { @@ -379,7 +420,10 @@ describe('Sidebar', () => { screen.getByText('Anderes').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'), ).toBeNull(); expect( - screen.getByText('Domaincheck').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'), + screen + .getByText('Domaincheck') + .closest('a') + ?.querySelector('[data-testid="nav-selection-pill"]'), ).toBeNull(); }); diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index 7123811..28d1304 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -1,20 +1,20 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared'; import { useTranslations } from 'next-intl'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { TesseraLogo } from '@/components/brand/tessera-logo'; -import { useSidebarStore } from '@/lib/stores/sidebar-store'; -import { useMarketplaceStore } from '@/lib/stores/marketplace-store'; -import { SidebarSearch } from '@/components/layout/sidebar-search'; -import { AppVersionBadge } from '@/components/layout/app-version-badge'; -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 { 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 { useMarketplaceStore } from '@/lib/stores/marketplace-store'; +import { useNavStore } from '@/lib/stores/nav-store'; +import { useSidebarStore } from '@/lib/stores/sidebar-store'; +import { useCategoryLabel } from '@/lib/use-category-label'; /** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */ function SelectionPill() { @@ -109,13 +109,17 @@ export function Sidebar() { const linkClass = (href: string) => itemClass(isActive(href)); const isEntryActive = (entry: SidebarEntry) => - isActive(entry.href) || (entry.extraActiveHref !== undefined && isActive(entry.extraActiveHref)); + 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. const moduleItemClass = (active: boolean) => - itemClass(active).replace('h-9', 'h-8').replace('gap-3', 'gap-2.5').replace('text-sm', 'text-[13px]'); + itemClass(active) + .replace('h-9', 'h-8') + .replace('gap-3', 'gap-2.5') + .replace('text-sm', 'text-[13px]'); // Eingeklappt (Nutzerwunsch 29.09.): Symbole etwas groesser (Kachel 24 statt // 20 px, Navigationssymbole 22 statt 20 px), Eintraege dichter — 34 px hoch @@ -186,28 +190,29 @@ export function Sidebar() { }); }; - // Eingeklappt in derselben Gruppenfolge wie ausgeklappt — „Eigene Module“ - // also auch hier immer zuletzt. + // Eintraege nach Kategorie gruppiert: „Eigene Module“ steht immer zuletzt, + // die uebrigen in Fundreihenfolge. Grundlage fuer BEIDE Ansichten. + const orderedCategories = useMemo(() => { + const categories = new Map(); + for (const entry of entries) { + const list = categories.get(entry.category); + if (list) list.push(entry); + else categories.set(entry.category, [entry]); + } + return Array.from(categories.entries()).sort( + ([a], [b]) => Number(a === CUSTOM_MODULE_CATEGORY) - Number(b === CUSTOM_MODULE_CATEGORY), + ); + }, [entries]); + + // Eingeklappt in derselben Gruppenfolge wie ausgeklappt — die Eintraege + // stehen gruppenweise hintereinander, „Eigene Module“ also auch hier zuletzt. const collapsedEntries = useMemo( - () => - [...entries].sort( - (a, b) => - Number(a.category === CUSTOM_MODULE_CATEGORY) - Number(b.category === CUSTOM_MODULE_CATEGORY), - ), - [entries], + () => orderedCategories.flatMap(([, categoryEntries]) => categoryEntries), + [orderedCategories], ); const filteredCategories = useMemo(() => { - const categories = new Map(); - for (const entry of entries) { - if (!categories.has(entry.category)) categories.set(entry.category, []); - categories.get(entry.category)!.push(entry); - } - - // „Eigene Module“ steht immer zuletzt, die uebrigen in Fundreihenfolge. - const ordered = Array.from(categories.entries()).sort( - ([a], [b]) => Number(a === CUSTOM_MODULE_CATEGORY) - Number(b === CUSTOM_MODULE_CATEGORY), - ); + const ordered = orderedCategories; if (!searchQuery) return ordered; const q = searchQuery.toLowerCase(); @@ -221,10 +226,22 @@ export function Sidebar() { } } return result; - }, [entries, searchQuery, categoryLabel]); + }, [orderedCategories, searchQuery, categoryLabel]); const navIcon = (children: React.ReactNode) => ( -
  • - + {isActive('/') && } {navIcon( <> @@ -248,7 +270,16 @@ export function Sidebar() {
  • - + {isActive('/marketplace') && } {navIcon( <> @@ -268,9 +299,18 @@ export function Sidebar() { const active = isEntryActive(entry); return (
  • - + {active && } - +
  • ); @@ -290,9 +330,7 @@ export function Sidebar() { {t('categories.empty')}
) : filteredCategories.length === 0 ? ( -
- {t('noResults')} -
+
{t('noResults')}
) : (
    {filteredCategories.map(([category, categoryEntries]) => { @@ -307,7 +345,19 @@ export function Sidebar() { className="group flex h-8 w-full items-center gap-1.5 rounded-md px-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" > {categoryLabel(category)} -
  • {active && } - + {entry.name}
  • @@ -353,7 +407,19 @@ export function Sidebar() { aria-label={isCollapsed ? t('expand') : t('collapse')} title={isCollapsed ? t('expand') : undefined} > -
    {t('notFound')}
    ; } + if (state.status === 'failed') { + return ( +
    + {t('loadError')} +
    + ); + } const { mod } = state; const urlOk = checkCustomModuleUrl(mod.url) === 'ok';
{t('name')}{t('url')}{t('category')} + {t('name')} + + {t('url')} + + {t('category')} + {tManage('actions')}