d9f2af32d6
- Messung an Ref-Rueckruf measureRef gehaengt statt an Effekt mit leerer Abhaengigkeitsliste: der Effekt sah den gemessenen Knoten nie, wenn er waehrend eines leeren Dashboards einhaengt, weil der fruehe Ruecksprung in den Leerzustand das <div> gar nicht rendert - Synchrone Erstmessung in der Commit-Phase, vor dem ersten Zeichnen - Fenster-Horcher als Netz, zusaetzlich zum ResizeObserver - applyWidth verwirft 0 und nicht endliche Werte (T-VDK-01/T-VDK-03) - Beobachter trennt sich im null-Zweig des Ref-Rueckrufs (T-VDK-02) - Zwei neue Regressionstests (Test 10/11), zuerst rot nachgewiesen - FREE_PLACEMENT_COMPACTOR, Konstanten, Leerzustand unveraendert Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
275 lines
12 KiB
TypeScript
275 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { Responsive, noCompactor } from 'react-grid-layout';
|
|
import type { Compactor, ResponsiveLayouts } from 'react-grid-layout';
|
|
import 'react-grid-layout/css/styles.css';
|
|
import 'react-resizable/css/styles.css';
|
|
import { useTranslations } from 'next-intl';
|
|
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: 24, md: 20, sm: 12, xs: 8, xxs: 2 };
|
|
|
|
// quick-260916-dyv: Ziehen zuverlaessig.
|
|
// - Griff ist die GANZE Karte (`widget-drag-handle` in widget-wrapper.tsx).
|
|
// - `cancel` verhindert den Drag-Start an Eingabefeldern, Knoepfen, Links,
|
|
// [contenteditable], [data-no-drag] und der Klasse `widgetNoDrag`
|
|
// (Favoriten-Widget). react-draggable 4.7.0 prueft
|
|
// `cancel` NACH `handle` und vom Ereignisziel aufwaerts bis zum RGL-Element
|
|
// (Draggable.js:417 + matchesSelectorAndParentsTo) — cancel gewinnt also auch
|
|
// INNERHALB des Griffs. RGL haengt `.react-resizable-handle` selbst voran
|
|
// (chunk-WGL5FSZH.mjs:526), der Groessen-Griff funktioniert weiter.
|
|
// - `threshold: 3` = RGL-Standard (Klick vs. Ziehen).
|
|
export const WIDGET_DRAG_HANDLE_SELECTOR = '.widget-drag-handle';
|
|
export const WIDGET_DRAG_CANCEL_SELECTOR =
|
|
'input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag';
|
|
|
|
// quick-260916-dyv: Kein Ueberlappen beim Ablegen, freie Platzierung bleibt.
|
|
// `noCompactor` (Commit c8f3361, "prevent auto-compaction on drag") ist gewollt:
|
|
// Luecken werden nicht geschlossen. OHNE `preventCollision` springt beim Ziehen
|
|
// auf ein belegtes Feld das gezogene Widget auf die Zeile des getroffenen und
|
|
// das getroffene rutscht um seine EIGENE Hoehe nach unten — ohne Kaskade und
|
|
// ohne Aufloesung, Ueberlappungen bleiben, weil `noCompactor.compact` die
|
|
// Identitaet ist (gemessen chunk-76RTO6EO.mjs:279-328); beim Vergroessern in
|
|
// einen Nachbarn entsteht die Ueberlappung stumm (chunk-WGL5FSZH.mjs:872-885).
|
|
// MIT `preventCollision: true` bleibt das gezogene Widget am Ausgangsort
|
|
// (`l.x = oldX; l.y = oldY`), und Vergroessern stoppt am Nachbarn.
|
|
// `preventCollision` lebt am Compactor-Objekt (chunk-WGL5FSZH.mjs:666).
|
|
export const FREE_PLACEMENT_COMPACTOR: Compactor = { ...noCompactor, preventCollision: true };
|
|
|
|
interface LayoutItemShape {
|
|
i: string;
|
|
x: number;
|
|
y: number;
|
|
w: number;
|
|
h: number;
|
|
minW?: number;
|
|
minH?: number;
|
|
}
|
|
|
|
interface DashboardGridProps {
|
|
layouts: Record<string, Array<LayoutItemShape>>;
|
|
widgets: Array<{ id: string; widgetType: string; config: Record<string, unknown> }>;
|
|
isEditMode: boolean;
|
|
onLayoutChange: (allLayouts: ResponsiveLayouts) => void;
|
|
onRemoveWidget: (id: string) => void;
|
|
}
|
|
|
|
/**
|
|
* quick-260916-dyv: Gespeicherte minW/minH aus den Konstanten ueberschreiben.
|
|
*
|
|
* Warum: react-grid-layout 2.2.3 nimmt in `synchronizeLayoutWithChildren`
|
|
* (chunk-WGL5FSZH.mjs:559-562) fuer jedes Kind MIT Eintrag im `layout` den
|
|
* Eintrag per `cloneLayoutItem` woertlich — inklusive minW/minH — und liest
|
|
* `data-grid` nur fuer Kinder OHNE Eintrag. Gespeicherte Anordnungen tragen
|
|
* minW/minH (RGL liefert sie in `onLayoutChange`, 260916-bwo hat sie verdoppelt
|
|
* persistiert). Eine Aenderung von WIDGET_CONSTRAINTS allein aendert fuer
|
|
* bestehende Widgets also NICHTS. Deshalb: in JEDEM Breakpoint minW/minH jedes
|
|
* Eintrags eines bekannten Typs aus der Tabelle setzen; die Konstanten sind
|
|
* die einzige Quelle (T-DYV-01: manipulierte Minima wirken nicht).
|
|
*
|
|
* Liegt die gespeicherte Groesse UNTER dem neuen Minimum (z. B. Rechner mit
|
|
* h 8, neues minH 10), wird w/h auf das Minimum angehoben: RGL selbst klemmt
|
|
* w/h nur beim Vergroessern/Verkleinern (`minMaxSize.constrainSize`,
|
|
* chunk-KDANGDDL.mjs:26-31; `minConstraints` an `Resizable`,
|
|
* chunk-WGL5FSZH.mjs:472-475), rendert die zu kleine Kachel aber woertlich —
|
|
* der Rechner bliebe bis zum ersten Anfassen unten abgeschnitten.
|
|
* Unbekannte Typen bleiben unveraendert; das Eingabeobjekt wird nicht mutiert.
|
|
*/
|
|
function applyConstraintMinima(
|
|
layouts: Record<string, Array<LayoutItemShape>>,
|
|
widgets: Array<{ id: string; widgetType: string }>,
|
|
): Record<string, Array<LayoutItemShape>> {
|
|
const typeById = new Map<string, string>();
|
|
for (const w of widgets) typeById.set(w.id, w.widgetType);
|
|
|
|
const result: Record<string, Array<LayoutItemShape>> = {};
|
|
for (const key of Object.keys(layouts)) {
|
|
const entries = layouts[key];
|
|
if (!Array.isArray(entries)) continue;
|
|
result[key] = entries.map((entry) => {
|
|
const type = typeById.get(entry.i);
|
|
const constraints = type ? WIDGET_CONSTRAINTS[type as WidgetType] : undefined;
|
|
if (!constraints) return { ...entry };
|
|
return {
|
|
...entry,
|
|
w: Math.max(entry.w, constraints.minW),
|
|
h: Math.max(entry.h, constraints.minH),
|
|
minW: constraints.minW,
|
|
minH: constraints.minH,
|
|
};
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Dashboard grid wrapper using react-grid-layout v2 Responsive component.
|
|
* Renders widgets in a draggable/resizable grid when in edit mode.
|
|
* Shows empty state when no widgets exist.
|
|
*
|
|
* v2 API: uses dragConfig/resizeConfig instead of isDraggable/isResizable.
|
|
*/
|
|
export function DashboardGrid({
|
|
layouts,
|
|
widgets,
|
|
isEditMode,
|
|
onLayoutChange,
|
|
onRemoveWidget,
|
|
}: DashboardGridProps) {
|
|
const t = useTranslations('widgets');
|
|
const [width, setWidth] = useState(1200);
|
|
|
|
// quick-260922-vdk: Messung haengt am eingehaengten Knoten (Ref-Rueckruf),
|
|
// nicht mehr an einem Effekt mit leerer Abhaengigkeitsliste.
|
|
//
|
|
// Warum: der fruehe Ruecksprung in den Leerzustand rendert das gemessene
|
|
// <div> gar nicht erst. Haengt DashboardGrid mit null Kacheln ein, sieht der
|
|
// alte Effekt (leere Abhaengigkeitsliste, laeuft genau einmal beim
|
|
// Einhaengen) den Ref als leer, bricht ab und laeuft nie wieder — auch nicht,
|
|
// wenn spaeter die erste Kachel erscheint und das <div> tatsaechlich
|
|
// entsteht. Die Breite blieb dann fuer die ganze Sitzung beim Startwert
|
|
// 1200, react-grid-layout vergleicht den Breakpoint strikt groesser als
|
|
// (`width > breakpoint`), 1200 ist damit NICHT `lg` sondern `md` -> 20 statt
|
|
// 24 Spalten, 51,6 statt 50 px Spaltenbreite, ein toter Streifen rechts.
|
|
//
|
|
// Der Ref-Rueckruf `measureRef` folgt dem Knoten ueber Aus- und Einhaengen
|
|
// hinweg (Leerzustand <-> gefuellt) und laeuft in der Commit-Phase — die dort
|
|
// ausgeloeste Zustandsaenderung wird vor dem Zeichnen abgearbeitet, ein
|
|
// zusaetzlicher useLayoutEffect ist damit ueberfluessig. Der Startwert 1200
|
|
// lebt deshalb nur noch bis zur Commit-Phase desselben Einhaengens; genau
|
|
// diesen Uebergang macht der Pfad "Neuladen mit vorhandenen Kacheln" heute
|
|
// schon in Produktion und er ist nachweislich richtig (459 px in 1176 px
|
|
// gemessen) — ein anderer Startwert wuerde eine bisher unerprobte
|
|
// Breakpoint-Folge einfuehren, ohne etwas zu verbessern.
|
|
//
|
|
// Der Fenster-Horcher ist ein zusaetzliches Netz fuer Faelle, in denen der
|
|
// ResizeObserver nichts meldet — nicht sein Ersatz. `applyWidth` verwirft 0
|
|
// und nicht endliche Werte (T-VDK-01/T-VDK-03), damit eine kurzzeitig
|
|
// zusammengefallene Flaeche das Raster nicht auf Null setzt und keine
|
|
// Rueckkopplungsschleife entsteht; React verwirft gleiche Werte selbst.
|
|
const nodeRef = useRef<HTMLDivElement | null>(null);
|
|
const observerRef = useRef<ResizeObserver | null>(null);
|
|
|
|
const applyWidth = useCallback((next: number) => {
|
|
if (Number.isFinite(next) && next > 0) {
|
|
setWidth(next);
|
|
}
|
|
}, []);
|
|
|
|
const measureRef = useCallback(
|
|
(node: HTMLDivElement | null) => {
|
|
// Ein eventuell laufender Beobachter zuerst trennen — auch der
|
|
// null-Zweig (Aushaengen) durchlaeuft diese Zeilen, das ist hier der
|
|
// Aufraeumpfad (T-VDK-02).
|
|
observerRef.current?.disconnect();
|
|
observerRef.current = null;
|
|
nodeRef.current = node;
|
|
if (!node) return;
|
|
|
|
// Synchrone Erstmessung in der Commit-Phase, vor dem ersten Zeichnen.
|
|
applyWidth(node.getBoundingClientRect().width);
|
|
|
|
const observer = new ResizeObserver((entries) => {
|
|
applyWidth(entries[0].contentRect.width);
|
|
});
|
|
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],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const onResize = () => {
|
|
if (nodeRef.current) {
|
|
applyWidth(nodeRef.current.getBoundingClientRect().width);
|
|
}
|
|
};
|
|
window.addEventListener('resize', onResize);
|
|
return () => window.removeEventListener('resize', onResize);
|
|
}, [applyWidth]);
|
|
|
|
// quick-260916-dyv: minW/minH (und zu kleine w/h) aus WIDGET_CONSTRAINTS —
|
|
// siehe applyConstraintMinima. Vor dem Leerzustand, damit die Hook-Reihenfolge
|
|
// stabil bleibt.
|
|
const effectiveLayouts = useMemo(() => applyConstraintMinima(layouts, widgets), [layouts, widgets]);
|
|
|
|
// Empty state (D-02)
|
|
if (widgets.length === 0) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
|
|
{/* Bildmarke statt des frueheren handgezeichneten Vier-Quadrate-Symbols.
|
|
Ohne Rahmen und ohne Kontur: die Marke steht hier frei auf dem
|
|
Seitenhintergrund, nicht auf einer eigenen Flaeche. */}
|
|
<TesseraLogo variant="mark" size={64} plateOutline={false} />
|
|
<h2 className="text-lg font-semibold text-muted-foreground">
|
|
{t('emptyHeading')}
|
|
</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('emptyBody')}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div ref={measureRef}>
|
|
<Responsive
|
|
width={width}
|
|
breakpoints={BREAKPOINTS}
|
|
cols={COLS}
|
|
layouts={effectiveLayouts as ResponsiveLayouts}
|
|
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_SELECTOR,
|
|
cancel: WIDGET_DRAG_CANCEL_SELECTOR,
|
|
threshold: 3,
|
|
}}
|
|
resizeConfig={{
|
|
enabled: isEditMode,
|
|
}}
|
|
compactor={FREE_PLACEMENT_COMPACTOR}
|
|
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
|
|
>
|
|
{widgets.map((widget) => {
|
|
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 ?? 4,
|
|
h: constraints?.defaultH ?? 4,
|
|
}),
|
|
minW: constraints?.minW ?? 4,
|
|
minH: constraints?.minH ?? 4,
|
|
}}
|
|
>
|
|
<WidgetWrapper
|
|
widget={widget}
|
|
isEditMode={isEditMode}
|
|
onRemove={onRemoveWidget}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</Responsive>
|
|
</div>
|
|
);
|
|
}
|