feat(quick-260923-ad9): Reiterleiste in der Oberflaeche - Task 3

dashboard-api.ts: fuenf neue Abrufe fuer die Reiter-Wege, die vier
bestehenden Aufrufe (Layout/Widgets lesen/schreiben) tragen jetzt die
Reiter-Kennung (Abfrageparameter bzw. Rumpf).

dashboard-store.ts: Zustand um dashboards/activeDashboardId/
isSwitchingDashboard erweitert. loadDashboard() holt zuerst die Reiter,
macht den ersten aktiv, laedt erst danach dessen Inhalt; ein modul-globales
Versprechen schuetzt gegen doppeltes Laden der Reiterliste bei doppeltem
Einhaengen. selectDashboard() schreibt eine ungespeicherte Anordnung ZUERST
fuer den alten Reiter (Kennung vor dem Wechsel gelesen) und ersetzt danach
Kacheln/Anordnung vollstaendig. createDashboard/renameDashboard/
deleteDashboard pflegen Reiterliste und aktiven Reiter; Loeschen des
aktiven Reiters macht den dann ersten Reiter aktiv. Die Marker-Umrechnung
(quick-260916-bwo) laeuft unveraendert je Reiter mit, auch beim Wechsel.

dashboard-tabs.tsx (neu): Reiterleiste, Klick wechselt immer; im
Bearbeitungsmodus zusaetzlich Anlegen, Umbenennen (an Ort und Stelle,
Enter/Escape) und Loeschen (mit Rueckfrage) - Loeschen-Knopf fehlt beim
letzten verbleibenden Reiter (D-10). Fokus beim Umbenennen ueber einen Ref
statt autoFocus (lint/a11y/noAutofocus).

(portal)/page.tsx: Leiste ueber dem Raster, kurze Ladezeile waehrend eines
Reiterwechsels statt des Rasters - die Leiste bleibt stehen. DashboardGrid
selbst unveraendert (D-06).

Uebersetzungen: neue Schluessel unter widgets.tabs.* in de.json/en.json,
Dialog-Knoepfe nutzen die vorhandenen common.cancel/common.delete.

Deviations (Rule 3 - blockierende Nachwirkung dieses Tasks, ausserhalb der
files_modified-Liste, aber direkt durch die dashboardId-Pflicht verursacht):
- settings/dashboard/page.tsx: fetchWidgets() verlangt jetzt eine
  Reiter-Kennung; die Seite ist nicht reiterbewusst (ausserhalb des
  Umfangs) und zeigt jetzt die Kacheln des ERSTEN Reiters - deckungsgleich
  mit dem bisherigen Verhalten fuer den haeufigen Fall genau eines Reiters.
- (portal)/page.test.tsx: mockStore brauchte die neuen Reiter-Felder/
  -Methoden, sonst waere DashboardTabs auf `dashboards.map` von undefined
  gescheitert.

