fix(quick-260916-dyv): Mindestgroessen inhaltsgetrieben (kleinste bedienbare Kachel je Typ), gespeicherte Minima ueberschrieben, Stoppuhr-Bedienleiste kompakt
- WIDGET_CONSTRAINTS: Minima aus dem Innenaufbau gerechnet (clock 2/2, search 6/2, calendar 3/3, note 4/4, calculator 3/9, favorites 3/3, link 3/2, stopwatch 4/3), Vorgaben unveraendert; Test A pinnt alle 32 Werte per toEqual - dashboard-grid.tsx: effectiveLayouts (useMemo) ueberschreibt minW/minH jedes gespeicherten Eintrags in jedem Breakpoint aus den Konstanten und hebt zu kleine w/h auf das Minimum an — RGL 2.2.3 nimmt gespeicherte Eintraege woertlich (synchronizeLayoutWithChildren) und liest data-grid nur ohne Eintrag - dashboard-grid.test.tsx: Test 5 angepasst, Test 9 (Ueberschreibung je Breakpoint, unbekannter Typ unveraendert) und Test 9b (h 8 -> 9, w >= minW bleibt) - stopwatch-widget.tsx: Bedienleiste px-2 py-1 text-xs, Zeile gap-1 py-1, damit Stop + Runde + Reset in 4 Spalten passen und die Kachel auf 4x3 schrumpfen kann Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
This commit is contained in:
@@ -178,7 +178,7 @@ describe('DashboardGrid', () => {
|
||||
expect(captured.props?.containerPadding).toBeUndefined();
|
||||
});
|
||||
|
||||
it('quick-260916-bwo Test 5: Widget ohne gespeicherten Eintrag bekommt die verdoppelten Vorgaben als data-grid', async () => {
|
||||
it('quick-260916-bwo Test 5: Widget ohne gespeicherten Eintrag bekommt die Vorgaben und die inhaltsgetriebenen Minima als data-grid', async () => {
|
||||
captured.props = null;
|
||||
const { DashboardGrid } = await import('./dashboard-grid');
|
||||
render(
|
||||
@@ -196,6 +196,81 @@ describe('DashboardGrid', () => {
|
||||
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 });
|
||||
// quick-260916-dyv: Uhr-Minimum 2x2 (vorher verdoppelt 4x4), Vorgabe 4x4 unveraendert.
|
||||
expect(props['data-grid']).toEqual({ x: 0, y: 0, w: 4, h: 4, minW: 2, minH: 2 });
|
||||
});
|
||||
|
||||
it('quick-260916-dyv Test 9: gespeicherte minW/minH werden in JEDEM Breakpoint aus WIDGET_CONSTRAINTS ueberschrieben, x/y/w/h bleiben, unbekannte Typen und Schluessel unveraendert', async () => {
|
||||
captured.props = null;
|
||||
const { DashboardGrid } = await import('./dashboard-grid');
|
||||
const layouts = {
|
||||
lg: [
|
||||
{ i: 'inst-1', x: 2, y: 4, w: 6, h: 6, minW: 8, minH: 8 },
|
||||
{ i: 'inst-x', x: 10, y: 0, w: 5, h: 5, minW: 7, minH: 7 },
|
||||
],
|
||||
md: [{ i: 'inst-1', x: 0, y: 0, w: 4, h: 4, minW: 4, minH: 4 }],
|
||||
sm: [],
|
||||
xs: [],
|
||||
xxs: [],
|
||||
};
|
||||
render(
|
||||
<DashboardGrid
|
||||
layouts={layouts}
|
||||
widgets={[
|
||||
{ id: 'inst-1', widgetType: 'clock', config: {} },
|
||||
{ id: 'inst-x', widgetType: 'unknown', config: {} },
|
||||
]}
|
||||
isEditMode={false}
|
||||
onLayoutChange={vi.fn()}
|
||||
onRemoveWidget={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
|
||||
expect(Object.keys(passed)).toEqual(Object.keys(layouts));
|
||||
expect(passed.lg[0]).toEqual({ i: 'inst-1', x: 2, y: 4, w: 6, h: 6, minW: 2, minH: 2 });
|
||||
expect(passed.md[0]).toEqual({ i: 'inst-1', x: 0, y: 0, w: 4, h: 4, minW: 2, minH: 2 });
|
||||
expect(passed.md[0].minW).toBe(2);
|
||||
// Unbekannter Typ: Eintrag bleibt woertlich erhalten (kein Absturz, T-DYV-01).
|
||||
expect(passed.lg[1]).toEqual({ i: 'inst-x', x: 10, y: 0, w: 5, h: 5, minW: 7, minH: 7 });
|
||||
expect(passed.sm).toEqual([]);
|
||||
});
|
||||
|
||||
it('quick-260916-dyv Test 9b: gespeicherte Groesse unter dem neuen Minimum wird auf das Minimum angehoben (Rechner h 8 -> 9, w bleibt wenn >= minW)', async () => {
|
||||
captured.props = null;
|
||||
const { DashboardGrid } = await import('./dashboard-grid');
|
||||
const layouts = {
|
||||
lg: [
|
||||
// Rechner: gespeichert mit h 8 (bwo-Minimum), neues Minimum 3x9 -> h wird 9, w 6 bleibt.
|
||||
{ i: 'calc-1', x: 0, y: 0, w: 6, h: 8, minW: 4, minH: 8 },
|
||||
// Suche: gespeichert mit w 4 (unter minW 6) -> w wird 6, h 4 bleibt.
|
||||
{ i: 'srch-1', x: 6, y: 0, w: 4, h: 4, minW: 6, minH: 4 },
|
||||
],
|
||||
md: [{ i: 'calc-1', x: 0, y: 0, w: 3, h: 7, minW: 4, minH: 8 }],
|
||||
sm: [],
|
||||
xs: [],
|
||||
xxs: [],
|
||||
};
|
||||
render(
|
||||
<DashboardGrid
|
||||
layouts={layouts}
|
||||
widgets={[
|
||||
{ id: 'calc-1', widgetType: 'calculator', config: {} },
|
||||
{ id: 'srch-1', widgetType: 'search', config: {} },
|
||||
]}
|
||||
isEditMode={false}
|
||||
onLayoutChange={vi.fn()}
|
||||
onRemoveWidget={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
|
||||
expect(passed.lg[0]).toEqual({ i: 'calc-1', x: 0, y: 0, w: 6, h: 9, minW: 3, minH: 9 });
|
||||
expect(passed.lg[1]).toEqual({ i: 'srch-1', x: 6, y: 0, w: 6, h: 4, minW: 6, minH: 2 });
|
||||
// Jeder Breakpoint: md-Rechner 3x7 -> 3x9 (w schon = minW, h angehoben).
|
||||
expect(passed.md[0]).toEqual({ i: 'calc-1', x: 0, y: 0, w: 3, h: 9, minW: 3, minH: 9 });
|
||||
// Eingabe unveraendert (keine Mutation des Store-Objekts).
|
||||
expect(layouts.lg[0].h).toBe(8);
|
||||
expect(layouts.lg[0].minH).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Responsive, noCompactor } from 'react-grid-layout';
|
||||
import type { ResponsiveLayouts } from 'react-grid-layout';
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
@@ -17,14 +17,72 @@ import { TesseraLogo } from '@/components/brand/tessera-logo';
|
||||
const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 };
|
||||
const COLS = { lg: 24, md: 20, sm: 12, xs: 8, xxs: 2 };
|
||||
|
||||
interface LayoutItemShape {
|
||||
i: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
minW?: number;
|
||||
minH?: number;
|
||||
}
|
||||
|
||||
interface DashboardGridProps {
|
||||
layouts: Record<string, Array<{ i: string; x: number; y: number; w: number; h: number }>>;
|
||||
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 9), 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.
|
||||
@@ -53,6 +111,11 @@ export function DashboardGrid({
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// 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 (
|
||||
@@ -77,7 +140,7 @@ export function DashboardGrid({
|
||||
width={width}
|
||||
breakpoints={BREAKPOINTS}
|
||||
cols={COLS}
|
||||
layouts={layouts as ResponsiveLayouts}
|
||||
layouts={effectiveLayouts as ResponsiveLayouts}
|
||||
rowHeight={20}
|
||||
margin={[8, 8] as [number, number]}
|
||||
// containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt
|
||||
@@ -98,7 +161,7 @@ export function DashboardGrid({
|
||||
<div
|
||||
key={widget.id}
|
||||
data-grid={{
|
||||
...(layouts.lg?.find((l) => l.i === widget.id) ?? {
|
||||
...(effectiveLayouts.lg?.find((l) => l.i === widget.id) ?? {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: constraints?.defaultW ?? 4,
|
||||
|
||||
@@ -53,24 +53,26 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
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.
|
||||
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert', () => {
|
||||
// Raster 24 Spalten / 20 px (quick-260916-bwo). Die Minima sind seit
|
||||
// quick-260916-dyv inhaltsgetrieben (Rechnung im Plan), die Vorgaben
|
||||
// (defaultW/defaultH) bleiben das Doppelte der alten 12-Spalten-Werte.
|
||||
// 3 und 9 sind ungerade — die bwo-Pruefung "jeder Wert ist gerade" entfaellt.
|
||||
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 },
|
||||
clock: { minW: 2, minH: 2, defaultW: 4, defaultH: 4 },
|
||||
search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 },
|
||||
calendar: { minW: 3, minH: 3, defaultW: 8, defaultH: 12 },
|
||||
note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 },
|
||||
calculator: { minW: 3, minH: 9, defaultW: 6, defaultH: 10 },
|
||||
favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 },
|
||||
link: { minW: 3, minH: 2, defaultW: 4, defaultH: 4 },
|
||||
stopwatch: { minW: 4, minH: 3, 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);
|
||||
expect(typeof WIDGET_CONSTRAINTS[type][field]).toBe('number');
|
||||
counted++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,17 +32,22 @@ export const WIDGET_CONSTRAINTS: Record<
|
||||
WidgetType,
|
||||
{ minW: number; minH: number; defaultW: number; defaultH: number }
|
||||
> = {
|
||||
// quick-260916-bwo: Raster verdoppelt (24 Spalten / 20 px) — jeder Wert ist das Doppelte des alten 12-Spalten-Werts, Widgets bleiben optisch gleich gross.
|
||||
// quick-260916-dyv: minW/minH = kleinste noch bedienbare Kachel je Typ im
|
||||
// 24-Spalten/20-px-Raster, aus dem Innenaufbau gerechnet (Suche: Auswahl 120 +
|
||||
// Eingabe + Knopf; Rechner: Anzeige 40 + Speicherzeile 28 + 5 Tastenreihen 28 =
|
||||
// 240 px -> 9 Zeilen; Stoppuhr: kompakte Bedienleiste). defaultW/defaultH =
|
||||
// altes 12-Spalten-Mass x2, unveraendert. Gespeicherte minW/minH werden in
|
||||
// dashboard-grid.tsx aus dieser Tabelle ueberschrieben.
|
||||
// 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 },
|
||||
clock: { minW: 2, minH: 2, defaultW: 4, defaultH: 4 },
|
||||
search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 },
|
||||
calendar: { minW: 3, minH: 3, defaultW: 8, defaultH: 12 },
|
||||
note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 },
|
||||
// Phase 8 new widgets (D-01)
|
||||
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 },
|
||||
calculator: { minW: 3, minH: 9, defaultW: 6, defaultH: 10 },
|
||||
favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 },
|
||||
link: { minW: 3, minH: 2, defaultW: 4, defaultH: 4 },
|
||||
stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -200,14 +200,17 @@ export function StopwatchWidget({ instanceId, config, isEditMode: _isEditMode }:
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-center gap-2 py-2">
|
||||
{/* Controls
|
||||
quick-260916-dyv: kompakte Bedienleiste, damit die laufende Stoppuhr
|
||||
(Stop + Runde + Reset) in 4 Spalten passt (ca. 151 px statt ca. 222 px)
|
||||
und die Kachel auf 4x3 schrumpfen kann. */}
|
||||
<div className="flex items-center justify-center gap-1 py-1">
|
||||
{!isRunning ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('stopwatch.start')}
|
||||
onClick={handleStart}
|
||||
className="rounded-md bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
|
||||
className="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{t('stopwatch.start')}
|
||||
</button>
|
||||
@@ -217,7 +220,7 @@ export function StopwatchWidget({ instanceId, config, isEditMode: _isEditMode }:
|
||||
type="button"
|
||||
aria-label={t('stopwatch.stop')}
|
||||
onClick={handleStop}
|
||||
className="rounded-md bg-muted px-4 py-1.5 text-sm font-medium text-foreground hover:opacity-90 transition-opacity"
|
||||
className="rounded-md bg-muted px-2 py-1 text-xs font-medium text-foreground hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{t('stopwatch.stop')}
|
||||
</button>
|
||||
@@ -225,7 +228,7 @@ export function StopwatchWidget({ instanceId, config, isEditMode: _isEditMode }:
|
||||
type="button"
|
||||
aria-label={t('stopwatch.lap')}
|
||||
onClick={handleLap}
|
||||
className="rounded-md bg-muted px-4 py-1.5 text-sm font-medium text-foreground hover:opacity-90 transition-opacity"
|
||||
className="rounded-md bg-muted px-2 py-1 text-xs font-medium text-foreground hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{t('stopwatch.lap')}
|
||||
</button>
|
||||
@@ -235,7 +238,7 @@ export function StopwatchWidget({ instanceId, config, isEditMode: _isEditMode }:
|
||||
type="button"
|
||||
aria-label={t('stopwatch.reset')}
|
||||
onClick={handleReset}
|
||||
className="rounded-md border border-border px-4 py-1.5 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="rounded-md border border-border px-2 py-1 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t('stopwatch.reset')}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user