feat(dashboard): auf jedem Bildschirm dasselbe Bild – Leinwand je Reiter, massstaeblich skaliert
Je Reiter wird die Flaeche des ersten Desktop-Bildschirms als __canvas im Layout-JSON gespeichert (ohne API/DB-Aenderung, GRID_VERSION bleibt 3). Das Raster rendert in Leinwandbreite und wird per transform: scale(min(bw/cw, bh/ch)) eingepasst, Schrift eingeschlossen, ohne Scrollen; unter 768 px wie bisher. Ziehen/Groesse aendern unter Skalierung ueber eine eigene Positionsstrategie (createScaledStrategy aus react-grid-layout 2.2.3 rechnet den Rasterversatz falsch). Im Browser nachgewiesen: 1920x1080 -> 1366x768 (Faktor 0,66), Ziehen +200 px folgt der Maus, 700 px ohne Skalierung. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,28 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot';
|
||||
import {
|
||||
BackgroundPicker,
|
||||
DashboardBackdrop,
|
||||
useDashboardBackground,
|
||||
} from '@/components/dashboard/dashboard-background';
|
||||
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
|
||||
import { DashboardTabs } from '@/components/dashboard/dashboard-tabs';
|
||||
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
|
||||
import { BackgroundPicker, DashboardBackdrop, useDashboardBackground } from '@/components/dashboard/dashboard-background';
|
||||
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
|
||||
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
||||
import { registerWidget } from '@/components/dashboard/widget-registry';
|
||||
import { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
|
||||
import { SearchWidget } from '@/components/dashboard/widgets/search-widget';
|
||||
import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget';
|
||||
import { NoteWidget } from '@/components/dashboard/widgets/note-widget';
|
||||
import { CalculatorWidget } from '@/components/dashboard/widgets/calculator-widget';
|
||||
import { StopwatchWidget } from '@/components/dashboard/widgets/stopwatch-widget';
|
||||
import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget';
|
||||
import { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
|
||||
import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget';
|
||||
import { NoteWidget } from '@/components/dashboard/widgets/note-widget';
|
||||
import { PictureFrameWidget } from '@/components/dashboard/widgets/picture-frame-widget';
|
||||
import { XframeWidget } from '@/components/dashboard/widgets/xframe-widget';
|
||||
import { ProxmoxWidget } from '@/components/dashboard/widgets/proxmox-widget';
|
||||
import { ReminderWidget } from '@/components/dashboard/widgets/reminder-widget';
|
||||
import { SearchWidget } from '@/components/dashboard/widgets/search-widget';
|
||||
import { StopwatchWidget } from '@/components/dashboard/widgets/stopwatch-widget';
|
||||
import { XframeWidget } from '@/components/dashboard/widgets/xframe-widget';
|
||||
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot';
|
||||
import { useDashboardStore } from '@/lib/stores/dashboard-store';
|
||||
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
@@ -71,6 +75,7 @@ export default function DashboardPage() {
|
||||
activeDashboardId,
|
||||
isSwitchingDashboard,
|
||||
layouts,
|
||||
canvas,
|
||||
widgets,
|
||||
isEditMode,
|
||||
isLoading,
|
||||
@@ -85,6 +90,7 @@ export default function DashboardPage() {
|
||||
renameDashboard,
|
||||
deleteDashboard,
|
||||
reorderDashboards,
|
||||
captureCanvas,
|
||||
} = useDashboardStore();
|
||||
|
||||
// Load dashboard data on mount
|
||||
@@ -200,6 +206,8 @@ export default function DashboardPage() {
|
||||
onRemoveWidget={removeWidget}
|
||||
onOpenCatalog={() => setCatalogOpen(true)}
|
||||
onQuickAdd={(type) => addWidget(type)}
|
||||
canvas={canvas}
|
||||
onCaptureCanvas={captureCanvas}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { Children, isValidElement } from 'react';
|
||||
import { createScaledStrategy, defaultPositionStrategy } from 'react-grid-layout/core';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { stubResizeObserver } from '@/test/fake-resize-observer';
|
||||
import { RESIZE_AXIS_FALLBACK } from './dashboard-grid';
|
||||
import {
|
||||
computeCanvasScale,
|
||||
DASHBOARD_BOTTOM_GAP_PX,
|
||||
RESIZE_AXIS_FALLBACK,
|
||||
scaledPositionStrategy,
|
||||
} from './dashboard-grid';
|
||||
|
||||
// Mock CSS imports that vitest cannot resolve
|
||||
vi.mock('react-grid-layout/css/styles.css', () => ({}));
|
||||
@@ -652,3 +658,139 @@ describe('RESIZE_AXIS_FALLBACK (Design „Mosaik“, Runde 3)', () => {
|
||||
expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 3, 9, 'se', context)).toEqual({ w: 3, h: 9 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Leinwand: Dashboard wie ein Bild mitskalieren', () => {
|
||||
const ONE = {
|
||||
layouts: { lg: [{ i: 'inst-1', x: 0, y: 0, w: 8, h: 4 }], md: [], sm: [], xs: [], xxs: [] },
|
||||
widgets: [{ id: 'inst-1', widgetType: 'clock', config: {} }],
|
||||
};
|
||||
// jsdom: innerHeight 768, getBoundingClientRect -> 0 -> verfuegbare Hoehe 748.
|
||||
const JSDOM_AVAILABLE_H = 768 - DASHBOARD_BOTTOM_GAP_PX;
|
||||
|
||||
function setInnerHeight(value: number) {
|
||||
Object.defineProperty(window, 'innerHeight', { value, configurable: true, writable: true });
|
||||
}
|
||||
afterEach(() => setInnerHeight(768));
|
||||
|
||||
async function renderGrid(props: {
|
||||
canvas?: { w: number; h: number } | null;
|
||||
onCaptureCanvas?: (area: { w: number; h: number }) => void;
|
||||
}) {
|
||||
captured.props = null;
|
||||
const { DashboardGrid } = await import('./dashboard-grid');
|
||||
const view = render(
|
||||
<DashboardGrid
|
||||
{...ONE}
|
||||
isEditMode={false}
|
||||
onLayoutChange={vi.fn()}
|
||||
onRemoveWidget={vi.fn()}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
const outer = view.container.firstElementChild as HTMLElement;
|
||||
const inner = outer.firstElementChild as HTMLElement;
|
||||
return { outer, inner };
|
||||
}
|
||||
|
||||
describe('computeCanvasScale', () => {
|
||||
it('breitenbegrenzt: freier Platz unten', () => {
|
||||
expect(computeCanvasScale({ w: 1280, h: 900 }, { w: 1600, h: 800 })).toBe(0.8);
|
||||
});
|
||||
it('hoehenbegrenzt: freier Platz rechts', () => {
|
||||
expect(computeCanvasScale({ w: 1920, h: 600 }, { w: 1600, h: 800 })).toBe(0.75);
|
||||
});
|
||||
it('Vergroesserung bei groesserem Bildschirm (s > 1)', () => {
|
||||
expect(computeCanvasScale({ w: 2400, h: 1300 }, { w: 1600, h: 800 })).toBe(1.5);
|
||||
});
|
||||
it('gleiche Flaeche -> 1', () => {
|
||||
expect(computeCanvasScale({ w: 1600, h: 800 }, { w: 1600, h: 800 })).toBe(1);
|
||||
});
|
||||
it('unbrauchbare Hoehe begrenzt nicht, dann zaehlt nur die Breite', () => {
|
||||
expect(computeCanvasScale({ w: 800, h: 0 }, { w: 1600, h: 800 })).toBe(0.5);
|
||||
expect(computeCanvasScale({ w: 800, h: Number.NaN }, { w: 1600, h: 800 })).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scaledPositionStrategy (Ziehen/Groesse aendern unter transform)', () => {
|
||||
it('s = 1 ist die Standardstrategie', () => {
|
||||
expect(scaledPositionStrategy(1)).toBe(defaultPositionStrategy);
|
||||
});
|
||||
|
||||
it('s != 1: transform-Strategie mit scale, OHNE calcDragPosition (RGL rechnet dann relativ zum Raster)', () => {
|
||||
const strategy = scaledPositionStrategy(0.5);
|
||||
expect(strategy.type).toBe('transform');
|
||||
expect(strategy.scale).toBe(0.5);
|
||||
expect(strategy.calcDragPosition).toBeUndefined();
|
||||
expect(strategy.calcStyle({ left: 10, top: 20, width: 30, height: 40 })).toEqual(
|
||||
defaultPositionStrategy.calcStyle({ left: 10, top: 20, width: 30, height: 40 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('Grund: createScaledStrategy rechnet den Ziehstart in Fensterkoordinaten (ohne Raster-Versatz)', () => {
|
||||
// Kachel sichtbar bei clientX 300 (Raster beginnt bei 250 neben der
|
||||
// Seitenleiste, Kachel also 50 px im Raster), Maus 10 px in der Kachel.
|
||||
const pos = createScaledStrategy(0.5).calcDragPosition?.(310, 110, 10, 10);
|
||||
// Richtig waere (300 - 250) / 0.5 = 100; geliefert wird 300 / 0.5 = 600.
|
||||
expect(pos?.left).toBe(600);
|
||||
});
|
||||
});
|
||||
|
||||
it('skalierter Modus: virtuelle Breite canvas.w, transform scale(s) links oben, sichtbare Hoehe = Rasterhoehe x s', async () => {
|
||||
stubResizeObserver({ width: 1000, height: 800 });
|
||||
const { outer, inner } = await renderGrid({ canvas: { w: 2000, h: 800 } });
|
||||
|
||||
// s = min(1000 / 2000, 748 / 800) = 0.5
|
||||
expect(captured.props?.width).toBe(2000);
|
||||
expect(inner.style.width).toBe('2000px');
|
||||
expect(inner.style.transform).toBe('scale(0.5)');
|
||||
expect(inner.style.transformOrigin).toBe('top left');
|
||||
expect(outer.style.height).toBe('400px');
|
||||
expect(outer.style.overflow).toBe('hidden');
|
||||
const strategy = captured.props?.positionStrategy as { scale: number };
|
||||
expect(strategy.scale).toBe(0.5);
|
||||
});
|
||||
|
||||
it('skalierter Modus hoehenbegrenzt und vergroessernd', async () => {
|
||||
stubResizeObserver({ width: 1000, height: 800 });
|
||||
setInnerHeight(420); // verfuegbar 400
|
||||
const first = await renderGrid({ canvas: { w: 1000, h: 800 } });
|
||||
expect(first.inner.style.transform).toBe('scale(0.5)');
|
||||
cleanup();
|
||||
|
||||
setInnerHeight(768);
|
||||
const second = await renderGrid({ canvas: { w: 800, h: 374 } });
|
||||
// min(1000 / 800, 748 / 374) = 1.25
|
||||
expect(second.inner.style.transform).toBe('scale(1.25)');
|
||||
expect(captured.props?.width).toBe(800);
|
||||
});
|
||||
|
||||
it('schmal (< 768): wie bisher — Containerbreite, kein transform, keine Erfassung', async () => {
|
||||
stubResizeObserver({ width: 600, height: 800 });
|
||||
const onCaptureCanvas = vi.fn();
|
||||
const { outer, inner } = await renderGrid({ canvas: { w: 1600, h: 800 }, onCaptureCanvas });
|
||||
expect(captured.props?.width).toBe(600);
|
||||
expect(inner.style.transform).toBe('');
|
||||
expect(outer.style.height).toBe('');
|
||||
expect(captured.props?.positionStrategy).toBe(defaultPositionStrategy);
|
||||
|
||||
cleanup();
|
||||
await renderGrid({ canvas: null, onCaptureCanvas });
|
||||
expect(onCaptureCanvas).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ohne Leinwand am Desktop: unskaliert und die verfuegbare Flaeche wird zum Erfassen gemeldet', async () => {
|
||||
stubResizeObserver({ width: 1000, height: 800 });
|
||||
const onCaptureCanvas = vi.fn();
|
||||
const { inner } = await renderGrid({ canvas: null, onCaptureCanvas });
|
||||
expect(captured.props?.width).toBe(1000);
|
||||
expect(inner.style.transform).toBe('');
|
||||
expect(onCaptureCanvas).toHaveBeenCalledWith({ w: 1000, h: JSDOM_AVAILABLE_H });
|
||||
});
|
||||
|
||||
it('mit Leinwand wird nicht erneut erfasst', async () => {
|
||||
stubResizeObserver({ width: 1000, height: 800 });
|
||||
const onCaptureCanvas = vi.fn();
|
||||
await renderGrid({ canvas: { w: 1000, h: 748 }, onCaptureCanvas });
|
||||
expect(onCaptureCanvas).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,10 +3,17 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Compactor, ResponsiveLayouts } from 'react-grid-layout';
|
||||
import { noCompactor, Responsive } from 'react-grid-layout';
|
||||
import { defaultConstraints, type LayoutConstraint } from 'react-grid-layout/core';
|
||||
import {
|
||||
defaultConstraints,
|
||||
defaultPositionStrategy,
|
||||
type LayoutConstraint,
|
||||
type PositionStrategy,
|
||||
transformStrategy,
|
||||
} from 'react-grid-layout/core';
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import 'react-resizable/css/styles.css';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { CANVAS_MIN_HEIGHT, CANVAS_MIN_WIDTH, type GridCanvas } from '@/lib/grid-layout-migration';
|
||||
import {
|
||||
clampWidthToCols,
|
||||
GRID_COLS,
|
||||
@@ -101,6 +108,66 @@ const GRID_CONSTRAINTS: LayoutConstraint[] = [...defaultConstraints, RESIZE_AXIS
|
||||
|
||||
const GRID_MARGIN = 12;
|
||||
|
||||
/**
|
||||
* Dashboard wie ein Bild mitskalieren (Leinwand).
|
||||
*
|
||||
* Wunsch: an PC 1 eingerichtet, an PC 2 mit anderer Aufloesung GENAU gleich
|
||||
* aussehen — alles (auch die Schrift) gleichmaessig kleiner oder groesser,
|
||||
* nie scrollen; passt die Bildschirmform nicht, bleibt rechts oder unten
|
||||
* Platz frei.
|
||||
*
|
||||
* Umsetzung: Jeder Reiter hat eine Leinwand `{ w, h }` (gespeichert als
|
||||
* `__canvas`, siehe grid-layout-migration.ts) — die verfuegbare Flaeche, auf
|
||||
* der er eingerichtet wurde (erfasst beim ersten Oeffnen am Desktop, danach
|
||||
* fest). Wo die Leinwand erfasst wurde, sieht das Dashboard 1:1 aus. Auf
|
||||
* jedem Desktop-Bildschirm zeichnet react-grid-layout das Raster mit der
|
||||
* VIRTUELLEN Breite `canvas.w` — Breakpoint und Spaltenbreite sind damit
|
||||
* ueberall identisch — und ein umgebendes Element verkleinert/vergroessert
|
||||
* das Ergebnis per `transform: scale(s)` mit
|
||||
* `s = min(Breite / canvas.w, Hoehe / canvas.h)` (computeCanvasScale),
|
||||
* links oben ausgerichtet (nicht zentriert, bewusst entfernt in bc4c011).
|
||||
*
|
||||
* Unter CANVAS_MIN_WIDTH (Handy/schmal) bleibt alles wie zuvor: Breite =
|
||||
* gemessene Containerbreite, kein transform, keine Erfassung.
|
||||
*/
|
||||
/** Abstand unter dem Raster bis zur Fensterkante: main p-3 (12) + Wrapper p-2 (8). */
|
||||
export const DASHBOARD_BOTTOM_GAP_PX = 20;
|
||||
|
||||
/**
|
||||
* Skalierungsfaktor der Leinwand fuer die verfuegbare Flaeche: das kleinere
|
||||
* der beiden Verhaeltnisse, damit alles hineinpasst (freier Platz rechts
|
||||
* ODER unten). Eine unbrauchbare Hoehe (<= 0, nicht endlich) begrenzt nicht —
|
||||
* dann entscheidet nur die Breite.
|
||||
*/
|
||||
export function computeCanvasScale(available: GridCanvas, canvas: GridCanvas): number {
|
||||
const byWidth = available.w / canvas.w;
|
||||
const byHeight = available.h / canvas.h;
|
||||
const heightUsable = Number.isFinite(byHeight) && byHeight > 0;
|
||||
const s = heightUsable ? Math.min(byWidth, byHeight) : byWidth;
|
||||
return Number.isFinite(s) && s > 0 ? s : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Positionsstrategie fuer ein per `transform: scale(s)` skaliertes Raster.
|
||||
*
|
||||
* Bewusst NICHT `createScaledStrategy(s)` aus react-grid-layout 2.2.3: deren
|
||||
* `calcDragPosition` rechnet `(clientX - offsetX) / scale` =
|
||||
* `clientRect.left / scale` (chunk-KDANGDDL.mjs:315-320) — eine FENSTER-
|
||||
* Koordinate ohne Abzug der Raster-Oberkante/-Linkskante. Im Portal liegt das
|
||||
* Raster rechts neben der Seitenleiste; die Kachel spraenge beim Ziehstart um
|
||||
* (Seitenleiste + Abstand) / s Pixel nach rechts unten. Ohne
|
||||
* `calcDragPosition` nimmt react-grid-layout den eigenen Weg
|
||||
* (chunk-WGL5FSZH.mjs:146-163): `(clientRect.left - parentRect.left) /
|
||||
* transformScale` — relativ zum Raster und korrekt entskaliert. Die
|
||||
* Mausbewegungen teilen `DraggableCore` (Prop `scale`) bzw. `Resizable`
|
||||
* (`deltaX / transformScale`) selbst durch `scale`; beide lesen ihn aus
|
||||
* `positionStrategy.scale` (chunk-WGL5FSZH.mjs:675, 512, 527).
|
||||
*/
|
||||
export function scaledPositionStrategy(scale: number): PositionStrategy {
|
||||
if (scale === 1) return defaultPositionStrategy;
|
||||
return { type: 'transform', scale, calcStyle: transformStrategy.calcStyle };
|
||||
}
|
||||
|
||||
interface LayoutItemShape {
|
||||
i: string;
|
||||
x: number;
|
||||
@@ -121,6 +188,10 @@ interface DashboardGridProps {
|
||||
onOpenCatalog?: () => void;
|
||||
/** Leerer Zustand: fuegt eine vorgeschlagene Kachel direkt hinzu. */
|
||||
onQuickAdd?: (type: WidgetType) => void;
|
||||
/** Leinwand des aktiven Reiters; null = noch nicht erfasst (dann unskaliert). */
|
||||
canvas?: GridCanvas | null;
|
||||
/** Wird mit der verfuegbaren Flaeche gerufen, solange `canvas` fehlt (nur Desktop). */
|
||||
onCaptureCanvas?: (area: GridCanvas) => void;
|
||||
}
|
||||
|
||||
/** Vorschlaege im leeren Zustand — Plattform-Kacheln, immer verfuegbar. */
|
||||
@@ -224,9 +295,15 @@ export function DashboardGrid({
|
||||
onRemoveWidget,
|
||||
onOpenCatalog,
|
||||
onQuickAdd,
|
||||
canvas = null,
|
||||
onCaptureCanvas,
|
||||
}: DashboardGridProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const [width, setWidth] = useState(1200);
|
||||
// Verfuegbare Hoehe bis zur unteren Fensterkante (Leinwand). 0 = unbekannt.
|
||||
const [availableHeight, setAvailableHeight] = useState(0);
|
||||
// Unskalierte Hoehe des Rasters (inneres Element) fuer die sichtbare Hoehe.
|
||||
const [innerHeight, setInnerHeight] = useState(0);
|
||||
|
||||
// quick-260922-vdk: Messung haengt am eingehaengten Knoten (Ref-Rueckruf),
|
||||
// nicht mehr an einem Effekt mit leerer Abhaengigkeitsliste.
|
||||
@@ -265,6 +342,16 @@ export function DashboardGrid({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Leinwand: Hoehe = Fensterhoehe minus Oberkante des Rasters im DOKUMENT
|
||||
// (rect.top + scrollY, also unabhaengig vom Scrollstand) minus Abstand
|
||||
// unten. Das aeussere Element ist selbst nicht transformiert, seine
|
||||
// Oberkante ist also die echte.
|
||||
const applyHeight = useCallback((node: HTMLElement) => {
|
||||
const top = node.getBoundingClientRect().top + window.scrollY;
|
||||
const next = window.innerHeight - top - DASHBOARD_BOTTOM_GAP_PX;
|
||||
setAvailableHeight(Number.isFinite(next) && next > 0 ? Math.floor(next) : 0);
|
||||
}, []);
|
||||
|
||||
const measureRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
// Ein eventuell laufender Beobachter zuerst trennen — auch der
|
||||
@@ -277,27 +364,64 @@ export function DashboardGrid({
|
||||
|
||||
// Synchrone Erstmessung in der Commit-Phase, vor dem ersten Zeichnen.
|
||||
applyWidth(node.getBoundingClientRect().width);
|
||||
applyHeight(node);
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
applyWidth(entries[0].contentRect.width);
|
||||
applyHeight(node);
|
||||
});
|
||||
observer.observe(node);
|
||||
observerRef.current = observer;
|
||||
// Bewusst keine Aufraeumfunktion zurueckgeben: React 19 ruft den
|
||||
// Ref-Rueckruf sonst beim Aushaengen nicht mehr mit null auf.
|
||||
},
|
||||
[applyWidth],
|
||||
[applyWidth, applyHeight],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
if (nodeRef.current) {
|
||||
applyWidth(nodeRef.current.getBoundingClientRect().width);
|
||||
applyHeight(nodeRef.current);
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [applyWidth]);
|
||||
}, [applyWidth, applyHeight]);
|
||||
|
||||
// Unskalierte Hoehe des inneren Elements (react-grid-layout setzt die
|
||||
// Rasterhoehe selbst). `contentRect`/`offsetHeight` sind Layout-Masse und
|
||||
// vom transform unberuehrt — genau das Gewuenschte.
|
||||
const innerObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const innerRef = useCallback((node: HTMLDivElement | null) => {
|
||||
innerObserverRef.current?.disconnect();
|
||||
innerObserverRef.current = null;
|
||||
if (!node) return;
|
||||
if (node.offsetHeight > 0) setInnerHeight(node.offsetHeight);
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const h = entries[0].contentRect.height;
|
||||
if (Number.isFinite(h) && h > 0) setInnerHeight(h);
|
||||
});
|
||||
observer.observe(node);
|
||||
innerObserverRef.current = observer;
|
||||
}, []);
|
||||
|
||||
// Desktop = verfuegbare Breite ab CANVAS_MIN_WIDTH; nur dort Leinwand.
|
||||
const isDesktop = width >= CANVAS_MIN_WIDTH;
|
||||
const scaled = isDesktop && canvas !== null;
|
||||
const scale = scaled ? computeCanvasScale({ w: width, h: availableHeight }, canvas) : 1;
|
||||
const positionStrategy = useMemo(() => scaledPositionStrategy(scale), [scale]);
|
||||
|
||||
// Leinwand erfassen: nur mit eingehaengtem Raster (also mit Kacheln), am
|
||||
// Desktop und mit brauchbarer Hoehe. Der Store uebernimmt sie einmal und
|
||||
// ignoriert weitere Aufrufe; danach ist `canvas` gesetzt und dieser
|
||||
// Effekt tut nichts mehr.
|
||||
const hasWidgets = widgets.length > 0;
|
||||
useEffect(() => {
|
||||
if (!onCaptureCanvas || canvas !== null || !hasWidgets) return;
|
||||
if (!isDesktop || availableHeight < CANVAS_MIN_HEIGHT) return;
|
||||
onCaptureCanvas({ w: width, h: availableHeight });
|
||||
}, [onCaptureCanvas, canvas, hasWidgets, isDesktop, width, availableHeight]);
|
||||
|
||||
// quick-260916-dyv: minW/minH (und zu kleine w/h) aus WIDGET_CONSTRAINTS —
|
||||
// siehe applyConstraintMinima. Vor dem Leerzustand, damit die Hook-Reihenfolge
|
||||
@@ -374,56 +498,72 @@ export function DashboardGrid({
|
||||
);
|
||||
}
|
||||
|
||||
// Gleiche DOM-Struktur in beiden Modi, damit react-grid-layout beim
|
||||
// Wechsel (etwa direkt nach dem Erfassen, s = 1) nicht neu einhaengt.
|
||||
// Aeusseres Element: volle Breite (Messung), im skalierten Modus die
|
||||
// sichtbare Hoehe = unskalierte Rasterhoehe x s und `overflow: hidden` —
|
||||
// der transform aendert die Layout-Box nicht, ohne beides bliebe toter
|
||||
// Scrollraum (s < 1) bzw. ragte die 1:1-Breite seitlich hinaus.
|
||||
const outerStyle: React.CSSProperties | undefined = scaled
|
||||
? { overflow: 'hidden', ...(innerHeight > 0 ? { height: innerHeight * scale } : {}) }
|
||||
: undefined;
|
||||
const innerStyle: React.CSSProperties | undefined = scaled
|
||||
? { width: canvas.w, transform: `scale(${scale})`, transformOrigin: 'top left' }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div ref={measureRef}>
|
||||
<Responsive
|
||||
width={width}
|
||||
breakpoints={BREAKPOINTS}
|
||||
cols={COLS}
|
||||
layouts={effectiveLayouts as ResponsiveLayouts}
|
||||
rowHeight={20}
|
||||
margin={[GRID_MARGIN, GRID_MARGIN] as [number, number]}
|
||||
constraints={GRID_CONSTRAINTS}
|
||||
// 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_SELECTOR,
|
||||
cancel: WIDGET_DRAG_CANCEL_SELECTOR,
|
||||
threshold: 3,
|
||||
}}
|
||||
resizeConfig={{
|
||||
enabled: isEditMode,
|
||||
}}
|
||||
compactor={FREE_PLACEMENT_COMPACTOR}
|
||||
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
|
||||
>
|
||||
{widgets.map((widget, index) => {
|
||||
const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType];
|
||||
return (
|
||||
<div
|
||||
key={widget.id}
|
||||
data-grid={{
|
||||
...(effectiveLayouts.lg?.find((l) => l.i === widget.id) ?? {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: constraints?.defaultW ?? 8,
|
||||
h: constraints?.defaultH ?? 4,
|
||||
}),
|
||||
minW: constraints?.minW ?? 8,
|
||||
minH: constraints?.minH ?? 4,
|
||||
}}
|
||||
>
|
||||
<WidgetWrapper
|
||||
widget={widget}
|
||||
isEditMode={isEditMode}
|
||||
onRemove={onRemoveWidget}
|
||||
enterIndex={index}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Responsive>
|
||||
<div ref={measureRef} style={outerStyle} data-canvas-scale={scaled ? scale : undefined}>
|
||||
<div ref={innerRef} style={innerStyle}>
|
||||
<Responsive
|
||||
width={scaled ? canvas.w : width}
|
||||
positionStrategy={positionStrategy}
|
||||
breakpoints={BREAKPOINTS}
|
||||
cols={COLS}
|
||||
layouts={effectiveLayouts as ResponsiveLayouts}
|
||||
rowHeight={20}
|
||||
margin={[GRID_MARGIN, GRID_MARGIN] as [number, number]}
|
||||
constraints={GRID_CONSTRAINTS}
|
||||
// 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_SELECTOR,
|
||||
cancel: WIDGET_DRAG_CANCEL_SELECTOR,
|
||||
threshold: 3,
|
||||
}}
|
||||
resizeConfig={{
|
||||
enabled: isEditMode,
|
||||
}}
|
||||
compactor={FREE_PLACEMENT_COMPACTOR}
|
||||
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
|
||||
>
|
||||
{widgets.map((widget, index) => {
|
||||
const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType];
|
||||
return (
|
||||
<div
|
||||
key={widget.id}
|
||||
data-grid={{
|
||||
...(effectiveLayouts.lg?.find((l) => l.i === widget.id) ?? {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: constraints?.defaultW ?? 8,
|
||||
h: constraints?.defaultH ?? 4,
|
||||
}),
|
||||
minW: constraints?.minW ?? 8,
|
||||
minH: constraints?.minH ?? 4,
|
||||
}}
|
||||
>
|
||||
<WidgetWrapper
|
||||
widget={widget}
|
||||
isEditMode={isEditMode}
|
||||
onRemove={onRemoveWidget}
|
||||
enterIndex={index}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Responsive>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
GRID_CANVAS_KEY,
|
||||
GRID_SCALE_FACTOR,
|
||||
GRID_VERSION,
|
||||
GRID_VERSION_KEY,
|
||||
@@ -192,10 +193,30 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
|
||||
expect(r.layouts.sm[0]).toEqual({ i: 'a', x: 4, y: 2, w: 4, h: 2 });
|
||||
expect(r.migrated).toBe(true);
|
||||
|
||||
expect(migrateGridLayouts(null)).toEqual({ layouts: {}, migrated: false, newer: false });
|
||||
expect(migrateGridLayouts(undefined)).toEqual({ layouts: {}, migrated: false, newer: false });
|
||||
expect(migrateGridLayouts(42)).toEqual({ layouts: {}, migrated: false, newer: false });
|
||||
expect(migrateGridLayouts([1, 2])).toEqual({ layouts: {}, migrated: false, newer: false });
|
||||
expect(migrateGridLayouts(null)).toEqual({
|
||||
layouts: {},
|
||||
migrated: false,
|
||||
newer: false,
|
||||
canvas: null,
|
||||
});
|
||||
expect(migrateGridLayouts(undefined)).toEqual({
|
||||
layouts: {},
|
||||
migrated: false,
|
||||
newer: false,
|
||||
canvas: null,
|
||||
});
|
||||
expect(migrateGridLayouts(42)).toEqual({
|
||||
layouts: {},
|
||||
migrated: false,
|
||||
newer: false,
|
||||
canvas: null,
|
||||
});
|
||||
expect(migrateGridLayouts([1, 2])).toEqual({
|
||||
layouts: {},
|
||||
migrated: false,
|
||||
newer: false,
|
||||
canvas: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('Test 8 (quick-260930): nur eine HOEHERE Marke meldet newer — v1/v2/v3 nicht', () => {
|
||||
@@ -209,3 +230,58 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
|
||||
expect(future.layouts).toEqual(alt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('grid-layout-migration: Leinwand `__canvas` (Dashboard wie ein Bild mitskalieren)', () => {
|
||||
const v3 = () => ({ lg: [{ i: 'a', x: 0, y: 0, w: 8, h: 4 }], [GRID_VERSION_KEY]: GRID_VERSION });
|
||||
|
||||
it('liest eine gueltige Leinwand und laesst sie aus `layouts` heraus', () => {
|
||||
const r = migrateGridLayouts({ ...v3(), [GRID_CANVAS_KEY]: { w: 1600, h: 820 } });
|
||||
expect(r.canvas).toEqual({ w: 1600, h: 820 });
|
||||
expect(Object.keys(r.layouts)).toEqual(['lg']);
|
||||
expect(r.migrated).toBe(false);
|
||||
});
|
||||
|
||||
it('fehlende oder ungueltige Werte -> null', () => {
|
||||
const bad: unknown[] = [
|
||||
undefined,
|
||||
null,
|
||||
'x',
|
||||
[1600, 800],
|
||||
{ w: 1600 },
|
||||
{ w: '1600', h: 800 },
|
||||
{ w: Number.NaN, h: 800 },
|
||||
{ w: Number.POSITIVE_INFINITY, h: 800 },
|
||||
{ w: 767, h: 800 },
|
||||
{ w: 1600, h: 199 },
|
||||
];
|
||||
for (const value of bad) {
|
||||
expect(migrateGridLayouts({ ...v3(), [GRID_CANVAS_KEY]: value }).canvas).toBeNull();
|
||||
}
|
||||
// Grenzwerte sind gueltig.
|
||||
expect(migrateGridLayouts({ ...v3(), [GRID_CANVAS_KEY]: { w: 768, h: 200 } }).canvas).toEqual({
|
||||
w: 768,
|
||||
h: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it('withGridVersion schreibt die Leinwand nur, wenn eine da ist', () => {
|
||||
const { layouts } = migrateGridLayouts(v3());
|
||||
expect(withGridVersion(layouts)).not.toHaveProperty(GRID_CANVAS_KEY);
|
||||
expect(withGridVersion(layouts, null)).not.toHaveProperty(GRID_CANVAS_KEY);
|
||||
expect(withGridVersion(layouts, { w: 1920, h: 950 })).toEqual({
|
||||
...layouts,
|
||||
[GRID_VERSION_KEY]: GRID_VERSION,
|
||||
[GRID_CANVAS_KEY]: { w: 1920, h: 950 },
|
||||
});
|
||||
});
|
||||
|
||||
it('Idempotenz mit Leinwand: Laden -> Speichern -> Laden liefert dasselbe, nichts skaliert', () => {
|
||||
const once = migrateGridLayouts({ ...altLayouts(), [GRID_CANVAS_KEY]: { w: 1400, h: 700 } });
|
||||
expect(once.migrated).toBe(true);
|
||||
expect(once.canvas).toEqual({ w: 1400, h: 700 });
|
||||
const twice = migrateGridLayouts(withGridVersion(once.layouts, once.canvas));
|
||||
expect(twice.migrated).toBe(false);
|
||||
expect(twice.layouts).toEqual(once.layouts);
|
||||
expect(twice.canvas).toEqual(once.canvas);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,19 @@
|
||||
* wuerde sie mit der alten Marke zurueckschreiben, und der neue Stand wuerde
|
||||
* sie beim naechsten Laden ein zweites Mal skalieren.
|
||||
*
|
||||
* Leinwand (Bezugsgroesse, "wie ein Bild mitskalieren"): Zusaetzlich kann das
|
||||
* JSON den Schluessel `__canvas: { w, h }` tragen — die verfuegbare Flaeche
|
||||
* (Pixel), auf der die Anordnung eingerichtet wurde. Das Raster zeichnet
|
||||
* sich auf JEDEM Desktop-Bildschirm mit genau dieser Breite und skaliert das
|
||||
* Ergebnis per `transform: scale` in die dortige Flaeche (siehe
|
||||
* dashboard-grid.tsx). Wie der Marker lebt `__canvas` nur im JSON, nicht in
|
||||
* `layouts`: `migrateGridLayouts` liefert ihn getrennt als `canvas`,
|
||||
* `withGridVersion(layouts, canvas)` schreibt ihn zurueck — JEDER
|
||||
* Speicherpfad muss ihn mitgeben, sonst geht er verloren. Kein neuer
|
||||
* `GRID_VERSION`: aeltere Programmstaende ueberspringen den Schluessel, weil
|
||||
* sein Wert kein Array ist (`!Array.isArray(value)` unten) — und schreiben
|
||||
* ihn beim Speichern nicht zurueck; dann wird er einfach neu erfasst.
|
||||
*
|
||||
* 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
|
||||
@@ -50,6 +63,17 @@
|
||||
export const GRID_VERSION = 3;
|
||||
export const GRID_VERSION_KEY = '__gridVersion';
|
||||
export const GRID_SCALE_FACTOR = 2;
|
||||
export const GRID_CANVAS_KEY = '__canvas';
|
||||
/** Unter dieser Breite gilt die Handy-/Schmalansicht, dort wird nicht skaliert. */
|
||||
export const CANVAS_MIN_WIDTH = 768;
|
||||
/** Kleinere Hoehen sind Messfehler (Fenster kurz zusammengeklappt o. ae.). */
|
||||
export const CANVAS_MIN_HEIGHT = 200;
|
||||
|
||||
/** Bezugsflaeche in Pixeln, auf der die Anordnung eingerichtet wurde. */
|
||||
export interface GridCanvas {
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface GridLayoutItem {
|
||||
i: string;
|
||||
@@ -74,6 +98,19 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft eine Leinwand: endliche Zahlen, w >= CANVAS_MIN_WIDTH,
|
||||
* h >= CANVAS_MIN_HEIGHT. Alles andere -> null (wird dann neu erfasst).
|
||||
*/
|
||||
export function parseGridCanvas(value: unknown): GridCanvas | null {
|
||||
if (!isPlainObject(value)) return null;
|
||||
const { w, h } = value;
|
||||
if (typeof w !== 'number' || typeof h !== 'number') return null;
|
||||
if (!Number.isFinite(w) || !Number.isFinite(h)) return null;
|
||||
if (w < CANVAS_MIN_WIDTH || h < CANVAS_MIN_HEIGHT) return null;
|
||||
return { w, h };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechnet eine rohe (aus der API geladene) Anordnung in die aktuellen
|
||||
* Raster-Einheiten um. Liefert die Anordnung OHNE Marker und die Angabe, ob
|
||||
@@ -84,9 +121,11 @@ export function migrateGridLayouts(raw: unknown): {
|
||||
migrated: boolean;
|
||||
/** Marke hoeher als `GRID_VERSION` — Anordnung stammt von einem neueren Programmstand. */
|
||||
newer: boolean;
|
||||
/** Gueltige Leinwand aus `__canvas`, sonst null. */
|
||||
canvas: GridCanvas | null;
|
||||
} {
|
||||
if (!isPlainObject(raw)) {
|
||||
return { layouts: {}, migrated: false, newer: false };
|
||||
return { layouts: {}, migrated: false, newer: false, canvas: null };
|
||||
}
|
||||
|
||||
const markerValue = raw[GRID_VERSION_KEY];
|
||||
@@ -100,7 +139,7 @@ export function migrateGridLayouts(raw: unknown): {
|
||||
let migrated = false;
|
||||
|
||||
for (const key of Object.keys(raw)) {
|
||||
if (key === GRID_VERSION_KEY) continue;
|
||||
if (key === GRID_VERSION_KEY || key === GRID_CANVAS_KEY) continue;
|
||||
const value = raw[key];
|
||||
if (!Array.isArray(value)) continue;
|
||||
|
||||
@@ -121,12 +160,18 @@ export function migrateGridLayouts(raw: unknown): {
|
||||
});
|
||||
}
|
||||
|
||||
return { layouts, migrated, newer };
|
||||
return { layouts, migrated, newer, canvas: parseGridCanvas(raw[GRID_CANVAS_KEY]) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Haengt den Marker fuer das Speichern an, ohne die Eingabe zu veraendern.
|
||||
* Haengt den Marker (und, falls vorhanden, die Leinwand) fuer das Speichern
|
||||
* an, ohne die Eingabe zu veraendern.
|
||||
*/
|
||||
export function withGridVersion(layouts: GridLayouts): Record<string, unknown> {
|
||||
return { ...layouts, [GRID_VERSION_KEY]: GRID_VERSION };
|
||||
export function withGridVersion(
|
||||
layouts: GridLayouts,
|
||||
canvas?: GridCanvas | null,
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = { ...layouts, [GRID_VERSION_KEY]: GRID_VERSION };
|
||||
if (canvas) result[GRID_CANVAS_KEY] = { w: canvas.w, h: canvas.h };
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ beforeEach(() => {
|
||||
isLoading: false,
|
||||
error: null,
|
||||
layoutFromNewerVersion: false,
|
||||
canvas: null,
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1]);
|
||||
@@ -454,3 +455,106 @@ describe('dashboard-store — optimistische Kachel-Konfiguration (quick-260930)'
|
||||
expect(useDashboardStore.getState().widgets[0].config).toEqual({ hideTitle: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('dashboard-store — Leinwand (Dashboard wie ein Bild mitskalieren)', () => {
|
||||
const V3 = { lg: [{ i: 'a', x: 0, y: 0, w: 8, h: 4 }], __gridVersion: 3 };
|
||||
|
||||
it('Test 24: Reiter ohne Leinwand -> captureCanvas setzt sie und speichert SOFORT mit __canvas und Marker', async () => {
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3 });
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
expect(useDashboardStore.getState().canvas).toBeNull();
|
||||
expect(api.saveLayout).not.toHaveBeenCalled();
|
||||
|
||||
await useDashboardStore.getState().captureCanvas({ w: 1600.4, h: 820.2 });
|
||||
|
||||
expect(useDashboardStore.getState().canvas).toEqual({ w: 1600, h: 820 });
|
||||
expect(api.saveLayout).toHaveBeenCalledTimes(1);
|
||||
expect(api.saveLayout).toHaveBeenCalledWith('dash-1', {
|
||||
lg: V3.lg,
|
||||
__gridVersion: 3,
|
||||
__canvas: { w: 1600, h: 820 },
|
||||
});
|
||||
|
||||
// Danach aendert sie sich nicht mehr automatisch.
|
||||
await useDashboardStore.getState().captureCanvas({ w: 1280, h: 700 });
|
||||
expect(useDashboardStore.getState().canvas).toEqual({ w: 1600, h: 820 });
|
||||
expect(api.saveLayout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Test 25: geladene Leinwand steht im Zustand, wird nicht neu erfasst, und saveLayout schreibt sie mit', async () => {
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3, __canvas: { w: 1920, h: 950 } });
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
expect(useDashboardStore.getState().canvas).toEqual({ w: 1920, h: 950 });
|
||||
|
||||
await useDashboardStore.getState().captureCanvas({ w: 1280, h: 700 });
|
||||
expect(api.saveLayout).not.toHaveBeenCalled();
|
||||
|
||||
await useDashboardStore.getState().saveLayout();
|
||||
expect(api.saveLayout).toHaveBeenCalledWith('dash-1', {
|
||||
lg: V3.lg,
|
||||
__gridVersion: 3,
|
||||
__canvas: { w: 1920, h: 950 },
|
||||
});
|
||||
});
|
||||
|
||||
it('Test 26: Sofort-Speichern nach der Umrechnung behaelt die Leinwand', async () => {
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({
|
||||
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }],
|
||||
__canvas: { w: 1400, h: 700 },
|
||||
});
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
expect(api.saveLayout).toHaveBeenCalledWith(
|
||||
'dash-1',
|
||||
expect.objectContaining({ __gridVersion: 3, __canvas: { w: 1400, h: 700 } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 27: Reiterwechsel setzt die Leinwand des neuen Reiters (auch null)', async () => {
|
||||
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]);
|
||||
vi.mocked(api.fetchLayout).mockImplementation(async (id: string) =>
|
||||
id === 'dash-1' ? { ...V3, __canvas: { w: 1920, h: 950 } } : { ...V3 },
|
||||
);
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
expect(useDashboardStore.getState().canvas).toEqual({ w: 1920, h: 950 });
|
||||
|
||||
await useDashboardStore.getState().selectDashboard('dash-2');
|
||||
expect(useDashboardStore.getState().canvas).toBeNull();
|
||||
|
||||
// Erfassen gilt nun fuer dash-2.
|
||||
await useDashboardStore.getState().captureCanvas({ w: 1280, h: 700 });
|
||||
expect(api.saveLayout).toHaveBeenLastCalledWith(
|
||||
'dash-2',
|
||||
expect.objectContaining({ __canvas: { w: 1280, h: 700 } }),
|
||||
);
|
||||
|
||||
await useDashboardStore.getState().selectDashboard('dash-1');
|
||||
expect(useDashboardStore.getState().canvas).toEqual({ w: 1920, h: 950 });
|
||||
});
|
||||
|
||||
it('Test 28: nicht erfassen bei Anordnung eines neueren Programmstands oder bei zu kleiner Flaeche', async () => {
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3, __gridVersion: 99 });
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
await useDashboardStore.getState().captureCanvas({ w: 1600, h: 800 });
|
||||
expect(useDashboardStore.getState().canvas).toBeNull();
|
||||
expect(api.saveLayout).not.toHaveBeenCalled();
|
||||
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3 });
|
||||
useDashboardStore.setState({ activeDashboardId: null });
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
await useDashboardStore.getState().captureCanvas({ w: 767, h: 800 });
|
||||
await useDashboardStore.getState().captureCanvas({ w: 1600, h: 150 });
|
||||
expect(useDashboardStore.getState().canvas).toBeNull();
|
||||
expect(api.saveLayout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Test 29: scheitert das Speichern beim Erfassen, bleibt die Leinwand im Zustand, console.error einmal', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3 });
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
vi.mocked(api.saveLayout).mockRejectedValueOnce(new Error('offline'));
|
||||
await useDashboardStore.getState().captureCanvas({ w: 1600, h: 800 });
|
||||
expect(useDashboardStore.getState().canvas).toEqual({ w: 1600, h: 800 });
|
||||
expect(useDashboardStore.getState().error).toBeNull();
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,14 @@ import type { WidgetType } from '@/components/dashboard/widget-registry';
|
||||
import { clampWidthToCols, WIDGET_CONSTRAINTS } from '@/components/dashboard/widget-registry';
|
||||
import type { DashboardTab } from '@/lib/dashboard-api';
|
||||
import * as api from '@/lib/dashboard-api';
|
||||
import { migrateGridLayouts, withGridVersion } from '@/lib/grid-layout-migration';
|
||||
import {
|
||||
CANVAS_MIN_HEIGHT,
|
||||
CANVAS_MIN_WIDTH,
|
||||
type GridCanvas,
|
||||
migrateGridLayouts,
|
||||
parseGridCanvas,
|
||||
withGridVersion,
|
||||
} from '@/lib/grid-layout-migration';
|
||||
|
||||
export interface WidgetInstance {
|
||||
id: string;
|
||||
@@ -28,6 +35,12 @@ interface DashboardState {
|
||||
* Bearbeitungsmodus bleibt gesperrt; die Oberflaeche bittet ums Neuladen.
|
||||
*/
|
||||
layoutFromNewerVersion: boolean;
|
||||
/**
|
||||
* Leinwand des aktiven Reiters (Bezugsflaeche in Pixeln), siehe
|
||||
* `captureCanvas`. Gehoert wie `layouts` zum aktiven Reiter; null =
|
||||
* noch nicht erfasst.
|
||||
*/
|
||||
canvas: GridCanvas | null;
|
||||
|
||||
setEditMode: (mode: boolean) => void;
|
||||
updateLayouts: (layouts: Record<string, unknown>) => void;
|
||||
@@ -38,6 +51,8 @@ interface DashboardState {
|
||||
loadDashboard: () => Promise<void>;
|
||||
saveLayout: () => Promise<void>;
|
||||
selectDashboard: (id: string) => Promise<void>;
|
||||
/** Erfasst einmalig die Leinwand des aktiven Reiters und speichert sie sofort. */
|
||||
captureCanvas: (area: GridCanvas) => Promise<void>;
|
||||
createDashboard: () => Promise<void>;
|
||||
renameDashboard: (id: string, name: string) => Promise<void>;
|
||||
deleteDashboard: (id: string) => Promise<void>;
|
||||
@@ -92,6 +107,14 @@ function loadDashboardsOnce(): Promise<DashboardTab[]> {
|
||||
* ist alt), setzt der Store `layoutFromNewerVersion`: `saveLayout` speichert
|
||||
* dann nie, `setEditMode(true)` wird ignoriert, die Oberflaeche bittet ums
|
||||
* Neuladen. Der Merker gilt je geladenem Reiter.
|
||||
*
|
||||
* Leinwand (Dashboard wie ein Bild mitskalieren): `canvas` ist die Flaeche,
|
||||
* auf der der Reiter eingerichtet wurde. Sie lebt im gespeicherten JSON als
|
||||
* `__canvas` und wird — wie der Marker — an JEDER Speicherstelle mit
|
||||
* `withGridVersion(layouts, canvas)` zurueckgeschrieben (Sofort-Speichern nach
|
||||
* der Umrechnung, `saveLayout`, `captureCanvas`); fehlt sie dort, ist sie
|
||||
* verloren und wird beim naechsten Oeffnen am dann benutzten Bildschirm neu
|
||||
* erfasst.
|
||||
*/
|
||||
export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
||||
dashboards: [],
|
||||
@@ -104,6 +127,7 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
||||
isLoading: false,
|
||||
error: null,
|
||||
layoutFromNewerVersion: false,
|
||||
canvas: null,
|
||||
|
||||
setEditMode: (mode: boolean) => {
|
||||
// quick-260930: Anordnung eines neueren Programmstands — nicht bearbeiten.
|
||||
@@ -214,11 +238,12 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
||||
api.fetchLayout(first.id),
|
||||
api.fetchWidgets(first.id),
|
||||
]);
|
||||
const { layouts: migratedLayouts, migrated, newer } = migrateGridLayouts(rawLayouts);
|
||||
const { layouts: migratedLayouts, migrated, newer, canvas } = migrateGridLayouts(rawLayouts);
|
||||
set({
|
||||
dashboards,
|
||||
activeDashboardId: first.id,
|
||||
layouts: migratedLayouts,
|
||||
canvas,
|
||||
widgets,
|
||||
isLoading: false,
|
||||
layoutFromNewerVersion: newer,
|
||||
@@ -229,7 +254,7 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
||||
// try/catch, damit ein Speicherfehler NICHT als Ladefehler erscheint.
|
||||
if (migrated) {
|
||||
try {
|
||||
await api.saveLayout(first.id, withGridVersion(migratedLayouts));
|
||||
await api.saveLayout(first.id, withGridVersion(migratedLayouts, canvas));
|
||||
} catch (err) {
|
||||
console.error('Failed to persist migrated layout:', err);
|
||||
}
|
||||
@@ -249,7 +274,7 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
||||
// sie ginge mit der alten Marke zurueck und wuerde doppelt skaliert.
|
||||
if (get().layoutFromNewerVersion) return;
|
||||
try {
|
||||
await api.saveLayout(dashboardId, withGridVersion(get().layouts));
|
||||
await api.saveLayout(dashboardId, withGridVersion(get().layouts, get().canvas));
|
||||
set({ isDirty: false });
|
||||
} catch (err) {
|
||||
console.error('Failed to save layout:', err);
|
||||
@@ -275,10 +300,11 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
||||
set({ isSwitchingDashboard: true, error: null });
|
||||
try {
|
||||
const [rawLayouts, widgets] = await Promise.all([api.fetchLayout(id), api.fetchWidgets(id)]);
|
||||
const { layouts: migratedLayouts, migrated, newer } = migrateGridLayouts(rawLayouts);
|
||||
const { layouts: migratedLayouts, migrated, newer, canvas } = migrateGridLayouts(rawLayouts);
|
||||
set({
|
||||
activeDashboardId: id,
|
||||
layouts: migratedLayouts,
|
||||
canvas,
|
||||
widgets,
|
||||
isDirty: false,
|
||||
isSwitchingDashboard: false,
|
||||
@@ -287,7 +313,7 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
||||
});
|
||||
if (migrated) {
|
||||
try {
|
||||
await api.saveLayout(id, withGridVersion(migratedLayouts));
|
||||
await api.saveLayout(id, withGridVersion(migratedLayouts, canvas));
|
||||
} catch (err) {
|
||||
console.error('Failed to persist migrated layout:', err);
|
||||
}
|
||||
@@ -298,6 +324,36 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Leinwand erfassen (Dashboard wie ein Bild mitskalieren). Das Raster ruft
|
||||
* das auf, solange der aktive Reiter keine Leinwand hat und die verfuegbare
|
||||
* Flaeche Desktop-Breite hat. Die Flaeche wird EINMAL uebernommen und
|
||||
* sofort gespeichert; danach aendert sie sich nicht mehr automatisch.
|
||||
* Wo die Leinwand erfasst wurde, sieht das Dashboard 1:1 aus — auf jedem
|
||||
* anderen Bildschirm wird es gleichmaessig (auch die Schrift) auf dessen
|
||||
* Flaeche verkleinert oder vergroessert, ohne Scrollen.
|
||||
*
|
||||
* Nichts geschieht bei vorhandener Leinwand, ungueltiger Flaeche (Handy,
|
||||
* Messfehler) oder einer Anordnung eines neueren Programmstands (die wird
|
||||
* nie gespeichert). Eigener try/catch wie beim Sofort-Speichern nach der
|
||||
* Umrechnung: ein Speicherfehler laesst die Leinwand im Zustand stehen, ein
|
||||
* spaeteres `saveLayout` schreibt sie dann mit.
|
||||
*/
|
||||
captureCanvas: async (area: GridCanvas) => {
|
||||
const state = get();
|
||||
const dashboardId = state.activeDashboardId;
|
||||
if (!dashboardId || state.canvas || state.layoutFromNewerVersion) return;
|
||||
if (area.w < CANVAS_MIN_WIDTH || area.h < CANVAS_MIN_HEIGHT) return;
|
||||
const canvas = parseGridCanvas({ w: Math.round(area.w), h: Math.round(area.h) });
|
||||
if (!canvas) return;
|
||||
set({ canvas });
|
||||
try {
|
||||
await api.saveLayout(dashboardId, withGridVersion(state.layouts, canvas));
|
||||
} catch (err) {
|
||||
console.error('Failed to persist dashboard canvas:', err);
|
||||
}
|
||||
},
|
||||
|
||||
/** Legt einen neuen, leeren Reiter an und macht ihn aktiv (D-08). */
|
||||
createDashboard: async () => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user