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:
@@ -20,6 +20,12 @@ vi.mock('next-intl', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const mockStore = {
|
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: [] },
|
layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] },
|
||||||
widgets: [] as Array<{ id: string; widgetType: string; config: Record<string, unknown> }>,
|
widgets: [] as Array<{ id: string; widgetType: string; config: Record<string, unknown> }>,
|
||||||
isEditMode: false,
|
isEditMode: false,
|
||||||
@@ -32,6 +38,10 @@ const mockStore = {
|
|||||||
removeWidget: vi.fn(),
|
removeWidget: vi.fn(),
|
||||||
loadDashboard: vi.fn(),
|
loadDashboard: vi.fn(),
|
||||||
saveLayout: vi.fn(),
|
saveLayout: vi.fn(),
|
||||||
|
selectDashboard: vi.fn(),
|
||||||
|
createDashboard: vi.fn(),
|
||||||
|
renameDashboard: vi.fn(),
|
||||||
|
deleteDashboard: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock('@/lib/stores/dashboard-store', () => ({
|
vi.mock('@/lib/stores/dashboard-store', () => ({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
|
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
|
||||||
|
import { DashboardTabs } from '@/components/dashboard/dashboard-tabs';
|
||||||
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
|
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
|
||||||
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
|
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
|
||||||
import { registerWidget } from '@/components/dashboard/widget-registry';
|
import { registerWidget } from '@/components/dashboard/widget-registry';
|
||||||
@@ -48,6 +49,9 @@ export default function DashboardPage() {
|
|||||||
const [accessibleModuleSlugs, setAccessibleModuleSlugs] = useState<string[] | null>(null);
|
const [accessibleModuleSlugs, setAccessibleModuleSlugs] = useState<string[] | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
dashboards,
|
||||||
|
activeDashboardId,
|
||||||
|
isSwitchingDashboard,
|
||||||
layouts,
|
layouts,
|
||||||
widgets,
|
widgets,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
@@ -58,6 +62,10 @@ export default function DashboardPage() {
|
|||||||
addWidget,
|
addWidget,
|
||||||
removeWidget,
|
removeWidget,
|
||||||
loadDashboard,
|
loadDashboard,
|
||||||
|
selectDashboard,
|
||||||
|
createDashboard,
|
||||||
|
renameDashboard,
|
||||||
|
deleteDashboard,
|
||||||
} = useDashboardStore();
|
} = useDashboardStore();
|
||||||
|
|
||||||
// Load dashboard data on mount
|
// Load dashboard data on mount
|
||||||
@@ -105,15 +113,34 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative p-2">
|
<div className="relative p-2">
|
||||||
{/* Dashboard grid — direkt im Container, ohne Abstands-Wrapper. */}
|
{/* Reiterleiste (quick-260923-ad9) — bleibt waehrend eines
|
||||||
<DashboardGrid
|
Reiterwechsels stehen, nur das Raster darunter zeigt eine kurze
|
||||||
layouts={layouts}
|
Ladezeile. */}
|
||||||
widgets={widgets}
|
<DashboardTabs
|
||||||
|
dashboards={dashboards}
|
||||||
|
activeDashboardId={activeDashboardId}
|
||||||
isEditMode={isEditMode}
|
isEditMode={isEditMode}
|
||||||
onLayoutChange={updateLayouts}
|
onSelect={selectDashboard}
|
||||||
onRemoveWidget={removeWidget}
|
onCreate={createDashboard}
|
||||||
|
onRename={renameDashboard}
|
||||||
|
onDelete={deleteDashboard}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{isSwitchingDashboard ? (
|
||||||
|
<div className="flex min-h-[40vh] items-center justify-center">
|
||||||
|
<p className="text-sm text-muted-foreground">{t('tabs.switching')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Dashboard grid — direkt im Container, ohne Abstands-Wrapper. */
|
||||||
|
<DashboardGrid
|
||||||
|
layouts={layouts}
|
||||||
|
widgets={widgets}
|
||||||
|
isEditMode={isEditMode}
|
||||||
|
onLayoutChange={updateLayouts}
|
||||||
|
onRemoveWidget={removeWidget}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Feste Aktionsleiste unten rechts.
|
{/* Feste Aktionsleiste unten rechts.
|
||||||
quick-260916-dyv: Umschalter unten rechts statt oben rechts, damit das
|
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).
|
Grid direkt unter der Kopfzeile beginnt (12 + 8 + 8 = 28 px statt 60 px).
|
||||||
|
|||||||
@@ -3,11 +3,20 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { WidgetSettingsPanel } from '@/components/settings/widget-settings-panel';
|
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).
|
* 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() {
|
export default function WidgetSettingsPage() {
|
||||||
const t = useTranslations('settings');
|
const t = useTranslations('settings');
|
||||||
@@ -18,12 +27,17 @@ export default function WidgetSettingsPage() {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchWidgets()
|
(async () => {
|
||||||
.then(setWidgets)
|
try {
|
||||||
.catch(() => {
|
const dashboards = await fetchDashboards();
|
||||||
|
const first = dashboards[0];
|
||||||
|
setWidgets(first ? await fetchWidgets(first.id) : []);
|
||||||
|
} catch {
|
||||||
// Silent fail — empty widget list shown
|
// Silent fail — empty widget list shown
|
||||||
})
|
} finally {
|
||||||
.finally(() => setIsLoading(false));
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string | null>(null);
|
||||||
|
const [draftName, setDraftName] = useState('');
|
||||||
|
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||||
|
const renameInputRef = useRef<HTMLInputElement>(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 (
|
||||||
|
<nav aria-label={t('tabs.navLabel')} className="mb-2 flex items-center gap-1 overflow-x-auto">
|
||||||
|
{dashboards.map((tab) => {
|
||||||
|
const isActive = tab.id === activeDashboardId;
|
||||||
|
const isRenaming = renamingId === tab.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={tab.id} className="flex items-center">
|
||||||
|
{isRenaming ? (
|
||||||
|
<input
|
||||||
|
ref={renameInputRef}
|
||||||
|
value={draftName}
|
||||||
|
onChange={(e) => setDraftName(e.target.value)}
|
||||||
|
onBlur={commitRename}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
commitRename();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
cancelRename();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
maxLength={40}
|
||||||
|
aria-label={t('tabs.renameInputLabel')}
|
||||||
|
className="h-8 w-32 rounded border border-border bg-background px-2 text-sm text-foreground"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(tab.id)}
|
||||||
|
aria-current={isActive ? 'true' : undefined}
|
||||||
|
className={`h-8 rounded-t px-3 text-sm font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'border border-b-transparent border-border bg-card text-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.name}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEditMode && isActive && !isRenaming && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => startRename(tab)}
|
||||||
|
aria-label={t('tabs.renameButtonLabel')}
|
||||||
|
title={t('tabs.renameButtonLabel')}
|
||||||
|
className="ml-0.5 rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEditMode && dashboards.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPendingDeleteId(tab.id)}
|
||||||
|
aria-label={t('tabs.deleteButtonLabel')}
|
||||||
|
title={t('tabs.deleteButtonLabel')}
|
||||||
|
className="ml-0.5 rounded p-1 text-muted-foreground hover:bg-muted hover:text-destructive"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{isEditMode && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCreate}
|
||||||
|
aria-label={t('tabs.add')}
|
||||||
|
title={t('tabs.add')}
|
||||||
|
className="ml-1 flex h-8 w-8 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pendingDeleteTab && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPendingDeleteId(null)}
|
||||||
|
aria-label={tCommon('cancel')}
|
||||||
|
className="fixed inset-0 bg-black/50"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t('tabs.deleteDialogTitle')}
|
||||||
|
className="relative z-50 mx-4 max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
|
||||||
|
>
|
||||||
|
<h3 className="mb-2 text-lg font-semibold text-foreground">
|
||||||
|
{t('tabs.deleteDialogTitle')}
|
||||||
|
</h3>
|
||||||
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
|
{t('tabs.deleteDialogBody', { name: pendingDeleteTab.name })}
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPendingDeleteId(null)}
|
||||||
|
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted"
|
||||||
|
>
|
||||||
|
{tCommon('cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onDelete(pendingDeleteTab.id);
|
||||||
|
setPendingDeleteId(null);
|
||||||
|
}}
|
||||||
|
className="rounded bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{tCommon('delete')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,44 +5,105 @@
|
|||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
export async function fetchLayout(): Promise<Record<string, unknown>> {
|
// --- Reiter (quick-260923-ad9) ---------------------------------------------
|
||||||
const res = await fetch(`${API_URL}/dashboard/layout`, {
|
|
||||||
|
export interface DashboardTab {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDashboards(): Promise<DashboardTab[]> {
|
||||||
|
const res = await fetch(`${API_URL}/dashboard/tabs`, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch dashboards');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDashboardTab(): Promise<DashboardTab> {
|
||||||
|
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<DashboardTab> {
|
||||||
|
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<void> {
|
||||||
|
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<DashboardTab[]> {
|
||||||
|
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<Record<string, unknown>> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_URL}/dashboard/layout?dashboardId=${encodeURIComponent(dashboardId)}`,
|
||||||
|
{ credentials: 'include' },
|
||||||
|
);
|
||||||
if (!res.ok) throw new Error('Failed to fetch layout');
|
if (!res.ok) throw new Error('Failed to fetch layout');
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveLayout(
|
export async function saveLayout(
|
||||||
|
dashboardId: string,
|
||||||
layouts: Record<string, unknown>,
|
layouts: Record<string, unknown>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const res = await fetch(`${API_URL}/dashboard/layout`, {
|
const res = await fetch(`${API_URL}/dashboard/layout`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ layouts }),
|
body: JSON.stringify({ dashboardId, layouts }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to save layout');
|
if (!res.ok) throw new Error('Failed to save layout');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchWidgets(): Promise<
|
export async function fetchWidgets(
|
||||||
Array<{ id: string; widgetType: string; config: Record<string, unknown> }>
|
dashboardId: string,
|
||||||
> {
|
): Promise<Array<{ id: string; widgetType: string; config: Record<string, unknown> }>> {
|
||||||
const res = await fetch(`${API_URL}/dashboard/widgets`, {
|
const res = await fetch(
|
||||||
credentials: 'include',
|
`${API_URL}/dashboard/widgets?dashboardId=${encodeURIComponent(dashboardId)}`,
|
||||||
});
|
{ credentials: 'include' },
|
||||||
|
);
|
||||||
if (!res.ok) throw new Error('Failed to fetch widgets');
|
if (!res.ok) throw new Error('Failed to fetch widgets');
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addWidget(
|
export async function addWidget(
|
||||||
|
dashboardId: string,
|
||||||
widgetType: string,
|
widgetType: string,
|
||||||
): Promise<{ id: string; widgetType: string; config: Record<string, unknown> }> {
|
): Promise<{ id: string; widgetType: string; config: Record<string, unknown> }> {
|
||||||
const res = await fetch(`${API_URL}/dashboard/widgets`, {
|
const res = await fetch(`${API_URL}/dashboard/widgets`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ widgetType }),
|
body: JSON.stringify({ dashboardId, widgetType }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to add widget');
|
if (!res.ok) throw new Error('Failed to add widget');
|
||||||
return res.json();
|
return res.json();
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
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:
|
* Sechs Tests (1-6) pinnen weiterhin die Anbindung der einmaligen
|
||||||
* `loadDashboard` rechnet alte Anordnungen um und speichert SOFORT mit
|
* Umrechnung im Store — `loadDashboard`/`selectDashboard` rechnen alte
|
||||||
* Marker; `saveLayout` traegt den Marker bei JEDEM Speichern (T-BWO-02 —
|
* Anordnungen um und speichern SOFORT mit Marker; `saveLayout` traegt den
|
||||||
* fehlt er, wuerde das naechste Laden erneut verdoppeln); der Zustand
|
* Marker bei JEDEM Speichern (T-BWO-02); der Zustand selbst bleibt
|
||||||
* selbst bleibt markerfrei (der Store iteriert mit Object.keys ueber die
|
* markerfrei; `addWidget` legt neue Eintraege in den verdoppelten
|
||||||
* Breakpoints); `addWidget` legt neue Eintraege in den verdoppelten
|
* Vorgabegroessen an. Signaturen sind an die Reiter-Kennung angepasst
|
||||||
* Vorgabegroessen an. Der Store hatte bisher keine Testdatei.
|
* (`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', () => ({
|
vi.mock('@/lib/dashboard-api', () => ({
|
||||||
|
fetchDashboards: vi.fn(),
|
||||||
|
createDashboardTab: vi.fn(),
|
||||||
|
renameDashboardTab: vi.fn(),
|
||||||
|
deleteDashboardTab: vi.fn(),
|
||||||
|
reorderDashboardTabs: vi.fn(),
|
||||||
fetchLayout: vi.fn(),
|
fetchLayout: vi.fn(),
|
||||||
fetchWidgets: vi.fn(),
|
fetchWidgets: vi.fn(),
|
||||||
saveLayout: vi.fn(),
|
saveLayout: vi.fn(),
|
||||||
@@ -25,9 +39,14 @@ import * as api from '@/lib/dashboard-api';
|
|||||||
const { useDashboardStore } = await import('./dashboard-store');
|
const { useDashboardStore } = await import('./dashboard-store');
|
||||||
|
|
||||||
const EMPTY = { lg: [], md: [], sm: [], xs: [], xxs: [] };
|
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(() => {
|
beforeEach(() => {
|
||||||
useDashboardStore.setState({
|
useDashboardStore.setState({
|
||||||
|
dashboards: [],
|
||||||
|
activeDashboardId: null,
|
||||||
|
isSwitchingDashboard: false,
|
||||||
layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] },
|
layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] },
|
||||||
widgets: [],
|
widgets: [],
|
||||||
isEditMode: false,
|
isEditMode: false,
|
||||||
@@ -36,7 +55,9 @@ beforeEach(() => {
|
|||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1]);
|
||||||
vi.mocked(api.fetchWidgets).mockResolvedValue([]);
|
vi.mocked(api.fetchWidgets).mockResolvedValue([]);
|
||||||
|
vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY });
|
||||||
vi.mocked(api.saveLayout).mockResolvedValue(undefined);
|
vi.mocked(api.saveLayout).mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,7 +66,7 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)', () => {
|
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({
|
vi.mocked(api.fetchLayout).mockResolvedValue({
|
||||||
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [],
|
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(Object.keys(state.layouts)).not.toContain('__gridVersion');
|
||||||
expect(api.saveLayout).toHaveBeenCalledTimes(1);
|
expect(api.saveLayout).toHaveBeenCalledTimes(1);
|
||||||
expect(api.saveLayout).toHaveBeenCalledWith(
|
expect(api.saveLayout).toHaveBeenCalledWith(
|
||||||
|
'dash-1',
|
||||||
expect.objectContaining({ __gridVersion: 2, lg: [{ i: 'a', x: 2, y: 2, w: 4, h: 4 }] }),
|
expect.objectContaining({ __gridVersion: 2, lg: [{ i: 'a', x: 2, y: 2, w: 4, h: 4 }] }),
|
||||||
);
|
);
|
||||||
expect(state.isDirty).toBe(false);
|
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);
|
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: [] };
|
const layouts = { lg: [{ i: 'a', x: 2, y: 2, w: 4, h: 4 }], md: [], sm: [], xs: [], xxs: [] };
|
||||||
useDashboardStore.getState().updateLayouts(layouts);
|
useDashboardStore.getState().updateLayouts(layouts);
|
||||||
expect(useDashboardStore.getState().isDirty).toBe(true);
|
expect(useDashboardStore.getState().isDirty).toBe(true);
|
||||||
@@ -94,7 +119,10 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)
|
|||||||
await useDashboardStore.getState().saveLayout();
|
await useDashboardStore.getState().saveLayout();
|
||||||
|
|
||||||
expect(api.saveLayout).toHaveBeenCalledTimes(1);
|
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(useDashboardStore.getState().isDirty).toBe(false);
|
||||||
expect(Object.keys(useDashboardStore.getState().layouts)).not.toContain('__gridVersion');
|
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);
|
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: {} });
|
vi.mocked(api.addWidget).mockResolvedValue({ id: 'n1', widgetType: 'clock', config: {} });
|
||||||
|
|
||||||
await useDashboardStore.getState().addWidget('clock');
|
await useDashboardStore.getState().addWidget('clock');
|
||||||
|
|
||||||
const state = useDashboardStore.getState();
|
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.layouts.lg).toContainEqual({ i: 'n1', x: 0, y: 0, w: 4, h: 4 });
|
||||||
expect(state.isDirty).toBe(true);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import * as api from '@/lib/dashboard-api';
|
import * as api from '@/lib/dashboard-api';
|
||||||
|
import type { DashboardTab } from '@/lib/dashboard-api';
|
||||||
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
||||||
import { WIDGET_CONSTRAINTS } from '@/components/dashboard/widget-registry';
|
import { WIDGET_CONSTRAINTS } from '@/components/dashboard/widget-registry';
|
||||||
import { migrateGridLayouts, withGridVersion } from '@/lib/grid-layout-migration';
|
import { migrateGridLayouts, withGridVersion } from '@/lib/grid-layout-migration';
|
||||||
@@ -11,6 +12,9 @@ export interface WidgetInstance {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DashboardState {
|
interface DashboardState {
|
||||||
|
dashboards: DashboardTab[];
|
||||||
|
activeDashboardId: string | null;
|
||||||
|
isSwitchingDashboard: boolean;
|
||||||
layouts: Record<string, Array<{ i: string; x: number; y: number; w: number; h: number }>>;
|
layouts: Record<string, Array<{ i: string; x: number; y: number; w: number; h: number }>>;
|
||||||
widgets: WidgetInstance[];
|
widgets: WidgetInstance[];
|
||||||
isEditMode: boolean;
|
isEditMode: boolean;
|
||||||
@@ -24,6 +28,33 @@ interface DashboardState {
|
|||||||
removeWidget: (id: string) => Promise<void>;
|
removeWidget: (id: string) => Promise<void>;
|
||||||
loadDashboard: () => Promise<void>;
|
loadDashboard: () => Promise<void>;
|
||||||
saveLayout: () => Promise<void>;
|
saveLayout: () => Promise<void>;
|
||||||
|
selectDashboard: (id: string) => Promise<void>;
|
||||||
|
createDashboard: () => Promise<void>;
|
||||||
|
renameDashboard: (id: string, name: string) => Promise<void>;
|
||||||
|
deleteDashboard: (id: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<DashboardTab[]> | null = null;
|
||||||
|
|
||||||
|
function loadDashboardsOnce(): Promise<DashboardTab[]> {
|
||||||
|
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
|
* ueber die Breakpoints). Fehlt der Marker beim Speichern, wird beim naechsten
|
||||||
* Laden erneut verdoppelt — deshalb `withGridVersion` an BEIDEN Speicherstellen
|
* Laden erneut verdoppelt — deshalb `withGridVersion` an BEIDEN Speicherstellen
|
||||||
* (Sofort-Speichern nach der Umrechnung und `saveLayout`), T-BWO-02.
|
* (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<DashboardState>()((set, get) => ({
|
export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
||||||
|
dashboards: [],
|
||||||
|
activeDashboardId: null,
|
||||||
|
isSwitchingDashboard: false,
|
||||||
layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] },
|
layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] },
|
||||||
widgets: [],
|
widgets: [],
|
||||||
isEditMode: false,
|
isEditMode: false,
|
||||||
@@ -63,8 +105,10 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
addWidget: async (widgetType: WidgetType) => {
|
addWidget: async (widgetType: WidgetType) => {
|
||||||
|
const dashboardId = get().activeDashboardId;
|
||||||
|
if (!dashboardId) return;
|
||||||
try {
|
try {
|
||||||
const newWidget = await api.addWidget(widgetType);
|
const newWidget = await api.addWidget(dashboardId, widgetType);
|
||||||
const constraints = WIDGET_CONSTRAINTS[widgetType];
|
const constraints = WIDGET_CONSTRAINTS[widgetType];
|
||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
@@ -117,12 +161,21 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
|||||||
loadDashboard: async () => {
|
loadDashboard: async () => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
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([
|
const [rawLayouts, widgets] = await Promise.all([
|
||||||
api.fetchLayout(),
|
api.fetchLayout(first.id),
|
||||||
api.fetchWidgets(),
|
api.fetchWidgets(first.id),
|
||||||
]);
|
]);
|
||||||
const { layouts: migratedLayouts, migrated } = migrateGridLayouts(rawLayouts);
|
const { layouts: migratedLayouts, migrated } = migrateGridLayouts(rawLayouts);
|
||||||
set({
|
set({
|
||||||
|
dashboards,
|
||||||
|
activeDashboardId: first.id,
|
||||||
layouts: migratedLayouts,
|
layouts: migratedLayouts,
|
||||||
widgets,
|
widgets,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -132,7 +185,7 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
|||||||
// try/catch, damit ein Speicherfehler NICHT als Ladefehler erscheint.
|
// try/catch, damit ein Speicherfehler NICHT als Ladefehler erscheint.
|
||||||
if (migrated) {
|
if (migrated) {
|
||||||
try {
|
try {
|
||||||
await api.saveLayout(withGridVersion(migratedLayouts));
|
await api.saveLayout(first.id, withGridVersion(migratedLayouts));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to persist migrated layout:', err);
|
console.error('Failed to persist migrated layout:', err);
|
||||||
}
|
}
|
||||||
@@ -146,11 +199,105 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
saveLayout: async () => {
|
saveLayout: async () => {
|
||||||
|
const dashboardId = get().activeDashboardId;
|
||||||
|
if (!dashboardId) return;
|
||||||
try {
|
try {
|
||||||
await api.saveLayout(withGridVersion(get().layouts));
|
await api.saveLayout(dashboardId, withGridVersion(get().layouts));
|
||||||
set({ isDirty: false });
|
set({ isDirty: false });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to save layout:', 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -214,6 +214,17 @@
|
|||||||
"layoutLoadError": "Dashboard konnte nicht geladen werden. Bitte laden Sie die Seite neu.",
|
"layoutLoadError": "Dashboard konnte nicht geladen werden. Bitte laden Sie die Seite neu.",
|
||||||
"widgetSaveError": "Änderungen konnten nicht gespeichert werden. Bitte versuchen Sie es erneut.",
|
"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.",
|
"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": {
|
"clock": {
|
||||||
"name": "Uhr",
|
"name": "Uhr",
|
||||||
"description": "Zeigt die aktuelle Uhrzeit an",
|
"description": "Zeigt die aktuelle Uhrzeit an",
|
||||||
|
|||||||
@@ -214,6 +214,17 @@
|
|||||||
"layoutLoadError": "Could not load dashboard. Please reload the page.",
|
"layoutLoadError": "Could not load dashboard. Please reload the page.",
|
||||||
"widgetSaveError": "Could not save changes. Please try again.",
|
"widgetSaveError": "Could not save changes. Please try again.",
|
||||||
"unavailable": "This tile is not available — the module it belongs to is not enabled for you.",
|
"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": {
|
"clock": {
|
||||||
"name": "Clock",
|
"name": "Clock",
|
||||||
"description": "Shows the current time",
|
"description": "Shows the current time",
|
||||||
|
|||||||
Reference in New Issue
Block a user