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) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 03:20:16 +02:00
parent f7213f5e45
commit be1e0035e0
11 changed files with 401 additions and 104 deletions
@@ -64,4 +64,12 @@ describe('UpdateCustomModuleDto', () => {
expect(await errorsFor(UpdateCustomModuleDto, { category: 'other' })).toContain('category'); expect(await errorsFor(UpdateCustomModuleDto, { category: 'other' })).toContain('category');
expect(await errorsFor(UpdateCustomModuleDto, { name: ' ' })).toContain('name'); 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);
});
}); });
@@ -78,7 +78,11 @@ export class CreateCustomModuleDto {
* `shared` ist ausgenommen — ob ein Eintrag gemeinsam oder persoenlich ist, * `shared` ist ausgenommen — ob ein Eintrag gemeinsam oder persoenlich ist,
* aendert sich nach dem Anlegen nicht (die globale Pipe verwirft das Feld * aendert sich nach dem Anlegen nicht (die globale Pipe verwirft das Feld
* dank `whitelist: true`). * 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( export class UpdateCustomModuleDto extends PartialType(
OmitType(CreateCustomModuleDto, ['shared'] as const), OmitType(CreateCustomModuleDto, ['shared'] as const),
{ skipNullProperties: false },
) {} ) {}
@@ -28,6 +28,8 @@ const messages: Record<string, Record<string, unknown>> = {
urlCredentials: 'Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.', urlCredentials: 'Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.',
saveError: 'Das Modul konnte nicht gespeichert werden.', saveError: 'Das Modul konnte nicht gespeichert werden.',
deleteError: 'Das Modul konnte nicht gelöscht 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: { deleteConfirm: {
title: 'Eigenes Modul löschen', title: 'Eigenes Modul löschen',
bodyShared: 'Möchten Sie „{name}“ wirklich löschen? Für alle Benutzer.', bodyShared: 'Möchten Sie „{name}“ wirklich löschen? Für alle Benutzer.',
@@ -58,8 +60,18 @@ vi.mock('next-intl', () => ({
})); }));
vi.mock('next/link', () => ({ vi.mock('next/link', () => ({
default: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => ( default: ({
<a href={href} className={className}>{children}</a> children,
href,
className,
}: {
children: React.ReactNode;
href: string;
className?: string;
}) => (
<a href={href} className={className}>
{children}
</a>
), ),
})); }));
@@ -68,13 +80,7 @@ vi.mock('@/lib/use-category-label', () => ({
({ infrastructure: 'Infrastruktur', 'security-tools': 'Sicherheit' })[slug] ?? slug, ({ infrastructure: 'Infrastruktur', 'security-tools': 'Sicherheit' })[slug] ?? slug,
})); }));
const { const { mockList, mockCreate, mockUpdate, mockDelete, mockBump } = vi.hoisted(() => ({
mockList,
mockCreate,
mockUpdate,
mockDelete,
mockBump,
} = vi.hoisted(() => ({
mockList: vi.fn(), mockList: vi.fn(),
mockCreate: vi.fn(), mockCreate: vi.fn(),
mockUpdate: vi.fn(), mockUpdate: vi.fn(),
@@ -106,6 +112,7 @@ vi.mock('@/lib/stores/auth-store', () => ({
selector({ user: { role: mockRole } }), selector({ user: { role: mockRole } }),
})); }));
import { CustomModuleRequestError } from '@/lib/custom-modules-api';
import AdminCustomModulesPage from './page'; import AdminCustomModulesPage from './page';
const wiki = { const wiki = {
@@ -136,7 +143,9 @@ describe('AdminCustomModulesPage', () => {
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
expect(await screen.findByText('Noch keine gemeinsamen Einträge')).toBeInTheDocument(); expect(await screen.findByText('Noch keine gemeinsamen Einträge')).toBeInTheDocument();
expect(screen.getByText('Legen Sie Ihren ersten gemeinsamen Eintrag an.')).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 () => { 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 () => { it('Anlegen: http-Adresse zeigt die Meldung und ruft createCustomModule nicht auf', async () => {
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge'); 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('Name'), 'Beispielseite');
await userEvent.type(screen.getByLabelText('Adresse'), 'http://example.com'); await userEvent.type(screen.getByLabelText('Adresse'), 'http://example.com');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' })); 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(mockCreate).not.toHaveBeenCalled();
expect(mockBump).not.toHaveBeenCalled(); expect(mockBump).not.toHaveBeenCalled();
}); });
@@ -168,7 +181,9 @@ describe('AdminCustomModulesPage', () => {
it('Anlegen: Adresse mit Zugangsdaten zeigt die passende Meldung', async () => { it('Anlegen: Adresse mit Zugangsdaten zeigt die passende Meldung', async () => {
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge'); 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('Name'), 'Beispielseite');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://user:pw@example.com'); 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 () => { it('Anlegen: leerer Name zeigt die Meldung', async () => {
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge'); 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('Name'), ' ');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com'); 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 () => { it('Anlegen: gültige Eingabe ruft create mit getrimmtem Namen, lädt neu und aktualisiert die Seitenleiste genau einmal', async () => {
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge'); 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('Name'), ' Beispielseite ');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com'); await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
@@ -217,11 +236,62 @@ describe('AdminCustomModulesPage', () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); 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(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge'); await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]); await userEvent.click(
expect(screen.getByLabelText('Kategorie')).toHaveValue('infrastructure'); 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(<AdminCustomModulesPage />);
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(<AdminCustomModulesPage />);
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(<AdminCustomModulesPage />);
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 () => { it('Bearbeiten: Formular ist vorbelegt, Speichern ruft update und aktualisiert die Seitenleiste', async () => {
@@ -254,13 +324,17 @@ describe('AdminCustomModulesPage', () => {
mockCreate.mockRejectedValue(new Error('500')); mockCreate.mockRejectedValue(new Error('500'));
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge'); 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('Name'), 'Beispielseite');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com'); await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' })); 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(screen.getByRole('dialog')).toBeInTheDocument();
expect(mockBump).not.toHaveBeenCalled(); expect(mockBump).not.toHaveBeenCalled();
}); });
@@ -271,7 +345,9 @@ describe('AdminCustomModulesPage', () => {
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' })); await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
const dialog = screen.getByRole('dialog'); 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 userEvent.click(within(dialog).getByRole('button', { name: 'Löschen' }));
await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('cm-1')); await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('cm-1'));
@@ -283,7 +359,9 @@ describe('AdminCustomModulesPage', () => {
mockList.mockResolvedValue([wiki]); mockList.mockResolvedValue([wiki]);
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' })); 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(mockDelete).not.toHaveBeenCalled();
expect(mockBump).not.toHaveBeenCalled(); expect(mockBump).not.toHaveBeenCalled();
@@ -295,13 +373,38 @@ describe('AdminCustomModulesPage', () => {
mockDelete.mockRejectedValue(new Error('500')); mockDelete.mockRejectedValue(new Error('500'));
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' })); 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(await screen.findByText('Das Modul konnte nicht gelöscht werden.')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(mockBump).not.toHaveBeenCalled(); expect(mockBump).not.toHaveBeenCalled();
}); });
it('Löschen mit 403 zeigt „keine Berechtigung“', async () => {
mockList.mockResolvedValue([wiki]);
mockDelete.mockRejectedValue(new CustomModuleRequestError(403, 'Forbidden'));
render(<AdminCustomModulesPage />);
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(<AdminCustomModulesPage />);
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 () => { it('zeigt nur gemeinsame Einträge, nie persönliche (Filter über personal)', async () => {
mockList.mockResolvedValue([ mockList.mockResolvedValue([
wiki, wiki,
@@ -129,9 +129,9 @@ describe('CustomModulesSettingsPage', () => {
render(<CustomModulesSettingsPage />); render(<CustomModulesSettingsPage />);
expect(await screen.findByText('Noch keine eigenen Module')).toBeInTheDocument(); expect(await screen.findByText('Noch keine eigenen Module')).toBeInTheDocument();
expect(screen.getByText('Diese Einträge sehen nur Sie.')).toBeInTheDocument(); expect(screen.getByText('Diese Einträge sehen nur Sie.')).toBeInTheDocument();
expect( expect(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' }).length).toBeGreaterThan(
screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' }).length, 0,
).toBeGreaterThan(0); );
}); });
it('listet nur die persönlichen Einträge, gemeinsame der Verwaltung nicht', async () => { it('listet nur die persönlichen Einträge, gemeinsame der Verwaltung nicht', async () => {
@@ -158,7 +158,7 @@ describe('CustomModulesSettingsPage', () => {
expect(mockCreate).toHaveBeenCalledWith({ expect(mockCreate).toHaveBeenCalledWith({
name: 'Mein Wiki', name: 'Mein Wiki',
url: 'https://example.com', url: 'https://example.com',
category: 'infrastructure', category: 'custom-modules',
}); });
}); });
expect(mockCreate.mock.calls[0][0]).not.toHaveProperty('shared'); 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.type(screen.getByLabelText('Adresse'), 'https://example.com');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' })); 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(screen.getByRole('dialog')).toBeInTheDocument();
expect(mockBump).not.toHaveBeenCalled(); expect(mockBump).not.toHaveBeenCalled();
}); });
@@ -1,10 +1,11 @@
'use client'; 'use client';
import { useState } from 'react'; import { CUSTOM_MODULE_CATEGORIES, CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { CUSTOM_MODULE_CATEGORIES } from '@tessera/shared'; import { useState } from 'react';
import { import {
type CustomModule, type CustomModule,
CustomModuleRequestError,
checkCustomModuleUrl, checkCustomModuleUrl,
createCustomModule, createCustomModule,
updateCustomModule, updateCustomModule,
@@ -27,6 +28,23 @@ interface CustomModuleFormModalProps {
const NAME_MAX = 100; const NAME_MAX = 100;
const URL_MAX = 2048; 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, string>) => 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`; * Formular „Eigenes Modul“ (quick-260929-9wc, Vorbild `GroupFormModal`;
* quick-260929-dzu: ein Formular, zwei Aufrufer — Verwaltung legt gemeinsame, * quick-260929-dzu: ein Formular, zwei Aufrufer — Verwaltung legt gemeinsame,
@@ -47,7 +65,9 @@ export function CustomModuleFormModal({
const [name, setName] = useState(mod?.name ?? ''); const [name, setName] = useState(mod?.name ?? '');
const [url, setUrl] = useState(mod?.url ?? ''); const [url, setUrl] = useState(mod?.url ?? '');
const [category, setCategory] = useState<string>(mod?.category ?? 'infrastructure'); // Neue Eintraege landen vorbelegt in der Gruppe „Eigene Module“; beim
// Bearbeiten bleibt die gespeicherte Kategorie.
const [category, setCategory] = useState<string>(mod?.category ?? CUSTOM_MODULE_CATEGORY);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -81,8 +101,8 @@ export function CustomModuleFormModal({
} }
onSaved(); onSaved();
onClose(); onClose();
} catch { } catch (err) {
setError(t('saveError')); setError(customModuleErrorText(err, t, 'saveError'));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -1,8 +1,8 @@
'use client'; 'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api'; import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store'; import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { useCategoryLabel } from '@/lib/use-category-label'; import { useCategoryLabel } from '@/lib/use-category-label';
@@ -86,9 +86,7 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 <h1 className={`${shared ? 'text-2xl' : 'text-lg'} font-semibold text-foreground`}>
className={`${shared ? 'text-2xl' : 'text-lg'} font-semibold text-foreground`}
>
{texts.title} {texts.title}
</h1> </h1>
<button type="button" onClick={openCreate} className="btn btn-primary"> <button type="button" onClick={openCreate} className="btn btn-primary">
@@ -104,9 +102,11 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
</div> </div>
)} )}
{/* Bei einem Ladefehler ohne Eintraege steht nur die Fehlermeldung da —
„keine Eintraege“ waere dann falsch. */}
{loading ? ( {loading ? (
<p className="text-muted-foreground">{tCommon('loading')}</p> <p className="text-muted-foreground">{tCommon('loading')}</p>
) : modules.length === 0 ? ( ) : modules.length === 0 && loadFailed ? null : modules.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center"> <div className="flex flex-col items-center justify-center py-16 text-center">
<h2 className="mb-2 text-lg font-semibold text-foreground">{texts.noModules}</h2> <h2 className="mb-2 text-lg font-semibold text-foreground">{texts.noModules}</h2>
<p className="mb-6 text-sm text-muted-foreground">{texts.noModulesBody}</p> <p className="mb-6 text-sm text-muted-foreground">{texts.noModulesBody}</p>
@@ -119,9 +119,15 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-muted/50"> <thead className="bg-muted/50">
<tr> <tr>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('name')}</th> <th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('url')}</th> {t('name')}
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('category')}</th> </th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('url')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('category')}
</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground"> <th className="px-4 py-3 text-right font-medium text-muted-foreground">
{tManage('actions')} {tManage('actions')}
</th> </th>
@@ -1,8 +1,9 @@
'use client'; 'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { type CustomModule, deleteCustomModule } from '@/lib/custom-modules-api'; import { type CustomModule, deleteCustomModule } from '@/lib/custom-modules-api';
import { customModuleErrorText } from './custom-module-form-modal';
interface DeleteCustomModuleDialogProps { interface DeleteCustomModuleDialogProps {
mod: CustomModule; mod: CustomModule;
@@ -37,8 +38,8 @@ export function DeleteCustomModuleDialog({
await deleteCustomModule(mod.id); await deleteCustomModule(mod.id);
onDeleted(); onDeleted();
onClose(); onClose();
} catch { } catch (err) {
setError(t('deleteError')); setError(customModuleErrorText(err, t, 'deleteError'));
} finally { } finally {
setDeleting(false); setDeleting(false);
} }
@@ -55,7 +56,11 @@ export function DeleteCustomModuleDialog({
<h2 id="delete-custom-module-title" className="mb-4 text-lg font-semibold text-foreground"> <h2 id="delete-custom-module-title" className="mb-4 text-lg font-semibold text-foreground">
{t('deleteConfirm.title')} {t('deleteConfirm.title')}
</h2> </h2>
<p className="mb-4 text-sm text-foreground">{t(shared ? 'deleteConfirm.bodyShared' : 'deleteConfirm.bodyPersonal', { name: mod.name })}</p> <p className="mb-4 text-sm text-foreground">
{t(shared ? 'deleteConfirm.bodyShared' : 'deleteConfirm.bodyPersonal', {
name: mod.name,
})}
</p>
{error && ( {error && (
<div <div
+54 -10
View File
@@ -8,8 +8,18 @@ vi.mock('next/navigation', () => ({
})); }));
vi.mock('next/link', () => ({ vi.mock('next/link', () => ({
default: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => ( default: ({
<a href={href} className={className}>{children}</a> children,
href,
className,
}: {
children: React.ReactNode;
href: string;
className?: string;
}) => (
<a href={href} className={className}>
{children}
</a>
), ),
})); }));
@@ -100,9 +110,7 @@ const mockActiveModules = [
function stubFetch() { function stubFetch() {
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
vi.fn(() => vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) })),
Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) }),
),
); );
} }
@@ -316,7 +324,9 @@ describe('Sidebar', () => {
expect(screen.getByText('Mein Wiki')).toBeInTheDocument(); expect(screen.getByText('Mein Wiki')).toBeInTheDocument();
}); });
const groups = Array.from( 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); ).map((n) => n.textContent);
expect(groups.at(-1)).toBe('custom-modules'); expect(groups.at(-1)).toBe('custom-modules');
expect(groups).toContain('fleet'); expect(groups).toContain('fleet');
@@ -340,12 +350,40 @@ describe('Sidebar', () => {
render(<Sidebar />); render(<Sidebar />);
const hrefs = () => 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(() => { await waitFor(() => {
expect(hrefs()).toContain('/modules/custom/cm-1'); expect(hrefs()).toContain('/modules/custom/cm-1');
}); });
expect(hrefs().at(-1)).toBe('/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(<Sidebar />);
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 () => { it('eingebaute Module stehen innerhalb einer Kategorie vor eigenen', async () => {
@@ -360,7 +398,10 @@ describe('Sidebar', () => {
.getByText('Domain-Tools') .getByText('Domain-Tools')
.closest('li') .closest('li')
?.querySelectorAll('a span.truncate'); ?.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/<id> traegt genau dieser Eintrag die Auswahlmarke', async () => { it('auf /modules/custom/<id> traegt genau dieser Eintrag die Auswahlmarke', async () => {
@@ -379,7 +420,10 @@ describe('Sidebar', () => {
screen.getByText('Anderes').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'), screen.getByText('Anderes').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
).toBeNull(); ).toBeNull();
expect( expect(
screen.getByText('Domaincheck').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'), screen
.getByText('Domaincheck')
.closest('a')
?.querySelector('[data-testid="nav-selection-pill"]'),
).toBeNull(); ).toBeNull();
}); });
+120 -45
View File
@@ -1,20 +1,20 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { TesseraLogo } from '@/components/brand/tessera-logo'; 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 { 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 { 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. */ /** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */
function SelectionPill() { function SelectionPill() {
@@ -109,13 +109,17 @@ export function Sidebar() {
const linkClass = (href: string) => itemClass(isActive(href)); const linkClass = (href: string) => itemClass(isActive(href));
const isEntryActive = (entry: SidebarEntry) => 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 // Moduleintraege unter einer Kategorie eine Stufe kleiner als die
// Hauptpunkte (quick-260928-wtt): 32 statt 36 px hoch, 13 statt 14 px Schrift; // Hauptpunkte (quick-260928-wtt): 32 statt 36 px hoch, 13 statt 14 px Schrift;
// die 20-px-Modulkachel passt weiter hinein. // die 20-px-Modulkachel passt weiter hinein.
const moduleItemClass = (active: boolean) => 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 // Eingeklappt (Nutzerwunsch 29.09.): Symbole etwas groesser (Kachel 24 statt
// 20 px, Navigationssymbole 22 statt 20 px), Eintraege dichter — 34 px hoch // 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“ // Eintraege nach Kategorie gruppiert: „Eigene Module“ steht immer zuletzt,
// also auch hier immer zuletzt. // die uebrigen in Fundreihenfolge. Grundlage fuer BEIDE Ansichten.
const orderedCategories = useMemo(() => {
const categories = new Map<string, SidebarEntry[]>();
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( const collapsedEntries = useMemo(
() => () => orderedCategories.flatMap(([, categoryEntries]) => categoryEntries),
[...entries].sort( [orderedCategories],
(a, b) =>
Number(a.category === CUSTOM_MODULE_CATEGORY) - Number(b.category === CUSTOM_MODULE_CATEGORY),
),
[entries],
); );
const filteredCategories = useMemo(() => { const filteredCategories = useMemo(() => {
const categories = new Map<string, SidebarEntry[]>(); const ordered = orderedCategories;
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),
);
if (!searchQuery) return ordered; if (!searchQuery) return ordered;
const q = searchQuery.toLowerCase(); const q = searchQuery.toLowerCase();
@@ -221,10 +226,22 @@ export function Sidebar() {
} }
} }
return result; return result;
}, [entries, searchQuery, categoryLabel]); }, [orderedCategories, searchQuery, categoryLabel]);
const navIcon = (children: React.ReactNode) => ( const navIcon = (children: React.ReactNode) => (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width={isCollapsed ? 22 : 20} height={isCollapsed ? 22 : 20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="shrink-0"> <svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width={isCollapsed ? 22 : 20}
height={isCollapsed ? 22 : 20}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0"
>
{children} {children}
</svg> </svg>
); );
@@ -234,7 +251,12 @@ export function Sidebar() {
<nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}> <nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}>
<ul className={`flex flex-col ${isCollapsed ? 'gap-px' : 'gap-0.5'}`}> <ul className={`flex flex-col ${isCollapsed ? 'gap-px' : 'gap-0.5'}`}>
<li> <li>
<Link href="/" className={isCollapsed ? collapsedItemClass(isActive('/')) : linkClass('/')} aria-label={t('dashboard')} title={isCollapsed ? t('dashboard') : undefined}> <Link
href="/"
className={isCollapsed ? collapsedItemClass(isActive('/')) : linkClass('/')}
aria-label={t('dashboard')}
title={isCollapsed ? t('dashboard') : undefined}
>
{isActive('/') && <SelectionPill />} {isActive('/') && <SelectionPill />}
{navIcon( {navIcon(
<> <>
@@ -248,7 +270,16 @@ export function Sidebar() {
</Link> </Link>
</li> </li>
<li> <li>
<Link href="/marketplace" className={isCollapsed ? collapsedItemClass(isActive('/marketplace')) : linkClass('/marketplace')} aria-label={t('marketplace')} title={isCollapsed ? t('marketplace') : undefined}> <Link
href="/marketplace"
className={
isCollapsed
? collapsedItemClass(isActive('/marketplace'))
: linkClass('/marketplace')
}
aria-label={t('marketplace')}
title={isCollapsed ? t('marketplace') : undefined}
>
{isActive('/marketplace') && <SelectionPill />} {isActive('/marketplace') && <SelectionPill />}
{navIcon( {navIcon(
<> <>
@@ -268,9 +299,18 @@ export function Sidebar() {
const active = isEntryActive(entry); const active = isEntryActive(entry);
return ( return (
<li key={entry.key}> <li key={entry.key}>
<Link href={entry.href} className={collapsedItemClass(active)} aria-label={entry.name} title={entry.name}> <Link
href={entry.href}
className={collapsedItemClass(active)}
aria-label={entry.name}
title={entry.name}
>
{active && <SelectionPill />} {active && <SelectionPill />}
<ModuleTile slug={entry.tileSlug} size={24} tone={active ? 'accent' : 'neutral'} /> <ModuleTile
slug={entry.tileSlug}
size={24}
tone={active ? 'accent' : 'neutral'}
/>
</Link> </Link>
</li> </li>
); );
@@ -290,9 +330,7 @@ export function Sidebar() {
{t('categories.empty')} {t('categories.empty')}
</div> </div>
) : filteredCategories.length === 0 ? ( ) : filteredCategories.length === 0 ? (
<div className="px-2.5 py-2 text-sm text-muted-foreground"> <div className="px-2.5 py-2 text-sm text-muted-foreground">{t('noResults')}</div>
{t('noResults')}
</div>
) : ( ) : (
<ul className="flex flex-col gap-2"> <ul className="flex flex-col gap-2">
{filteredCategories.map(([category, categoryEntries]) => { {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" 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"
> >
<span className="truncate">{categoryLabel(category)}</span> <span className="truncate">{categoryLabel(category)}</span>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" className={`ml-auto shrink-0 opacity-70 transition-transform duration-150 ${open ? '' : '-rotate-90'}`}> <svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.25"
strokeLinecap="round"
strokeLinejoin="round"
className={`ml-auto shrink-0 opacity-70 transition-transform duration-150 ${open ? '' : '-rotate-90'}`}
>
<polyline points="6 9 12 15 18 9" /> <polyline points="6 9 12 15 18 9" />
</svg> </svg>
</button> </button>
@@ -319,7 +369,11 @@ export function Sidebar() {
<li key={entry.key}> <li key={entry.key}>
<Link href={entry.href} className={moduleItemClass(active)}> <Link href={entry.href} className={moduleItemClass(active)}>
{active && <SelectionPill />} {active && <SelectionPill />}
<ModuleTile slug={entry.tileSlug} size={20} tone={active ? 'accent' : 'neutral'} /> <ModuleTile
slug={entry.tileSlug}
size={20}
tone={active ? 'accent' : 'neutral'}
/>
<span className="truncate">{entry.name}</span> <span className="truncate">{entry.name}</span>
</Link> </Link>
</li> </li>
@@ -353,7 +407,19 @@ export function Sidebar() {
aria-label={isCollapsed ? t('expand') : t('collapse')} aria-label={isCollapsed ? t('expand') : t('collapse')}
title={isCollapsed ? t('expand') : undefined} title={isCollapsed ? t('expand') : undefined}
> >
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}> <svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}
>
<rect x="3" y="3" width="18" height="18" rx="2" /> <rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" /> <path d="M9 3v18" />
<path d="m16 15-3-3 3-3" /> <path d="m16 15-3-3 3-3" />
@@ -377,9 +443,7 @@ export function Sidebar() {
<aside <aside
className="fixed left-0 top-[var(--header-height)] z-30 hidden h-[calc(100vh-var(--header-height))] border-r border-sidebar-border transition-[width] duration-200 md:block" className="fixed left-0 top-[var(--header-height)] z-30 hidden h-[calc(100vh-var(--header-height))] border-r border-sidebar-border transition-[width] duration-200 md:block"
style={{ style={{
width: isCollapsed width: isCollapsed ? 'var(--sidebar-width-collapsed)' : 'var(--sidebar-width)',
? 'var(--sidebar-width-collapsed)'
: 'var(--sidebar-width)',
}} }}
> >
{sidebarContent} {sidebarContent}
@@ -408,7 +472,18 @@ export function Sidebar() {
aria-label={tCommon('close')} aria-label={tCommon('close')}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-appbar-muted transition-colors hover:bg-appbar-hover hover:text-appbar-foreground" className="inline-flex h-8 w-8 items-center justify-center rounded-md text-appbar-muted transition-colors hover:bg-appbar-hover hover:text-appbar-foreground"
> >
<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="2" strokeLinecap="round" strokeLinejoin="round"> <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="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" /> <line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" />
</svg> </svg>
@@ -1,12 +1,14 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react'; import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config'; import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
import { CustomModuleRequestError } from '@/lib/custom-modules-api';
vi.mock('next-intl', () => ({ vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => { useTranslations: () => (key: string) => {
const map: Record<string, string> = { const map: Record<string, string> = {
openInNewTab: 'In neuem Tab öffnen', openInNewTab: 'In neuem Tab öffnen',
notFound: 'Dieses Modul gibt es nicht mehr.', notFound: 'Dieses Modul gibt es nicht mehr.',
loadError: 'Dieses Modul konnte nicht geladen werden.',
invalidUrl: 'Ungültige Adresse', invalidUrl: 'Ungültige Adresse',
loading: 'Wird geladen', loading: 'Wird geladen',
}; };
@@ -106,4 +108,19 @@ describe('CustomModuleView', () => {
}); });
expect(container.querySelector('iframe')).toBeNull(); expect(container.querySelector('iframe')).toBeNull();
}); });
it.each([
['500', new CustomModuleRequestError(500, 'Internal server error')],
['403', new CustomModuleRequestError(403, 'Forbidden')],
['Netzwerkfehler', new TypeError('Failed to fetch')],
])('zeigt bei %s „konnte nicht geladen werden“ statt „nicht gefunden“', async (_, err) => {
mockGetCustomModule.mockRejectedValue(err);
const { container } = render(<CustomModuleView id="abc" />);
expect(await screen.findByRole('alert')).toHaveTextContent(
'Dieses Modul konnte nicht geladen werden.',
);
expect(screen.queryByText('Dieses Modul gibt es nicht mehr.')).toBeNull();
expect(container.querySelector('iframe')).toBeNull();
});
}); });
@@ -1,13 +1,17 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useTranslations } from 'next-intl';
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot';
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config'; import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot';
import { type CustomModule, checkCustomModuleUrl, getCustomModule } from '@/lib/custom-modules-api'; import { type CustomModule, checkCustomModuleUrl, getCustomModule } from '@/lib/custom-modules-api';
type LoadState = { status: 'loading' } | { status: 'missing' } | { status: 'ready'; mod: CustomModule }; type LoadState =
| { status: 'loading' }
| { status: 'missing' }
| { status: 'failed' }
| { status: 'ready'; mod: CustomModule };
/** /**
* Rahmen-Ansicht eines eigenen Moduls (quick-260929-9wc, D-06): der Rahmen * Rahmen-Ansicht eines eigenen Moduls (quick-260929-9wc, D-06): der Rahmen
@@ -19,6 +23,8 @@ type LoadState = { status: 'loading' } | { status: 'missing' } | { status: 'read
* Referrer). Iframe und Link entstehen NUR bei einer https-Adresse ohne * Referrer). Iframe und Link entstehen NUR bei einer https-Adresse ohne
* Zugangsdaten — auch eine von Hand veraenderte Datenbankzeile mit * Zugangsdaten — auch eine von Hand veraenderte Datenbankzeile mit
* `javascript:` oder `http:` wird nie gerendert (T-9WC-03). * `javascript:` oder `http:` wird nie gerendert (T-9WC-03).
* Nur ein 404 heisst „gibt es nicht mehr“; 403, 500 oder ein Netzwerkfehler
* zeigen „konnte nicht geladen werden“ — der Eintrag existiert vielleicht noch.
*/ */
export function CustomModuleView({ id }: { id: string }) { export function CustomModuleView({ id }: { id: string }) {
const t = useTranslations('customModules'); const t = useTranslations('customModules');
@@ -37,7 +43,7 @@ export function CustomModuleView({ id }: { id: string }) {
setState(mod ? { status: 'ready', mod } : { status: 'missing' }); setState(mod ? { status: 'ready', mod } : { status: 'missing' });
}) })
.catch(() => { .catch(() => {
if (!cancelled) setState({ status: 'missing' }); if (!cancelled) setState({ status: 'failed' });
}); });
return () => { return () => {
cancelled = true; cancelled = true;
@@ -50,6 +56,13 @@ export function CustomModuleView({ id }: { id: string }) {
if (state.status === 'missing') { if (state.status === 'missing') {
return <div className="p-4 text-sm text-muted-foreground">{t('notFound')}</div>; return <div className="p-4 text-sm text-muted-foreground">{t('notFound')}</div>;
} }
if (state.status === 'failed') {
return (
<div role="alert" className="p-4 text-sm text-destructive">
{t('loadError')}
</div>
);
}
const { mod } = state; const { mod } = state;
const urlOk = checkCustomModuleUrl(mod.url) === 'ok'; const urlOk = checkCustomModuleUrl(mod.url) === 'ok';