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:
@@ -579,6 +579,48 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
// --- Durchreichung fuer das feinere Dashboard-Raster (quick-260916-bwo) ---
|
||||
// Die API kennt weder die Raster-Einheiten noch den Marker; sie reicht das
|
||||
// `layouts`-JSON (`@IsObject()`) unveraendert durch. Diese beiden Tests sind
|
||||
// nach heutigem Code bereits gruen — sie PINNEN die Durchreichung, damit eine
|
||||
// spaetere Bereinigung des JSON die Frontend-Umrechnung nicht unbemerkt
|
||||
// bricht (fehlt der Marker beim Laden, verdoppelt das Frontend erneut).
|
||||
|
||||
it('quick-260916-bwo Test A: der Marker __gridVersion ueberlebt saveLayout -> getLayout unveraendert', async () => {
|
||||
const prisma = makeFakePrisma({});
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
const stored = { lg: [{ i: 'w1', x: 0, y: 0, w: 4, h: 4 }], __gridVersion: 2 };
|
||||
await service.saveLayout('user-1', 'tenant-1', { layouts: stored } as any);
|
||||
|
||||
const result = await service.getLayout('user-1', 'tenant-1');
|
||||
|
||||
expect(result).toEqual(stored);
|
||||
expect((result as Record<string, unknown>).__gridVersion).toBe(2);
|
||||
});
|
||||
|
||||
it('quick-260916-bwo Test B: timeFontSizePt wird in die Widget-Konfiguration gemischt, null ueberschreibt, timezone bleibt', async () => {
|
||||
// Die API prueft Config-Felder nicht (`@IsObject()`); die Grenzen 8..200
|
||||
// liegen im Frontend (`clock-font-size.ts`), ein Fremdwert faellt dort auf
|
||||
// "automatisch" zurueck (T-BWO-01).
|
||||
const widget = makeWidget({ id: 'w1', userId: 'user-1', config: { timezone: 'Europe/Berlin' } });
|
||||
const prisma = makeFakePrisma({ widgets: [widget] });
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
const first = await service.updateWidgetConfig('w1', 'user-1', 'tenant-1', {
|
||||
config: { timeFontSizePt: 36 },
|
||||
} as any);
|
||||
expect(first.config).toEqual({ timezone: 'Europe/Berlin', timeFontSizePt: 36 });
|
||||
|
||||
const second = await service.updateWidgetConfig('w1', 'user-1', 'tenant-1', {
|
||||
config: { timeFontSizePt: null },
|
||||
} as any);
|
||||
expect((second.config as Record<string, unknown>).timeFontSizePt).toBeNull();
|
||||
expect((second.config as Record<string, unknown>).timezone).toBe('Europe/Berlin');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Bindung an forTenant() (260910-krx, Aufgabe 3: Suchmaschinen) ---------
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Children, isValidElement } from 'react';
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -34,13 +35,15 @@ vi.mock('next-intl', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock react-grid-layout Responsive to a simple passthrough for jsdom stability
|
||||
// Mock react-grid-layout Responsive to a simple passthrough for jsdom stability.
|
||||
// quick-260916-bwo: der Mock faengt zusaetzlich die Props ein, damit die
|
||||
// Raster-Konstanten (cols, rowHeight, margin, breakpoints) pruefbar sind.
|
||||
const captured = vi.hoisted(() => ({ props: null as Record<string, unknown> | null }));
|
||||
vi.mock('react-grid-layout', () => ({
|
||||
Responsive: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => <div data-testid="responsive-grid">{children}</div>,
|
||||
Responsive: (props: Record<string, unknown> & { children: React.ReactNode }) => {
|
||||
captured.props = props;
|
||||
return <div data-testid="responsive-grid">{props.children}</div>;
|
||||
},
|
||||
noCompactor: () => null,
|
||||
}));
|
||||
|
||||
@@ -153,4 +156,46 @@ describe('DashboardGrid', () => {
|
||||
expect(screen.getByLabelText('Remove widget')).toBeInTheDocument();
|
||||
expect(document.querySelector('.widget-drag-handle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('quick-260916-bwo Test 4: Grid-Props — 24/20/12/8/2 Spalten, rowHeight 20, margin 8, Breakpoints unveraendert, containerPadding folgt dem margin', async () => {
|
||||
captured.props = null;
|
||||
const { DashboardGrid } = await import('./dashboard-grid');
|
||||
render(
|
||||
<DashboardGrid
|
||||
layouts={{ lg: [{ i: 'inst-1', x: 0, y: 0, w: 4, h: 4 }], md: [], sm: [], xs: [], xxs: [] }}
|
||||
widgets={[{ id: 'inst-1', widgetType: 'clock', config: {} }]}
|
||||
isEditMode={false}
|
||||
onLayoutChange={vi.fn()}
|
||||
onRemoveWidget={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(captured.props).not.toBeNull();
|
||||
expect(captured.props?.cols).toEqual({ lg: 24, md: 20, sm: 12, xs: 8, xxs: 2 });
|
||||
expect(captured.props?.rowHeight).toBe(20);
|
||||
expect(captured.props?.margin).toEqual([8, 8]);
|
||||
expect(captured.props?.breakpoints).toEqual({ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 });
|
||||
expect(captured.props?.containerPadding).toBeUndefined();
|
||||
});
|
||||
|
||||
it('quick-260916-bwo Test 5: Widget ohne gespeicherten Eintrag bekommt die verdoppelten Vorgaben als data-grid', async () => {
|
||||
captured.props = null;
|
||||
const { DashboardGrid } = await import('./dashboard-grid');
|
||||
render(
|
||||
<DashboardGrid
|
||||
layouts={{ lg: [], md: [], sm: [], xs: [], xxs: [] }}
|
||||
widgets={[{ id: 'inst-3', widgetType: 'clock', config: {} }]}
|
||||
isEditMode={false}
|
||||
onLayoutChange={vi.fn()}
|
||||
onRemoveWidget={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const children = Children.toArray(captured.props?.children as React.ReactNode);
|
||||
expect(children).toHaveLength(1);
|
||||
const first = children[0];
|
||||
expect(isValidElement(first)).toBe(true);
|
||||
const props = (first as React.ReactElement<Record<string, unknown>>).props;
|
||||
expect(props['data-grid']).toEqual({ x: 0, y: 0, w: 4, h: 4, minW: 4, minH: 4 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,8 +10,12 @@ import { WIDGET_CONSTRAINTS, type WidgetType } from './widget-registry';
|
||||
import { WidgetWrapper } from './widgets/widget-wrapper';
|
||||
import { TesseraLogo } from '@/components/brand/tessera-logo';
|
||||
|
||||
// quick-260916-bwo: Raster doppelt so fein wie zuvor (24 statt 12 Spalten,
|
||||
// 20 statt 40 px Zeilenhoehe, 8 statt 16 px Abstand). Gespeicherte Anordnungen
|
||||
// in alten Einheiten werden in `@/lib/grid-layout-migration` einmalig
|
||||
// umgerechnet und mit `__gridVersion: 2` markiert. BREAKPOINTS unveraendert.
|
||||
const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 };
|
||||
const COLS = { lg: 12, md: 10, sm: 6, xs: 4, xxs: 1 };
|
||||
const COLS = { lg: 24, md: 20, sm: 12, xs: 8, xxs: 2 };
|
||||
|
||||
interface DashboardGridProps {
|
||||
layouts: Record<string, Array<{ i: string; x: number; y: number; w: number; h: number }>>;
|
||||
@@ -74,8 +78,10 @@ export function DashboardGrid({
|
||||
breakpoints={BREAKPOINTS}
|
||||
cols={COLS}
|
||||
layouts={layouts as ResponsiveLayouts}
|
||||
rowHeight={40}
|
||||
margin={[16, 16] as [number, number]}
|
||||
rowHeight={20}
|
||||
margin={[8, 8] as [number, number]}
|
||||
// containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt
|
||||
// dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin).
|
||||
dragConfig={{
|
||||
enabled: isEditMode,
|
||||
handle: '.widget-drag-handle',
|
||||
@@ -95,11 +101,11 @@ export function DashboardGrid({
|
||||
...(layouts.lg?.find((l) => l.i === widget.id) ?? {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: constraints?.defaultW ?? 2,
|
||||
h: constraints?.defaultH ?? 2,
|
||||
w: constraints?.defaultW ?? 4,
|
||||
h: constraints?.defaultH ?? 4,
|
||||
}),
|
||||
minW: constraints?.minW ?? 2,
|
||||
minH: constraints?.minH ?? 2,
|
||||
minW: constraints?.minW ?? 4,
|
||||
minH: constraints?.minH ?? 4,
|
||||
}}
|
||||
>
|
||||
<WidgetWrapper
|
||||
|
||||
@@ -52,4 +52,28 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('link');
|
||||
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('stopwatch');
|
||||
});
|
||||
|
||||
it('quick-260916-bwo: jede Groesse ist exakt das Doppelte der alten 12-Spalten-Werte', () => {
|
||||
// Raster seit quick-260916-bwo 24 Spalten / 20 px statt 12 / 40 — jeder Wert
|
||||
// ist das Doppelte, damit Widgets optisch gleich gross bleiben.
|
||||
expect(WIDGET_CONSTRAINTS).toEqual({
|
||||
clock: { minW: 4, minH: 4, defaultW: 4, defaultH: 4 },
|
||||
search: { minW: 6, minH: 4, defaultW: 12, defaultH: 4 },
|
||||
calendar: { minW: 6, minH: 6, defaultW: 8, defaultH: 12 },
|
||||
note: { minW: 4, minH: 6, defaultW: 6, defaultH: 8 },
|
||||
calculator: { minW: 4, minH: 8, defaultW: 6, defaultH: 10 },
|
||||
favorites: { minW: 4, minH: 6, defaultW: 6, defaultH: 10 },
|
||||
link: { minW: 4, minH: 4, defaultW: 4, defaultH: 4 },
|
||||
stopwatch: { minW: 4, minH: 4, defaultW: 6, defaultH: 6 },
|
||||
});
|
||||
|
||||
let counted = 0;
|
||||
for (const type of ALL_WIDGET_TYPES) {
|
||||
for (const field of ['minW', 'minH', 'defaultW', 'defaultH'] as const) {
|
||||
expect(WIDGET_CONSTRAINTS[type][field] % 2, `${type}.${field} muss gerade sein`).toBe(0);
|
||||
counted++;
|
||||
}
|
||||
}
|
||||
expect(counted).toBe(32);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,16 +32,17 @@ export const WIDGET_CONSTRAINTS: Record<
|
||||
WidgetType,
|
||||
{ minW: number; minH: number; defaultW: number; defaultH: number }
|
||||
> = {
|
||||
// Existing widgets (values unchanged)
|
||||
clock: { minW: 2, minH: 2, defaultW: 2, defaultH: 2 },
|
||||
search: { minW: 3, minH: 2, defaultW: 6, defaultH: 2 },
|
||||
calendar: { minW: 3, minH: 3, defaultW: 4, defaultH: 6 },
|
||||
note: { minW: 2, minH: 3, defaultW: 3, defaultH: 4 },
|
||||
// quick-260916-bwo: Raster verdoppelt (24 Spalten / 20 px) — jeder Wert ist das Doppelte des alten 12-Spalten-Werts, Widgets bleiben optisch gleich gross.
|
||||
// Existing widgets
|
||||
clock: { minW: 4, minH: 4, defaultW: 4, defaultH: 4 },
|
||||
search: { minW: 6, minH: 4, defaultW: 12, defaultH: 4 },
|
||||
calendar: { minW: 6, minH: 6, defaultW: 8, defaultH: 12 },
|
||||
note: { minW: 4, minH: 6, defaultW: 6, defaultH: 8 },
|
||||
// Phase 8 new widgets (D-01)
|
||||
calculator: { minW: 2, minH: 4, defaultW: 3, defaultH: 5 },
|
||||
favorites: { minW: 2, minH: 3, defaultW: 3, defaultH: 5 },
|
||||
link: { minW: 2, minH: 2, defaultW: 2, defaultH: 2 },
|
||||
stopwatch: { minW: 2, minH: 2, defaultW: 3, defaultH: 3 },
|
||||
calculator: { minW: 4, minH: 8, defaultW: 6, defaultH: 10 },
|
||||
favorites: { minW: 4, minH: 6, defaultW: 6, defaultH: 10 },
|
||||
link: { minW: 4, minH: 4, defaultW: 4, defaultH: 4 },
|
||||
stopwatch: { minW: 4, minH: 4, defaultW: 6, defaultH: 6 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
GRID_SCALE_FACTOR,
|
||||
GRID_VERSION,
|
||||
GRID_VERSION_KEY,
|
||||
migrateGridLayouts,
|
||||
withGridVersion,
|
||||
} from './grid-layout-migration';
|
||||
|
||||
/**
|
||||
* grid-layout-migration.test — NEU (quick-260916-bwo, feineres Dashboard-Raster).
|
||||
*
|
||||
* Sieben Tests fuer die einmalige Umrechnung gespeicherter Anordnungen von
|
||||
* den alten Einheiten (12 Spalten / 40 px) in die neuen (24 Spalten / 20 px).
|
||||
* Kern ist T-BWO-02: die Verdopplung darf GENAU EINMAL geschehen. Der Marker
|
||||
* `__gridVersion` steht dafuer im gespeicherten JSON, nie im Zustand — Test 4
|
||||
* (Idempotenz) wird rot, sobald jemand die Marker-Pruefung entfernt.
|
||||
*/
|
||||
|
||||
function altLayouts() {
|
||||
return {
|
||||
lg: [
|
||||
{ i: 'a', x: 1, y: 2, w: 2, h: 3, minW: 2, minH: 2, moved: false, static: false },
|
||||
{ i: 'b', x: 2, y: 0, w: 6, h: 2, maxW: 12, maxH: 8 },
|
||||
],
|
||||
md: [{ i: 'a', x: 0, y: 0, w: 2, h: 2 }],
|
||||
sm: [],
|
||||
xs: [],
|
||||
xxs: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('grid-layout-migration (quick-260916-bwo)', () => {
|
||||
it('Test 1: alte Anordnung ohne Marker -> alle Positionen und Groessen x2, migrated true, kein Marker im Ergebnis', () => {
|
||||
const { layouts, migrated } = migrateGridLayouts(altLayouts());
|
||||
|
||||
expect(layouts.lg[0]).toEqual({
|
||||
i: 'a', x: 2, y: 4, w: 4, h: 6, minW: 4, minH: 4, moved: false, static: false,
|
||||
});
|
||||
expect(layouts.lg[1]).toEqual({ i: 'b', x: 4, y: 0, w: 12, h: 4, maxW: 24, maxH: 16 });
|
||||
expect(layouts.md[0]).toEqual({ i: 'a', x: 0, y: 0, w: 4, h: 4 });
|
||||
expect(layouts.sm).toEqual([]);
|
||||
expect(layouts.xs).toEqual([]);
|
||||
expect(layouts.xxs).toEqual([]);
|
||||
expect(migrated).toBe(true);
|
||||
expect(Object.keys(layouts)).not.toContain(GRID_VERSION_KEY);
|
||||
expect(GRID_SCALE_FACTOR).toBe(2);
|
||||
});
|
||||
|
||||
it('Test 2: markierte Anordnung (__gridVersion 2) bleibt unveraendert, migrated false', () => {
|
||||
const alt = altLayouts();
|
||||
const { layouts, migrated } = migrateGridLayouts({ ...alt, __gridVersion: 2 });
|
||||
|
||||
expect(layouts).toEqual(alt);
|
||||
expect(migrated).toBe(false);
|
||||
expect(Object.keys(layouts)).not.toContain(GRID_VERSION_KEY);
|
||||
});
|
||||
|
||||
it('Test 3: leere Anordnung bleibt leer, migrated false (kein Speichern noetig)', () => {
|
||||
const empty = { lg: [], md: [], sm: [], xs: [], xxs: [] };
|
||||
const r1 = migrateGridLayouts(empty);
|
||||
expect(r1.layouts).toEqual(empty);
|
||||
expect(r1.migrated).toBe(false);
|
||||
|
||||
const r2 = migrateGridLayouts({});
|
||||
expect(r2.layouts).toEqual({});
|
||||
expect(r2.migrated).toBe(false);
|
||||
});
|
||||
|
||||
it('Test 4: Idempotenz — einmal umgerechnet und markiert wird nicht erneut verdoppelt (T-BWO-02)', () => {
|
||||
const once = migrateGridLayouts(altLayouts());
|
||||
const twice = migrateGridLayouts(withGridVersion(once.layouts));
|
||||
|
||||
expect(twice.layouts).toEqual(once.layouts);
|
||||
expect(twice.migrated).toBe(false);
|
||||
});
|
||||
|
||||
it('Test 5: withGridVersion haengt den Marker an, ohne die Eingabe zu veraendern', () => {
|
||||
const { layouts } = migrateGridLayouts(altLayouts());
|
||||
const marked = withGridVersion(layouts);
|
||||
|
||||
expect(marked[GRID_VERSION_KEY]).toBe(GRID_VERSION);
|
||||
expect(GRID_VERSION).toBe(2);
|
||||
expect(marked.lg).toBe(layouts.lg);
|
||||
expect(marked.md).toBe(layouts.md);
|
||||
expect(Object.keys(layouts)).not.toContain(GRID_VERSION_KEY);
|
||||
expect((layouts as Record<string, unknown>)[GRID_VERSION_KEY]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Test 6: Zukunft und Robustheit — Marker 3 bleibt, Zeichenketten-Marker zaehlt nicht, nicht-numerische Felder bleiben', () => {
|
||||
const alt = altLayouts();
|
||||
|
||||
const future = migrateGridLayouts({ ...alt, __gridVersion: 3 });
|
||||
expect(future.layouts).toEqual(alt);
|
||||
expect(future.migrated).toBe(false);
|
||||
|
||||
const stringMarker = migrateGridLayouts({ ...alt, __gridVersion: '2' });
|
||||
expect(stringMarker.migrated).toBe(true);
|
||||
expect(stringMarker.layouts.lg[0].x).toBe(2);
|
||||
expect(Object.keys(stringMarker.layouts)).not.toContain(GRID_VERSION_KEY);
|
||||
|
||||
const odd = migrateGridLayouts({
|
||||
lg: [{ i: 'c', x: 'kaputt', y: 1, w: 2, h: 2, resizeHandles: ['se'], moved: true, static: false }],
|
||||
});
|
||||
expect(odd.layouts.lg[0]).toEqual({
|
||||
i: 'c', x: 'kaputt', y: 2, w: 4, h: 4, resizeHandles: ['se'], moved: true, static: false,
|
||||
});
|
||||
expect(Number.isNaN(odd.layouts.lg[0].x)).toBe(false);
|
||||
expect(odd.migrated).toBe(true);
|
||||
});
|
||||
|
||||
it('Test 7: Fremdwerte — Nicht-Arrays werden weggelassen, Nicht-Objekte liefern eine leere Anordnung', () => {
|
||||
const r = migrateGridLayouts({ lg: 'kaputt', md: null, sm: [{ i: 'a', x: 1, y: 1, w: 1, h: 1 }] });
|
||||
expect(Object.keys(r.layouts)).toEqual(['sm']);
|
||||
expect(r.layouts.sm[0]).toEqual({ i: 'a', x: 2, y: 2, w: 2, h: 2 });
|
||||
expect(r.migrated).toBe(true);
|
||||
|
||||
expect(migrateGridLayouts(null)).toEqual({ layouts: {}, migrated: false });
|
||||
expect(migrateGridLayouts(undefined)).toEqual({ layouts: {}, migrated: false });
|
||||
expect(migrateGridLayouts(42)).toEqual({ layouts: {}, migrated: false });
|
||||
expect(migrateGridLayouts([1, 2])).toEqual({ layouts: {}, migrated: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* grid-layout-migration — einmalige Umrechnung gespeicherter Dashboard-
|
||||
* Anordnungen in die feineren Raster-Einheiten (quick-260916-bwo).
|
||||
*
|
||||
* Warum: Das Raster wurde von 12 Spalten / 40 px Zeilenhoehe auf 24 Spalten /
|
||||
* 20 px verdoppelt (`dashboard-grid.tsx`). Eine in ALTEN Einheiten gespeicherte
|
||||
* Anordnung wuerde im neuen Raster halb so gross und an der halben Position
|
||||
* erscheinen. Deshalb werden `x, y, w, h` (und, falls vorhanden, `minW, minH,
|
||||
* maxW, maxH`) jedes Elements in jedem Breakpoint GENAU EINMAL mit 2
|
||||
* multipliziert.
|
||||
*
|
||||
* Marker: Damit die Verdopplung nur einmal geschieht, traegt das gespeicherte
|
||||
* JSON den Schluessel `__gridVersion: 2`. Der Marker lebt NUR im persistierten
|
||||
* JSON (Spalte `DashboardLayout.layouts`, Json, kein Schema noetig) — nie im
|
||||
* Zustand des Stores, der mit `Object.keys` ueber die Breakpoints iteriert und
|
||||
* `.filter` auf jedem Wert aufruft (ein Zahlwert wuerde dort abstuerzen).
|
||||
* `migrateGridLayouts` entfernt den Marker beim Laden, `withGridVersion`
|
||||
* haengt ihn beim Speichern wieder an. Fehlt der Marker beim Speichern, wird
|
||||
* beim naechsten Laden ERNEUT verdoppelt — deshalb muss JEDER Speichervorgang
|
||||
* `withGridVersion` benutzen (T-BWO-02, Store-Tests pinnen das).
|
||||
*
|
||||
* Idempotenz: `migrateGridLayouts(withGridVersion(migrateGridLayouts(alt).layouts))`
|
||||
* liefert dasselbe Ergebnis wie `migrateGridLayouts(alt)` mit `migrated: false`.
|
||||
*
|
||||
* Ort: Frontend, weil die Raster-Einheiten Frontend-Konstanten sind, die API
|
||||
* das JSON nur durchreicht (`@IsObject()`) und so kein Schreiben auf einem
|
||||
* GET und keine Aenderung am API-Dienst noetig ist. Reine Funktionen ohne
|
||||
* React- oder Store-Abhaengigkeit.
|
||||
*/
|
||||
|
||||
export const GRID_VERSION = 2;
|
||||
export const GRID_VERSION_KEY = '__gridVersion';
|
||||
export const GRID_SCALE_FACTOR = 2;
|
||||
|
||||
export interface GridLayoutItem {
|
||||
i: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type GridLayouts = Record<string, GridLayoutItem[]>;
|
||||
|
||||
const SCALED_FIELDS = ['x', 'y', 'w', 'h', 'minW', 'minH', 'maxW', 'maxH'] as const;
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechnet eine rohe (aus der API geladene) Anordnung in die aktuellen
|
||||
* Raster-Einheiten um. Liefert die Anordnung OHNE Marker und die Angabe, ob
|
||||
* etwas verdoppelt wurde (dann muss der Aufrufer sofort mit Marker speichern).
|
||||
*/
|
||||
export function migrateGridLayouts(raw: unknown): { layouts: GridLayouts; migrated: boolean } {
|
||||
if (!isPlainObject(raw)) {
|
||||
return { layouts: {}, migrated: false };
|
||||
}
|
||||
|
||||
const markerValue = raw[GRID_VERSION_KEY];
|
||||
// Nur eine Zahl ist ein Marker; alles andere (fehlend, Zeichenkette) zaehlt als alt.
|
||||
const version = typeof markerValue === 'number' ? markerValue : 1;
|
||||
const needsScaling = version < GRID_VERSION;
|
||||
|
||||
const layouts: GridLayouts = {};
|
||||
let migrated = false;
|
||||
|
||||
for (const key of Object.keys(raw)) {
|
||||
if (key === GRID_VERSION_KEY) continue;
|
||||
const value = raw[key];
|
||||
if (!Array.isArray(value)) continue;
|
||||
|
||||
layouts[key] = value.map((item) => {
|
||||
const copy = { ...(item as GridLayoutItem) };
|
||||
if (needsScaling) {
|
||||
for (const field of SCALED_FIELDS) {
|
||||
const n = copy[field];
|
||||
if (typeof n === 'number') {
|
||||
copy[field] = n * GRID_SCALE_FACTOR;
|
||||
}
|
||||
}
|
||||
migrated = true;
|
||||
}
|
||||
return copy;
|
||||
});
|
||||
}
|
||||
|
||||
return { layouts, migrated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Haengt den Marker fuer das Speichern an, ohne die Eingabe zu veraendern.
|
||||
*/
|
||||
export function withGridVersion(layouts: GridLayouts): Record<string, unknown> {
|
||||
return { ...layouts, [GRID_VERSION_KEY]: GRID_VERSION };
|
||||
}
|
||||
@@ -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