diff --git a/apps/web/src/app/(portal)/page.test.tsx b/apps/web/src/app/(portal)/page.test.tsx new file mode 100644 index 0000000..df2851c --- /dev/null +++ b/apps/web/src/app/(portal)/page.test.tsx @@ -0,0 +1,118 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// quick-260916-dyv: Dashboard-Seite — feste Aktionsleiste unten rechts +// (Stift/Haekchen + "Widget hinzufuegen"), Grid direkt im Container p-2, +// kein Abstands-Wrapper (mt-8) und kein Block oben rechts (top-2) mehr. +// Muster: admin/groups/groups-page.test.tsx (next-intl-Mock mit Namensraum). + +const messages: Record> = { + widgets: { + editMode: 'Dashboard bearbeiten', + saveChanges: 'Änderungen speichern', + addWidget: 'Widget hinzufügen', + layoutLoadError: 'Dashboard konnte nicht geladen werden. Bitte laden Sie die Seite neu.', + }, +}; + +vi.mock('next-intl', () => ({ + useTranslations: (ns: string) => (key: string) => messages[ns]?.[key] ?? key, +})); + +const mockStore = { + layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] }, + widgets: [] as Array<{ id: string; widgetType: string; config: Record }>, + isEditMode: false, + isDirty: false, + isLoading: false, + error: null as string | null, + setEditMode: vi.fn(), + updateLayouts: vi.fn(), + addWidget: vi.fn(), + removeWidget: vi.fn(), + loadDashboard: vi.fn(), + saveLayout: vi.fn(), +}; + +vi.mock('@/lib/stores/dashboard-store', () => ({ + useDashboardStore: (selector?: (state: typeof mockStore) => unknown) => { + if (selector) return selector(mockStore); + return mockStore; + }, +})); + +vi.mock('@/components/dashboard/dashboard-grid', () => ({ + DashboardGrid: (p: { isEditMode: boolean }) => ( +
+ ), +})); + +vi.mock('@/components/dashboard/widget-catalog-modal', () => ({ + WidgetCatalogModal: () => null, +})); + +vi.mock('@/components/dashboard/widgets/clock-widget', () => ({ ClockWidget: () => null })); +vi.mock('@/components/dashboard/widgets/search-widget', () => ({ SearchWidget: () => null })); +vi.mock('@/components/dashboard/widgets/calendar-widget', () => ({ CalendarWidget: () => null })); +vi.mock('@/components/dashboard/widgets/note-widget', () => ({ NoteWidget: () => null })); +vi.mock('@/components/dashboard/widgets/calculator-widget', () => ({ CalculatorWidget: () => null })); +vi.mock('@/components/dashboard/widgets/stopwatch-widget', () => ({ StopwatchWidget: () => null })); +vi.mock('@/components/dashboard/widgets/favorites-widget', () => ({ FavoritesWidget: () => null })); +vi.mock('@/components/dashboard/widgets/link-widget', () => ({ LinkWidget: () => null })); + +beforeEach(() => { + mockStore.isEditMode = false; + mockStore.isLoading = false; + mockStore.error = null; + mockStore.setEditMode = vi.fn(); + mockStore.loadDashboard = vi.fn(); +}); + +afterEach(() => { + cleanup(); +}); + +describe('DashboardPage (quick-260916-dyv)', () => { + it('Test 1: Ansichtsmodus — Stift in fester Leiste unten rechts, kein "Widget hinzufuegen", kein mt-8/top-2, Grid direkt im Container p-2', async () => { + const { default: DashboardPage } = await import('./page'); + render(); + + const toggle = screen.getByRole('button', { name: 'Dashboard bearbeiten' }); + const bar = toggle.parentElement as HTMLElement; + expect(bar.classList.contains('fixed')).toBe(true); + expect(bar.classList.contains('bottom-6')).toBe(true); + expect(bar.classList.contains('right-6')).toBe(true); + expect(bar.classList.contains('z-20')).toBe(true); + + expect(screen.queryByRole('button', { name: 'Widget hinzufügen' })).toBeNull(); + expect(document.querySelector('.mt-8')).toBeNull(); + expect(document.querySelector('.top-2')).toBeNull(); + + const grid = screen.getByTestId('dashboard-grid'); + expect((grid.parentElement as HTMLElement).classList.contains('p-2')).toBe(true); + expect(grid.getAttribute('data-edit')).toBe('false'); + }); + + it('Test 2: Bearbeitungsmodus — "Widget hinzufuegen" links neben dem Haekchen in derselben Leiste, Grid im Bearbeitungsmodus', async () => { + mockStore.isEditMode = true; + const { default: DashboardPage } = await import('./page'); + render(); + + const toggle = screen.getByRole('button', { name: 'Änderungen speichern' }); + const add = screen.getByRole('button', { name: 'Widget hinzufügen' }); + expect(add.parentElement).toBe(toggle.parentElement); + // "Widget hinzufuegen" steht im DOM VOR dem Umschalter (links daneben). + expect(add.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + + expect(screen.getByTestId('dashboard-grid').getAttribute('data-edit')).toBe('true'); + }); + + it('Test 3: Umschalten — Klick auf "Dashboard bearbeiten" ruft setEditMode(true) genau einmal', async () => { + const { default: DashboardPage } = await import('./page'); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Dashboard bearbeiten' })); + expect(mockStore.setEditMode).toHaveBeenCalledTimes(1); + expect(mockStore.setEditMode).toHaveBeenCalledWith(true); + }); +}); diff --git a/apps/web/src/app/(portal)/page.tsx b/apps/web/src/app/(portal)/page.tsx index cbc44be..b53dab0 100644 --- a/apps/web/src/app/(portal)/page.tsx +++ b/apps/web/src/app/(portal)/page.tsx @@ -67,32 +67,21 @@ export default function DashboardPage() { return (
- {/* Edit mode toggle — top right */} -
- setEditMode(!isEditMode)} - /> -
+ {/* Dashboard grid — direkt im Container, ohne Abstands-Wrapper. */} + - {/* Dashboard grid. - quick-260916-bwo: mt-8 bleibt bewusst — der Umschalter ist 36 px hoch - (p-2 plus 20-px-Symbol) und liegt mit top-2 bei 8..44 px; das erste - Widget beginnt mit mt-8 bei 8+32+8 = 48 px (4 px Abstand). Eine - Halbierung auf mt-4 legte es bei 32 px mitten in den Umschalter. */} -
- -
- - {/* Add widget button (edit mode only) */} - {isEditMode && ( -
+ {/* Feste Aktionsleiste unten rechts. + quick-260916-dyv: Umschalter unten rechts statt oben rechts, damit das + Grid direkt unter der Kopfzeile beginnt (12 + 8 + 8 = 28 px statt 60 px). + Im Bearbeitungsmodus steht "Widget hinzufuegen" links neben dem Haekchen. */} +
+ {isEditMode && ( -
- )} + )} + setEditMode(!isEditMode)} + /> +
{/* Widget catalog modal */} ({ addWidget: 'Add widget', catalogTitle: 'Add widget', deleteTooltip: 'Remove widget', + dragHint: 'Drag the tile to move it', 'clock.name': 'Clock', 'clock.description': 'Shows the current time', 'search.name': 'Search', @@ -38,13 +39,15 @@ vi.mock('next-intl', () => ({ // 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. +// quick-260916-dyv: Mock per importOriginal, damit `noCompactor` das echte +// Objekt ist (Test 7 prueft compact als Identitaets-Kopie), nur Responsive ersetzt. const captured = vi.hoisted(() => ({ props: null as Record | null })); -vi.mock('react-grid-layout', () => ({ +vi.mock('react-grid-layout', async (importOriginal) => ({ + ...(await importOriginal()), Responsive: (props: Record & { children: React.ReactNode }) => { captured.props = props; return
{props.children}
; }, - noCompactor: () => null, })); // Mock dashboard-api @@ -273,4 +276,115 @@ describe('DashboardGrid', () => { expect(layouts.lg[0].h).toBe(8); expect(layouts.lg[0].minH).toBe(8); }); + + const CANCEL = 'input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag'; + + it('quick-260916-dyv Test 6: dragConfig-Pin — handle Karte, cancel fuer Interaktives, threshold 3; resizeConfig folgt dem Bearbeitungsmodus', async () => { + const { DashboardGrid } = await import('./dashboard-grid'); + const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }]; + const layouts = { lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [] }; + + captured.props = null; + render( + , + ); + expect(captured.props?.dragConfig).toEqual({ + enabled: true, + handle: '.widget-drag-handle', + cancel: CANCEL, + threshold: 3, + }); + expect(captured.props?.resizeConfig).toEqual({ enabled: true }); + cleanup(); + + captured.props = null; + render( + , + ); + expect(captured.props?.dragConfig).toEqual({ + enabled: false, + handle: '.widget-drag-handle', + cancel: CANCEL, + threshold: 3, + }); + expect(captured.props?.resizeConfig).toEqual({ enabled: false }); + }); + + it('quick-260916-dyv Test 7: Compactor-Pin — echter noCompactor plus preventCollision: true, compact ist Identitaets-Kopie (freie Platzierung)', async () => { + captured.props = null; + const { DashboardGrid } = await import('./dashboard-grid'); + render( + , + ); + + const compactor = captured.props?.compactor as { + type: unknown; + allowOverlap: boolean; + preventCollision?: boolean; + compact: (layout: Array>, cols: number) => Array>; + }; + expect(compactor).toMatchObject({ type: null, allowOverlap: false, preventCollision: true }); + expect(typeof compactor.compact).toBe('function'); + const input = [{ i: 'a', x: 0, y: 0, w: 2, h: 2 }]; + const out = compactor.compact(input, 24); + // Gemessen (RGL 2.2.3): noCompactor.compact = cloneLayout -> cloneLayoutItem + // kopiert i/x/y/w/h unveraendert und normalisiert moved/static zu false + // (minW/maxW/... als undefined). Keine Verschiebung, keine Kompaktierung. + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ i: 'a', x: 0, y: 0, w: 2, h: 2, moved: false, static: false }); + expect(out).not.toBe(input); + expect(out[0]).not.toBe(input[0]); + expect(input[0]).toEqual({ i: 'a', x: 0, y: 0, w: 2, h: 2 }); + }); + + it('quick-260916-dyv Test 8: cancel/handle-Semantik im DOM — Karte ist Griff, Loesch-Knopf/Eingaben/widgetNoDrag passen auf cancel, Kopfleiste als Overlay nur im Bearbeitungsmodus', async () => { + const mod = await import('./dashboard-grid'); + const { DashboardGrid, WIDGET_DRAG_HANDLE_SELECTOR, WIDGET_DRAG_CANCEL_SELECTOR } = mod; + expect(WIDGET_DRAG_HANDLE_SELECTOR).toBe('.widget-drag-handle'); + expect(WIDGET_DRAG_CANCEL_SELECTOR).toBe(CANCEL); + + const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }]; + const layouts = { lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [] }; + + render( + , + ); + + const card = document.querySelector('[data-widget-id="inst-2"]') as HTMLElement; + expect(card).not.toBeNull(); + expect(card.matches(WIDGET_DRAG_HANDLE_SELECTOR)).toBe(true); + // Die Karte selbst darf NICHT auf cancel passen, sonst zieht nichts mehr. + expect(card.closest(WIDGET_DRAG_CANCEL_SELECTOR)).toBeNull(); + + const button = screen.getByLabelText('Remove widget'); + expect(button.closest(WIDGET_DRAG_CANCEL_SELECTOR)).toBe(button); + expect(button.hasAttribute('data-no-drag')).toBe(true); + + const input = document.createElement('input'); + card.appendChild(input); + expect(input.closest(WIDGET_DRAG_CANCEL_SELECTOR)).toBe(input); + + const noDrag = document.createElement('div'); + noDrag.className = 'widgetNoDrag'; + card.appendChild(noDrag); + expect(noDrag.closest(WIDGET_DRAG_CANCEL_SELECTOR)).toBe(noDrag); + + const head = screen.getByTitle('Drag the tile to move it'); + expect(card.contains(head)).toBe(true); + expect(head.classList.contains('absolute')).toBe(true); + expect(head.classList.contains('h-5')).toBe(true); + cleanup(); + + render( + , + ); + expect(screen.queryByTitle('Drag the tile to move it')).toBeNull(); + expect(document.querySelector('.widget-drag-handle')).toBeNull(); + }); }); diff --git a/apps/web/src/components/dashboard/dashboard-grid.tsx b/apps/web/src/components/dashboard/dashboard-grid.tsx index 1b4fd59..36273fc 100644 --- a/apps/web/src/components/dashboard/dashboard-grid.tsx +++ b/apps/web/src/components/dashboard/dashboard-grid.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Responsive, noCompactor } from 'react-grid-layout'; -import type { ResponsiveLayouts } 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'; @@ -17,6 +17,33 @@ 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 }; +// 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/ +// Link-Widget, bisher nirgends verdrahtet). 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; @@ -147,12 +174,14 @@ export function DashboardGrid({ // dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin). dragConfig={{ enabled: isEditMode, - handle: '.widget-drag-handle', + handle: WIDGET_DRAG_HANDLE_SELECTOR, + cancel: WIDGET_DRAG_CANCEL_SELECTOR, + threshold: 3, }} resizeConfig={{ enabled: isEditMode, }} - compactor={noCompactor} + compactor={FREE_PLACEMENT_COMPACTOR} onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)} > {widgets.map((widget) => { diff --git a/apps/web/src/components/dashboard/edit-mode-toggle.tsx b/apps/web/src/components/dashboard/edit-mode-toggle.tsx index c4b7c5b..3bdebce 100644 --- a/apps/web/src/components/dashboard/edit-mode-toggle.tsx +++ b/apps/web/src/components/dashboard/edit-mode-toggle.tsx @@ -10,6 +10,9 @@ interface EditModeToggleProps { /** * Pencil / Checkmark icon button to toggle dashboard edit mode (D-01). * Active state (edit mode) uses primary background. + * Schwebt in der festen Aktionsleiste unten rechts (quick-260916-dyv): + * inaktiv mit Karten-Hintergrund, Rahmen und Schatten, damit der Stift + * ueber Widgets sichtbar bleibt. */ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) { const t = useTranslations('widgets'); @@ -17,10 +20,10 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) { return ( + +
)} {/* Widget body. @@ -70,8 +100,10 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro damit die Widget-Inhalte per cqw/cqh mit der Kachel skalieren. Braucht eine definite Hoehe — die kommt ueber h-full aus der Karte, die das RGL-Element mit Pixelhoehe fuellt. Steht am Rumpf statt an der Karte, weil die Karte - im Bearbeitungsmodus zusaetzlich den Griff traegt. */} -
+ im Bearbeitungsmodus zusaetzlich den Griff traegt. + quick-260916-dyv: Die Kopfleiste liegt als Overlay ueber dem Rumpf und + aendert die Hoehenkette nicht. */} +
{WidgetComponent ? (