feat(quick-260923-ad9): Reiter per Ziehen umsortieren - Task 4
dashboard-tabs.tsx: Ziehen ueber Pointer-Ereignisse (Muster xframe-config-form.tsx, D-05 - keine neue Abhaengigkeit). Schwelle 4 px waagerechte Auslenkung trennt Klick von Ziehen; darueber wird der Zeiger eingefangen (jsdom-Schutzhuelle), die Leiste zeigt die Vorschau-Reihenfolge (computeReorderedIds, Einfuegen vor dem ersten Nachbarn mit Mittelpunkt rechts vom Zeiger), beim Loslassen geht die VOLLSTAENDIGE Kennungsliste an onReorder. Abbruch des Zeigers verwirft die Vorschau ohne zu senden. Ein hasDraggedRef-Merker unterdrueckt den Klick, der im echten Browser nach einem Ziehen folgt. Ziehen ist IMMER moeglich, nicht nur im Bearbeitungsmodus (D-09) - ein Hinweistext erklaert, dass der erste Reiter beim Oeffnen geladen wird. dashboard-store.ts: reorderDashboards() setzt die neue Reihenfolge SOFORT optimistisch, sendet sie und stellt bei einem Fehler die vorherige Reihenfolge wieder her; der aktive Reiter bleibt aktiv. dashboard-tabs.test.tsx: 21 Tests (12 alte aus Task 3 + 9 neue fuer jeden Punkt des Verhaltensblocks). getBoundingClientRect wird je Reiter-Wrapper ueber data-tab-index gestubbt (Reiter i belegt 100i..100i+100 - jsdom liefert keine echten Masse). dashboard-store.test.ts: 17 Tests (15 alte + 2 neue fuer Optimismus/Ruecknahme). Messages: widgets.tabs.dragHint war bereits in Task 3 eingetragen (vorausschauend) - in diesem Task keine weitere Aenderung an de.json/en.json noetig. Gemessene Abweichung von der Plan-Erwartung (kein Rule-1/2/3-Fall, reine Zahlendifferenz): `grep -c "react-grid-layout|dnd|sortable" apps/web/package.json` liefert 2 statt der im Plan erwarteten 1 - der zweite Treffer ist `@types/react-grid-layout`, bereits vor diesem Task vorhanden (siehe `git diff --stat apps/web/package.json`: keine Aenderung in keinem der vier Tasks). D-05 (keine neue Zieh-Abhaengigkeit) ist damit weiterhin erfuellt, nur an der leeren package.json-Diff nachgewiesen statt an der im Plan vorausgesagten Zahl. Ebenso liefert `pnpm --filter @tessera/web test` 82 statt der erwarteten 83 Dateien - Task 4 fuegt (siehe files_modified oben) keine neue Testdatei hinzu, Task 3 hatte die Dateizahl bereits auf 82 gebracht. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,7 @@ const mockStore = {
|
|||||||
createDashboard: vi.fn(),
|
createDashboard: vi.fn(),
|
||||||
renameDashboard: vi.fn(),
|
renameDashboard: vi.fn(),
|
||||||
deleteDashboard: vi.fn(),
|
deleteDashboard: vi.fn(),
|
||||||
|
reorderDashboards: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock('@/lib/stores/dashboard-store', () => ({
|
vi.mock('@/lib/stores/dashboard-store', () => ({
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export default function DashboardPage() {
|
|||||||
createDashboard,
|
createDashboard,
|
||||||
renameDashboard,
|
renameDashboard,
|
||||||
deleteDashboard,
|
deleteDashboard,
|
||||||
|
reorderDashboards,
|
||||||
} = useDashboardStore();
|
} = useDashboardStore();
|
||||||
|
|
||||||
// Load dashboard data on mount
|
// Load dashboard data on mount
|
||||||
@@ -124,6 +125,7 @@ export default function DashboardPage() {
|
|||||||
onCreate={createDashboard}
|
onCreate={createDashboard}
|
||||||
onRename={renameDashboard}
|
onRename={renameDashboard}
|
||||||
onDelete={deleteDashboard}
|
onDelete={deleteDashboard}
|
||||||
|
onReorder={reorderDashboards}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isSwitchingDashboard ? (
|
{isSwitchingDashboard ? (
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ vi.mock('next-intl', () => ({
|
|||||||
'tabs.deleteButtonLabel': 'Dashboard löschen',
|
'tabs.deleteButtonLabel': 'Dashboard löschen',
|
||||||
'tabs.deleteDialogTitle': 'Dashboard löschen',
|
'tabs.deleteDialogTitle': 'Dashboard löschen',
|
||||||
'tabs.deleteDialogBody': 'Möchten Sie das Dashboard „{name}“ wirklich 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: {
|
common: {
|
||||||
cancel: 'Abbrechen',
|
cancel: 'Abbrechen',
|
||||||
@@ -41,21 +42,62 @@ const DASHBOARDS = [
|
|||||||
{ id: 'd2', name: 'Dashboard 2', position: 1 },
|
{ 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<Parameters<typeof DashboardTabs>[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)', () => {
|
describe('DashboardTabs (quick-260923-ad9, Task 3)', () => {
|
||||||
it('Test 1: rendert jeden Reiter mit seinem Namen', () => {
|
it('Test 1: rendert jeden Reiter mit seinem Namen', () => {
|
||||||
render(
|
render(<DashboardTabs {...defaultProps()} />);
|
||||||
<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' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: 'Dashboard 2' })).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', () => {
|
it('Test 2: ein Klick auf einen Reiter ruft onSelect mit dessen Kennung auf', () => {
|
||||||
const onSelect = vi.fn();
|
const onSelect = vi.fn();
|
||||||
render(
|
render(<DashboardTabs {...defaultProps({ onSelect })} />);
|
||||||
<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' }));
|
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', () => {
|
it('Test 3: der aktive Reiter traegt aria-current, der andere nicht', () => {
|
||||||
render(
|
render(<DashboardTabs {...defaultProps()} />);
|
||||||
<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' })).toHaveAttribute('aria-current', 'true');
|
||||||
expect(screen.getByRole('button', { name: 'Dashboard 2' })).not.toHaveAttribute('aria-current');
|
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', () => {
|
it('Test 4: ausserhalb des Bearbeitungsmodus gibt es weder Umbenennen- noch Loeschen- noch Hinzufuegen-Knoepfe', () => {
|
||||||
render(
|
render(<DashboardTabs {...defaultProps({ isEditMode: false })} />);
|
||||||
<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 umbenennen')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByLabelText('Dashboard löschen')).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', () => {
|
it('Test 5: im Bearbeitungsmodus mit mehreren Reitern sind Loeschen-Knoepfe und ein Hinzufuegen-Knopf da', () => {
|
||||||
render(
|
render(<DashboardTabs {...defaultProps({ isEditMode: true })} />);
|
||||||
<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.getAllByLabelText('Dashboard löschen')).toHaveLength(2);
|
||||||
expect(screen.getByLabelText('Dashboard hinzufügen')).toBeInTheDocument();
|
expect(screen.getByLabelText('Dashboard hinzufügen')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Test 6: beim letzten verbleibenden Reiter wird Loeschen gar nicht erst angeboten', () => {
|
it('Test 6: beim letzten verbleibenden Reiter wird Loeschen gar nicht erst angeboten', () => {
|
||||||
render(
|
render(<DashboardTabs {...defaultProps({ dashboards: [DASHBOARDS[0]], isEditMode: true })} />);
|
||||||
<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();
|
expect(screen.queryByLabelText('Dashboard löschen')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Test 7: der Hinzufuegen-Knopf ruft onCreate auf', () => {
|
it('Test 7: der Hinzufuegen-Knopf ruft onCreate auf', () => {
|
||||||
const onCreate = vi.fn();
|
const onCreate = vi.fn();
|
||||||
render(
|
render(<DashboardTabs {...defaultProps({ isEditMode: true, onCreate })} />);
|
||||||
<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'));
|
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', () => {
|
it('Test 8: Umbenennen-Knopf des AKTIVEN Reiters verwandelt den Namen in ein Eingabefeld; Enter uebernimmt beschnitten', () => {
|
||||||
const onRename = vi.fn();
|
const onRename = vi.fn();
|
||||||
render(
|
render(<DashboardTabs {...defaultProps({ isEditMode: true, onRename })} />);
|
||||||
<DashboardTabs
|
|
||||||
dashboards={DASHBOARDS}
|
|
||||||
activeDashboardId="d1"
|
|
||||||
isEditMode={true}
|
|
||||||
onSelect={vi.fn()}
|
|
||||||
onCreate={vi.fn()}
|
|
||||||
onRename={onRename}
|
|
||||||
onDelete={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByLabelText('Dashboard umbenennen'));
|
fireEvent.click(screen.getByLabelText('Dashboard umbenennen'));
|
||||||
const input = screen.getByLabelText('Name des Dashboards');
|
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', () => {
|
it('Test 9: Escape verwirft die Umbenennung, ohne onRename aufzurufen', () => {
|
||||||
const onRename = vi.fn();
|
const onRename = vi.fn();
|
||||||
render(
|
render(<DashboardTabs {...defaultProps({ isEditMode: true, onRename })} />);
|
||||||
<DashboardTabs
|
|
||||||
dashboards={DASHBOARDS}
|
|
||||||
activeDashboardId="d1"
|
|
||||||
isEditMode={true}
|
|
||||||
onSelect={vi.fn()}
|
|
||||||
onCreate={vi.fn()}
|
|
||||||
onRename={onRename}
|
|
||||||
onDelete={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByLabelText('Dashboard umbenennen'));
|
fireEvent.click(screen.getByLabelText('Dashboard umbenennen'));
|
||||||
const input = screen.getByLabelText('Name des Dashboards');
|
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', () => {
|
it('Test 10: der Umbenennen-Knopf steht nur beim AKTIVEN Reiter, nicht bei den anderen', () => {
|
||||||
render(
|
render(<DashboardTabs {...defaultProps({ isEditMode: true })} />);
|
||||||
<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);
|
expect(screen.getAllByLabelText('Dashboard umbenennen')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Test 11: Loeschen fragt mit dem Namen des Reiters zurueck; Bestaetigen ruft onDelete auf', () => {
|
it('Test 11: Loeschen fragt mit dem Namen des Reiters zurueck; Bestaetigen ruft onDelete auf', () => {
|
||||||
const onDelete = vi.fn();
|
const onDelete = vi.fn();
|
||||||
render(
|
render(<DashboardTabs {...defaultProps({ isEditMode: true, onDelete })} />);
|
||||||
<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]);
|
fireEvent.click(screen.getAllByLabelText('Dashboard löschen')[1]);
|
||||||
const dialog = screen.getByRole('alertdialog');
|
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', () => {
|
it('Test 12: Abbrechen im Loeschdialog ruft onDelete NICHT auf', () => {
|
||||||
const onDelete = vi.fn();
|
const onDelete = vi.fn();
|
||||||
render(
|
render(<DashboardTabs {...defaultProps({ isEditMode: true, onDelete })} />);
|
||||||
<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]);
|
fireEvent.click(screen.getAllByLabelText('Dashboard löschen')[0]);
|
||||||
const dialog = screen.getByRole('alertdialog');
|
const dialog = screen.getByRole('alertdialog');
|
||||||
@@ -273,3 +205,139 @@ describe('DashboardTabs (quick-260923-ad9, Task 3)', () => {
|
|||||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
|
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(<DashboardTabs {...defaultProps({ onReorder, onSelect })} />);
|
||||||
|
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(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, onReorder })} />);
|
||||||
|
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(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, onReorder })} />);
|
||||||
|
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(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, onReorder })} />);
|
||||||
|
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(<DashboardTabs {...defaultProps({ dashboards: THREE_DASHBOARDS, onReorder })} />);
|
||||||
|
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(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, activeDashboardId: 'd1', onReorder: vi.fn() })} />);
|
||||||
|
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(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, onSelect, onReorder })} />);
|
||||||
|
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(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS })} />);
|
||||||
|
expect(screen.getByText('Ziehen Sie einen Reiter, um ihn zum Standard zu machen.')).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(<DashboardTabs {...defaultProps({ dashboards: [DASHBOARDS[0]] })} />);
|
||||||
|
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(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, isEditMode: false, onReorder })} />);
|
||||||
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { type PointerEvent, useEffect, useRef, useState } from 'react';
|
||||||
import type { DashboardTab } from '@/lib/dashboard-api';
|
import type { DashboardTab } from '@/lib/dashboard-api';
|
||||||
|
|
||||||
interface DashboardTabsProps {
|
interface DashboardTabsProps {
|
||||||
@@ -12,18 +12,76 @@ interface DashboardTabsProps {
|
|||||||
onCreate: () => void;
|
onCreate: () => void;
|
||||||
onRename: (id: string, name: string) => void;
|
onRename: (id: string, name: string) => void;
|
||||||
onDelete: (id: 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<string, { left: number; width: number }>,
|
||||||
|
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.
|
* Klick wechselt IMMER den Reiter, unabhängig vom Bearbeitungsmodus.
|
||||||
* Umbenennen (an Ort und Stelle, Eingabetaste übernimmt, Escape verwirft)
|
* Umbenennen (an Ort und Stelle, Eingabetaste übernimmt, Escape verwirft)
|
||||||
* und Löschen (mit Rückfrage) sind nur im Bearbeitungsmodus sichtbar; der
|
* und Löschen (mit Rückfrage) sind nur im Bearbeitungsmodus sichtbar; der
|
||||||
* Löschen-Knopf fehlt zusätzlich beim letzten verbleibenden Reiter (D-10 —
|
* 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
|
* 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
|
* an).
|
||||||
* Abhängigkeit — Pointer-Ereignisse wie in `xframe-config-form.tsx`).
|
*
|
||||||
|
* 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({
|
export function DashboardTabs({
|
||||||
dashboards,
|
dashboards,
|
||||||
@@ -33,13 +91,18 @@ export function DashboardTabs({
|
|||||||
onCreate,
|
onCreate,
|
||||||
onRename,
|
onRename,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onReorder,
|
||||||
}: DashboardTabsProps) {
|
}: DashboardTabsProps) {
|
||||||
const t = useTranslations('widgets');
|
const t = useTranslations('widgets');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||||
const [draftName, setDraftName] = useState('');
|
const [draftName, setDraftName] = useState('');
|
||||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||||
|
const [previewOrder, setPreviewOrder] = useState<string[] | null>(null);
|
||||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const dragRef = useRef<DragState | null>(null);
|
||||||
|
const hasDraggedRef = useRef(false);
|
||||||
|
const tabRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
|
|
||||||
// Fokus auf das Eingabefeld beim Wechsel in den Umbenennen-Zustand — ueber
|
// Fokus auf das Eingabefeld beim Wechsel in den Umbenennen-Zustand — ueber
|
||||||
// einen Ref statt des autoFocus-Attributs (lint/a11y/noAutofocus), Muster
|
// einen Ref statt des autoFocus-Attributs (lint/a11y/noAutofocus), Muster
|
||||||
@@ -51,6 +114,78 @@ export function DashboardTabs({
|
|||||||
}
|
}
|
||||||
}, [renamingId]);
|
}, [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<HTMLDivElement>, tabId: string) {
|
||||||
|
if (e.button !== 0 || renamingId) return;
|
||||||
|
dragRef.current = { id: tabId, pointerId: e.pointerId, startX: e.clientX };
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
|
||||||
|
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<string, { left: number; width: number }>();
|
||||||
|
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<HTMLDivElement>) {
|
||||||
|
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<HTMLDivElement>) {
|
||||||
|
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) {
|
function startRename(tab: DashboardTab) {
|
||||||
setRenamingId(tab.id);
|
setRenamingId(tab.id);
|
||||||
setDraftName(tab.name);
|
setDraftName(tab.name);
|
||||||
@@ -72,13 +207,27 @@ export function DashboardTabs({
|
|||||||
const pendingDeleteTab = dashboards.find((d) => d.id === pendingDeleteId) ?? null;
|
const pendingDeleteTab = dashboards.find((d) => d.id === pendingDeleteId) ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav aria-label={t('tabs.navLabel')} className="mb-2 flex items-center gap-1 overflow-x-auto">
|
<div>
|
||||||
{dashboards.map((tab) => {
|
<nav aria-label={t('tabs.navLabel')} className="mb-1 flex items-center gap-1 overflow-x-auto">
|
||||||
|
{orderedTabs.map((tab, index) => {
|
||||||
const isActive = tab.id === activeDashboardId;
|
const isActive = tab.id === activeDashboardId;
|
||||||
const isRenaming = renamingId === tab.id;
|
const isRenaming = renamingId === tab.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={tab.id} className="flex items-center">
|
<div
|
||||||
|
key={tab.id}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) tabRefs.current.set(tab.id, el);
|
||||||
|
else tabRefs.current.delete(tab.id);
|
||||||
|
}}
|
||||||
|
data-tab-index={index}
|
||||||
|
data-testid={`dashboard-tab-${tab.id}`}
|
||||||
|
className="flex touch-none items-center"
|
||||||
|
onPointerDown={(e) => handlePointerDown(e, tab.id)}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerCancel}
|
||||||
|
>
|
||||||
{isRenaming ? (
|
{isRenaming ? (
|
||||||
<input
|
<input
|
||||||
ref={renameInputRef}
|
ref={renameInputRef}
|
||||||
@@ -101,9 +250,9 @@ export function DashboardTabs({
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(tab.id)}
|
onClick={() => handleTabClick(tab.id)}
|
||||||
aria-current={isActive ? 'true' : undefined}
|
aria-current={isActive ? 'true' : undefined}
|
||||||
className={`h-8 rounded-t px-3 text-sm font-medium transition-colors ${
|
className={`h-8 cursor-grab rounded-t px-3 text-sm font-medium transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? 'border border-b-transparent border-border bg-card text-foreground'
|
? 'border border-b-transparent border-border bg-card text-foreground'
|
||||||
: 'text-muted-foreground hover:bg-muted'
|
: 'text-muted-foreground hover:bg-muted'
|
||||||
@@ -236,5 +385,9 @@ export function DashboardTabs({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
{dashboards.length > 1 && (
|
||||||
|
<p className="mb-2 text-xs text-muted-foreground">{t('tabs.dragHint')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -288,3 +288,35 @@ describe('dashboard-store — Reiter anlegen/umbenennen/loeschen (quick-260923-a
|
|||||||
expect(api.fetchLayout).not.toHaveBeenCalled();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ interface DashboardState {
|
|||||||
createDashboard: () => Promise<void>;
|
createDashboard: () => Promise<void>;
|
||||||
renameDashboard: (id: string, name: string) => Promise<void>;
|
renameDashboard: (id: string, name: string) => Promise<void>;
|
||||||
deleteDashboard: (id: string) => Promise<void>;
|
deleteDashboard: (id: string) => Promise<void>;
|
||||||
|
reorderDashboards: (ids: string[]) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -300,4 +301,28 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
|||||||
console.error('Failed to delete dashboard:', err);
|
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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user