Tests: dashboard-store.test.ts 15 (6 alte angepasste Signaturen + 9 neue),
dashboard-tabs.test.tsx 12 (neu). web gesamt 682 Tests in 82 Dateien,
dashboard-grid.test.tsx unveraendert bei 12. type-check 4/4, lint 5/5 mit
weiterhin genau 53 Warnungen in web.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 08:10:45 +02:00
parent df7a5e7e8e
commit d34f682c28
10 changed files with 999 additions and 40 deletions
@@ -0,0 +1,275 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
/**
* Mock next-intl mit einfacher `{param}`-Ersetzung — anders als das
* schlichtere Muster in `widget-catalog-modal.test.tsx` (das Parameter
* ignoriert), weil Test 8 unten die eingesetzte Reiterbezeichnung im
* Loeschdialog prueft.
*/
vi.mock('next-intl', () => ({
useTranslations: (namespace: string) => (key: string, params?: Record<string, string>) => {
const translations: Record<string, Record<string, string>> = {
widgets: {
'tabs.navLabel': 'Dashboard-Reiter',
'tabs.add': 'Dashboard hinzufügen',
'tabs.renameButtonLabel': 'Dashboard umbenennen',
'tabs.renameInputLabel': 'Name des Dashboards',
'tabs.deleteButtonLabel': 'Dashboard löschen',
'tabs.deleteDialogTitle': 'Dashboard löschen',
'tabs.deleteDialogBody': 'Möchten Sie das Dashboard „{name}“ wirklich löschen?',
},
common: {
cancel: 'Abbrechen',
delete: 'Löschen',
},
};
let text = translations[namespace]?.[key] ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) {
text = text.replace(`{${k}}`, v);
}
}
return text;
},
}));
import { DashboardTabs } from './dashboard-tabs';
const DASHBOARDS = [
{ id: 'd1', name: 'Dashboard', position: 0 },
{ id: 'd2', name: 'Dashboard 2', position: 1 },
];
afterEach(cleanup);
describe('DashboardTabs (quick-260923-ad9, Task 3)', () => {
it('Test 1: rendert jeden Reiter mit seinem Namen', () => {
render(
<DashboardTabs
dashboards={DASHBOARDS}
activeDashboardId="d1"
isEditMode={false}
onSelect={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
onDelete={vi.fn()}
/>,
);
expect(screen.getByRole('button', { name: 'Dashboard' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Dashboard 2' })).toBeInTheDocument();
});
it('Test 2: ein Klick auf einen Reiter ruft onSelect mit dessen Kennung auf', () => {
const onSelect = vi.fn();
render(
<DashboardTabs
dashboards={DASHBOARDS}
activeDashboardId="d1"
isEditMode={false}
onSelect={onSelect}
onCreate={vi.fn()}
onRename={vi.fn()}
onDelete={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Dashboard 2' }));
expect(onSelect).toHaveBeenCalledWith('d2');
});
it('Test 3: der aktive Reiter traegt aria-current, der andere nicht', () => {
render(
<DashboardTabs
dashboards={DASHBOARDS}
activeDashboardId="d1"
isEditMode={false}
onSelect={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
onDelete={vi.fn()}
/>,
);
expect(screen.getByRole('button', { name: 'Dashboard' })).toHaveAttribute('aria-current', 'true');
expect(screen.getByRole('button', { name: 'Dashboard 2' })).not.toHaveAttribute('aria-current');
});
it('Test 4: ausserhalb des Bearbeitungsmodus gibt es weder Umbenennen- noch Loeschen- noch Hinzufuegen-Knoepfe', () => {
render(
<DashboardTabs
dashboards={DASHBOARDS}
activeDashboardId="d1"
isEditMode={false}
onSelect={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
onDelete={vi.fn()}
/>,
);
expect(screen.queryByLabelText('Dashboard umbenennen')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Dashboard löschen')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Dashboard hinzufügen')).not.toBeInTheDocument();
});
it('Test 5: im Bearbeitungsmodus mit mehreren Reitern sind Loeschen-Knoepfe und ein Hinzufuegen-Knopf da', () => {
render(
<DashboardTabs
dashboards={DASHBOARDS}
activeDashboardId="d1"
isEditMode={true}
onSelect={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
onDelete={vi.fn()}
/>,
);
expect(screen.getAllByLabelText('Dashboard löschen')).toHaveLength(2);
expect(screen.getByLabelText('Dashboard hinzufügen')).toBeInTheDocument();
});
it('Test 6: beim letzten verbleibenden Reiter wird Loeschen gar nicht erst angeboten', () => {
render(
<DashboardTabs
dashboards={[DASHBOARDS[0]]}
activeDashboardId="d1"
isEditMode={true}
onSelect={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
onDelete={vi.fn()}
/>,
);
expect(screen.queryByLabelText('Dashboard löschen')).not.toBeInTheDocument();
});
it('Test 7: der Hinzufuegen-Knopf ruft onCreate auf', () => {
const onCreate = vi.fn();
render(
<DashboardTabs
dashboards={DASHBOARDS}
activeDashboardId="d1"
isEditMode={true}
onSelect={vi.fn()}
onCreate={onCreate}
onRename={vi.fn()}
onDelete={vi.fn()}
/>,
);
fireEvent.click(screen.getByLabelText('Dashboard hinzufügen'));
expect(onCreate).toHaveBeenCalledTimes(1);
});
it('Test 8: Umbenennen-Knopf des AKTIVEN Reiters verwandelt den Namen in ein Eingabefeld; Enter uebernimmt beschnitten', () => {
const onRename = vi.fn();
render(
<DashboardTabs
dashboards={DASHBOARDS}
activeDashboardId="d1"
isEditMode={true}
onSelect={vi.fn()}
onCreate={vi.fn()}
onRename={onRename}
onDelete={vi.fn()}
/>,
);
fireEvent.click(screen.getByLabelText('Dashboard umbenennen'));
const input = screen.getByLabelText('Name des Dashboards');
fireEvent.change(input, { target: { value: ' Finanzen ' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(onRename).toHaveBeenCalledWith('d1', 'Finanzen');
});
it('Test 9: Escape verwirft die Umbenennung, ohne onRename aufzurufen', () => {
const onRename = vi.fn();
render(
<DashboardTabs
dashboards={DASHBOARDS}
activeDashboardId="d1"
isEditMode={true}
onSelect={vi.fn()}
onCreate={vi.fn()}
onRename={onRename}
onDelete={vi.fn()}
/>,
);
fireEvent.click(screen.getByLabelText('Dashboard umbenennen'));
const input = screen.getByLabelText('Name des Dashboards');
fireEvent.change(input, { target: { value: 'Verworfen' } });
fireEvent.keyDown(input, { key: 'Escape' });
expect(onRename).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'Dashboard' })).toBeInTheDocument();
});
it('Test 10: der Umbenennen-Knopf steht nur beim AKTIVEN Reiter, nicht bei den anderen', () => {
render(
<DashboardTabs
dashboards={DASHBOARDS}
activeDashboardId="d1"
isEditMode={true}
onSelect={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
onDelete={vi.fn()}
/>,
);
expect(screen.getAllByLabelText('Dashboard umbenennen')).toHaveLength(1);
});
it('Test 11: Loeschen fragt mit dem Namen des Reiters zurueck; Bestaetigen ruft onDelete auf', () => {
const onDelete = vi.fn();
render(
<DashboardTabs
dashboards={DASHBOARDS}
activeDashboardId="d1"
isEditMode={true}
onSelect={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
onDelete={onDelete}
/>,
);
fireEvent.click(screen.getAllByLabelText('Dashboard löschen')[1]);
const dialog = screen.getByRole('alertdialog');
expect(dialog).toHaveTextContent('Dashboard 2');
fireEvent.click(within(dialog).getByRole('button', { name: 'Löschen' }));
expect(onDelete).toHaveBeenCalledWith('d2');
});
it('Test 12: Abbrechen im Loeschdialog ruft onDelete NICHT auf', () => {
const onDelete = vi.fn();
render(
<DashboardTabs
dashboards={DASHBOARDS}
activeDashboardId="d1"
isEditMode={true}
onSelect={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
onDelete={onDelete}
/>,
);
fireEvent.click(screen.getAllByLabelText('Dashboard löschen')[0]);
const dialog = screen.getByRole('alertdialog');
fireEvent.click(within(dialog).getByRole('button', { name: 'Abbrechen' }));
expect(onDelete).not.toHaveBeenCalled();
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
});
});