diff --git a/apps/web/src/app/(portal)/page.test.tsx b/apps/web/src/app/(portal)/page.test.tsx index 4df34f5..64071bc 100644 --- a/apps/web/src/app/(portal)/page.test.tsx +++ b/apps/web/src/app/(portal)/page.test.tsx @@ -42,6 +42,7 @@ const mockStore = { createDashboard: vi.fn(), renameDashboard: vi.fn(), deleteDashboard: vi.fn(), + reorderDashboards: 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 f533be7..edac819 100644 --- a/apps/web/src/app/(portal)/page.tsx +++ b/apps/web/src/app/(portal)/page.tsx @@ -66,6 +66,7 @@ export default function DashboardPage() { createDashboard, renameDashboard, deleteDashboard, + reorderDashboards, } = useDashboardStore(); // Load dashboard data on mount @@ -124,6 +125,7 @@ export default function DashboardPage() { onCreate={createDashboard} onRename={renameDashboard} onDelete={deleteDashboard} + onReorder={reorderDashboards} /> {isSwitchingDashboard ? ( diff --git a/apps/web/src/components/dashboard/dashboard-tabs.test.tsx b/apps/web/src/components/dashboard/dashboard-tabs.test.tsx index 3d10fb3..7cde6fe 100644 --- a/apps/web/src/components/dashboard/dashboard-tabs.test.tsx +++ b/apps/web/src/components/dashboard/dashboard-tabs.test.tsx @@ -18,6 +18,7 @@ vi.mock('next-intl', () => ({ 'tabs.deleteButtonLabel': 'Dashboard löschen', 'tabs.deleteDialogTitle': 'Dashboard löschen', 'tabs.deleteDialogBody': 'Möchten Sie das Dashboard „{name}“ wirklich löschen?', + 'tabs.dragHint': 'Ziehen Sie einen Reiter, um ihn zum Standard zu machen.', }, common: { cancel: 'Abbrechen', @@ -41,21 +42,62 @@ const DASHBOARDS = [ { id: 'd2', name: 'Dashboard 2', position: 1 }, ]; -afterEach(cleanup); +const THREE_DASHBOARDS = [ + { id: 'd1', name: 'Dashboard', position: 0 }, + { id: 'd2', name: 'Dashboard 2', position: 1 }, + { id: 'd3', name: 'Dashboard 3', position: 2 }, +]; + +/** + * Stubbt `getBoundingClientRect` fuer JEDEN Reiter-Wrapper so, dass Reiter i + * (nach seiner AKTUELLEN Stelle im DOM, `data-tab-index`) die Spanne von + * i·100 bis i·100+100 belegt — echte Masse liefert jsdom hier nicht (Plan- + * Vorgabe, Task 4). Die Zeigerpositionen der Tests unten rechnen gegen + * genau diese Spannen. + */ +function stubTabRects() { + vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockImplementation(function ( + this: HTMLDivElement, + ) { + const idx = Number(this.dataset.tabIndex ?? '0'); + return { + x: idx * 100, + y: 0, + left: idx * 100, + top: 0, + right: idx * 100 + 100, + bottom: 32, + width: 100, + height: 32, + toJSON() { + return {}; + }, + } as DOMRect; + }); +} + +function defaultProps(overrides: Partial[0]> = {}) { + return { + dashboards: DASHBOARDS, + activeDashboardId: 'd1', + isEditMode: false, + onSelect: vi.fn(), + onCreate: vi.fn(), + onRename: vi.fn(), + onDelete: vi.fn(), + onReorder: vi.fn(), + ...overrides, + }; +} + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); describe('DashboardTabs (quick-260923-ad9, Task 3)', () => { it('Test 1: rendert jeden Reiter mit seinem Namen', () => { - render( - , - ); + render(); expect(screen.getByRole('button', { name: 'Dashboard' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Dashboard 2' })).toBeInTheDocument(); @@ -63,17 +105,7 @@ describe('DashboardTabs (quick-260923-ad9, Task 3)', () => { it('Test 2: ein Klick auf einen Reiter ruft onSelect mit dessen Kennung auf', () => { const onSelect = vi.fn(); - render( - , - ); + render(); fireEvent.click(screen.getByRole('button', { name: 'Dashboard 2' })); @@ -81,34 +113,14 @@ describe('DashboardTabs (quick-260923-ad9, Task 3)', () => { }); it('Test 3: der aktive Reiter traegt aria-current, der andere nicht', () => { - render( - , - ); + 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( - , - ); + render(); expect(screen.queryByLabelText('Dashboard umbenennen')).not.toBeInTheDocument(); expect(screen.queryByLabelText('Dashboard löschen')).not.toBeInTheDocument(); @@ -116,51 +128,21 @@ describe('DashboardTabs (quick-260923-ad9, Task 3)', () => { }); it('Test 5: im Bearbeitungsmodus mit mehreren Reitern sind Loeschen-Knoepfe und ein Hinzufuegen-Knopf da', () => { - render( - , - ); + 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( - , - ); + render(); expect(screen.queryByLabelText('Dashboard löschen')).not.toBeInTheDocument(); }); it('Test 7: der Hinzufuegen-Knopf ruft onCreate auf', () => { const onCreate = vi.fn(); - render( - , - ); + render(); fireEvent.click(screen.getByLabelText('Dashboard hinzufügen')); @@ -169,17 +151,7 @@ describe('DashboardTabs (quick-260923-ad9, Task 3)', () => { it('Test 8: Umbenennen-Knopf des AKTIVEN Reiters verwandelt den Namen in ein Eingabefeld; Enter uebernimmt beschnitten', () => { const onRename = vi.fn(); - render( - , - ); + render(); fireEvent.click(screen.getByLabelText('Dashboard umbenennen')); const input = screen.getByLabelText('Name des Dashboards'); @@ -191,17 +163,7 @@ describe('DashboardTabs (quick-260923-ad9, Task 3)', () => { it('Test 9: Escape verwirft die Umbenennung, ohne onRename aufzurufen', () => { const onRename = vi.fn(); - render( - , - ); + render(); fireEvent.click(screen.getByLabelText('Dashboard umbenennen')); const input = screen.getByLabelText('Name des Dashboards'); @@ -213,34 +175,14 @@ describe('DashboardTabs (quick-260923-ad9, Task 3)', () => { }); it('Test 10: der Umbenennen-Knopf steht nur beim AKTIVEN Reiter, nicht bei den anderen', () => { - render( - , - ); + 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( - , - ); + render(); fireEvent.click(screen.getAllByLabelText('Dashboard löschen')[1]); const dialog = screen.getByRole('alertdialog'); @@ -253,17 +195,7 @@ describe('DashboardTabs (quick-260923-ad9, Task 3)', () => { it('Test 12: Abbrechen im Loeschdialog ruft onDelete NICHT auf', () => { const onDelete = vi.fn(); - render( - , - ); + render(); fireEvent.click(screen.getAllByLabelText('Dashboard löschen')[0]); const dialog = screen.getByRole('alertdialog'); @@ -273,3 +205,139 @@ describe('DashboardTabs (quick-260923-ad9, Task 3)', () => { expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); }); }); + +describe('DashboardTabs — Ziehen zum Umsortieren (quick-260923-ad9, Task 4)', () => { + it('Test 13: Druecken und Loslassen OHNE nennenswerte Bewegung sendet KEINE neue Reihenfolge (der nachfolgende Klick waehlt weiterhin)', () => { + stubTabRects(); + const onReorder = vi.fn(); + const onSelect = vi.fn(); + render(); + const tab = screen.getByTestId('dashboard-tab-d2'); + + fireEvent.pointerDown(tab, { clientX: 150, pointerId: 1, button: 0 }); + fireEvent.pointerUp(tab, { clientX: 150, pointerId: 1 }); + + expect(onReorder).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Dashboard 2' })); + expect(onSelect).toHaveBeenCalledWith('d2'); + }); + + it('Test 14: Druecken, mehr als die Schwelle nach RECHTS bewegen und loslassen verschiebt den Reiter hinter seinen rechten Nachbarn', () => { + stubTabRects(); + const onReorder = vi.fn(); + render(); + const tab = screen.getByTestId('dashboard-tab-d1'); // Spanne 0..100 + + fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 }); + // ueber die Schwelle hinaus, weit rechts von d2s Mittelpunkt (150) + fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 }); + fireEvent.pointerUp(tab, { clientX: 180, pointerId: 1 }); + + expect(onReorder).toHaveBeenCalledWith(['d2', 'd1']); + }); + + it('Test 15: dasselbe nach LINKS verschiebt vor den linken Nachbarn', () => { + stubTabRects(); + const onReorder = vi.fn(); + render(); + const tab = screen.getByTestId('dashboard-tab-d2'); // Spanne 100..200 + + fireEvent.pointerDown(tab, { clientX: 150, pointerId: 1, button: 0 }); + // weit links von d1s Mittelpunkt (50) + fireEvent.pointerMove(tab, { clientX: 10, pointerId: 1 }); + fireEvent.pointerUp(tab, { clientX: 10, pointerId: 1 }); + + expect(onReorder).toHaveBeenCalledWith(['d2', 'd1']); + }); + + it('Test 16: waehrend des Ziehens zeigt die Leiste die Vorschau-Reihenfolge; Abbruch des Zeigers verwirft sie, ohne zu senden', () => { + stubTabRects(); + const onReorder = vi.fn(); + render(); + const tab = screen.getByTestId('dashboard-tab-d1'); + + fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 }); + fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 }); + + // Vorschau: d2 steht jetzt vor d1 im DOM. + const buttons = screen.getAllByRole('button').filter((b) => b.textContent === 'Dashboard' || b.textContent === 'Dashboard 2'); + expect(buttons.map((b) => b.textContent)).toEqual(['Dashboard 2', 'Dashboard']); + + fireEvent.pointerCancel(tab, { pointerId: 1 }); + + expect(onReorder).not.toHaveBeenCalled(); + const buttonsAfterCancel = screen + .getAllByRole('button') + .filter((b) => b.textContent === 'Dashboard' || b.textContent === 'Dashboard 2'); + expect(buttonsAfterCancel.map((b) => b.textContent)).toEqual(['Dashboard', 'Dashboard 2']); + }); + + it('Test 17: ein Ziehen, das den ersten Reiter verdraengt, macht den vorgezogenen Reiter zum ersten in der gesendeten Liste', () => { + stubTabRects(); + const onReorder = vi.fn(); + render(); + const tab = screen.getByTestId('dashboard-tab-d3'); // Spanne 200..300 + + fireEvent.pointerDown(tab, { clientX: 250, pointerId: 1, button: 0 }); + // weit links von d1s Mittelpunkt (50) -> wird der neue Erste + fireEvent.pointerMove(tab, { clientX: 5, pointerId: 1 }); + fireEvent.pointerUp(tab, { clientX: 5, pointerId: 1 }); + + expect(onReorder).toHaveBeenCalledWith(['d3', 'd1', 'd2']); + }); + + it('Test 18: der aktive Reiter bleibt beim Ziehen aktiv, auch wenn er seine Position wechselt', () => { + stubTabRects(); + render(); + const tab = screen.getByTestId('dashboard-tab-d1'); + + fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 }); + fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 }); + + expect(screen.getByRole('button', { name: 'Dashboard' })).toHaveAttribute('aria-current', 'true'); + }); + + it('Test 19: nach einem echten Ziehen unterdrueckt der naechste Klick die Auswahl EINMAL, danach funktioniert Klicken wieder normal', () => { + stubTabRects(); + const onSelect = vi.fn(); + const onReorder = vi.fn(); + render(); + const tab = screen.getByTestId('dashboard-tab-d1'); + + fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 }); + fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 }); + fireEvent.pointerUp(tab, { clientX: 180, pointerId: 1 }); + expect(onReorder).toHaveBeenCalledTimes(1); + + // Ein echter Browser wuerde nach dem Ziehen noch einen "click" nachreichen. + fireEvent.click(screen.getByRole('button', { name: 'Dashboard' })); + expect(onSelect).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Dashboard' })); + expect(onSelect).toHaveBeenCalledWith('d1'); + }); + + it('Test 20: der Hinweistext zum Ziehen steht bei mehr als einem Reiter, aber nicht bei genau einem', () => { + const { rerender } = render(); + expect(screen.getByText('Ziehen Sie einen Reiter, um ihn zum Standard zu machen.')).toBeInTheDocument(); + + rerender(); + expect( + screen.queryByText('Ziehen Sie einen Reiter, um ihn zum Standard zu machen.'), + ).not.toBeInTheDocument(); + }); + + it('Test 21: Ziehen ist auch AUSSERHALB des Bearbeitungsmodus moeglich (D-09)', () => { + stubTabRects(); + const onReorder = vi.fn(); + render(); + const tab = screen.getByTestId('dashboard-tab-d1'); + + fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 }); + fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 }); + fireEvent.pointerUp(tab, { clientX: 180, pointerId: 1 }); + + expect(onReorder).toHaveBeenCalledWith(['d2', 'd1']); + }); +}); diff --git a/apps/web/src/components/dashboard/dashboard-tabs.tsx b/apps/web/src/components/dashboard/dashboard-tabs.tsx index 9cc166a..8cc21ff 100644 --- a/apps/web/src/components/dashboard/dashboard-tabs.tsx +++ b/apps/web/src/components/dashboard/dashboard-tabs.tsx @@ -1,7 +1,7 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; import { useTranslations } from 'next-intl'; +import { type PointerEvent, useEffect, useRef, useState } from 'react'; import type { DashboardTab } from '@/lib/dashboard-api'; interface DashboardTabsProps { @@ -12,18 +12,76 @@ interface DashboardTabsProps { onCreate: () => void; onRename: (id: string, name: string) => void; onDelete: (id: string) => void; + onReorder: (ids: string[]) => void; +} + +/** Ab dieser waagerechten Auslenkung (Bildschirmpixel) wird aus einem Klick ein Ziehen (Task 4). */ +const DRAG_THRESHOLD_PX = 4; + +// jsdom kennt kein setPointerCapture — im Browser wird gefangen, im Test +// feuern Move/Up auf demselben Element (Muster xframe-config-form.tsx). +function capturePointer(el: HTMLElement, id: number) { + if (typeof el.setPointerCapture === 'function') el.setPointerCapture(id); +} + +function releasePointer(el: HTMLElement, id: number) { + if (typeof el.releasePointerCapture === 'function') el.releasePointerCapture(id); +} + +interface DragState { + id: string; + pointerId: number; + startX: number; } /** - * Reiterleiste über dem Dashboard-Raster (quick-260923-ad9, Task 3). + * Berechnet die neue Reihenfolge, wenn `draggedId` an die Zeigerposition + * `clientX` verschoben wird: der gezogene Reiter wird aus der Liste + * entfernt, dann vor dem ERSTEN verbleibenden Reiter eingefuegt, dessen + * Mittelpunkt rechts vom Zeiger liegt (ansonsten ans Ende). + */ +function computeReorderedIds( + order: string[], + draggedId: string, + rects: Map, + clientX: number, +): string[] { + const others = order.filter((id) => id !== draggedId); + let insertAt = others.length; + for (let i = 0; i < others.length; i++) { + const rect = rects.get(others[i]); + if (!rect) continue; + const midpoint = rect.left + rect.width / 2; + if (clientX < midpoint) { + insertAt = i; + break; + } + } + const next = others.slice(); + next.splice(insertAt, 0, draggedId); + return next; +} + +/** + * Reiterleiste über dem Dashboard-Raster (quick-260923-ad9, Task 3/4). * * 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`). + * an). + * + * Ziehen (Task 4, D-05 — keine neue Abhängigkeit, Pointer-Ereignisse wie in + * `xframe-config-form.tsx`) ist IMMER möglich, nicht nur im Bearbeitungs- + * modus: nach vorn ziehen IST das Festlegen des Standards (D-09). Unter der + * Schwelle von {@link DRAG_THRESHOLD_PX} bleibt es ein Klick (`onClick` + * wechselt den Reiter, kein `onReorder`); darüber wird der Zeiger + * eingefangen, die Leiste zeigt die Vorschau-Reihenfolge, und beim + * Loslassen geht die VOLLSTÄNDIGE Kennungsliste an `onReorder`. Ein + * `hasDraggedRef`-Merker unterdrückt den `onClick`, der nach einem echten + * Ziehen im echten Browser folgt (jsdom feuert ihn in Tests nicht von + * selbst nach). Abbruch des Zeigers verwirft die Vorschau, ohne zu senden. */ export function DashboardTabs({ dashboards, @@ -33,13 +91,18 @@ export function DashboardTabs({ onCreate, onRename, onDelete, + onReorder, }: DashboardTabsProps) { const t = useTranslations('widgets'); const tCommon = useTranslations('common'); const [renamingId, setRenamingId] = useState(null); const [draftName, setDraftName] = useState(''); const [pendingDeleteId, setPendingDeleteId] = useState(null); + const [previewOrder, setPreviewOrder] = useState(null); const renameInputRef = useRef(null); + const dragRef = useRef(null); + const hasDraggedRef = useRef(false); + const tabRefs = useRef>(new Map()); // Fokus auf das Eingabefeld beim Wechsel in den Umbenennen-Zustand — ueber // einen Ref statt des autoFocus-Attributs (lint/a11y/noAutofocus), Muster @@ -51,6 +114,78 @@ export function DashboardTabs({ } }, [renamingId]); + const order = previewOrder ?? dashboards.map((d) => d.id); + const orderedTabs = order + .map((id) => dashboards.find((d) => d.id === id)) + .filter((d): d is DashboardTab => d !== undefined); + + function handlePointerDown(e: PointerEvent, tabId: string) { + if (e.button !== 0 || renamingId) return; + dragRef.current = { id: tabId, pointerId: e.pointerId, startX: e.clientX }; + } + + function handlePointerMove(e: PointerEvent) { + const drag = dragRef.current; + if (!drag) return; + + const wasNotDragging = previewOrder === null; + if (wasNotDragging) { + if (Math.abs(e.clientX - drag.startX) < DRAG_THRESHOLD_PX) return; + hasDraggedRef.current = true; + capturePointer(e.currentTarget, drag.pointerId); + } + + // Auf dem SELBEN Ereignis, das die Schwelle ueberschreitet, wird sofort + // die Zielposition berechnet — nicht erst beim naechsten Move. Ohne das + // wuerde ein einzelner grosser Sprung (Druecken, weit bewegen, Loslassen + // — die Form, in der ein Ziehen unter jsdom typischerweise ausgeloest + // wird) keine Verschiebung zeigen, weil der ERSTE Move-Aufruf nur in den + // Ziehzustand wechselt, ohne die Position auszuwerten. + const baseOrder = previewOrder ?? dashboards.map((d) => d.id); + const rects = new Map(); + for (const id of baseOrder) { + const el = tabRefs.current.get(id); + if (el) { + const rect = el.getBoundingClientRect(); + rects.set(id, { left: rect.left, width: rect.width }); + } + } + const next = computeReorderedIds(baseOrder, drag.id, rects, e.clientX); + if (wasNotDragging || next.join('\u0000') !== baseOrder.join('\u0000')) { + setPreviewOrder(next); + } + } + + function handlePointerUp(e: PointerEvent) { + const drag = dragRef.current; + dragRef.current = null; + if (!drag) return; + + if (previewOrder !== null) { + releasePointer(e.currentTarget, drag.pointerId); + const finalOrder = previewOrder; + setPreviewOrder(null); + onReorder(finalOrder); + } + } + + function handlePointerCancel(e: PointerEvent) { + const drag = dragRef.current; + dragRef.current = null; + if (drag && previewOrder !== null) { + releasePointer(e.currentTarget, drag.pointerId); + } + setPreviewOrder(null); + } + + function handleTabClick(tabId: string) { + if (hasDraggedRef.current) { + hasDraggedRef.current = false; + return; + } + onSelect(tabId); + } + function startRename(tab: DashboardTab) { setRenamingId(tab.id); setDraftName(tab.name); @@ -72,169 +207,187 @@ export function DashboardTabs({ const pendingDeleteTab = dashboards.find((d) => d.id === pendingDeleteId) ?? null; return ( - + {dashboards.length > 1 && ( +

{t('tabs.dragHint')}

)} - + ); } diff --git a/apps/web/src/lib/stores/dashboard-store.test.ts b/apps/web/src/lib/stores/dashboard-store.test.ts index 7745d5b..9c2a543 100644 --- a/apps/web/src/lib/stores/dashboard-store.test.ts +++ b/apps/web/src/lib/stores/dashboard-store.test.ts @@ -288,3 +288,35 @@ describe('dashboard-store — Reiter anlegen/umbenennen/loeschen (quick-260923-a expect(api.fetchLayout).not.toHaveBeenCalled(); }); }); + +describe('dashboard-store — Reiter per Ziehen umsortieren (quick-260923-ad9, Task 4)', () => { + it('Test 16: setzt die neue Reihenfolge SOFORT optimistisch, sendet die vollstaendige Kennungsliste, der aktive Reiter bleibt aktiv', async () => { + vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]); + await useDashboardStore.getState().loadDashboard(); + vi.mocked(api.reorderDashboardTabs).mockResolvedValue([DASH_2, DASH_1]); + + const promise = useDashboardStore.getState().reorderDashboards(['dash-2', 'dash-1']); + + // Optimistisch VOR der Antwort des Servers gesetzt. + expect(useDashboardStore.getState().dashboards.map((d) => d.id)).toEqual(['dash-2', 'dash-1']); + expect(useDashboardStore.getState().activeDashboardId).toBe('dash-1'); + + await promise; + + expect(api.reorderDashboardTabs).toHaveBeenCalledWith(['dash-2', 'dash-1']); + expect(useDashboardStore.getState().dashboards).toEqual([DASH_2, DASH_1]); + expect(useDashboardStore.getState().activeDashboardId).toBe('dash-1'); + }); + + it('Test 17: schlaegt das Speichern fehl, steht die VORHERIGE Reihenfolge wieder im Zustand', async () => { + vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]); + await useDashboardStore.getState().loadDashboard(); + vi.mocked(api.reorderDashboardTabs).mockRejectedValue(new Error('PUT failed')); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await useDashboardStore.getState().reorderDashboards(['dash-2', 'dash-1']); + + expect(useDashboardStore.getState().dashboards).toEqual([DASH_1, DASH_2]); + expect(errorSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/lib/stores/dashboard-store.ts b/apps/web/src/lib/stores/dashboard-store.ts index c9fefc4..1a49cb0 100644 --- a/apps/web/src/lib/stores/dashboard-store.ts +++ b/apps/web/src/lib/stores/dashboard-store.ts @@ -32,6 +32,7 @@ interface DashboardState { createDashboard: () => Promise; renameDashboard: (id: string, name: string) => Promise; deleteDashboard: (id: string) => Promise; + reorderDashboards: (ids: string[]) => Promise; } /** @@ -300,4 +301,28 @@ export const useDashboardStore = create()((set, get) => ({ console.error('Failed to delete dashboard:', err); } }, + + /** + * Persistiert eine per Ziehen bestimmte Reihenfolge (quick-260923-ad9, + * Task 4). Setzt die neue Reihenfolge SOFORT im Zustand (optimistisch — + * die Leiste in `dashboard-tabs.tsx` zeigt schon ihre eigene Vorschau + * waehrend des Ziehens, dieses `set` uebernimmt sie beim Loslassen als + * Tatsache) und stellt bei einem fehlgeschlagenen Speichern die VORHERIGE + * Reihenfolge wieder her. Der aktive Reiter bleibt aktiv, auch wenn er + * seine Position wechselt — `activeDashboardId` bleibt unberuehrt. + */ + reorderDashboards: async (ids: string[]) => { + const previous = get().dashboards; + const byId = new Map(previous.map((d) => [d.id, d])); + const optimistic = ids.map((id) => byId.get(id)).filter((d): d is DashboardTab => d !== undefined); + set({ dashboards: optimistic }); + + try { + const updated = await api.reorderDashboardTabs(ids); + set({ dashboards: updated }); + } catch (err) { + console.error('Failed to reorder dashboards:', err); + set({ dashboards: previous }); + } + }, }));