diff --git a/apps/web/src/app/(portal)/page.test.tsx b/apps/web/src/app/(portal)/page.test.tsx index db98ae4..4df34f5 100644 --- a/apps/web/src/app/(portal)/page.test.tsx +++ b/apps/web/src/app/(portal)/page.test.tsx @@ -20,6 +20,12 @@ vi.mock('next-intl', () => ({ })); const mockStore = { + // quick-260923-ad9: Reiter — leere Liste rendert die Reiterleiste ohne + // Reiter-Knoepfe, damit diese Datei bei den bestehenden Zusammensetzungs- + // Pruefungen bleibt, statt den Reiter-Speicher selbst nachzubauen. + dashboards: [] as Array<{ id: string; name: string; position: number }>, + activeDashboardId: null as string | null, + isSwitchingDashboard: false, layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] }, widgets: [] as Array<{ id: string; widgetType: string; config: Record }>, isEditMode: false, @@ -32,6 +38,10 @@ const mockStore = { removeWidget: vi.fn(), loadDashboard: vi.fn(), saveLayout: vi.fn(), + selectDashboard: vi.fn(), + createDashboard: vi.fn(), + renameDashboard: vi.fn(), + deleteDashboard: vi.fn(), }; vi.mock('@/lib/stores/dashboard-store', () => ({ diff --git a/apps/web/src/app/(portal)/page.tsx b/apps/web/src/app/(portal)/page.tsx index c9a459c..f533be7 100644 --- a/apps/web/src/app/(portal)/page.tsx +++ b/apps/web/src/app/(portal)/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; import { DashboardGrid } from '@/components/dashboard/dashboard-grid'; +import { DashboardTabs } from '@/components/dashboard/dashboard-tabs'; import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle'; import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal'; import { registerWidget } from '@/components/dashboard/widget-registry'; @@ -48,6 +49,9 @@ export default function DashboardPage() { const [accessibleModuleSlugs, setAccessibleModuleSlugs] = useState(null); const { + dashboards, + activeDashboardId, + isSwitchingDashboard, layouts, widgets, isEditMode, @@ -58,6 +62,10 @@ export default function DashboardPage() { addWidget, removeWidget, loadDashboard, + selectDashboard, + createDashboard, + renameDashboard, + deleteDashboard, } = useDashboardStore(); // Load dashboard data on mount @@ -105,15 +113,34 @@ export default function DashboardPage() { return (
- {/* Dashboard grid — direkt im Container, ohne Abstands-Wrapper. */} - + {isSwitchingDashboard ? ( +
+

{t('tabs.switching')}

+
+ ) : ( + /* Dashboard grid — direkt im Container, ohne Abstands-Wrapper. */ + + )} + {/* Feste Aktionsleiste unten rechts. quick-260916-dyv: Umschalter unten rechts statt oben rechts, damit das Grid direkt unter der Kopfzeile beginnt (12 + 8 + 8 = 28 px statt 60 px). diff --git a/apps/web/src/app/(portal)/settings/dashboard/page.tsx b/apps/web/src/app/(portal)/settings/dashboard/page.tsx index 6467edb..db45e6f 100644 --- a/apps/web/src/app/(portal)/settings/dashboard/page.tsx +++ b/apps/web/src/app/(portal)/settings/dashboard/page.tsx @@ -3,11 +3,20 @@ import { useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; import { WidgetSettingsPanel } from '@/components/settings/widget-settings-panel'; -import { fetchWidgets } from '@/lib/dashboard-api'; +import { fetchDashboards, fetchWidgets } from '@/lib/dashboard-api'; /** * Settings > Dashboard > Widgets page (D-03). - * Fetches user's widget instances and renders per-instance config forms. + * Fetches the widget instances of the user's FIRST dashboard tab (the + * standard tab, quick-260923-ad9) and renders per-instance config forms. + * + * quick-260923-ad9 (Task 3, deviation Rule 3 — blocking compile issue): + * `fetchWidgets` now requires a `dashboardId` since widgets are scoped per + * tab. This page is not itself tab-aware (out of this plan's scope — see + * "Nicht im Umfang") and previously showed every widget the user had, back + * when there was exactly one dashboard per user; it now shows the first + * tab's widgets, which is the same set for the (overwhelmingly common) + * case of a user who has not yet created a second tab. */ export default function WidgetSettingsPage() { const t = useTranslations('settings'); @@ -18,12 +27,17 @@ export default function WidgetSettingsPage() { const [isLoading, setIsLoading] = useState(true); useEffect(() => { - fetchWidgets() - .then(setWidgets) - .catch(() => { + (async () => { + try { + const dashboards = await fetchDashboards(); + const first = dashboards[0]; + setWidgets(first ? await fetchWidgets(first.id) : []); + } catch { // Silent fail — empty widget list shown - }) - .finally(() => setIsLoading(false)); + } finally { + setIsLoading(false); + } + })(); }, []); return ( diff --git a/apps/web/src/components/dashboard/dashboard-tabs.test.tsx b/apps/web/src/components/dashboard/dashboard-tabs.test.tsx new file mode 100644 index 0000000..3d10fb3 --- /dev/null +++ b/apps/web/src/components/dashboard/dashboard-tabs.test.tsx @@ -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) => { + const translations: Record> = { + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + expect(screen.queryByLabelText('Dashboard löschen')).not.toBeInTheDocument(); + }); + + it('Test 7: der Hinzufuegen-Knopf ruft onCreate auf', () => { + const onCreate = vi.fn(); + render( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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(); + }); +}); diff --git a/apps/web/src/components/dashboard/dashboard-tabs.tsx b/apps/web/src/components/dashboard/dashboard-tabs.tsx new file mode 100644 index 0000000..9cc166a --- /dev/null +++ b/apps/web/src/components/dashboard/dashboard-tabs.tsx @@ -0,0 +1,240 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import type { DashboardTab } from '@/lib/dashboard-api'; + +interface DashboardTabsProps { + dashboards: DashboardTab[]; + activeDashboardId: string | null; + isEditMode: boolean; + onSelect: (id: string) => void; + onCreate: () => void; + onRename: (id: string, name: string) => void; + onDelete: (id: string) => void; +} + +/** + * Reiterleiste über dem Dashboard-Raster (quick-260923-ad9, Task 3). + * + * Klick wechselt IMMER den Reiter, unabhängig vom Bearbeitungsmodus. + * Umbenennen (an Ort und Stelle, Eingabetaste übernimmt, Escape verwirft) + * und Löschen (mit Rückfrage) sind nur im Bearbeitungsmodus sichtbar; der + * Löschen-Knopf fehlt zusätzlich beim letzten verbleibenden Reiter (D-10 — + * der Server weist das ohnehin ab, die Oberfläche bietet es gar nicht erst + * an). Das Ziehen zum Umsortieren kommt in Task 4 hinzu (D-05, keine neue + * Abhängigkeit — Pointer-Ereignisse wie in `xframe-config-form.tsx`). + */ +export function DashboardTabs({ + dashboards, + activeDashboardId, + isEditMode, + onSelect, + onCreate, + onRename, + onDelete, +}: DashboardTabsProps) { + const t = useTranslations('widgets'); + const tCommon = useTranslations('common'); + const [renamingId, setRenamingId] = useState(null); + const [draftName, setDraftName] = useState(''); + const [pendingDeleteId, setPendingDeleteId] = useState(null); + const renameInputRef = useRef(null); + + // Fokus auf das Eingabefeld beim Wechsel in den Umbenennen-Zustand — ueber + // einen Ref statt des autoFocus-Attributs (lint/a11y/noAutofocus), Muster + // `widget-catalog-modal.tsx` (dialogRef.current?.focus()). + useEffect(() => { + if (renamingId) { + renameInputRef.current?.focus(); + renameInputRef.current?.select(); + } + }, [renamingId]); + + function startRename(tab: DashboardTab) { + setRenamingId(tab.id); + setDraftName(tab.name); + } + + function commitRename() { + const id = renamingId; + const name = draftName.trim(); + setRenamingId(null); + if (id && name) { + onRename(id, name); + } + } + + function cancelRename() { + setRenamingId(null); + } + + const pendingDeleteTab = dashboards.find((d) => d.id === pendingDeleteId) ?? null; + + return ( +
+ + )} + + ); +} diff --git a/apps/web/src/lib/dashboard-api.ts b/apps/web/src/lib/dashboard-api.ts index 4c404b2..02fba96 100644 --- a/apps/web/src/lib/dashboard-api.ts +++ b/apps/web/src/lib/dashboard-api.ts @@ -5,44 +5,105 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; -export async function fetchLayout(): Promise> { - const res = await fetch(`${API_URL}/dashboard/layout`, { +// --- Reiter (quick-260923-ad9) --------------------------------------------- + +export interface DashboardTab { + id: string; + name: string; + position: number; +} + +export async function fetchDashboards(): Promise { + const res = await fetch(`${API_URL}/dashboard/tabs`, { credentials: 'include', }); + if (!res.ok) throw new Error('Failed to fetch dashboards'); + return res.json(); +} + +export async function createDashboardTab(): Promise { + const res = await fetch(`${API_URL}/dashboard/tabs`, { + method: 'POST', + credentials: 'include', + }); + if (!res.ok) throw new Error('Failed to create dashboard'); + return res.json(); +} + +export async function renameDashboardTab(id: string, name: string): Promise { + const res = await fetch(`${API_URL}/dashboard/tabs/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ name }), + }); + if (!res.ok) throw new Error('Failed to rename dashboard'); + return res.json(); +} + +export async function deleteDashboardTab(id: string): Promise { + const res = await fetch(`${API_URL}/dashboard/tabs/${id}`, { + method: 'DELETE', + credentials: 'include', + }); + if (!res.ok) throw new Error('Failed to delete dashboard'); +} + +export async function reorderDashboardTabs(ids: string[]): Promise { + const res = await fetch(`${API_URL}/dashboard/tabs/order`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ ids }), + }); + if (!res.ok) throw new Error('Failed to reorder dashboards'); + return res.json(); +} + +// --- Anordnung und Kacheln — je Reiter (quick-260923-ad9) ------------------ + +export async function fetchLayout(dashboardId: string): Promise> { + const res = await fetch( + `${API_URL}/dashboard/layout?dashboardId=${encodeURIComponent(dashboardId)}`, + { credentials: 'include' }, + ); if (!res.ok) throw new Error('Failed to fetch layout'); return res.json(); } export async function saveLayout( + dashboardId: string, layouts: Record, ): Promise { const res = await fetch(`${API_URL}/dashboard/layout`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: JSON.stringify({ layouts }), + body: JSON.stringify({ dashboardId, layouts }), }); if (!res.ok) throw new Error('Failed to save layout'); } -export async function fetchWidgets(): Promise< - Array<{ id: string; widgetType: string; config: Record }> -> { - const res = await fetch(`${API_URL}/dashboard/widgets`, { - credentials: 'include', - }); +export async function fetchWidgets( + dashboardId: string, +): Promise }>> { + const res = await fetch( + `${API_URL}/dashboard/widgets?dashboardId=${encodeURIComponent(dashboardId)}`, + { credentials: 'include' }, + ); if (!res.ok) throw new Error('Failed to fetch widgets'); return res.json(); } export async function addWidget( + dashboardId: string, widgetType: string, ): Promise<{ id: string; widgetType: string; config: Record }> { const res = await fetch(`${API_URL}/dashboard/widgets`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: JSON.stringify({ widgetType }), + body: JSON.stringify({ dashboardId, widgetType }), }); if (!res.ok) throw new Error('Failed to add widget'); return res.json(); diff --git a/apps/web/src/lib/stores/dashboard-store.test.ts b/apps/web/src/lib/stores/dashboard-store.test.ts index 068621f..7745d5b 100644 --- a/apps/web/src/lib/stores/dashboard-store.test.ts +++ b/apps/web/src/lib/stores/dashboard-store.test.ts @@ -1,17 +1,31 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; /** - * dashboard-store.test — NEU (quick-260916-bwo, feineres Dashboard-Raster). + * dashboard-store.test — quick-260916-bwo (Marker-Umrechnung) + erweitert um + * quick-260923-ad9 (Task 3, Dashboard-Reiter). * - * Sechs Tests fuer die Anbindung der einmaligen Umrechnung im Store: - * `loadDashboard` rechnet alte Anordnungen um und speichert SOFORT mit - * Marker; `saveLayout` traegt den Marker bei JEDEM Speichern (T-BWO-02 — - * fehlt er, wuerde das naechste Laden erneut verdoppeln); der Zustand - * selbst bleibt markerfrei (der Store iteriert mit Object.keys ueber die - * Breakpoints); `addWidget` legt neue Eintraege in den verdoppelten - * Vorgabegroessen an. Der Store hatte bisher keine Testdatei. + * Sechs Tests (1-6) pinnen weiterhin die Anbindung der einmaligen + * Umrechnung im Store — `loadDashboard`/`selectDashboard` rechnen alte + * Anordnungen um und speichern SOFORT mit Marker; `saveLayout` traegt den + * Marker bei JEDEM Speichern (T-BWO-02); der Zustand selbst bleibt + * markerfrei; `addWidget` legt neue Eintraege in den verdoppelten + * Vorgabegroessen an. Signaturen sind an die Reiter-Kennung angepasst + * (`fetchLayout(dashboardId)`, `saveLayout(dashboardId, layouts)`, + * `addWidget(dashboardId, widgetType)`), das gepruefte Verhalten ist + * dasselbe. + * + * Ab Test 7: Reiter-Verwaltung — Laden holt zuerst die Reiterliste, macht + * den ersten aktiv und laedt erst danach dessen Inhalt; ein Reiterwechsel + * schreibt eine ungespeicherte Anordnung zuerst fuer den ALTEN Reiter; + * Anlegen/Umbenennen/Loeschen pflegen die Reiterliste und den aktiven + * Reiter. */ vi.mock('@/lib/dashboard-api', () => ({ + fetchDashboards: vi.fn(), + createDashboardTab: vi.fn(), + renameDashboardTab: vi.fn(), + deleteDashboardTab: vi.fn(), + reorderDashboardTabs: vi.fn(), fetchLayout: vi.fn(), fetchWidgets: vi.fn(), saveLayout: vi.fn(), @@ -25,9 +39,14 @@ import * as api from '@/lib/dashboard-api'; const { useDashboardStore } = await import('./dashboard-store'); const EMPTY = { lg: [], md: [], sm: [], xs: [], xxs: [] }; +const DASH_1 = { id: 'dash-1', name: 'Dashboard', position: 0 }; +const DASH_2 = { id: 'dash-2', name: 'Dashboard 2', position: 1 }; beforeEach(() => { useDashboardStore.setState({ + dashboards: [], + activeDashboardId: null, + isSwitchingDashboard: false, layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] }, widgets: [], isEditMode: false, @@ -36,7 +55,9 @@ beforeEach(() => { error: null, }); vi.clearAllMocks(); + vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1]); vi.mocked(api.fetchWidgets).mockResolvedValue([]); + vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY }); vi.mocked(api.saveLayout).mockResolvedValue(undefined); }); @@ -45,7 +66,7 @@ afterEach(() => { }); describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)', () => { - it('Test 1: alte Anordnung wird beim Laden umgerechnet und SOFORT mit Marker gespeichert', async () => { + it('Test 1: alte Anordnung wird beim Laden umgerechnet und SOFORT fuer den ersten Reiter mit Marker gespeichert', async () => { vi.mocked(api.fetchLayout).mockResolvedValue({ lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], }); @@ -57,6 +78,7 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo) expect(Object.keys(state.layouts)).not.toContain('__gridVersion'); expect(api.saveLayout).toHaveBeenCalledTimes(1); expect(api.saveLayout).toHaveBeenCalledWith( + 'dash-1', expect.objectContaining({ __gridVersion: 2, lg: [{ i: 'a', x: 2, y: 2, w: 4, h: 4 }] }), ); expect(state.isDirty).toBe(false); @@ -86,7 +108,10 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo) expect(useDashboardStore.getState().layouts).toEqual(EMPTY); }); - it('Test 4: jedes Speichern traegt den Marker (T-BWO-02), der Zustand bleibt markerfrei', async () => { + it('Test 4: jedes Speichern traegt den Marker (T-BWO-02) unter der aktiven Reiter-Kennung, der Zustand bleibt markerfrei', async () => { + await useDashboardStore.getState().loadDashboard(); + vi.mocked(api.saveLayout).mockClear(); + const layouts = { lg: [{ i: 'a', x: 2, y: 2, w: 4, h: 4 }], md: [], sm: [], xs: [], xxs: [] }; useDashboardStore.getState().updateLayouts(layouts); expect(useDashboardStore.getState().isDirty).toBe(true); @@ -94,7 +119,10 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo) await useDashboardStore.getState().saveLayout(); expect(api.saveLayout).toHaveBeenCalledTimes(1); - expect(api.saveLayout).toHaveBeenCalledWith(expect.objectContaining({ __gridVersion: 2, ...layouts })); + expect(api.saveLayout).toHaveBeenCalledWith( + 'dash-1', + expect.objectContaining({ __gridVersion: 2, ...layouts }), + ); expect(useDashboardStore.getState().isDirty).toBe(false); expect(Object.keys(useDashboardStore.getState().layouts)).not.toContain('__gridVersion'); }); @@ -115,13 +143,148 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo) expect(errorSpy).toHaveBeenCalledTimes(1); }); - it('Test 6: neues Widget wird in den neuen (verdoppelten) Vorgabegroessen angelegt', async () => { + it('Test 6: neues Widget wird in den neuen (verdoppelten) Vorgabegroessen fuer den aktiven Reiter angelegt', async () => { + await useDashboardStore.getState().loadDashboard(); vi.mocked(api.addWidget).mockResolvedValue({ id: 'n1', widgetType: 'clock', config: {} }); await useDashboardStore.getState().addWidget('clock'); const state = useDashboardStore.getState(); + expect(api.addWidget).toHaveBeenCalledWith('dash-1', 'clock'); expect(state.layouts.lg).toContainEqual({ i: 'n1', x: 0, y: 0, w: 4, h: 4 }); expect(state.isDirty).toBe(true); }); }); + +describe('dashboard-store — Reiter laden (quick-260923-ad9, Task 3)', () => { + it('Test 7: holt zuerst die Reiterliste, macht den ERSTEN aktiv und laedt erst danach dessen Kacheln und Anordnung', async () => { + vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]); + const callOrder: string[] = []; + vi.mocked(api.fetchDashboards).mockImplementation(async () => { + callOrder.push('tabs'); + return [DASH_1, DASH_2]; + }); + vi.mocked(api.fetchLayout).mockImplementation(async () => { + callOrder.push('layout'); + return { ...EMPTY }; + }); + vi.mocked(api.fetchWidgets).mockImplementation(async () => { + callOrder.push('widgets'); + return []; + }); + + await useDashboardStore.getState().loadDashboard(); + + expect(callOrder[0]).toBe('tabs'); + expect(callOrder.slice(1).sort()).toEqual(['layout', 'widgets']); + expect(api.fetchLayout).toHaveBeenCalledWith('dash-1'); + expect(api.fetchWidgets).toHaveBeenCalledWith('dash-1'); + expect(useDashboardStore.getState().activeDashboardId).toBe('dash-1'); + expect(useDashboardStore.getState().dashboards).toEqual([DASH_1, DASH_2]); + }); + + it('Test 8: zweimaliges Aufrufen von loadDashboard hintereinander loest nur EINEN Abruf der Reiterliste aus', async () => { + const first = useDashboardStore.getState().loadDashboard(); + const second = useDashboardStore.getState().loadDashboard(); + + await Promise.all([first, second]); + + expect(api.fetchDashboards).toHaveBeenCalledTimes(1); + }); +}); + +describe('dashboard-store — Reiterwechsel (quick-260923-ad9, Task 3)', () => { + it('Test 9: ein Wechsel mit ungespeicherter Anordnung schreibt die Anordnung ZUERST fuer den ALTEN Reiter', async () => { + vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]); + await useDashboardStore.getState().loadDashboard(); + useDashboardStore.getState().updateLayouts({ lg: [{ i: 'x', x: 0, y: 0, w: 1, h: 1 }], md: [], sm: [], xs: [], xxs: [] }); + vi.mocked(api.saveLayout).mockClear(); + vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY }); + vi.mocked(api.fetchWidgets).mockResolvedValue([]); + + await useDashboardStore.getState().selectDashboard('dash-2'); + + expect(api.saveLayout).toHaveBeenCalledWith('dash-1', expect.anything()); + expect(useDashboardStore.getState().activeDashboardId).toBe('dash-2'); + }); + + it('Test 10: nach dem Wechsel stehen im Zustand AUSSCHLIESSLICH Kacheln und Anordnung des neuen Reiters', async () => { + vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]); + await useDashboardStore.getState().loadDashboard(); + + vi.mocked(api.fetchLayout).mockResolvedValue({ + lg: [{ i: 'only-on-dash-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], __gridVersion: 2, + }); + vi.mocked(api.fetchWidgets).mockResolvedValue([{ id: 'w-on-dash-2', widgetType: 'clock', config: {} }]); + + await useDashboardStore.getState().selectDashboard('dash-2'); + + const state = useDashboardStore.getState(); + expect(state.widgets).toEqual([{ id: 'w-on-dash-2', widgetType: 'clock', config: {} }]); + expect(state.layouts.lg).toEqual([{ i: 'only-on-dash-2', x: 0, y: 0, w: 2, h: 2 }]); + }); + + it('Test 11: ein Wechsel auf den bereits aktiven Reiter tut nichts', async () => { + await useDashboardStore.getState().loadDashboard(); + vi.mocked(api.fetchLayout).mockClear(); + vi.mocked(api.fetchWidgets).mockClear(); + + await useDashboardStore.getState().selectDashboard('dash-1'); + + expect(api.fetchLayout).not.toHaveBeenCalled(); + expect(api.fetchWidgets).not.toHaveBeenCalled(); + }); +}); + +describe('dashboard-store — Reiter anlegen/umbenennen/loeschen (quick-260923-ad9, Task 3)', () => { + it('Test 12: createDashboard haengt den neuen Reiter hinten an, macht ihn aktiv, die Kachelflaeche ist leer', async () => { + await useDashboardStore.getState().loadDashboard(); + vi.mocked(api.createDashboardTab).mockResolvedValue(DASH_2); + vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY }); + vi.mocked(api.fetchWidgets).mockResolvedValue([]); + + await useDashboardStore.getState().createDashboard(); + + const state = useDashboardStore.getState(); + expect(state.dashboards.map((d) => d.id)).toEqual(['dash-1', 'dash-2']); + expect(state.activeDashboardId).toBe('dash-2'); + expect(state.widgets).toEqual([]); + }); + + it('Test 13: renameDashboard aktualisiert den Namen in der Reiterliste', async () => { + await useDashboardStore.getState().loadDashboard(); + vi.mocked(api.renameDashboardTab).mockResolvedValue({ id: 'dash-1', name: 'Finanzen', position: 0 }); + + await useDashboardStore.getState().renameDashboard('dash-1', 'Finanzen'); + + expect(useDashboardStore.getState().dashboards[0].name).toBe('Finanzen'); + }); + + it('Test 14: deleteDashboard des AKTIVEN Reiters macht den dann ersten verbleibenden Reiter aktiv und laedt dessen Inhalt', async () => { + vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]); + await useDashboardStore.getState().loadDashboard(); + vi.mocked(api.deleteDashboardTab).mockResolvedValue(undefined); + vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY }); + vi.mocked(api.fetchWidgets).mockResolvedValue([{ id: 'w-on-dash-2', widgetType: 'clock', config: {} }]); + + await useDashboardStore.getState().deleteDashboard('dash-1'); + + const state = useDashboardStore.getState(); + expect(state.dashboards.map((d) => d.id)).toEqual(['dash-2']); + expect(state.activeDashboardId).toBe('dash-2'); + expect(state.widgets).toEqual([{ id: 'w-on-dash-2', widgetType: 'clock', config: {} }]); + }); + + it('Test 15: deleteDashboard eines INAKTIVEN Reiters aendert den aktiven Reiter nicht', async () => { + vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]); + await useDashboardStore.getState().loadDashboard(); + vi.mocked(api.deleteDashboardTab).mockResolvedValue(undefined); + vi.mocked(api.fetchLayout).mockClear(); + + await useDashboardStore.getState().deleteDashboard('dash-2'); + + expect(useDashboardStore.getState().activeDashboardId).toBe('dash-1'); + expect(useDashboardStore.getState().dashboards.map((d) => d.id)).toEqual(['dash-1']); + expect(api.fetchLayout).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/lib/stores/dashboard-store.ts b/apps/web/src/lib/stores/dashboard-store.ts index 18cb943..c9fefc4 100644 --- a/apps/web/src/lib/stores/dashboard-store.ts +++ b/apps/web/src/lib/stores/dashboard-store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; import * as api from '@/lib/dashboard-api'; +import type { DashboardTab } from '@/lib/dashboard-api'; import type { WidgetType } from '@/components/dashboard/widget-registry'; import { WIDGET_CONSTRAINTS } from '@/components/dashboard/widget-registry'; import { migrateGridLayouts, withGridVersion } from '@/lib/grid-layout-migration'; @@ -11,6 +12,9 @@ export interface WidgetInstance { } interface DashboardState { + dashboards: DashboardTab[]; + activeDashboardId: string | null; + isSwitchingDashboard: boolean; layouts: Record>; widgets: WidgetInstance[]; isEditMode: boolean; @@ -24,6 +28,33 @@ interface DashboardState { removeWidget: (id: string) => Promise; loadDashboard: () => Promise; saveLayout: () => Promise; + selectDashboard: (id: string) => Promise; + createDashboard: () => Promise; + renameDashboard: (id: string, name: string) => Promise; + deleteDashboard: (id: string) => Promise; +} + +/** + * Schutz gegen doppeltes Laden der Reiterliste (quick-260923-ad9, Task 3, + * T-AD9-07 — Gegenstück zur Transaktionssperre in `listDashboards` auf der + * API): ein modul-globales Versprechen statt eines Zustandsfeldes, damit + * ZWEI synchron hintereinander ausgeloeste `loadDashboard()`-Aufrufe (z. B. + * React StrictMode, doppeltes Einhaengen) sich dasselbe, noch nicht + * aufgeloeste Versprechen teilen — `api.fetchDashboards()` laeuft dann nur + * EINMAL. Nach Abschluss (Erfolg ODER Fehler) wird die Sperre zurueckgesetzt, + * damit ein spaeteres, echtes Neuladen (Seite erneut besucht, langer + * Zeitabstand) wieder ein frisches Netzwerkergebnis holt statt fuer immer + * auf dem ersten Ergebnis zu bleiben. + */ +let dashboardsLoadPromise: Promise | null = null; + +function loadDashboardsOnce(): Promise { + if (!dashboardsLoadPromise) { + dashboardsLoadPromise = api.fetchDashboards().finally(() => { + dashboardsLoadPromise = null; + }); + } + return dashboardsLoadPromise; } /** @@ -37,8 +68,19 @@ interface DashboardState { * ueber die Breakpoints). Fehlt der Marker beim Speichern, wird beim naechsten * Laden erneut verdoppelt — deshalb `withGridVersion` an BEIDEN Speicherstellen * (Sofort-Speichern nach der Umrechnung und `saveLayout`), T-BWO-02. + * + * quick-260923-ad9 (Task 3): Reiter — mehrere Dashboards je Benutzer. Der + * erste (`position` aufsteigend) ist der Standard und wird beim Laden aktiv. + * `layouts`/`widgets` gehoeren IMMER zum `activeDashboardId` — ein + * Reiterwechsel ersetzt beide vollstaendig, nie ein Zusammenfuehren. Die + * Marker-Umrechnung oben gilt weiterhin je Reiter: `selectDashboard` + * durchlaeuft dieselbe Umrechnung-plus-Sofort-Speichern-Logik wie + * `loadDashboard`. */ export const useDashboardStore = create()((set, get) => ({ + dashboards: [], + activeDashboardId: null, + isSwitchingDashboard: false, layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] }, widgets: [], isEditMode: false, @@ -63,8 +105,10 @@ export const useDashboardStore = create()((set, get) => ({ }, addWidget: async (widgetType: WidgetType) => { + const dashboardId = get().activeDashboardId; + if (!dashboardId) return; try { - const newWidget = await api.addWidget(widgetType); + const newWidget = await api.addWidget(dashboardId, widgetType); const constraints = WIDGET_CONSTRAINTS[widgetType]; set((state) => { @@ -117,12 +161,21 @@ export const useDashboardStore = create()((set, get) => ({ loadDashboard: async () => { set({ isLoading: true, error: null }); try { + const dashboards = await loadDashboardsOnce(); + const first = dashboards[0]; + if (!first) { + set({ error: 'Failed to load dashboard', isLoading: false }); + return; + } + const [rawLayouts, widgets] = await Promise.all([ - api.fetchLayout(), - api.fetchWidgets(), + api.fetchLayout(first.id), + api.fetchWidgets(first.id), ]); const { layouts: migratedLayouts, migrated } = migrateGridLayouts(rawLayouts); set({ + dashboards, + activeDashboardId: first.id, layouts: migratedLayouts, widgets, isLoading: false, @@ -132,7 +185,7 @@ export const useDashboardStore = create()((set, get) => ({ // try/catch, damit ein Speicherfehler NICHT als Ladefehler erscheint. if (migrated) { try { - await api.saveLayout(withGridVersion(migratedLayouts)); + await api.saveLayout(first.id, withGridVersion(migratedLayouts)); } catch (err) { console.error('Failed to persist migrated layout:', err); } @@ -146,11 +199,105 @@ export const useDashboardStore = create()((set, get) => ({ }, saveLayout: async () => { + const dashboardId = get().activeDashboardId; + if (!dashboardId) return; try { - await api.saveLayout(withGridVersion(get().layouts)); + await api.saveLayout(dashboardId, withGridVersion(get().layouts)); set({ isDirty: false }); } catch (err) { console.error('Failed to save layout:', err); } }, + + /** + * Wechselt den aktiven Reiter (quick-260923-ad9, Task 3). Eine + * ungespeicherte Anordnung wird ZUERST fuer den ALTEN Reiter geschrieben + * (`get().saveLayout()` liest `activeDashboardId` VOR dem `set` unten, + * schreibt also unter der Kennung des alten Reiters) — erst danach werden + * Kacheln und Anordnung des neuen Reiters geladen und ersetzen den + * Zustand VOLLSTAENDIG (kein Zusammenfuehren). + */ + selectDashboard: async (id: string) => { + const state = get(); + if (id === state.activeDashboardId) return; + + if (state.isDirty) { + await get().saveLayout(); + } + + set({ isSwitchingDashboard: true, error: null }); + try { + const [rawLayouts, widgets] = await Promise.all([ + api.fetchLayout(id), + api.fetchWidgets(id), + ]); + const { layouts: migratedLayouts, migrated } = migrateGridLayouts(rawLayouts); + set({ + activeDashboardId: id, + layouts: migratedLayouts, + widgets, + isDirty: false, + isSwitchingDashboard: false, + }); + if (migrated) { + try { + await api.saveLayout(id, withGridVersion(migratedLayouts)); + } catch (err) { + console.error('Failed to persist migrated layout:', err); + } + } + } catch (err) { + console.error('Failed to switch dashboard:', err); + set({ isSwitchingDashboard: false, error: 'Failed to switch dashboard' }); + } + }, + + /** Legt einen neuen, leeren Reiter an und macht ihn aktiv (D-08). */ + createDashboard: async () => { + try { + const created = await api.createDashboardTab(); + set((state) => ({ dashboards: [...state.dashboards, created] })); + await get().selectDashboard(created.id); + } catch (err) { + console.error('Failed to create dashboard:', err); + } + }, + + renameDashboard: async (id: string, name: string) => { + try { + const updated = await api.renameDashboardTab(id, name); + set((state) => ({ + dashboards: state.dashboards.map((d) => (d.id === id ? updated : d)), + })); + } catch (err) { + console.error('Failed to rename dashboard:', err); + } + }, + + /** + * Loescht einen Reiter. Beim aktiven Reiter wird danach der dann erste + * verbleibende Reiter aktiv und sein Inhalt geladen (`selectDashboard`). + * Die verbleibende Liste wird lokal gefiltert statt neu geladen — die vom + * Server neu vergebenen `position`-Werte werden dabei nicht nachgezogen; + * das ist fuer die Reihenfolge in der Leiste unschaedlich (sie filtert nur, + * sortiert nicht neu), ein spaeteres Umsortieren sendet ohnehin die + * VOLLSTAENDIGE Kennungsliste, nicht die Positionszahlen. + */ + deleteDashboard: async (id: string) => { + try { + await api.deleteDashboardTab(id); + const remaining = get().dashboards.filter((d) => d.id !== id); + set({ dashboards: remaining }); + + if (get().activeDashboardId === id) { + const next = remaining[0]; + if (next) { + set({ activeDashboardId: null }); + await get().selectDashboard(next.id); + } + } + } catch (err) { + console.error('Failed to delete dashboard:', err); + } + }, })); diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 03998ea..47da4a7 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -214,6 +214,17 @@ "layoutLoadError": "Dashboard konnte nicht geladen werden. Bitte laden Sie die Seite neu.", "widgetSaveError": "Änderungen konnten nicht gespeichert werden. Bitte versuchen Sie es erneut.", "unavailable": "Diese Kachel steht nicht zur Verfügung — das zugehörige Modul ist nicht freigegeben.", + "tabs": { + "navLabel": "Dashboard-Reiter", + "add": "Dashboard hinzufügen", + "switching": "Dashboard wird gewechselt …", + "renameButtonLabel": "Dashboard umbenennen", + "renameInputLabel": "Name des Dashboards", + "deleteButtonLabel": "Dashboard löschen", + "deleteDialogTitle": "Dashboard löschen", + "deleteDialogBody": "Möchten Sie das Dashboard „{name}“ wirklich löschen? Seine Kacheln und seine Anordnung werden mit gelöscht.", + "dragHint": "Ziehen Sie einen Reiter mit der Maus an eine andere Stelle. Der erste Reiter wird beim Öffnen geladen." + }, "clock": { "name": "Uhr", "description": "Zeigt die aktuelle Uhrzeit an", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index d993a28..87f7053 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -214,6 +214,17 @@ "layoutLoadError": "Could not load dashboard. Please reload the page.", "widgetSaveError": "Could not save changes. Please try again.", "unavailable": "This tile is not available — the module it belongs to is not enabled for you.", + "tabs": { + "navLabel": "Dashboard tabs", + "add": "Add dashboard", + "switching": "Switching dashboard …", + "renameButtonLabel": "Rename dashboard", + "renameInputLabel": "Dashboard name", + "deleteButtonLabel": "Delete dashboard", + "deleteDialogTitle": "Delete dashboard", + "deleteDialogBody": "Do you really want to delete the dashboard \"{name}\"? Its tiles and layout will be deleted as well.", + "dragHint": "Drag a tab to a different position with your mouse. The first tab loads when you open the dashboard." + }, "clock": { "name": "Clock", "description": "Shows the current time",