feat(quick-260916-bwo): Dashboard-Raster verdoppelt (24 Spalten, 20 px, 8 px Abstand), Konstanten x2, einmalige Umrechnung gespeicherter Anordnungen mit Marker __gridVersion
- dashboard-grid.tsx: COLS 24/20/12/8/2, rowHeight 20, margin 8 (containerPadding folgt), Rueckfallwerte 4 - widget-registry.tsx: alle 32 Werte in WIDGET_CONSTRAINTS verdoppelt - grid-layout-migration.ts (neu): migrateGridLayouts/withGridVersion, Marker nur im JSON, Idempotenz (T-BWO-02) - dashboard-store.ts: Umrechnung beim Laden, Sofort-Speichern mit Marker, withGridVersion bei jedem saveLayout - Tests: Migration 7 (neu), Store 6 (neu), Registry +1 (Tabelle), Grid +2 (Props ueber Mock), API-Spec +2 (Durchreichung __gridVersion, timeFontSizePt) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* dashboard-store.test — NEU (quick-260916-bwo, feineres Dashboard-Raster).
|
||||
*
|
||||
* Sechs Tests fuer die Anbindung der einmaligen Umrechnung im Store:
|
||||
* `loadDashboard` rechnet alte Anordnungen um und speichert SOFORT mit
|
||||
* Marker; `saveLayout` traegt den Marker bei JEDEM Speichern (T-BWO-02 —
|
||||
* fehlt er, wuerde das naechste Laden erneut verdoppeln); der Zustand
|
||||
* selbst bleibt markerfrei (der Store iteriert mit Object.keys ueber die
|
||||
* Breakpoints); `addWidget` legt neue Eintraege in den verdoppelten
|
||||
* Vorgabegroessen an. Der Store hatte bisher keine Testdatei.
|
||||
*/
|
||||
vi.mock('@/lib/dashboard-api', () => ({
|
||||
fetchLayout: vi.fn(),
|
||||
fetchWidgets: vi.fn(),
|
||||
saveLayout: vi.fn(),
|
||||
addWidget: vi.fn(),
|
||||
removeWidget: vi.fn(),
|
||||
updateWidgetConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as api from '@/lib/dashboard-api';
|
||||
|
||||
const { useDashboardStore } = await import('./dashboard-store');
|
||||
|
||||
const EMPTY = { lg: [], md: [], sm: [], xs: [], xxs: [] };
|
||||
|
||||
beforeEach(() => {
|
||||
useDashboardStore.setState({
|
||||
layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] },
|
||||
widgets: [],
|
||||
isEditMode: false,
|
||||
isDirty: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(api.fetchWidgets).mockResolvedValue([]);
|
||||
vi.mocked(api.saveLayout).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({
|
||||
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [],
|
||||
});
|
||||
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
|
||||
const state = useDashboardStore.getState();
|
||||
expect(state.layouts.lg[0]).toEqual({ i: 'a', x: 2, y: 2, w: 4, h: 4 });
|
||||
expect(Object.keys(state.layouts)).not.toContain('__gridVersion');
|
||||
expect(api.saveLayout).toHaveBeenCalledTimes(1);
|
||||
expect(api.saveLayout).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ __gridVersion: 2, lg: [{ i: 'a', x: 2, y: 2, w: 4, h: 4 }] }),
|
||||
);
|
||||
expect(state.isDirty).toBe(false);
|
||||
expect(state.isLoading).toBe(false);
|
||||
expect(state.error).toBeNull();
|
||||
});
|
||||
|
||||
it('Test 2: markierte Anordnung bleibt unveraendert, kein Speichern, kein Marker im Zustand', async () => {
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({
|
||||
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], __gridVersion: 2,
|
||||
});
|
||||
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
|
||||
const state = useDashboardStore.getState();
|
||||
expect(state.layouts.lg[0]).toEqual({ i: 'a', x: 1, y: 1, w: 2, h: 2 });
|
||||
expect(api.saveLayout).not.toHaveBeenCalled();
|
||||
expect(Object.keys(state.layouts)).not.toContain('__gridVersion');
|
||||
});
|
||||
|
||||
it('Test 3: leere Vorgabe-Anordnung ohne Marker -> kein Speichern', async () => {
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY });
|
||||
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
|
||||
expect(api.saveLayout).not.toHaveBeenCalled();
|
||||
expect(useDashboardStore.getState().layouts).toEqual(EMPTY);
|
||||
});
|
||||
|
||||
it('Test 4: jedes Speichern traegt den Marker (T-BWO-02), der Zustand bleibt markerfrei', async () => {
|
||||
const layouts = { lg: [{ i: 'a', x: 2, y: 2, w: 4, h: 4 }], md: [], sm: [], xs: [], xxs: [] };
|
||||
useDashboardStore.getState().updateLayouts(layouts);
|
||||
expect(useDashboardStore.getState().isDirty).toBe(true);
|
||||
|
||||
await useDashboardStore.getState().saveLayout();
|
||||
|
||||
expect(api.saveLayout).toHaveBeenCalledTimes(1);
|
||||
expect(api.saveLayout).toHaveBeenCalledWith(expect.objectContaining({ __gridVersion: 2, ...layouts }));
|
||||
expect(useDashboardStore.getState().isDirty).toBe(false);
|
||||
expect(Object.keys(useDashboardStore.getState().layouts)).not.toContain('__gridVersion');
|
||||
});
|
||||
|
||||
it('Test 5: scheitert das Sofort-Speichern, bleibt der Zustand umgerechnet, kein Fehlerzustand, console.error einmal', async () => {
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({
|
||||
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [],
|
||||
});
|
||||
vi.mocked(api.saveLayout).mockRejectedValue(new Error('PUT failed'));
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await expect(useDashboardStore.getState().loadDashboard()).resolves.toBeUndefined();
|
||||
|
||||
const state = useDashboardStore.getState();
|
||||
expect(state.layouts.lg[0].x).toBe(2);
|
||||
expect(state.error).toBeNull();
|
||||
expect(state.isLoading).toBe(false);
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Test 6: neues Widget wird in den neuen (verdoppelten) Vorgabegroessen angelegt', async () => {
|
||||
vi.mocked(api.addWidget).mockResolvedValue({ id: 'n1', widgetType: 'clock', config: {} });
|
||||
|
||||
await useDashboardStore.getState().addWidget('clock');
|
||||
|
||||
const state = useDashboardStore.getState();
|
||||
expect(state.layouts.lg).toContainEqual({ i: 'n1', x: 0, y: 0, w: 4, h: 4 });
|
||||
expect(state.isDirty).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { create } from 'zustand';
|
||||
import * as api from '@/lib/dashboard-api';
|
||||
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
||||
import { WIDGET_CONSTRAINTS } from '@/components/dashboard/widget-registry';
|
||||
import { migrateGridLayouts, withGridVersion } from '@/lib/grid-layout-migration';
|
||||
|
||||
export interface WidgetInstance {
|
||||
id: string;
|
||||
@@ -28,6 +29,14 @@ interface DashboardState {
|
||||
/**
|
||||
* Dashboard Zustand store. NO persist middleware — layout comes from DB (D-05).
|
||||
* Save only on exiting edit mode (D-01), not on every drag.
|
||||
*
|
||||
* quick-260916-bwo: Gespeicherte Anordnungen in alten Raster-Einheiten werden
|
||||
* beim Laden einmalig umgerechnet (`migrateGridLayouts`) und sofort mit dem
|
||||
* Marker `__gridVersion` zurueckgeschrieben. Der Marker lebt NUR im
|
||||
* gespeicherten JSON, nie im Zustand (dieser Store iteriert mit Object.keys
|
||||
* ueber die Breakpoints). Fehlt der Marker beim Speichern, wird beim naechsten
|
||||
* Laden erneut verdoppelt — deshalb `withGridVersion` an BEIDEN Speicherstellen
|
||||
* (Sofort-Speichern nach der Umrechnung und `saveLayout`), T-BWO-02.
|
||||
*/
|
||||
export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
||||
layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] },
|
||||
@@ -108,15 +117,26 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
||||
loadDashboard: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const [layouts, widgets] = await Promise.all([
|
||||
const [rawLayouts, widgets] = await Promise.all([
|
||||
api.fetchLayout(),
|
||||
api.fetchWidgets(),
|
||||
]);
|
||||
const { layouts: migratedLayouts, migrated } = migrateGridLayouts(rawLayouts);
|
||||
set({
|
||||
layouts: layouts as DashboardState['layouts'],
|
||||
layouts: migratedLayouts,
|
||||
widgets,
|
||||
isLoading: false,
|
||||
});
|
||||
// Umgerechnete Anordnung SOFORT mit Marker persistieren — nach dem set,
|
||||
// damit die Oberflaeche unabhaengig vom Speichern rendert; eigener
|
||||
// try/catch, damit ein Speicherfehler NICHT als Ladefehler erscheint.
|
||||
if (migrated) {
|
||||
try {
|
||||
await api.saveLayout(withGridVersion(migratedLayouts));
|
||||
} catch (err) {
|
||||
console.error('Failed to persist migrated layout:', err);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
set({
|
||||
error: 'Failed to load dashboard',
|
||||
@@ -127,7 +147,7 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
||||
|
||||
saveLayout: async () => {
|
||||
try {
|
||||
await api.saveLayout(get().layouts);
|
||||
await api.saveLayout(withGridVersion(get().layouts));
|
||||
set({ isDirty: false });
|
||||
} catch (err) {
|
||||
console.error('Failed to save layout:', err);
|
||||
|
||||
Reference in New Issue
Block a user