Compare commits
6 Commits
86ad95f74e
...
v1.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
| af78157536 | |||
| 7e70fc4d32 | |||
| f78b422abf | |||
| c846eb49cb | |||
| af87c2c112 | |||
| bd73fa5e74 |
@@ -4,12 +4,17 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
|||||||
|
|
||||||
## Unveröffentlicht
|
## Unveröffentlicht
|
||||||
|
|
||||||
|
## 1.8.0 – 2026-09-30
|
||||||
|
|
||||||
### Neu
|
### Neu
|
||||||
|
|
||||||
- Dashboard: Neues Widget „Erinnerungen“. Sie legen eine Erinnerung mit Datum, Uhrzeit, Titel und Beschreibung an, und Tessera meldet sich genau zur gewählten Zeit: im Browser mit einer Benachrichtigung (der Browser fragt dafür einmal um Erlaubnis, und zwar beim ersten Anlegen), in der Desktop-App mit einer Windows-Benachrichtigung – auch wenn das Fenster im Infobereich liegt. Wenn Sie möchten, schickt Tessera zusätzlich eine E-Mail an Ihre Adresse, auch dann, wenn Tessera gerade nirgends geöffnet ist. Eine fällige Erinnerung bleibt im Widget hervorgehoben stehen, bis Sie „Erledigt“ wählen oder mit „Später erinnern“ verschieben – auf in 10 Minuten, in 1 Stunde oder morgen zur gleichen Uhrzeit; dann meldet sich Tessera (und bei Bedarf die E-Mail) noch einmal. Erinnerungen sind persönlich: nur Sie sehen und ändern Ihre. Für die Desktop-Benachrichtigungen braucht die Desktop-App ihre neue Version, die Sie über „Auf Version … aktualisieren“ im Menü des Tessera-Symbols erhalten; Widget und E-Mail funktionieren auch mit der bisherigen Version.
|
- Dashboard: Neues Widget „Erinnerungen“. Sie legen eine Erinnerung mit Datum, Uhrzeit, Titel und Beschreibung an, und Tessera meldet sich genau zur gewählten Zeit: im Browser mit einer Benachrichtigung (der Browser fragt dafür einmal um Erlaubnis, und zwar beim ersten Anlegen), in der Desktop-App mit einer Windows-Benachrichtigung – auch wenn das Fenster im Infobereich liegt. Wenn Sie möchten, schickt Tessera zusätzlich eine E-Mail an Ihre Adresse, auch dann, wenn Tessera gerade nirgends geöffnet ist. Eine fällige Erinnerung bleibt im Widget hervorgehoben stehen, bis Sie „Erledigt“ wählen oder mit „Später erinnern“ verschieben – auf in 10 Minuten, in 1 Stunde oder morgen zur gleichen Uhrzeit; dann meldet sich Tessera (und bei Bedarf die E-Mail) noch einmal. Erinnerungen sind persönlich: nur Sie sehen und ändern Ihre. Für die Desktop-Benachrichtigungen braucht die Desktop-App ihre neue Version, die Sie über „Auf Version … aktualisieren“ im Menü des Tessera-Symbols erhalten; Widget und E-Mail funktionieren auch mit der bisherigen Version.
|
||||||
|
|
||||||
### Geändert
|
### Geändert
|
||||||
|
|
||||||
|
- Eigene Module: Einmal geöffnete Seiten bleiben im Hintergrund offen. Wechseln Sie zurück, ist die Seite sofort da – im selben Zustand, ohne neu zu laden. Tessera hält die fünf zuletzt benutzten offen; beim Abmelden werden sie geschlossen.
|
||||||
|
- Dashboard, Favoriten: In der Kachelansicht stehen die Symbole enger beieinander; der Abstand zwischen ihnen ist etwa halb so groß, in eine Zeile passen mehr Favoriten. Passt ein Name nicht in eine Zeile, wird er kleiner geschrieben und auf zwei Zeilen umbrochen.
|
||||||
|
- Dashboard: Es sieht jetzt auf jedem Bildschirm gleich aus. Tessera merkt sich die Fläche des Bildschirms, an dem Sie ein Dashboard zuerst öffnen, und zeigt es auf anderen Bildschirmen maßstäblich verkleinert oder vergrößert – samt Schrift und ohne Scrollen. Ist ein Dashboard länger als der Bildschirm, wird es so weit verkleinert, dass es ganz hineinpasst. Auf dem Handy bleibt es bei der bisherigen Anordnung untereinander.
|
||||||
- Eigene Module: Neue Einträge sind mit der Kategorie „Eigene Module“ vorbelegt.
|
- Eigene Module: Neue Einträge sind mit der Kategorie „Eigene Module“ vorbelegt.
|
||||||
- Dashboard: Der Kalender lässt sich nicht mehr so schmal ziehen, dass seine Überschrift abgeschnitten wird.
|
- Dashboard: Der Kalender lässt sich nicht mehr so schmal ziehen, dass seine Überschrift abgeschnitten wird.
|
||||||
- Eigene Module: Die Seite füllt jetzt den ganzen Inhaltsbereich. Name und Hinweiszeile darüber sind weggefallen – der Name steht ohnehin oben in der Leiste, und „In neuem Tab öffnen“ sitzt jetzt dort rechts.
|
- Eigene Module: Die Seite füllt jetzt den ganzen Inhaltsbereich. Name und Hinweiszeile darüber sind weggefallen – der Name steht ohnehin oben in der Leiste, und „In neuem Tab öffnen“ sitzt jetzt dort rechts.
|
||||||
|
|||||||
@@ -1,28 +1,32 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslations } from 'next-intl';
|
import {
|
||||||
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot';
|
BackgroundPicker,
|
||||||
|
DashboardBackdrop,
|
||||||
|
useDashboardBackground,
|
||||||
|
} from '@/components/dashboard/dashboard-background';
|
||||||
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
|
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
|
||||||
import { DashboardTabs } from '@/components/dashboard/dashboard-tabs';
|
import { DashboardTabs } from '@/components/dashboard/dashboard-tabs';
|
||||||
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
|
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 { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
|
||||||
|
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
||||||
import { registerWidget } 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 { 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 { 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 { 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 { ProxmoxWidget } from '@/components/dashboard/widgets/proxmox-widget';
|
||||||
import { ReminderWidget } from '@/components/dashboard/widgets/reminder-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 { 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';
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
@@ -71,6 +75,7 @@ export default function DashboardPage() {
|
|||||||
activeDashboardId,
|
activeDashboardId,
|
||||||
isSwitchingDashboard,
|
isSwitchingDashboard,
|
||||||
layouts,
|
layouts,
|
||||||
|
canvas,
|
||||||
widgets,
|
widgets,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -85,6 +90,7 @@ export default function DashboardPage() {
|
|||||||
renameDashboard,
|
renameDashboard,
|
||||||
deleteDashboard,
|
deleteDashboard,
|
||||||
reorderDashboards,
|
reorderDashboards,
|
||||||
|
captureCanvas,
|
||||||
} = useDashboardStore();
|
} = useDashboardStore();
|
||||||
|
|
||||||
// Load dashboard data on mount
|
// Load dashboard data on mount
|
||||||
@@ -200,6 +206,8 @@ export default function DashboardPage() {
|
|||||||
onRemoveWidget={removeWidget}
|
onRemoveWidget={removeWidget}
|
||||||
onOpenCatalog={() => setCatalogOpen(true)}
|
onOpenCatalog={() => setCatalogOpen(true)}
|
||||||
onQuickAdd={(type) => addWidget(type)}
|
onQuickAdd={(type) => addWidget(type)}
|
||||||
|
canvas={canvas}
|
||||||
|
onCaptureCanvas={captureCanvas}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
createCustomModule,
|
createCustomModule,
|
||||||
updateCustomModule,
|
updateCustomModule,
|
||||||
} from '@/lib/custom-modules-api';
|
} from '@/lib/custom-modules-api';
|
||||||
|
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
|
||||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||||
|
|
||||||
interface CustomModuleFormModalProps {
|
interface CustomModuleFormModalProps {
|
||||||
@@ -96,6 +97,10 @@ export function CustomModuleFormModal({
|
|||||||
const input = { name: trimmedName, url: trimmedUrl, category };
|
const input = { name: trimmedName, url: trimmedUrl, category };
|
||||||
if (mod) {
|
if (mod) {
|
||||||
await updateCustomModule(mod.id, input);
|
await updateCustomModule(mod.id, input);
|
||||||
|
// Sitzungsspeicher der offen gehaltenen Module nachziehen — nur wenn
|
||||||
|
// das Modul dort schon liegt; eine neue Adresse laedt dessen <iframe> um.
|
||||||
|
const cache = useCustomModuleCacheStore.getState();
|
||||||
|
if (cache.modules[mod.id]) cache.remember({ id: mod.id, name: input.name, url: input.url });
|
||||||
} else {
|
} else {
|
||||||
await createCustomModule(shared ? { ...input, shared: true } : input);
|
await createCustomModule(shared ? { ...input, shared: true } : input);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { type CustomModule, deleteCustomModule } from '@/lib/custom-modules-api';
|
import { type CustomModule, deleteCustomModule } from '@/lib/custom-modules-api';
|
||||||
|
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
|
||||||
import { customModuleErrorText } from './custom-module-form-modal';
|
import { customModuleErrorText } from './custom-module-form-modal';
|
||||||
|
|
||||||
interface DeleteCustomModuleDialogProps {
|
interface DeleteCustomModuleDialogProps {
|
||||||
@@ -36,6 +37,8 @@ export function DeleteCustomModuleDialog({
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await deleteCustomModule(mod.id);
|
await deleteCustomModule(mod.id);
|
||||||
|
// Offen gehaltenen <iframe> und Sitzungsspeicher des Moduls verwerfen.
|
||||||
|
useCustomModuleCacheStore.getState().forget(mod.id);
|
||||||
onDeleted();
|
onDeleted();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||||
import { Children, isValidElement } from 'react';
|
import { Children, isValidElement } from 'react';
|
||||||
|
import { createScaledStrategy, defaultPositionStrategy } from 'react-grid-layout/core';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { stubResizeObserver } from '@/test/fake-resize-observer';
|
import { stubResizeObserver } from '@/test/fake-resize-observer';
|
||||||
import { RESIZE_AXIS_FALLBACK } from './dashboard-grid';
|
import {
|
||||||
|
computeCanvasScale,
|
||||||
|
fitCanvasToContent,
|
||||||
|
DASHBOARD_BOTTOM_GAP_PX,
|
||||||
|
RESIZE_AXIS_FALLBACK,
|
||||||
|
scaledPositionStrategy,
|
||||||
|
} from './dashboard-grid';
|
||||||
|
|
||||||
// Mock CSS imports that vitest cannot resolve
|
// Mock CSS imports that vitest cannot resolve
|
||||||
vi.mock('react-grid-layout/css/styles.css', () => ({}));
|
vi.mock('react-grid-layout/css/styles.css', () => ({}));
|
||||||
@@ -652,3 +659,166 @@ describe('RESIZE_AXIS_FALLBACK (Design „Mosaik“, Runde 3)', () => {
|
|||||||
expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 3, 9, 'se', context)).toEqual({ w: 3, h: 9 });
|
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('fitCanvasToContent', () => {
|
||||||
|
it('Inhalt passt in die Leinwand: Leinwand bleibt (1:1 am Original-PC)', () => {
|
||||||
|
expect(fitCanvasToContent({ w: 1641, h: 849 }, 700)).toEqual({ w: 1641, h: 849 });
|
||||||
|
});
|
||||||
|
it('Inhalt hoeher als die Leinwand: Inhaltshoehe zaehlt, damit nie gescrollt wird', () => {
|
||||||
|
expect(fitCanvasToContent({ w: 1641, h: 849 }, 940)).toEqual({ w: 1641, h: 940 });
|
||||||
|
// am Original-PC selbst: 849 / 940 -> alles passt hinein
|
||||||
|
expect(
|
||||||
|
computeCanvasScale({ w: 1641, h: 849 }, fitCanvasToContent({ w: 1641, h: 849 }, 940)),
|
||||||
|
).toBeCloseTo(849 / 940);
|
||||||
|
});
|
||||||
|
it('unbrauchbare Hoehe: Leinwand bleibt', () => {
|
||||||
|
expect(fitCanvasToContent({ w: 1600, h: 800 }, Number.NaN)).toEqual({ w: 1600, h: 800 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
// Rasterinhalt niedriger als die Leinwand (300 < 374) — sonst zaehlte die
|
||||||
|
// Inhaltshoehe (fitCanvasToContent).
|
||||||
|
stubResizeObserver({ width: 1000, height: 300 });
|
||||||
|
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('Inhalt hoeher als die Leinwand: eingepasst, damit nie gescrollt wird', async () => {
|
||||||
|
stubResizeObserver({ width: 1000, height: 1600 });
|
||||||
|
setInnerHeight(820); // verfuegbar 800
|
||||||
|
const { inner } = await renderGrid({ canvas: { w: 1000, h: 800 } });
|
||||||
|
// min(1000 / 1000, 800 / 1600) = 0.5
|
||||||
|
expect(inner.style.transform).toBe('scale(0.5)');
|
||||||
|
});
|
||||||
|
|
||||||
|
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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { Compactor, ResponsiveLayouts } from 'react-grid-layout';
|
import type { Compactor, ResponsiveLayouts } from 'react-grid-layout';
|
||||||
import { noCompactor, Responsive } 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-grid-layout/css/styles.css';
|
||||||
import 'react-resizable/css/styles.css';
|
import 'react-resizable/css/styles.css';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { CANVAS_MIN_HEIGHT, CANVAS_MIN_WIDTH, type GridCanvas } from '@/lib/grid-layout-migration';
|
||||||
import {
|
import {
|
||||||
clampWidthToCols,
|
clampWidthToCols,
|
||||||
GRID_COLS,
|
GRID_COLS,
|
||||||
@@ -101,6 +108,76 @@ const GRID_CONSTRAINTS: LayoutConstraint[] = [...defaultConstraints, RESIZE_AXIS
|
|||||||
|
|
||||||
const GRID_MARGIN = 12;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leinwand fuer die Einpassung: ist der Inhalt (unskalierte Rasterhoehe)
|
||||||
|
* hoeher als die Leinwand, gilt die Inhaltshoehe, sonst die Leinwand selbst —
|
||||||
|
* so bleibt es am Original-PC bei 1:1, solange alles draufpasst.
|
||||||
|
*/
|
||||||
|
export function fitCanvasToContent(canvas: GridCanvas, contentHeight: number): GridCanvas {
|
||||||
|
if (!Number.isFinite(contentHeight) || contentHeight <= canvas.h) return canvas;
|
||||||
|
return { w: canvas.w, h: contentHeight };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
interface LayoutItemShape {
|
||||||
i: string;
|
i: string;
|
||||||
x: number;
|
x: number;
|
||||||
@@ -121,6 +198,10 @@ interface DashboardGridProps {
|
|||||||
onOpenCatalog?: () => void;
|
onOpenCatalog?: () => void;
|
||||||
/** Leerer Zustand: fuegt eine vorgeschlagene Kachel direkt hinzu. */
|
/** Leerer Zustand: fuegt eine vorgeschlagene Kachel direkt hinzu. */
|
||||||
onQuickAdd?: (type: WidgetType) => void;
|
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. */
|
/** Vorschlaege im leeren Zustand — Plattform-Kacheln, immer verfuegbar. */
|
||||||
@@ -224,9 +305,15 @@ export function DashboardGrid({
|
|||||||
onRemoveWidget,
|
onRemoveWidget,
|
||||||
onOpenCatalog,
|
onOpenCatalog,
|
||||||
onQuickAdd,
|
onQuickAdd,
|
||||||
|
canvas = null,
|
||||||
|
onCaptureCanvas,
|
||||||
}: DashboardGridProps) {
|
}: DashboardGridProps) {
|
||||||
const t = useTranslations('widgets');
|
const t = useTranslations('widgets');
|
||||||
const [width, setWidth] = useState(1200);
|
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),
|
// quick-260922-vdk: Messung haengt am eingehaengten Knoten (Ref-Rueckruf),
|
||||||
// nicht mehr an einem Effekt mit leerer Abhaengigkeitsliste.
|
// nicht mehr an einem Effekt mit leerer Abhaengigkeitsliste.
|
||||||
@@ -265,6 +352,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(
|
const measureRef = useCallback(
|
||||||
(node: HTMLDivElement | null) => {
|
(node: HTMLDivElement | null) => {
|
||||||
// Ein eventuell laufender Beobachter zuerst trennen — auch der
|
// Ein eventuell laufender Beobachter zuerst trennen — auch der
|
||||||
@@ -277,27 +374,76 @@ export function DashboardGrid({
|
|||||||
|
|
||||||
// Synchrone Erstmessung in der Commit-Phase, vor dem ersten Zeichnen.
|
// Synchrone Erstmessung in der Commit-Phase, vor dem ersten Zeichnen.
|
||||||
applyWidth(node.getBoundingClientRect().width);
|
applyWidth(node.getBoundingClientRect().width);
|
||||||
|
applyHeight(node);
|
||||||
|
|
||||||
const observer = new ResizeObserver((entries) => {
|
const observer = new ResizeObserver((entries) => {
|
||||||
applyWidth(entries[0].contentRect.width);
|
applyWidth(entries[0].contentRect.width);
|
||||||
|
applyHeight(node);
|
||||||
});
|
});
|
||||||
observer.observe(node);
|
observer.observe(node);
|
||||||
observerRef.current = observer;
|
observerRef.current = observer;
|
||||||
// Bewusst keine Aufraeumfunktion zurueckgeben: React 19 ruft den
|
// Bewusst keine Aufraeumfunktion zurueckgeben: React 19 ruft den
|
||||||
// Ref-Rueckruf sonst beim Aushaengen nicht mehr mit null auf.
|
// Ref-Rueckruf sonst beim Aushaengen nicht mehr mit null auf.
|
||||||
},
|
},
|
||||||
[applyWidth],
|
[applyWidth, applyHeight],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onResize = () => {
|
const onResize = () => {
|
||||||
if (nodeRef.current) {
|
if (nodeRef.current) {
|
||||||
applyWidth(nodeRef.current.getBoundingClientRect().width);
|
applyWidth(nodeRef.current.getBoundingClientRect().width);
|
||||||
|
applyHeight(nodeRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('resize', onResize);
|
window.addEventListener('resize', onResize);
|
||||||
return () => window.removeEventListener('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;
|
||||||
|
// "Nie scrollen" (Nutzerentscheidung): ist der Inhalt hoeher als die
|
||||||
|
// Leinwand (schon am Original-PC laenger als der Bildschirm), zaehlt die
|
||||||
|
// Inhaltshoehe — dann passt auch dort alles hinein, und jeder andere
|
||||||
|
// Bildschirm zeigt dasselbe Bild. Im Bearbeitungsmodus bleibt die Hoehe vom
|
||||||
|
// Beginn des Bearbeitens stehen, sonst wuerde das Raster beim Ziehen nach
|
||||||
|
// unten laufend kleiner und die Kachel liefe dem Mauszeiger davon.
|
||||||
|
const editHeightRef = useRef<number | null>(null);
|
||||||
|
if (isEditMode && editHeightRef.current === null) editHeightRef.current = innerHeight;
|
||||||
|
if (!isEditMode) editHeightRef.current = null;
|
||||||
|
const contentHeight = isEditMode ? (editHeightRef.current ?? innerHeight) : innerHeight;
|
||||||
|
const scale = scaled
|
||||||
|
? computeCanvasScale({ w: width, h: availableHeight }, fitCanvasToContent(canvas, contentHeight))
|
||||||
|
: 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 —
|
// quick-260916-dyv: minW/minH (und zu kleine w/h) aus WIDGET_CONSTRAINTS —
|
||||||
// siehe applyConstraintMinima. Vor dem Leerzustand, damit die Hook-Reihenfolge
|
// siehe applyConstraintMinima. Vor dem Leerzustand, damit die Hook-Reihenfolge
|
||||||
@@ -374,56 +520,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 (
|
return (
|
||||||
<div ref={measureRef}>
|
<div ref={measureRef} style={outerStyle} data-canvas-scale={scaled ? scale : undefined}>
|
||||||
<Responsive
|
<div ref={innerRef} style={innerStyle}>
|
||||||
width={width}
|
<Responsive
|
||||||
breakpoints={BREAKPOINTS}
|
width={scaled ? canvas.w : width}
|
||||||
cols={COLS}
|
positionStrategy={positionStrategy}
|
||||||
layouts={effectiveLayouts as ResponsiveLayouts}
|
breakpoints={BREAKPOINTS}
|
||||||
rowHeight={20}
|
cols={COLS}
|
||||||
margin={[GRID_MARGIN, GRID_MARGIN] as [number, number]}
|
layouts={effectiveLayouts as ResponsiveLayouts}
|
||||||
constraints={GRID_CONSTRAINTS}
|
rowHeight={20}
|
||||||
// containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt
|
margin={[GRID_MARGIN, GRID_MARGIN] as [number, number]}
|
||||||
// dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin).
|
constraints={GRID_CONSTRAINTS}
|
||||||
dragConfig={{
|
// containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt
|
||||||
enabled: isEditMode,
|
// dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin).
|
||||||
handle: WIDGET_DRAG_HANDLE_SELECTOR,
|
dragConfig={{
|
||||||
cancel: WIDGET_DRAG_CANCEL_SELECTOR,
|
enabled: isEditMode,
|
||||||
threshold: 3,
|
handle: WIDGET_DRAG_HANDLE_SELECTOR,
|
||||||
}}
|
cancel: WIDGET_DRAG_CANCEL_SELECTOR,
|
||||||
resizeConfig={{
|
threshold: 3,
|
||||||
enabled: isEditMode,
|
}}
|
||||||
}}
|
resizeConfig={{
|
||||||
compactor={FREE_PLACEMENT_COMPACTOR}
|
enabled: isEditMode,
|
||||||
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
|
}}
|
||||||
>
|
compactor={FREE_PLACEMENT_COMPACTOR}
|
||||||
{widgets.map((widget, index) => {
|
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
|
||||||
const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType];
|
>
|
||||||
return (
|
{widgets.map((widget, index) => {
|
||||||
<div
|
const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType];
|
||||||
key={widget.id}
|
return (
|
||||||
data-grid={{
|
<div
|
||||||
...(effectiveLayouts.lg?.find((l) => l.i === widget.id) ?? {
|
key={widget.id}
|
||||||
x: 0,
|
data-grid={{
|
||||||
y: 0,
|
...(effectiveLayouts.lg?.find((l) => l.i === widget.id) ?? {
|
||||||
w: constraints?.defaultW ?? 8,
|
x: 0,
|
||||||
h: constraints?.defaultH ?? 4,
|
y: 0,
|
||||||
}),
|
w: constraints?.defaultW ?? 8,
|
||||||
minW: constraints?.minW ?? 8,
|
h: constraints?.defaultH ?? 4,
|
||||||
minH: constraints?.minH ?? 4,
|
}),
|
||||||
}}
|
minW: constraints?.minW ?? 8,
|
||||||
>
|
minH: constraints?.minH ?? 4,
|
||||||
<WidgetWrapper
|
}}
|
||||||
widget={widget}
|
>
|
||||||
isEditMode={isEditMode}
|
<WidgetWrapper
|
||||||
onRemove={onRemoveWidget}
|
widget={widget}
|
||||||
enterIndex={index}
|
isEditMode={isEditMode}
|
||||||
/>
|
onRemove={onRemoveWidget}
|
||||||
</div>
|
enterIndex={index}
|
||||||
);
|
/>
|
||||||
})}
|
</div>
|
||||||
</Responsive>
|
);
|
||||||
|
})}
|
||||||
|
</Responsive>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,20 +37,20 @@ vi.mock('@/lib/dashboard-api', () => ({
|
|||||||
updateWidgetConfig: vi.fn().mockResolvedValue(undefined),
|
updateWidgetConfig: vi.fn().mockResolvedValue(undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
||||||
import {
|
import {
|
||||||
|
createFavorite,
|
||||||
|
deleteFavorite,
|
||||||
FavoriteRequestError,
|
FavoriteRequestError,
|
||||||
fetchFavorites,
|
fetchFavorites,
|
||||||
createFavorite,
|
|
||||||
updateFavorite,
|
|
||||||
deleteFavorite,
|
|
||||||
reorderFavorites,
|
|
||||||
uploadFavoriteIcon,
|
|
||||||
removeFavoriteIcon,
|
removeFavoriteIcon,
|
||||||
|
reorderFavorites,
|
||||||
|
updateFavorite,
|
||||||
|
uploadFavoriteIcon,
|
||||||
} from '@/lib/favorites-api';
|
} from '@/lib/favorites-api';
|
||||||
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
|
||||||
|
|
||||||
// Must import AFTER mocks
|
// Must import AFTER mocks
|
||||||
import { FavoritesWidget } from './favorites-widget';
|
import { FavoritesWidget, LauncherLabel } from './favorites-widget';
|
||||||
|
|
||||||
// Typed mock helpers
|
// Typed mock helpers
|
||||||
const mockFetch = fetchFavorites as ReturnType<typeof vi.fn>;
|
const mockFetch = fetchFavorites as ReturnType<typeof vi.fn>;
|
||||||
@@ -106,13 +106,7 @@ describe('FavoritesWidget', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('calls fetchFavorites with instanceId on mount and renders link titles', async () => {
|
it('calls fetchFavorites with instanceId on mount and renders link titles', async () => {
|
||||||
render(
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||||
<FavoritesWidget
|
|
||||||
instanceId="fav-1"
|
|
||||||
config={{}}
|
|
||||||
isEditMode={false}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
// fetchFavorites must be called with the instanceId (widgetId scope — Pitfall 3)
|
// fetchFavorites must be called with the instanceId (widgetId scope — Pitfall 3)
|
||||||
expect(mockFetch).toHaveBeenCalledWith('fav-1');
|
expect(mockFetch).toHaveBeenCalledWith('fav-1');
|
||||||
@@ -127,13 +121,7 @@ describe('FavoritesWidget', () => {
|
|||||||
it('shows empty message when fetchFavorites resolves to empty array', async () => {
|
it('shows empty message when fetchFavorites resolves to empty array', async () => {
|
||||||
mockFetch.mockResolvedValue([]);
|
mockFetch.mockResolvedValue([]);
|
||||||
|
|
||||||
render(
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||||
<FavoritesWidget
|
|
||||||
instanceId="fav-1"
|
|
||||||
config={{}}
|
|
||||||
isEditMode={false}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('favorites.empty')).toBeInTheDocument();
|
expect(screen.getByText('favorites.empty')).toBeInTheDocument();
|
||||||
@@ -143,13 +131,7 @@ describe('FavoritesWidget', () => {
|
|||||||
it('add: submit form in edit mode calls createFavorite with widgetId, title, url', async () => {
|
it('add: submit form in edit mode calls createFavorite with widgetId, title, url', async () => {
|
||||||
mockFetch.mockResolvedValue([]);
|
mockFetch.mockResolvedValue([]);
|
||||||
|
|
||||||
render(
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||||
<FavoritesWidget
|
|
||||||
instanceId="fav-1"
|
|
||||||
config={{}}
|
|
||||||
isEditMode={true}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('favorites.empty')).toBeInTheDocument();
|
expect(screen.getByText('favorites.empty')).toBeInTheDocument();
|
||||||
@@ -189,13 +171,7 @@ describe('FavoritesWidget', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('edit: clicking edit, changing title, saving calls updateFavorite with id and new title', async () => {
|
it('edit: clicking edit, changing title, saving calls updateFavorite with id and new title', async () => {
|
||||||
render(
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||||
<FavoritesWidget
|
|
||||||
instanceId="fav-1"
|
|
||||||
config={{}}
|
|
||||||
isEditMode={true}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||||
@@ -239,13 +215,7 @@ describe('FavoritesWidget', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('delete: clicking delete calls deleteFavorite with id and removes the row', async () => {
|
it('delete: clicking delete calls deleteFavorite with id and removes the row', async () => {
|
||||||
render(
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||||
<FavoritesWidget
|
|
||||||
instanceId="fav-1"
|
|
||||||
config={{}}
|
|
||||||
isEditMode={true}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||||
@@ -271,13 +241,7 @@ describe('FavoritesWidget', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('view toggle: switching to grid in edit mode renders grid container', async () => {
|
it('view toggle: switching to grid in edit mode renders grid container', async () => {
|
||||||
render(
|
render(<FavoritesWidget instanceId="fav-1" config={{ viewMode: 'list' }} isEditMode={true} />);
|
||||||
<FavoritesWidget
|
|
||||||
instanceId="fav-1"
|
|
||||||
config={{ viewMode: 'list' }}
|
|
||||||
isEditMode={true}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||||
@@ -299,13 +263,7 @@ describe('FavoritesWidget', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('letter fallback: link with iconUrl null renders first uppercase letter of title', async () => {
|
it('letter fallback: link with iconUrl null renders first uppercase letter of title', async () => {
|
||||||
render(
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||||
<FavoritesWidget
|
|
||||||
instanceId="fav-1"
|
|
||||||
config={{}}
|
|
||||||
isEditMode={false}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Notion')).toBeInTheDocument();
|
expect(screen.getByText('Notion')).toBeInTheDocument();
|
||||||
@@ -318,24 +276,16 @@ describe('FavoritesWidget', () => {
|
|||||||
// quick-260916-iex: optionaler Titel
|
// quick-260916-iex: optionaler Titel
|
||||||
describe('optionaler Titel (quick-260916-iex)', () => {
|
describe('optionaler Titel (quick-260916-iex)', () => {
|
||||||
it('Ansicht ohne (leeren/ungueltigen) Titel zeigt keine Kopfzeile', async () => {
|
it('Ansicht ohne (leeren/ungueltigen) Titel zeigt keine Kopfzeile', async () => {
|
||||||
const r1 = render(
|
const r1 = render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
|
|
||||||
);
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
||||||
expect(
|
expect(screen.queryByPlaceholderText('favorites.titlePlaceholder')).not.toBeInTheDocument();
|
||||||
screen.queryByPlaceholderText('favorites.titlePlaceholder'),
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
r1.unmount();
|
r1.unmount();
|
||||||
|
|
||||||
const r2 = render(
|
const r2 = render(
|
||||||
<FavoritesWidget
|
<FavoritesWidget instanceId="fav-1" config={{ title: ' ' }} isEditMode={false} />,
|
||||||
instanceId="fav-1"
|
|
||||||
config={{ title: ' ' }}
|
|
||||||
isEditMode={false}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||||
@@ -343,13 +293,7 @@ describe('FavoritesWidget', () => {
|
|||||||
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
||||||
r2.unmount();
|
r2.unmount();
|
||||||
|
|
||||||
render(
|
render(<FavoritesWidget instanceId="fav-1" config={{ title: 42 }} isEditMode={false} />);
|
||||||
<FavoritesWidget
|
|
||||||
instanceId="fav-1"
|
|
||||||
config={{ title: 42 }}
|
|
||||||
isEditMode={false}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -358,11 +302,7 @@ describe('FavoritesWidget', () => {
|
|||||||
|
|
||||||
it('Ansicht mit Titel zeigt eine H2-Kopfzeile', async () => {
|
it('Ansicht mit Titel zeigt eine H2-Kopfzeile', async () => {
|
||||||
render(
|
render(
|
||||||
<FavoritesWidget
|
<FavoritesWidget instanceId="fav-1" config={{ title: 'Werkzeuge' }} isEditMode={false} />,
|
||||||
instanceId="fav-1"
|
|
||||||
config={{ title: 'Werkzeuge' }}
|
|
||||||
isEditMode={false}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||||
@@ -371,15 +311,11 @@ describe('FavoritesWidget', () => {
|
|||||||
expect(heading.tagName).toBe('H2');
|
expect(heading.tagName).toBe('H2');
|
||||||
expect(heading.className).toContain('text-sm');
|
expect(heading.className).toContain('text-sm');
|
||||||
expect(heading.className).toContain('font-semibold');
|
expect(heading.className).toContain('font-semibold');
|
||||||
expect(
|
expect(screen.queryByPlaceholderText('favorites.titlePlaceholder')).not.toBeInTheDocument();
|
||||||
screen.queryByPlaceholderText('favorites.titlePlaceholder'),
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Bearbeitungsmodus ohne Titel zeigt ein Titelfeld statt einer Kopfzeile', async () => {
|
it('Bearbeitungsmodus ohne Titel zeigt ein Titelfeld statt einer Kopfzeile', async () => {
|
||||||
render(
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
|
|
||||||
);
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -391,25 +327,17 @@ describe('FavoritesWidget', () => {
|
|||||||
expect(titleField.className).toContain('widgetNoDrag');
|
expect(titleField.className).toContain('widgetNoDrag');
|
||||||
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
||||||
|
|
||||||
expect(
|
expect(screen.getByRole('button', { name: /favorites\.listView/i })).toBeInTheDocument();
|
||||||
screen.getByRole('button', { name: /favorites\.listView/i }),
|
expect(screen.getByRole('button', { name: /favorites\.gridView/i })).toBeInTheDocument();
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByRole('button', { name: /favorites\.gridView/i }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('entprellt das Speichern des Titels um 1500 ms', async () => {
|
it('entprellt das Speichern des Titels um 1500 ms', async () => {
|
||||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
||||||
try {
|
try {
|
||||||
render(
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
|
|
||||||
);
|
|
||||||
await act(async () => {});
|
await act(async () => {});
|
||||||
|
|
||||||
const titleField = screen.getByPlaceholderText(
|
const titleField = screen.getByPlaceholderText('favorites.titlePlaceholder');
|
||||||
'favorites.titlePlaceholder',
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const value of ['W', 'We', 'Werkzeuge']) {
|
for (const value of ['W', 'We', 'Werkzeuge']) {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -444,9 +372,7 @@ describe('FavoritesWidget', () => {
|
|||||||
// Sortierpfeile im Bearbeitungsmodus.
|
// Sortierpfeile im Bearbeitungsmodus.
|
||||||
describe('Ersatzbild und Sortierung (quick-260917-jdd)', () => {
|
describe('Ersatzbild und Sortierung (quick-260917-jdd)', () => {
|
||||||
it('Ersatzbild bei iconUrl null: Direktbild statt Proxy-Bild, referrerpolicy no-referrer', async () => {
|
it('Ersatzbild bei iconUrl null: Direktbild statt Proxy-Bild, referrerpolicy no-referrer', async () => {
|
||||||
render(
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Notion')).toBeInTheDocument();
|
expect(screen.getByText('Notion')).toBeInTheDocument();
|
||||||
@@ -460,9 +386,7 @@ describe('FavoritesWidget', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Kette Proxy -> direkt -> Buchstabe', async () => {
|
it('Kette Proxy -> direkt -> Buchstabe', async () => {
|
||||||
render(
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||||
@@ -561,12 +485,17 @@ describe('FavoritesWidget', () => {
|
|||||||
|
|
||||||
it('kein Direktbild bei Nicht-http-URL', async () => {
|
it('kein Direktbild bei Nicht-http-URL', async () => {
|
||||||
mockFetch.mockResolvedValue([
|
mockFetch.mockResolvedValue([
|
||||||
{ id: 'fav-id-3', widgetId: 'fav-1', title: 'Ablage', url: 'ftp://files.example', iconUrl: null, position: 0 },
|
{
|
||||||
|
id: 'fav-id-3',
|
||||||
|
widgetId: 'fav-1',
|
||||||
|
title: 'Ablage',
|
||||||
|
url: 'ftp://files.example',
|
||||||
|
iconUrl: null,
|
||||||
|
position: 0,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
render(
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Ablage')).toBeInTheDocument();
|
expect(screen.getByText('Ablage')).toBeInTheDocument();
|
||||||
@@ -583,9 +512,7 @@ describe('FavoritesWidget', () => {
|
|||||||
{ ...BASE_FAVORITES[0], position: 1 },
|
{ ...BASE_FAVORITES[0], position: 1 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
render(
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||||
@@ -618,9 +545,7 @@ describe('FavoritesWidget', () => {
|
|||||||
it('Fehler beim Sortieren -> Neuladen, Fehlermeldung, alte Reihenfolge', async () => {
|
it('Fehler beim Sortieren -> Neuladen, Fehlermeldung, alte Reihenfolge', async () => {
|
||||||
mockReorder.mockRejectedValue(new Error('boom'));
|
mockReorder.mockRejectedValue(new Error('boom'));
|
||||||
|
|
||||||
render(
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||||
@@ -656,9 +581,7 @@ describe('FavoritesWidget', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
rerender(
|
rerender(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
|
||||||
});
|
});
|
||||||
@@ -761,7 +684,7 @@ describe('FavoritesWidget', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Datei im Bearbeitungsformular waehlen, Speichern -> updateFavorite, danach uploadFavoriteIcon(\'fav-id-1\', Datei); Formular schliesst', async () => {
|
it("Datei im Bearbeitungsformular waehlen, Speichern -> updateFavorite, danach uploadFavoriteIcon('fav-id-1', Datei); Formular schliesst", async () => {
|
||||||
mockUpdate.mockResolvedValue({ ...BASE_FAVORITES[0], iconVersion: 1 });
|
mockUpdate.mockResolvedValue({ ...BASE_FAVORITES[0], iconVersion: 1 });
|
||||||
mockUploadIcon.mockResolvedValue({
|
mockUploadIcon.mockResolvedValue({
|
||||||
...BASE_FAVORITES[0],
|
...BASE_FAVORITES[0],
|
||||||
@@ -789,7 +712,10 @@ describe('FavoritesWidget', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockUpdate).toHaveBeenCalledWith('fav-id-1', expect.objectContaining({ title: 'GitHub' }));
|
expect(mockUpdate).toHaveBeenCalledWith(
|
||||||
|
'fav-id-1',
|
||||||
|
expect.objectContaining({ title: 'GitHub' }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockUploadIcon).toHaveBeenCalledWith('fav-id-1', file);
|
expect(mockUploadIcon).toHaveBeenCalledWith('fav-id-1', file);
|
||||||
@@ -857,12 +783,16 @@ describe('FavoritesWidget', () => {
|
|||||||
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
|
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Knopf favorites.iconRemoveButton nur bei gesetztem uploadedIconMime; Klick -> removeFavoriteIcon(\'fav-id-1\'), danach verschwindet der Knopf, Formular bleibt offen', async () => {
|
it("Knopf favorites.iconRemoveButton nur bei gesetztem uploadedIconMime; Klick -> removeFavoriteIcon('fav-id-1'), danach verschwindet der Knopf, Formular bleibt offen", async () => {
|
||||||
mockFetch.mockResolvedValue([
|
mockFetch.mockResolvedValue([
|
||||||
{ ...BASE_FAVORITES[0], uploadedIconMime: 'image/png', iconVersion: 1 },
|
{ ...BASE_FAVORITES[0], uploadedIconMime: 'image/png', iconVersion: 1 },
|
||||||
BASE_FAVORITES[1],
|
BASE_FAVORITES[1],
|
||||||
]);
|
]);
|
||||||
mockRemoveIcon.mockResolvedValue({ ...BASE_FAVORITES[0], uploadedIconMime: null, iconVersion: 2 });
|
mockRemoveIcon.mockResolvedValue({
|
||||||
|
...BASE_FAVORITES[0],
|
||||||
|
uploadedIconMime: null,
|
||||||
|
iconVersion: 2,
|
||||||
|
});
|
||||||
|
|
||||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||||
|
|
||||||
@@ -881,7 +811,9 @@ describe('FavoritesWidget', () => {
|
|||||||
expect(mockRemoveIcon).toHaveBeenCalledWith('fav-id-1');
|
expect(mockRemoveIcon).toHaveBeenCalledWith('fav-id-1');
|
||||||
});
|
});
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryByRole('button', { name: 'favorites.iconRemoveButton' })).not.toBeInTheDocument();
|
expect(
|
||||||
|
screen.queryByRole('button', { name: 'favorites.iconRemoveButton' }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
|
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -895,7 +827,9 @@ describe('FavoritesWidget', () => {
|
|||||||
|
|
||||||
openEditForGitHub();
|
openEditForGitHub();
|
||||||
|
|
||||||
expect(screen.queryByRole('button', { name: 'favorites.iconRemoveButton' })).not.toBeInTheDocument();
|
expect(
|
||||||
|
screen.queryByRole('button', { name: 'favorites.iconRemoveButton' }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Hinzufuegen mit gewaehlter Datei -> createFavorite, danach uploadFavoriteIcon(created.id, Datei)', async () => {
|
it('Hinzufuegen mit gewaehlter Datei -> createFavorite, danach uploadFavoriteIcon(created.id, Datei)', async () => {
|
||||||
@@ -935,7 +869,11 @@ describe('FavoritesWidget', () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockCreate).toHaveBeenCalledWith(
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ widgetId: 'fav-1', title: 'New Link', url: 'https://example.com' }),
|
expect.objectContaining({
|
||||||
|
widgetId: 'fav-1',
|
||||||
|
title: 'New Link',
|
||||||
|
url: 'https://example.com',
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -947,3 +885,31 @@ describe('FavoritesWidget', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('LauncherLabel — Beschriftung in der Kachelansicht', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockWidths(scroll: number, client: number) {
|
||||||
|
vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(scroll);
|
||||||
|
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('bleibt in normaler Groesse, wenn der Titel in eine Zeile passt', () => {
|
||||||
|
mockWidths(40, 60);
|
||||||
|
render(<LauncherLabel text="Wiki" />);
|
||||||
|
const label = screen.getByText('Wiki');
|
||||||
|
expect(label).not.toHaveAttribute('data-small');
|
||||||
|
expect(label.className).toContain('text-xs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wird kleiner und zweizeilig, wenn der Titel nicht in eine Zeile passt', () => {
|
||||||
|
mockWidths(120, 60);
|
||||||
|
render(<LauncherLabel text="Reisekostenabrechnung" />);
|
||||||
|
const label = screen.getByText('Reisekostenabrechnung');
|
||||||
|
expect(label).toHaveAttribute('data-small', 'true');
|
||||||
|
expect(label.className).toContain('text-[10px]');
|
||||||
|
expect(label.className).toContain('line-clamp-2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { WidgetIcon } from './widget-icon';
|
|
||||||
import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { type FormEvent, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { WidgetProps } from '@/components/dashboard/widget-registry';
|
||||||
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
||||||
import {
|
import {
|
||||||
FavoriteRequestError,
|
|
||||||
createFavorite,
|
createFavorite,
|
||||||
deleteFavorite,
|
deleteFavorite,
|
||||||
|
type FavoriteLink,
|
||||||
|
FavoriteRequestError,
|
||||||
fetchFavorites,
|
fetchFavorites,
|
||||||
removeFavoriteIcon,
|
removeFavoriteIcon,
|
||||||
reorderFavorites,
|
reorderFavorites,
|
||||||
updateFavorite,
|
updateFavorite,
|
||||||
uploadFavoriteIcon,
|
uploadFavoriteIcon,
|
||||||
type FavoriteLink,
|
|
||||||
} from '@/lib/favorites-api';
|
} from '@/lib/favorites-api';
|
||||||
import type { WidgetProps } from '@/components/dashboard/widget-registry';
|
import { WidgetIcon } from './widget-icon';
|
||||||
|
|
||||||
// quick-260916-iex: wie DEBOUNCE_MS im Notiz-Widget
|
// quick-260916-iex: wie DEBOUNCE_MS im Notiz-Widget
|
||||||
const TITLE_DEBOUNCE_MS = 1500;
|
const TITLE_DEBOUNCE_MS = 1500;
|
||||||
@@ -51,11 +51,7 @@ function favoriteErrorKey(err: unknown): string {
|
|||||||
* - Sortierung per Pfeilen im Bearbeitungsmodus (260917-jdd): optimistisch,
|
* - Sortierung per Pfeilen im Bearbeitungsmodus (260917-jdd): optimistisch,
|
||||||
* persistiert ueber PUT /favorites/order
|
* persistiert ueber PUT /favorites/order
|
||||||
*/
|
*/
|
||||||
export function FavoritesWidget({
|
export function FavoritesWidget({ instanceId, config, isEditMode }: WidgetProps) {
|
||||||
instanceId,
|
|
||||||
config,
|
|
||||||
isEditMode,
|
|
||||||
}: WidgetProps) {
|
|
||||||
const t = useTranslations('widgets');
|
const t = useTranslations('widgets');
|
||||||
|
|
||||||
const [favorites, setFavorites] = useState<FavoriteLink[]>([]);
|
const [favorites, setFavorites] = useState<FavoriteLink[]>([]);
|
||||||
@@ -68,12 +64,8 @@ export function FavoritesWidget({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Optionaler Titel (quick-260916-iex)
|
// Optionaler Titel (quick-260916-iex)
|
||||||
const [title, setTitle] = useState<string>(
|
const [title, setTitle] = useState<string>(typeof config.title === 'string' ? config.title : '');
|
||||||
typeof config.title === 'string' ? config.title : '',
|
const titleTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
);
|
|
||||||
const titleTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => clearTimeout(titleTimerRef.current);
|
return () => clearTimeout(titleTimerRef.current);
|
||||||
@@ -335,174 +327,167 @@ export function FavoritesWidget({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
hasTitle && (
|
hasTitle && (
|
||||||
<h2 className="truncate text-sm font-semibold text-foreground">
|
<h2 className="truncate text-sm font-semibold text-foreground">{title.trim()}</h2>
|
||||||
{title.trim()}
|
|
||||||
</h2>
|
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`flex flex-1 flex-col gap-2 overflow-auto px-2 pb-2 ${showHeader ? '' : 'pt-2'}`}>
|
<div
|
||||||
{/* View mode toggle (edit mode only) */}
|
className={`flex flex-1 flex-col gap-2 overflow-auto px-2 pb-2 ${showHeader ? '' : 'pt-2'}`}
|
||||||
{isEditMode && (
|
>
|
||||||
// role="toolbar" (D-03): die bisherige Beschriftung ("Favoriten")
|
{/* View mode toggle (edit mode only) */}
|
||||||
// war sachlich falsch fuer einen Umschalter zwischen Listen- und
|
{isEditMode && (
|
||||||
// Kachelansicht — hier greift D-03 im Sinn des Streichens (die
|
// role="toolbar" (D-03): die bisherige Beschriftung ("Favoriten")
|
||||||
// Beschriftung trug keine echte Bedeutung), eine richtige
|
// war sachlich falsch fuer einen Umschalter zwischen Listen- und
|
||||||
// Beschriftung ist trotzdem besser als gar keine.
|
// Kachelansicht — hier greift D-03 im Sinn des Streichens (die
|
||||||
<div
|
// Beschriftung trug keine echte Bedeutung), eine richtige
|
||||||
className="flex gap-1 widgetNoDrag"
|
// Beschriftung ist trotzdem besser als gar keine.
|
||||||
role="toolbar"
|
|
||||||
aria-label={t('favorites.viewModeLabel')}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleViewMode('list')}
|
|
||||||
className={`px-2 py-1 text-xs rounded ${
|
|
||||||
viewMode === 'list'
|
|
||||||
? 'bg-foreground text-background'
|
|
||||||
: 'bg-muted text-muted-foreground hover:text-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t('favorites.listView')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleViewMode('grid')}
|
|
||||||
className={`px-2 py-1 text-xs rounded ${
|
|
||||||
viewMode === 'grid'
|
|
||||||
? 'bg-foreground text-background'
|
|
||||||
: 'bg-muted text-muted-foreground hover:text-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t('favorites.gridView')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Status messages */}
|
|
||||||
{loading && (
|
|
||||||
<p className="text-xs text-muted-foreground">{t('favorites.loading')}</p>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<p className="text-xs text-destructive">{error}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Favorites list / grid */}
|
|
||||||
{!loading && sortedFavorites.length === 0 && (
|
|
||||||
<p className="text-xs text-muted-foreground">{t('favorites.empty')}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && sortedFavorites.length > 0 && (
|
|
||||||
viewMode === 'grid' ? (
|
|
||||||
<div
|
<div
|
||||||
data-testid="favorites-grid"
|
className="flex gap-1 widgetNoDrag"
|
||||||
className="grid grid-cols-[repeat(auto-fill,minmax(76px,1fr))] gap-1"
|
role="toolbar"
|
||||||
|
aria-label={t('favorites.viewModeLabel')}
|
||||||
>
|
>
|
||||||
{sortedFavorites.map((fav, index) => (
|
<button
|
||||||
<FavoriteTile
|
type="button"
|
||||||
launcher
|
onClick={() => handleViewMode('list')}
|
||||||
key={fav.id}
|
className={`px-2 py-1 text-xs rounded ${
|
||||||
fav={fav}
|
viewMode === 'list'
|
||||||
isEditMode={isEditMode}
|
? 'bg-foreground text-background'
|
||||||
editingId={editingId}
|
: 'bg-muted text-muted-foreground hover:text-foreground'
|
||||||
editTitle={editTitle}
|
}`}
|
||||||
editUrl={editUrl}
|
>
|
||||||
editIconUrl={editIconUrl}
|
{t('favorites.listView')}
|
||||||
editError={editError}
|
</button>
|
||||||
editBusy={editBusy}
|
<button
|
||||||
getFallbackLetter={getFallbackLetter}
|
type="button"
|
||||||
onStartEdit={startEdit}
|
onClick={() => handleViewMode('grid')}
|
||||||
onCancelEdit={cancelEdit}
|
className={`px-2 py-1 text-xs rounded ${
|
||||||
onSaveEdit={handleSaveEdit}
|
viewMode === 'grid'
|
||||||
onDelete={handleDelete}
|
? 'bg-foreground text-background'
|
||||||
onEditTitleChange={setEditTitle}
|
: 'bg-muted text-muted-foreground hover:text-foreground'
|
||||||
onEditUrlChange={setEditUrl}
|
}`}
|
||||||
onEditIconUrlChange={setEditIconUrl}
|
>
|
||||||
onEditIconFileChange={setEditIconFile}
|
{t('favorites.gridView')}
|
||||||
onRemoveIcon={handleRemoveIcon}
|
</button>
|
||||||
canMoveUp={index > 0}
|
|
||||||
canMoveDown={index < sortedFavorites.length - 1}
|
|
||||||
onMove={(fid, dir) => void handleMove(fid, dir)}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)}
|
||||||
<div
|
|
||||||
data-testid="favorites-list"
|
|
||||||
className="flex flex-col gap-0.5"
|
|
||||||
>
|
|
||||||
{sortedFavorites.map((fav, index) => (
|
|
||||||
<FavoriteTile
|
|
||||||
key={fav.id}
|
|
||||||
fav={fav}
|
|
||||||
isEditMode={isEditMode}
|
|
||||||
editingId={editingId}
|
|
||||||
editTitle={editTitle}
|
|
||||||
editUrl={editUrl}
|
|
||||||
editIconUrl={editIconUrl}
|
|
||||||
editError={editError}
|
|
||||||
editBusy={editBusy}
|
|
||||||
getFallbackLetter={getFallbackLetter}
|
|
||||||
onStartEdit={startEdit}
|
|
||||||
onCancelEdit={cancelEdit}
|
|
||||||
onSaveEdit={handleSaveEdit}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
onEditTitleChange={setEditTitle}
|
|
||||||
onEditUrlChange={setEditUrl}
|
|
||||||
onEditIconUrlChange={setEditIconUrl}
|
|
||||||
onEditIconFileChange={setEditIconFile}
|
|
||||||
onRemoveIcon={handleRemoveIcon}
|
|
||||||
canMoveUp={index > 0}
|
|
||||||
canMoveDown={index < sortedFavorites.length - 1}
|
|
||||||
onMove={(fid, dir) => void handleMove(fid, dir)}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Add form (edit mode only, D-04) */}
|
{/* Status messages */}
|
||||||
{isEditMode && (
|
{loading && <p className="text-xs text-muted-foreground">{t('favorites.loading')}</p>}
|
||||||
<form
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
onSubmit={(e) => void handleAdd(e)}
|
|
||||||
className="flex flex-col gap-1 pt-1 border-t border-border widgetNoDrag"
|
{/* Favorites list / grid */}
|
||||||
>
|
{!loading && sortedFavorites.length === 0 && (
|
||||||
<input
|
<p className="text-xs text-muted-foreground">{t('favorites.empty')}</p>
|
||||||
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
)}
|
||||||
value={newTitle}
|
|
||||||
onChange={(e) => setNewTitle(e.target.value)}
|
{!loading &&
|
||||||
placeholder={t('favorites.addTitle')}
|
sortedFavorites.length > 0 &&
|
||||||
/>
|
(viewMode === 'grid' ? (
|
||||||
<input
|
<div
|
||||||
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
data-testid="favorites-grid"
|
||||||
value={newUrl}
|
className="grid grid-cols-[repeat(auto-fill,minmax(52px,1fr))] gap-1"
|
||||||
onChange={(e) => setNewUrl(e.target.value)}
|
>
|
||||||
placeholder={t('favorites.addUrl')}
|
{sortedFavorites.map((fav, index) => (
|
||||||
/>
|
<FavoriteTile
|
||||||
{/* 260923-lrr: eigenes Symbol beim Hinzufuegen */}
|
launcher
|
||||||
<label className="text-xs text-muted-foreground">
|
key={fav.id}
|
||||||
{t('favorites.iconUploadLabel')}
|
fav={fav}
|
||||||
|
isEditMode={isEditMode}
|
||||||
|
editingId={editingId}
|
||||||
|
editTitle={editTitle}
|
||||||
|
editUrl={editUrl}
|
||||||
|
editIconUrl={editIconUrl}
|
||||||
|
editError={editError}
|
||||||
|
editBusy={editBusy}
|
||||||
|
getFallbackLetter={getFallbackLetter}
|
||||||
|
onStartEdit={startEdit}
|
||||||
|
onCancelEdit={cancelEdit}
|
||||||
|
onSaveEdit={handleSaveEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onEditTitleChange={setEditTitle}
|
||||||
|
onEditUrlChange={setEditUrl}
|
||||||
|
onEditIconUrlChange={setEditIconUrl}
|
||||||
|
onEditIconFileChange={setEditIconFile}
|
||||||
|
onRemoveIcon={handleRemoveIcon}
|
||||||
|
canMoveUp={index > 0}
|
||||||
|
canMoveDown={index < sortedFavorites.length - 1}
|
||||||
|
onMove={(fid, dir) => void handleMove(fid, dir)}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div data-testid="favorites-list" className="flex flex-col gap-0.5">
|
||||||
|
{sortedFavorites.map((fav, index) => (
|
||||||
|
<FavoriteTile
|
||||||
|
key={fav.id}
|
||||||
|
fav={fav}
|
||||||
|
isEditMode={isEditMode}
|
||||||
|
editingId={editingId}
|
||||||
|
editTitle={editTitle}
|
||||||
|
editUrl={editUrl}
|
||||||
|
editIconUrl={editIconUrl}
|
||||||
|
editError={editError}
|
||||||
|
editBusy={editBusy}
|
||||||
|
getFallbackLetter={getFallbackLetter}
|
||||||
|
onStartEdit={startEdit}
|
||||||
|
onCancelEdit={cancelEdit}
|
||||||
|
onSaveEdit={handleSaveEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onEditTitleChange={setEditTitle}
|
||||||
|
onEditUrlChange={setEditUrl}
|
||||||
|
onEditIconUrlChange={setEditIconUrl}
|
||||||
|
onEditIconFileChange={setEditIconFile}
|
||||||
|
onRemoveIcon={handleRemoveIcon}
|
||||||
|
canMoveUp={index > 0}
|
||||||
|
canMoveDown={index < sortedFavorites.length - 1}
|
||||||
|
onMove={(fid, dir) => void handleMove(fid, dir)}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add form (edit mode only, D-04) */}
|
||||||
|
{isEditMode && (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => void handleAdd(e)}
|
||||||
|
className="flex flex-col gap-1 pt-1 border-t border-border widgetNoDrag"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="file"
|
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
data-testid="favorite-add-icon-upload"
|
value={newTitle}
|
||||||
ref={newIconFileInputRef}
|
onChange={(e) => setNewTitle(e.target.value)}
|
||||||
accept="image/png,image/jpeg,image/gif,image/webp,image/x-icon,image/vnd.microsoft.icon,image/svg+xml,.ico,.svg"
|
placeholder={t('favorites.addTitle')}
|
||||||
className="mt-0.5 block w-full text-xs"
|
|
||||||
onChange={(e) => setNewIconFile(e.target.files?.[0] ?? null)}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
<input
|
||||||
<button
|
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
type="submit"
|
value={newUrl}
|
||||||
className="w-full rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:opacity-90 transition-opacity"
|
onChange={(e) => setNewUrl(e.target.value)}
|
||||||
>
|
placeholder={t('favorites.addUrl')}
|
||||||
{t('favorites.addButton')}
|
/>
|
||||||
</button>
|
{/* 260923-lrr: eigenes Symbol beim Hinzufuegen */}
|
||||||
</form>
|
<label className="text-xs text-muted-foreground">
|
||||||
)}
|
{t('favorites.iconUploadLabel')}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
data-testid="favorite-add-icon-upload"
|
||||||
|
ref={newIconFileInputRef}
|
||||||
|
accept="image/png,image/jpeg,image/gif,image/webp,image/x-icon,image/vnd.microsoft.icon,image/svg+xml,.ico,.svg"
|
||||||
|
className="mt-0.5 block w-full text-xs"
|
||||||
|
onChange={(e) => setNewIconFile(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
{t('favorites.addButton')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -677,6 +662,60 @@ type FavoriteTileProps = {
|
|||||||
launcher?: boolean;
|
launcher?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beschriftung einer Favoriten-Kachel in der Kachelansicht. Passt der Titel
|
||||||
|
* in normaler Groesse (12 px) nicht in eine Zeile, wuerde er an die
|
||||||
|
* Nachbarkachel stossen bzw. mitten im Wort umbrechen — dann wird die Schrift
|
||||||
|
* kleiner (10 px) und darf zweizeilig umbrechen (Silbentrennung, danach "…").
|
||||||
|
* Gemessen wird immer in normaler Groesse und einzeilig, damit das Ergebnis
|
||||||
|
* nicht von der eigenen Umschaltung abhaengt (keine Rueckkopplung). Der
|
||||||
|
* ResizeObserver misst neu, wenn die Kachel breiter oder schmaler wird;
|
||||||
|
* scrollWidth/clientWidth sind Layoutmasse und von der Dashboard-Skalierung
|
||||||
|
* (transform) unberuehrt.
|
||||||
|
*/
|
||||||
|
export function LauncherLabel({ text }: { text: string }) {
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
const [small, setSmall] = useState(false);
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: neuer Titel = neu messen, der Wert selbst steht im DOM
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const measure = () => {
|
||||||
|
const prev = {
|
||||||
|
whiteSpace: el.style.whiteSpace,
|
||||||
|
fontSize: el.style.fontSize,
|
||||||
|
display: el.style.display,
|
||||||
|
};
|
||||||
|
el.style.whiteSpace = 'nowrap';
|
||||||
|
el.style.fontSize = '12px';
|
||||||
|
el.style.display = 'block';
|
||||||
|
const overflow = el.scrollWidth > el.clientWidth + 1;
|
||||||
|
el.style.whiteSpace = prev.whiteSpace;
|
||||||
|
el.style.fontSize = prev.fontSize;
|
||||||
|
el.style.display = prev.display;
|
||||||
|
setSmall(overflow);
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
if (typeof ResizeObserver === 'undefined') return;
|
||||||
|
const observer = new ResizeObserver(measure);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
ref={ref}
|
||||||
|
data-small={small ? 'true' : undefined}
|
||||||
|
className={`line-clamp-2 w-full break-words hyphens-auto ${
|
||||||
|
small ? 'text-[10px] leading-3' : 'text-xs leading-4'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FavoriteTile({
|
function FavoriteTile({
|
||||||
fav,
|
fav,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
@@ -728,9 +767,11 @@ function FavoriteTile({
|
|||||||
large={launcher}
|
large={launcher}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span className={launcher ? 'line-clamp-2 w-full break-words text-xs leading-4' : 'truncate text-sm'}>
|
{launcher ? (
|
||||||
{fav.title}
|
<LauncherLabel text={fav.title} />
|
||||||
</span>
|
) : (
|
||||||
|
<span className="truncate text-sm">{fav.title}</span>
|
||||||
|
)}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{/* Action buttons (edit mode only, D-04) */}
|
{/* Action buttons (edit mode only, D-04) */}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
|
||||||
import { installErrorBuffer } from '@/lib/error-buffer';
|
|
||||||
import { Header } from '@/components/layout/header';
|
import { Header } from '@/components/layout/header';
|
||||||
import { Sidebar } from '@/components/layout/sidebar';
|
import { Sidebar } from '@/components/layout/sidebar';
|
||||||
|
import { CustomModuleFrameHost } from '@/components/modules/custom-module-frame-host';
|
||||||
import { ReleaseNoticeHost } from '@/components/release-notice/release-notice-host';
|
import { ReleaseNoticeHost } from '@/components/release-notice/release-notice-host';
|
||||||
import { ReminderNotifier } from '@/components/reminders/reminder-notifier';
|
import { ReminderNotifier } from '@/components/reminders/reminder-notifier';
|
||||||
|
import { installErrorBuffer } from '@/lib/error-buffer';
|
||||||
|
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
||||||
|
|
||||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
const isCollapsed = useSidebarStore((s) => s.isCollapsed);
|
const isCollapsed = useSidebarStore((s) => s.isCollapsed);
|
||||||
@@ -24,9 +25,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
// Desktop: sidebar pushes content via margin-left
|
// Desktop: sidebar pushes content via margin-left
|
||||||
// Mobile: sidebar overlays, no margin needed (handled by app-shell-main class in globals.css)
|
// Mobile: sidebar overlays, no margin needed (handled by app-shell-main class in globals.css)
|
||||||
const sidebarWidth = isCollapsed
|
const sidebarWidth = isCollapsed ? 'var(--sidebar-width-collapsed)' : 'var(--sidebar-width)';
|
||||||
? 'var(--sidebar-width-collapsed)'
|
|
||||||
: 'var(--sidebar-width)';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
@@ -35,7 +34,9 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|||||||
{/* Seitenrahmen seit quick-260916-bwo halbiert: p-3 (12 px) statt vorher 24 px (Stufe 6), gilt fuer alle Seiten. */}
|
{/* Seitenrahmen seit quick-260916-bwo halbiert: p-3 (12 px) statt vorher 24 px (Stufe 6), gilt fuer alle Seiten. */}
|
||||||
<main
|
<main
|
||||||
className="app-shell-main min-h-[calc(100vh-var(--header-height))] transition-all duration-200 p-3"
|
className="app-shell-main min-h-[calc(100vh-var(--header-height))] transition-all duration-200 p-3"
|
||||||
style={mounted ? { '--current-sidebar-width': sidebarWidth } as React.CSSProperties : undefined}
|
style={
|
||||||
|
mounted ? ({ '--current-sidebar-width': sidebarWidth } as React.CSSProperties) : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
@@ -46,6 +47,10 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|||||||
Benachrichtigung auf jeder Portalseite ausloest — nicht nur, wenn die
|
Benachrichtigung auf jeder Portalseite ausloest — nicht nur, wenn die
|
||||||
Kachel auf dem Dashboard sichtbar ist. Rendert nichts. */}
|
Kachel auf dem Dashboard sichtbar ist. Rendert nichts. */}
|
||||||
<ReminderNotifier />
|
<ReminderNotifier />
|
||||||
|
{/* Eigene Module offen halten (30.09.2026): die <iframe>s leben hier im
|
||||||
|
Rahmen und ueberstehen so das Wegnavigieren; die Modulseite zeigt nur
|
||||||
|
einen Platzhalter, ueber den der Behaelter das aktive Modul legt. */}
|
||||||
|
<CustomModuleFrameHost />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { TesseraLogo } from '@/components/brand/tessera-logo';
|
import { TesseraLogo } from '@/components/brand/tessera-logo';
|
||||||
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
|
||||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
|
||||||
import { fetchSessionState, logout } from '@/lib/auth-actions';
|
|
||||||
import { buildNextParam } from '@/lib/safe-next';
|
|
||||||
import { ThemeToggle } from '@/components/theme-toggle';
|
|
||||||
import { BugReportButton } from '@/components/bug-report/bug-report-button';
|
import { BugReportButton } from '@/components/bug-report/bug-report-button';
|
||||||
import { HEADER_ACTIONS_SLOT_ID, HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot';
|
import { HEADER_ACTIONS_SLOT_ID, HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot';
|
||||||
import { usePathname } from 'next/navigation';
|
import { ThemeToggle } from '@/components/theme-toggle';
|
||||||
|
import { fetchSessionState, logout } from '@/lib/auth-actions';
|
||||||
|
import { buildNextParam } from '@/lib/safe-next';
|
||||||
|
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||||
|
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
|
||||||
import { resolvePageTitle, useNavStore } from '@/lib/stores/nav-store';
|
import { resolvePageTitle, useNavStore } from '@/lib/stores/nav-store';
|
||||||
|
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
@@ -60,13 +61,8 @@ export function Header() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
const next = buildNextParam(
|
const next = buildNextParam(window.location.pathname, window.location.search);
|
||||||
window.location.pathname,
|
window.location.href = next ? `/login?next=${encodeURIComponent(next)}` : '/login';
|
||||||
window.location.search,
|
|
||||||
);
|
|
||||||
window.location.href = next
|
|
||||||
? `/login?next=${encodeURIComponent(next)}`
|
|
||||||
: '/login';
|
|
||||||
}
|
}
|
||||||
// 'unavailable' (T-gyd-02): kein Redirect, bisheriges stilles
|
// 'unavailable' (T-gyd-02): kein Redirect, bisheriges stilles
|
||||||
// Verhalten -- ein API-Ausfall darf keine Abmelde-Schleife ausloesen.
|
// Verhalten -- ein API-Ausfall darf keine Abmelde-Schleife ausloesen.
|
||||||
@@ -76,10 +72,7 @@ export function Header() {
|
|||||||
// Close dropdown on outside click
|
// Close dropdown on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
if (
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
dropdownRef.current &&
|
|
||||||
!dropdownRef.current.contains(event.target as Node)
|
|
||||||
) {
|
|
||||||
setDropdownOpen(false);
|
setDropdownOpen(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,9 +84,7 @@ export function Header() {
|
|||||||
|
|
||||||
const [avatarError, setAvatarError] = useState(false);
|
const [avatarError, setAvatarError] = useState(false);
|
||||||
|
|
||||||
const userInitial = user
|
const userInitial = user ? (user.displayName ?? user.username).charAt(0).toUpperCase() : '?';
|
||||||
? (user.displayName ?? user.username).charAt(0).toUpperCase()
|
|
||||||
: '?';
|
|
||||||
|
|
||||||
const roleLabel = user ? tHeader(`role.${user.role}`) : '';
|
const roleLabel = user ? tHeader(`role.${user.role}`) : '';
|
||||||
|
|
||||||
@@ -101,11 +92,7 @@ export function Header() {
|
|||||||
// der Name der aktuellen Seite bzw. des Moduls.
|
// der Name der aktuellen Seite bzw. des Moduls.
|
||||||
const navModules = useNavStore((s) => s.modules);
|
const navModules = useNavStore((s) => s.modules);
|
||||||
const pageTitle = resolvePageTitle(pathname, navModules);
|
const pageTitle = resolvePageTitle(pathname, navModules);
|
||||||
const pageTitleText = pageTitle
|
const pageTitleText = pageTitle ? ('key' in pageTitle ? t(pageTitle.key) : pageTitle.text) : null;
|
||||||
? 'key' in pageTitle
|
|
||||||
? t(pageTitle.key)
|
|
||||||
: pageTitle.text
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 flex h-[var(--header-height)] items-center bg-appbar px-2 text-appbar-foreground sm:px-3 dark:shadow-[inset_0_-1px_0_oklch(1_0_0/0.07)]">
|
<header className="sticky top-0 z-50 flex h-[var(--header-height)] items-center bg-appbar px-2 text-appbar-foreground sm:px-3 dark:shadow-[inset_0_-1px_0_oklch(1_0_0/0.07)]">
|
||||||
@@ -258,7 +245,18 @@ export function Header() {
|
|||||||
: 'text-foreground hover:bg-muted'
|
: 'text-foreground hover:bg-muted'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||||
</svg>
|
</svg>
|
||||||
{tHeader('admin.label')}
|
{tHeader('admin.label')}
|
||||||
@@ -273,6 +271,9 @@ export function Header() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDropdownOpen(false);
|
setDropdownOpen(false);
|
||||||
|
// Offen gehaltene eigene Module und ihr Sitzungsspeicher
|
||||||
|
// gehoeren zur Sitzung — beim Abmelden alles verwerfen.
|
||||||
|
useCustomModuleCacheStore.getState().clear();
|
||||||
logout();
|
logout();
|
||||||
}}
|
}}
|
||||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground hover:bg-muted transition-colors"
|
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground hover:bg-muted transition-colors"
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
|
||||||
|
import { checkCustomModuleUrl } from '@/lib/custom-modules-api';
|
||||||
|
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||||
|
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
|
||||||
|
|
||||||
|
interface FrameRect {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Behaelter fuer die offen gehaltenen eigenen Module (30.09.2026). Dauerhaft
|
||||||
|
* im Portal-Rahmen (AppShell) eingehaengt, damit ein <iframe> das Wegnavigieren
|
||||||
|
* ueberlebt: zurueck auf dem Modul ist die Fremdseite sofort da, mit
|
||||||
|
* Scrollstand, Anmeldung und Eingaben.
|
||||||
|
*
|
||||||
|
* Positionierung — warum Ueberlagern statt Einhaengen in die Seite: Ein
|
||||||
|
* <iframe>, das im DOM verschoben wird (auch per Portal in einen anderen
|
||||||
|
* Elternknoten), laedt in allen Browsern neu. Deshalb bleibt jedes <iframe>
|
||||||
|
* immer im selben Elternknoten hier, und die Modulseite rendert nur einen
|
||||||
|
* leeren Platzhalter (custom-module-view.tsx). Das aktive <iframe> wird per
|
||||||
|
* `position: fixed` deckungsgleich ueber den Platzhalter gelegt; gemessen wird
|
||||||
|
* mit `getBoundingClientRect` bei jeder Groessenaenderung des Platzhalters
|
||||||
|
* (ResizeObserver — faengt auch das Ein-/Ausklappen der Seitenleiste ab, deren
|
||||||
|
* Uebergang die Breite des Inhaltsbereichs Bild fuer Bild aendert), bei
|
||||||
|
* Fenstergroesse, beim Scrollen und am Ende von CSS-Uebergaengen. Eine reine
|
||||||
|
* CSS-Nachbildung der Rahmenmasse (Kopfzeile, Seitenleiste, Innenabstand)
|
||||||
|
* waere zerbrechlicher: jede Layoutaenderung dort muesste hier nachgezogen
|
||||||
|
* werden.
|
||||||
|
*
|
||||||
|
* Versteckt wird mit `visibility: hidden` + `pointer-events: none` an der
|
||||||
|
* zuletzt gemessenen Stelle — NICHT mit `display: none`: dort haette die
|
||||||
|
* Fremdseite die Groesse 0, manche Seiten rechnen dann ihr Layout neu und
|
||||||
|
* verlieren den Scrollstand. `visibility: hidden` nimmt das <iframe> zudem aus
|
||||||
|
* Tab-Reihenfolge und Vorlesebaum.
|
||||||
|
*
|
||||||
|
* Ebene: `z-index: 10` — ueber dem Inhaltsbereich (Platzhalter), aber unter
|
||||||
|
* Seitenleiste (30), Kopfzeile und Dialogen (50) und der Handy-Seitenleiste
|
||||||
|
* (60), die das Modul also weiterhin ueberdecken.
|
||||||
|
*
|
||||||
|
* Sandbox und Attribute wie bisher in der Modulansicht bzw. im XFrame-Widget
|
||||||
|
* (`XFRAME_SANDBOX`, `allow=""`, kein Referrer); ein <iframe> entsteht nur bei
|
||||||
|
* einer https-Adresse ohne Zugangsdaten (T-9WC-03).
|
||||||
|
*/
|
||||||
|
export function CustomModuleFrameHost() {
|
||||||
|
const modules = useCustomModuleCacheStore((s) => s.modules);
|
||||||
|
const openIds = useCustomModuleCacheStore((s) => s.openIds);
|
||||||
|
const active = useCustomModuleCacheStore((s) => s.active);
|
||||||
|
const clear = useCustomModuleCacheStore((s) => s.clear);
|
||||||
|
const [rect, setRect] = useState<FrameRect | null>(null);
|
||||||
|
|
||||||
|
// Benutzerwechsel ohne Neuladen der Seite: nichts vom vorigen Benutzer
|
||||||
|
// weiterzeigen. (Das Abmelden selbst leert den Speicher in der Kopfzeile.)
|
||||||
|
const userId = useAuthStore((s) => s.user?.id ?? null);
|
||||||
|
const lastUserId = useRef<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (userId && lastUserId.current && lastUserId.current !== userId) clear();
|
||||||
|
if (userId) lastUserId.current = userId;
|
||||||
|
}, [userId, clear]);
|
||||||
|
|
||||||
|
const slot = active?.slot ?? null;
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!slot) return;
|
||||||
|
let frame = 0;
|
||||||
|
const measure = () => {
|
||||||
|
frame = 0;
|
||||||
|
const r = slot.getBoundingClientRect();
|
||||||
|
setRect((prev) =>
|
||||||
|
prev &&
|
||||||
|
prev.top === r.top &&
|
||||||
|
prev.left === r.left &&
|
||||||
|
prev.width === r.width &&
|
||||||
|
prev.height === r.height
|
||||||
|
? prev
|
||||||
|
: { top: r.top, left: r.left, width: r.width, height: r.height },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const schedule = () => {
|
||||||
|
if (!frame) frame = requestAnimationFrame(measure);
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(schedule) : null;
|
||||||
|
observer?.observe(slot);
|
||||||
|
window.addEventListener('resize', schedule);
|
||||||
|
window.addEventListener('scroll', schedule, true);
|
||||||
|
document.addEventListener('transitionend', schedule, true);
|
||||||
|
return () => {
|
||||||
|
if (frame) cancelAnimationFrame(frame);
|
||||||
|
observer?.disconnect();
|
||||||
|
window.removeEventListener('resize', schedule);
|
||||||
|
window.removeEventListener('scroll', schedule, true);
|
||||||
|
document.removeEventListener('transitionend', schedule, true);
|
||||||
|
};
|
||||||
|
}, [slot]);
|
||||||
|
|
||||||
|
// Immer derselbe Elternknoten, auch ohne offene Module — sonst haengte React
|
||||||
|
// die <iframe>s beim Wechsel zwischen leer und nicht leer neu ein.
|
||||||
|
return (
|
||||||
|
<div data-testid="custom-module-frame-host">
|
||||||
|
{openIds.map((id) => {
|
||||||
|
const mod = modules[id];
|
||||||
|
if (!mod || checkCustomModuleUrl(mod.url) !== 'ok') return null;
|
||||||
|
const visible = active?.id === id && rect !== null;
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
key={id}
|
||||||
|
src={mod.url}
|
||||||
|
title={mod.name}
|
||||||
|
sandbox={XFRAME_SANDBOX}
|
||||||
|
allow=""
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
className="fixed z-10 rounded-lg border-0 bg-background"
|
||||||
|
style={{
|
||||||
|
top: rect?.top ?? 0,
|
||||||
|
left: rect?.left ?? 0,
|
||||||
|
width: rect?.width ?? 0,
|
||||||
|
height: rect?.height ?? 0,
|
||||||
|
visibility: visible ? 'visible' : 'hidden',
|
||||||
|
pointerEvents: visible ? 'auto' : 'none',
|
||||||
|
}}
|
||||||
|
aria-hidden={visible ? undefined : true}
|
||||||
|
data-custom-module-id={id}
|
||||||
|
data-testid="custom-module-frame"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
|
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
|
||||||
import { CustomModuleRequestError } from '@/lib/custom-modules-api';
|
import { CustomModuleRequestError } from '@/lib/custom-modules-api';
|
||||||
@@ -24,12 +24,33 @@ vi.mock('@/lib/custom-modules-api', async () => {
|
|||||||
return { ...actual, getCustomModule: mockGetCustomModule };
|
return { ...actual, getCustomModule: mockGetCustomModule };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
|
||||||
|
import { CustomModuleFrameHost } from './custom-module-frame-host';
|
||||||
import { CustomModuleView } from './custom-module-view';
|
import { CustomModuleView } from './custom-module-view';
|
||||||
|
|
||||||
function mod(url: string) {
|
/**
|
||||||
|
* Der <iframe> lebt seit dem Offenhalten (30.09.2026) im Behaelter des
|
||||||
|
* Portal-Rahmens; die Modulseite zeigt nur den Platzhalter. Die Tests rendern
|
||||||
|
* deshalb beide nebeneinander wie in AppShell. `route` bildet den
|
||||||
|
* Seitenwechsel nach: nur die Modulseite wechselt, der Behaelter bleibt.
|
||||||
|
*/
|
||||||
|
function Portal({ route }: { route: string | null }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<main>{route ? <CustomModuleView id={route} /> : <p>Dashboard</p>}</main>
|
||||||
|
<CustomModuleFrameHost />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModule(id: string) {
|
||||||
|
return render(<Portal route={id} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mod(url: string, id = 'abc', name = 'Wiki') {
|
||||||
return {
|
return {
|
||||||
id: 'abc',
|
id,
|
||||||
name: 'Wiki',
|
name,
|
||||||
url,
|
url,
|
||||||
category: 'infrastructure',
|
category: 'infrastructure',
|
||||||
createdAt: '2026-09-29T00:00:00.000Z',
|
createdAt: '2026-09-29T00:00:00.000Z',
|
||||||
@@ -39,6 +60,7 @@ function mod(url: string) {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockGetCustomModule.mockReset();
|
mockGetCustomModule.mockReset();
|
||||||
|
useCustomModuleCacheStore.getState().clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -48,7 +70,7 @@ afterEach(() => {
|
|||||||
describe('CustomModuleView', () => {
|
describe('CustomModuleView', () => {
|
||||||
it('rendert den Rahmen mit Sandbox, ohne Referrer und ohne allow', async () => {
|
it('rendert den Rahmen mit Sandbox, ohne Referrer und ohne allow', async () => {
|
||||||
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
|
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
|
||||||
render(<CustomModuleView id="abc" />);
|
renderModule('abc');
|
||||||
|
|
||||||
const frame = await screen.findByTitle('Wiki');
|
const frame = await screen.findByTitle('Wiki');
|
||||||
expect(frame.tagName).toBe('IFRAME');
|
expect(frame.tagName).toBe('IFRAME');
|
||||||
@@ -62,7 +84,7 @@ describe('CustomModuleView', () => {
|
|||||||
|
|
||||||
it('zeigt immer den Link „In neuem Tab öffnen“, aber keine Name- oder Hinweiszeile', async () => {
|
it('zeigt immer den Link „In neuem Tab öffnen“, aber keine Name- oder Hinweiszeile', async () => {
|
||||||
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
|
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
|
||||||
render(<CustomModuleView id="abc" />);
|
renderModule('abc');
|
||||||
|
|
||||||
const link = await screen.findByRole('link', { name: 'In neuem Tab öffnen' });
|
const link = await screen.findByRole('link', { name: 'In neuem Tab öffnen' });
|
||||||
expect(link).toHaveAttribute('href', 'https://example.com/wiki');
|
expect(link).toHaveAttribute('href', 'https://example.com/wiki');
|
||||||
@@ -77,7 +99,7 @@ describe('CustomModuleView', () => {
|
|||||||
document.body.appendChild(slot);
|
document.body.appendChild(slot);
|
||||||
try {
|
try {
|
||||||
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
|
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
|
||||||
render(<CustomModuleView id="abc" />);
|
renderModule('abc');
|
||||||
const link = await screen.findByRole('link', { name: 'In neuem Tab öffnen' });
|
const link = await screen.findByRole('link', { name: 'In neuem Tab öffnen' });
|
||||||
expect(slot.contains(link)).toBe(true);
|
expect(slot.contains(link)).toBe(true);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -92,7 +114,7 @@ describe('CustomModuleView', () => {
|
|||||||
'https://user:pw@example.com',
|
'https://user:pw@example.com',
|
||||||
])('rendert bei der Adresse %s weder Rahmen noch Link', async (url) => {
|
])('rendert bei der Adresse %s weder Rahmen noch Link', async (url) => {
|
||||||
mockGetCustomModule.mockResolvedValue(mod(url));
|
mockGetCustomModule.mockResolvedValue(mod(url));
|
||||||
const { container } = render(<CustomModuleView id="abc" />);
|
const { container } = renderModule('abc');
|
||||||
|
|
||||||
await screen.findByText('Ungültige Adresse');
|
await screen.findByText('Ungültige Adresse');
|
||||||
expect(container.querySelector('iframe')).toBeNull();
|
expect(container.querySelector('iframe')).toBeNull();
|
||||||
@@ -101,7 +123,7 @@ describe('CustomModuleView', () => {
|
|||||||
|
|
||||||
it('zeigt bei 404 den Nicht-gefunden-Text', async () => {
|
it('zeigt bei 404 den Nicht-gefunden-Text', async () => {
|
||||||
mockGetCustomModule.mockResolvedValue(null);
|
mockGetCustomModule.mockResolvedValue(null);
|
||||||
const { container } = render(<CustomModuleView id="weg" />);
|
const { container } = renderModule('weg');
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Dieses Modul gibt es nicht mehr.')).toBeInTheDocument();
|
expect(screen.getByText('Dieses Modul gibt es nicht mehr.')).toBeInTheDocument();
|
||||||
@@ -115,7 +137,7 @@ describe('CustomModuleView', () => {
|
|||||||
['Netzwerkfehler', new TypeError('Failed to fetch')],
|
['Netzwerkfehler', new TypeError('Failed to fetch')],
|
||||||
])('zeigt bei %s „konnte nicht geladen werden“ statt „nicht gefunden“', async (_, err) => {
|
])('zeigt bei %s „konnte nicht geladen werden“ statt „nicht gefunden“', async (_, err) => {
|
||||||
mockGetCustomModule.mockRejectedValue(err);
|
mockGetCustomModule.mockRejectedValue(err);
|
||||||
const { container } = render(<CustomModuleView id="abc" />);
|
const { container } = renderModule('abc');
|
||||||
|
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
'Dieses Modul konnte nicht geladen werden.',
|
'Dieses Modul konnte nicht geladen werden.',
|
||||||
@@ -124,3 +146,99 @@ describe('CustomModuleView', () => {
|
|||||||
expect(container.querySelector('iframe')).toBeNull();
|
expect(container.querySelector('iframe')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Eigene Module offen halten', () => {
|
||||||
|
it('haelt den Rahmen beim Weg- und Zuruecknavigieren als dasselbe Element im DOM', async () => {
|
||||||
|
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
|
||||||
|
const { rerender } = render(<Portal route="abc" />);
|
||||||
|
const frame = await screen.findByTitle('Wiki');
|
||||||
|
expect(frame).toHaveStyle({ visibility: 'visible' });
|
||||||
|
|
||||||
|
rerender(<Portal route={null} />);
|
||||||
|
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||||
|
const hidden = document.querySelector('iframe');
|
||||||
|
expect(hidden).toBe(frame);
|
||||||
|
expect(frame.isConnected).toBe(true);
|
||||||
|
expect(frame).toHaveStyle({ visibility: 'hidden', pointerEvents: 'none' });
|
||||||
|
|
||||||
|
rerender(<Portal route="abc" />);
|
||||||
|
await waitFor(() => expect(frame).toHaveStyle({ visibility: 'visible' }));
|
||||||
|
expect(document.querySelector('iframe')).toBe(frame);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt ein schon geladenes Modul sofort und laedt im Hintergrund nach', async () => {
|
||||||
|
useCustomModuleCacheStore.getState().remember(mod('https://example.com/alt'));
|
||||||
|
let resolve: (value: ReturnType<typeof mod>) => void = () => {};
|
||||||
|
mockGetCustomModule.mockReturnValue(
|
||||||
|
new Promise((r) => {
|
||||||
|
resolve = r;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderModule('abc');
|
||||||
|
|
||||||
|
// Ohne auf die API zu warten: kein Ladehinweis, Rahmen schon da.
|
||||||
|
expect(screen.queryByText('Wird geladen')).toBeNull();
|
||||||
|
const frame = screen.getByTitle('Wiki');
|
||||||
|
expect(frame).toHaveAttribute('src', 'https://example.com/alt');
|
||||||
|
expect(mockGetCustomModule).toHaveBeenCalledWith('abc');
|
||||||
|
|
||||||
|
// Nachgeladene neue Adresse landet im selben Rahmen.
|
||||||
|
await act(async () => {
|
||||||
|
resolve(mod('https://example.com/neu'));
|
||||||
|
});
|
||||||
|
expect(screen.getByTitle('Wiki')).toBe(frame);
|
||||||
|
expect(frame).toHaveAttribute('src', 'https://example.com/neu');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bleibt bei gespeichertem Stand sichtbar, wenn nur das Nachladen scheitert', async () => {
|
||||||
|
useCustomModuleCacheStore.getState().remember(mod('https://example.com/wiki'));
|
||||||
|
mockGetCustomModule.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||||
|
renderModule('abc');
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockGetCustomModule).toHaveBeenCalled());
|
||||||
|
expect(screen.queryByRole('alert')).toBeNull();
|
||||||
|
expect(screen.getByTitle('Wiki').tagName).toBe('IFRAME');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verwirft bei 404 Speicherstand und offenen Rahmen und zeigt „nicht gefunden“', async () => {
|
||||||
|
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
|
||||||
|
const { rerender } = render(<Portal route="abc" />);
|
||||||
|
await screen.findByTitle('Wiki');
|
||||||
|
rerender(<Portal route={null} />);
|
||||||
|
|
||||||
|
mockGetCustomModule.mockResolvedValue(null);
|
||||||
|
rerender(<Portal route="abc" />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Dieses Modul gibt es nicht mehr.')).toBeInTheDocument();
|
||||||
|
expect(document.querySelector('iframe')).toBeNull();
|
||||||
|
const state = useCustomModuleCacheStore.getState();
|
||||||
|
expect(state.modules.abc).toBeUndefined();
|
||||||
|
expect(state.openIds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('haelt hoechstens fuenf Module offen und verwirft das am laengsten unbenutzte', async () => {
|
||||||
|
mockGetCustomModule.mockImplementation(async (id: string) =>
|
||||||
|
mod(`https://example.com/${id}`, id, `Modul ${id}`),
|
||||||
|
);
|
||||||
|
const { rerender } = render(<Portal route="m1" />);
|
||||||
|
await screen.findByTitle('Modul m1');
|
||||||
|
for (const id of ['m2', 'm3', 'm4', 'm5', 'm6']) {
|
||||||
|
rerender(<Portal route={id} />);
|
||||||
|
await screen.findByTitle(`Modul ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(document.querySelectorAll('iframe')).toHaveLength(5);
|
||||||
|
expect(screen.queryByTitle('Modul m1')).toBeNull();
|
||||||
|
expect(screen.getByTitle('Modul m2')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verwirft beim Leeren des Speichers (Abmelden) alle Rahmen', async () => {
|
||||||
|
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
|
||||||
|
const { rerender } = render(<Portal route="abc" />);
|
||||||
|
await screen.findByTitle('Wiki');
|
||||||
|
rerender(<Portal route={null} />);
|
||||||
|
|
||||||
|
act(() => useCustomModuleCacheStore.getState().clear());
|
||||||
|
expect(document.querySelector('iframe')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
|
|
||||||
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot';
|
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot';
|
||||||
import { type CustomModule, checkCustomModuleUrl, getCustomModule } from '@/lib/custom-modules-api';
|
import { checkCustomModuleUrl, getCustomModule } from '@/lib/custom-modules-api';
|
||||||
|
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
|
||||||
|
|
||||||
type LoadState =
|
type LoadStatus = 'loading' | 'missing' | 'failed' | 'ready';
|
||||||
| { status: 'loading' }
|
|
||||||
| { status: 'missing' }
|
|
||||||
| { status: 'failed' }
|
|
||||||
| { status: 'ready'; mod: CustomModule };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rahmen-Ansicht eines eigenen Moduls (quick-260929-9wc, D-06): der Rahmen
|
* Rahmen-Ansicht eines eigenen Moduls (quick-260929-9wc, D-06): der Rahmen
|
||||||
@@ -25,47 +21,83 @@ type LoadState =
|
|||||||
* `javascript:` oder `http:` wird nie gerendert (T-9WC-03).
|
* `javascript:` oder `http:` wird nie gerendert (T-9WC-03).
|
||||||
* Nur ein 404 heisst „gibt es nicht mehr“; 403, 500 oder ein Netzwerkfehler
|
* Nur ein 404 heisst „gibt es nicht mehr“; 403, 500 oder ein Netzwerkfehler
|
||||||
* zeigen „konnte nicht geladen werden“ — der Eintrag existiert vielleicht noch.
|
* zeigen „konnte nicht geladen werden“ — der Eintrag existiert vielleicht noch.
|
||||||
|
*
|
||||||
|
* Offen halten (30.09.2026): Der <iframe> selbst lebt NICHT mehr hier, sondern
|
||||||
|
* im Behaelter `CustomModuleFrameHost` im Portal-Rahmen; diese Seite rendert
|
||||||
|
* nur den Platzhalter, ueber den der Behaelter ihn legt (Begruendung dort).
|
||||||
|
* Name und Adresse kommen aus dem Sitzungsspeicher
|
||||||
|
* (`custom-module-cache-store.ts`): ein schon einmal geladenes Modul erscheint
|
||||||
|
* sofort, `getCustomModule` laeuft trotzdem im Hintergrund. Eine geaenderte
|
||||||
|
* Adresse landet so im Speicher und laedt den <iframe> um; ein 404 verwirft den
|
||||||
|
* Eintrag samt <iframe> und zeigt „nicht gefunden“. Scheitert NUR das
|
||||||
|
* Nachladen (403, 500, Netz) bei vorhandenem Speicherstand, bleibt das Modul
|
||||||
|
* sichtbar — ohne Speicherstand gilt wie bisher „konnte nicht geladen werden“.
|
||||||
*/
|
*/
|
||||||
export function CustomModuleView({ id }: { id: string }) {
|
export function CustomModuleView({ id }: { id: string }) {
|
||||||
const t = useTranslations('customModules');
|
const t = useTranslations('customModules');
|
||||||
const [state, setState] = useState<LoadState>({ status: 'loading' });
|
const mod = useCustomModuleCacheStore((s) => s.modules[id]);
|
||||||
|
const remember = useCustomModuleCacheStore((s) => s.remember);
|
||||||
|
const forget = useCustomModuleCacheStore((s) => s.forget);
|
||||||
|
const activate = useCustomModuleCacheStore((s) => s.activate);
|
||||||
|
const deactivate = useCustomModuleCacheStore((s) => s.deactivate);
|
||||||
|
const [status, setStatus] = useState<LoadStatus>(() =>
|
||||||
|
useCustomModuleCacheStore.getState().modules[id] ? 'ready' : 'loading',
|
||||||
|
);
|
||||||
const [actionsSlot, setActionsSlot] = useState<HTMLElement | null>(null);
|
const [actionsSlot, setActionsSlot] = useState<HTMLElement | null>(null);
|
||||||
|
const slotRef = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActionsSlot(document.getElementById(HEADER_ACTIONS_SLOT_ID));
|
setActionsSlot(document.getElementById(HEADER_ACTIONS_SLOT_ID));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setState({ status: 'loading' });
|
const hasCached = () => Boolean(useCustomModuleCacheStore.getState().modules[id]);
|
||||||
|
setStatus(hasCached() ? 'ready' : 'loading');
|
||||||
getCustomModule(id)
|
getCustomModule(id)
|
||||||
.then((mod) => {
|
.then((loaded) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setState(mod ? { status: 'ready', mod } : { status: 'missing' });
|
if (loaded) {
|
||||||
|
remember(loaded);
|
||||||
|
setStatus('ready');
|
||||||
|
} else {
|
||||||
|
forget(id);
|
||||||
|
setStatus('missing');
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setState({ status: 'failed' });
|
if (!cancelled) setStatus(hasCached() ? 'ready' : 'failed');
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [id]);
|
}, [id, remember, forget]);
|
||||||
|
|
||||||
if (state.status === 'loading') {
|
const urlOk = mod ? checkCustomModuleUrl(mod.url) === 'ok' : false;
|
||||||
return <div className="p-4 text-sm text-muted-foreground">{t('loading')}</div>;
|
const showFrame = status === 'ready' && urlOk;
|
||||||
}
|
|
||||||
if (state.status === 'missing') {
|
// Platzhalter beim Behaelter anmelden; beim Verlassen wird der <iframe> nur
|
||||||
|
// versteckt. Wechselt die Adresse auf ungueltig, verschwindet der
|
||||||
|
// Platzhalter und damit auch die Anmeldung (`showFrame`).
|
||||||
|
useEffect(() => {
|
||||||
|
const slot = slotRef.current;
|
||||||
|
if (!showFrame || !slot) return;
|
||||||
|
activate(id, slot);
|
||||||
|
return () => deactivate(slot);
|
||||||
|
}, [showFrame, id, activate, deactivate]);
|
||||||
|
|
||||||
|
if (status === 'missing') {
|
||||||
return <div className="p-4 text-sm text-muted-foreground">{t('notFound')}</div>;
|
return <div className="p-4 text-sm text-muted-foreground">{t('notFound')}</div>;
|
||||||
}
|
}
|
||||||
if (state.status === 'failed') {
|
if (status === 'failed') {
|
||||||
return (
|
return (
|
||||||
<div role="alert" className="p-4 text-sm text-destructive">
|
<div role="alert" className="p-4 text-sm text-destructive">
|
||||||
{t('loadError')}
|
{t('loadError')}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (status === 'loading' || !mod) {
|
||||||
const { mod } = state;
|
return <div className="p-4 text-sm text-muted-foreground">{t('loading')}</div>;
|
||||||
const urlOk = checkCustomModuleUrl(mod.url) === 'ok';
|
}
|
||||||
|
|
||||||
const openLink = urlOk ? (
|
const openLink = urlOk ? (
|
||||||
<a
|
<a
|
||||||
@@ -98,14 +130,10 @@ export function CustomModuleView({ id }: { id: string }) {
|
|||||||
<div className="flex h-[calc(100vh-var(--header-height)-1.5rem)] min-h-[320px] flex-col gap-2">
|
<div className="flex h-[calc(100vh-var(--header-height)-1.5rem)] min-h-[320px] flex-col gap-2">
|
||||||
{openLink && (actionsSlot ? createPortal(openLink, actionsSlot) : openLink)}
|
{openLink && (actionsSlot ? createPortal(openLink, actionsSlot) : openLink)}
|
||||||
{urlOk ? (
|
{urlOk ? (
|
||||||
<iframe
|
<div
|
||||||
src={mod.url}
|
ref={slotRef}
|
||||||
title={mod.name}
|
className="w-full flex-1 rounded-lg bg-background"
|
||||||
sandbox={XFRAME_SANDBOX}
|
data-testid="custom-module-slot"
|
||||||
allow=""
|
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
className="w-full flex-1 rounded-lg border-0 bg-background"
|
|
||||||
data-testid="custom-module-frame"
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border border-border p-4 text-sm text-muted-foreground">
|
<div className="rounded-md border border-border p-4 text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
GRID_CANVAS_KEY,
|
||||||
GRID_SCALE_FACTOR,
|
GRID_SCALE_FACTOR,
|
||||||
GRID_VERSION,
|
GRID_VERSION,
|
||||||
GRID_VERSION_KEY,
|
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.layouts.sm[0]).toEqual({ i: 'a', x: 4, y: 2, w: 4, h: 2 });
|
||||||
expect(r.migrated).toBe(true);
|
expect(r.migrated).toBe(true);
|
||||||
|
|
||||||
expect(migrateGridLayouts(null)).toEqual({ layouts: {}, migrated: false, newer: false });
|
expect(migrateGridLayouts(null)).toEqual({
|
||||||
expect(migrateGridLayouts(undefined)).toEqual({ layouts: {}, migrated: false, newer: false });
|
layouts: {},
|
||||||
expect(migrateGridLayouts(42)).toEqual({ layouts: {}, migrated: false, newer: false });
|
migrated: false,
|
||||||
expect(migrateGridLayouts([1, 2])).toEqual({ layouts: {}, migrated: false, newer: 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', () => {
|
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);
|
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
|
* wuerde sie mit der alten Marke zurueckschreiben, und der neue Stand wuerde
|
||||||
* sie beim naechsten Laden ein zweites Mal skalieren.
|
* 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
|
* Ort: Frontend, weil die Raster-Einheiten Frontend-Konstanten sind, die API
|
||||||
* das JSON nur durchreicht (`@IsObject()`) und so kein Schreiben auf einem
|
* das JSON nur durchreicht (`@IsObject()`) und so kein Schreiben auf einem
|
||||||
* GET und keine Aenderung am API-Dienst noetig ist. Reine Funktionen ohne
|
* 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 = 3;
|
||||||
export const GRID_VERSION_KEY = '__gridVersion';
|
export const GRID_VERSION_KEY = '__gridVersion';
|
||||||
export const GRID_SCALE_FACTOR = 2;
|
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 {
|
export interface GridLayoutItem {
|
||||||
i: string;
|
i: string;
|
||||||
@@ -74,6 +98,19 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|||||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
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
|
* Rechnet eine rohe (aus der API geladene) Anordnung in die aktuellen
|
||||||
* Raster-Einheiten um. Liefert die Anordnung OHNE Marker und die Angabe, ob
|
* Raster-Einheiten um. Liefert die Anordnung OHNE Marker und die Angabe, ob
|
||||||
@@ -84,9 +121,11 @@ export function migrateGridLayouts(raw: unknown): {
|
|||||||
migrated: boolean;
|
migrated: boolean;
|
||||||
/** Marke hoeher als `GRID_VERSION` — Anordnung stammt von einem neueren Programmstand. */
|
/** Marke hoeher als `GRID_VERSION` — Anordnung stammt von einem neueren Programmstand. */
|
||||||
newer: boolean;
|
newer: boolean;
|
||||||
|
/** Gueltige Leinwand aus `__canvas`, sonst null. */
|
||||||
|
canvas: GridCanvas | null;
|
||||||
} {
|
} {
|
||||||
if (!isPlainObject(raw)) {
|
if (!isPlainObject(raw)) {
|
||||||
return { layouts: {}, migrated: false, newer: false };
|
return { layouts: {}, migrated: false, newer: false, canvas: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const markerValue = raw[GRID_VERSION_KEY];
|
const markerValue = raw[GRID_VERSION_KEY];
|
||||||
@@ -100,7 +139,7 @@ export function migrateGridLayouts(raw: unknown): {
|
|||||||
let migrated = false;
|
let migrated = false;
|
||||||
|
|
||||||
for (const key of Object.keys(raw)) {
|
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];
|
const value = raw[key];
|
||||||
if (!Array.isArray(value)) continue;
|
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> {
|
export function withGridVersion(
|
||||||
return { ...layouts, [GRID_VERSION_KEY]: GRID_VERSION };
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
CUSTOM_MODULE_KEEP_ALIVE_MAX,
|
||||||
|
touchLru,
|
||||||
|
useCustomModuleCacheStore,
|
||||||
|
} from './custom-module-cache-store';
|
||||||
|
|
||||||
|
describe('touchLru', () => {
|
||||||
|
it('setzt ein neues Modul nach vorn ohne zu verwerfen', () => {
|
||||||
|
expect(touchLru(['a', 'b'], 'c', 5)).toEqual({ recent: ['c', 'a', 'b'], evicted: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holt ein vorhandenes Modul nach vorn, ohne es zu verdoppeln', () => {
|
||||||
|
expect(touchLru(['a', 'b', 'c'], 'c', 5)).toEqual({ recent: ['c', 'a', 'b'], evicted: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verwirft ueber der Grenze das am laengsten unbenutzte', () => {
|
||||||
|
expect(touchLru(['e', 'd', 'c', 'b', 'a'], 'f', 5)).toEqual({
|
||||||
|
recent: ['f', 'e', 'd', 'c', 'b'],
|
||||||
|
evicted: ['a'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('haelt das gerade benutzte Modul auch bei unsinniger Grenze', () => {
|
||||||
|
expect(touchLru(['a'], 'b', 0)).toEqual({ recent: ['b'], evicted: ['a'] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useCustomModuleCacheStore', () => {
|
||||||
|
const slot = () => document.createElement('div');
|
||||||
|
const remember = (id: string) =>
|
||||||
|
useCustomModuleCacheStore.getState().remember({ id, name: id, url: `https://x.test/${id}` });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useCustomModuleCacheStore.getState().clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('haelt die Einhaengereihenfolge stabil und verwirft nach LRU', () => {
|
||||||
|
const ids = Array.from({ length: CUSTOM_MODULE_KEEP_ALIVE_MAX + 1 }, (_, i) => `m${i}`);
|
||||||
|
for (const id of ids) remember(id);
|
||||||
|
const { activate } = useCustomModuleCacheStore.getState();
|
||||||
|
for (const id of ids.slice(0, CUSTOM_MODULE_KEEP_ALIVE_MAX)) activate(id, slot());
|
||||||
|
// m0 erneut benutzen -> m1 ist jetzt am laengsten unbenutzt.
|
||||||
|
activate('m0', slot());
|
||||||
|
expect(useCustomModuleCacheStore.getState().openIds).toEqual(['m0', 'm1', 'm2', 'm3', 'm4']);
|
||||||
|
|
||||||
|
activate('m5', slot());
|
||||||
|
const state = useCustomModuleCacheStore.getState();
|
||||||
|
expect(state.openIds).toEqual(['m0', 'm2', 'm3', 'm4', 'm5']);
|
||||||
|
expect(state.recent[0]).toBe('m5');
|
||||||
|
// Metadaten bleiben — nur der offene Rahmen wird verworfen.
|
||||||
|
expect(state.modules.m1).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aktiviert kein Modul ohne Metadaten', () => {
|
||||||
|
useCustomModuleCacheStore.getState().activate('unbekannt', slot());
|
||||||
|
expect(useCustomModuleCacheStore.getState().openIds).toEqual([]);
|
||||||
|
expect(useCustomModuleCacheStore.getState().active).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deaktiviert nur den eigenen Platzhalter', () => {
|
||||||
|
remember('a');
|
||||||
|
const first = slot();
|
||||||
|
const { activate, deactivate } = useCustomModuleCacheStore.getState();
|
||||||
|
activate('a', first);
|
||||||
|
deactivate(slot());
|
||||||
|
expect(useCustomModuleCacheStore.getState().active?.slot).toBe(first);
|
||||||
|
deactivate(first);
|
||||||
|
expect(useCustomModuleCacheStore.getState().active).toBeNull();
|
||||||
|
expect(useCustomModuleCacheStore.getState().openIds).toEqual(['a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('vergisst ein Modul samt offenem Rahmen', () => {
|
||||||
|
remember('a');
|
||||||
|
useCustomModuleCacheStore.getState().activate('a', slot());
|
||||||
|
useCustomModuleCacheStore.getState().forget('a');
|
||||||
|
const state = useCustomModuleCacheStore.getState();
|
||||||
|
expect(state.modules).toEqual({});
|
||||||
|
expect(state.openIds).toEqual([]);
|
||||||
|
expect(state.recent).toEqual([]);
|
||||||
|
expect(state.active).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { CustomModule } from '@/lib/custom-modules-api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eigene Module offen halten (30.09.2026, Nutzerwunsch „der
|
||||||
|
* Aufruf dauert jedes Mal lange“). Zwei Dinge liegen hier im Speicher der
|
||||||
|
* Sitzung (bewusst OHNE persist — nach dem Neuladen der Seite ist alles leer):
|
||||||
|
*
|
||||||
|
* 1. `modules`: Name und Adresse je bereits geladenem Modul. Ein erneuter
|
||||||
|
* Aufruf zeigt sofort diesen Stand und laedt im Hintergrund nach
|
||||||
|
* (custom-module-view.tsx), statt erst auf `getCustomModule` zu warten.
|
||||||
|
* 2. `openIds`/`recent`: welche Module ihren <iframe> behalten
|
||||||
|
* (custom-module-frame-host.tsx). `openIds` ist die Einhaengereihenfolge
|
||||||
|
* und bleibt STABIL — neue Module kommen hinten dazu, verworfene fallen
|
||||||
|
* heraus. Wuerde die Liste bei jedem Aufruf umsortiert, verschoebe React
|
||||||
|
* die <iframe>-Knoten im DOM, und ein verschobenes <iframe> laedt neu.
|
||||||
|
* Die Nutzungsreihenfolge fuer die Begrenzung steht deshalb getrennt in
|
||||||
|
* `recent` (zuletzt benutzt vorn).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hoechstens so viele eigene Module halten ihren <iframe> offen. Jede offene
|
||||||
|
* Fremdseite belegt weiter Speicher und laeuft im Hintergrund mit (Skripte,
|
||||||
|
* Abfragen); fuenf deckt den ueblichen Wechsel zwischen wenigen Werkzeugen ab,
|
||||||
|
* ohne den Browser bei vielen Eintraegen in der Seitenleiste zu belasten.
|
||||||
|
*/
|
||||||
|
export const CUSTOM_MODULE_KEEP_ALIVE_MAX = 5;
|
||||||
|
|
||||||
|
export type CachedCustomModule = Pick<CustomModule, 'id' | 'name' | 'url'>;
|
||||||
|
|
||||||
|
export interface LruResult {
|
||||||
|
/** Neue Nutzungsreihenfolge, zuletzt benutzt vorn. */
|
||||||
|
recent: string[];
|
||||||
|
/** Ids, die dabei ueber die Grenze gefallen sind (aelteste zuerst verworfen). */
|
||||||
|
evicted: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reine LRU-Regel: `id` nach vorn, alles jenseits von `max` wird verworfen. */
|
||||||
|
export function touchLru(recent: readonly string[], id: string, max: number): LruResult {
|
||||||
|
const next = [id, ...recent.filter((other) => other !== id)];
|
||||||
|
const limit = Math.max(1, max);
|
||||||
|
return { recent: next.slice(0, limit), evicted: next.slice(limit) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveCustomModuleSlot {
|
||||||
|
id: string;
|
||||||
|
/** Platzhalter der Modulseite, ueber den der Behaelter den <iframe> legt. */
|
||||||
|
slot: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomModuleCacheState {
|
||||||
|
modules: Record<string, CachedCustomModule>;
|
||||||
|
openIds: string[];
|
||||||
|
recent: string[];
|
||||||
|
active: ActiveCustomModuleSlot | null;
|
||||||
|
/** Metadaten merken oder aktualisieren (Name/Adresse nach dem Laden oder Speichern). */
|
||||||
|
remember: (mod: CachedCustomModule) => void;
|
||||||
|
/** Modul vergessen (404 oder geloescht): Metadaten UND offener <iframe> weg. */
|
||||||
|
forget: (id: string) => void;
|
||||||
|
/** Die Modulseite zeigt `id` im Platzhalter `slot`: offen halten und nach vorn. */
|
||||||
|
activate: (id: string, slot: HTMLElement) => void;
|
||||||
|
/** Die Modulseite verschwindet: <iframe> nur verstecken, nicht verwerfen. */
|
||||||
|
deactivate: (slot: HTMLElement) => void;
|
||||||
|
/** Abmelden/Benutzerwechsel: alles verwerfen. */
|
||||||
|
clear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCustomModuleCacheStore = create<CustomModuleCacheState>()((set) => ({
|
||||||
|
modules: {},
|
||||||
|
openIds: [],
|
||||||
|
recent: [],
|
||||||
|
active: null,
|
||||||
|
remember: (mod) =>
|
||||||
|
set((s) => {
|
||||||
|
const prev = s.modules[mod.id];
|
||||||
|
if (prev && prev.name === mod.name && prev.url === mod.url) return s;
|
||||||
|
return { modules: { ...s.modules, [mod.id]: { id: mod.id, name: mod.name, url: mod.url } } };
|
||||||
|
}),
|
||||||
|
forget: (id) =>
|
||||||
|
set((s) => {
|
||||||
|
const { [id]: _removed, ...modules } = s.modules;
|
||||||
|
return {
|
||||||
|
modules,
|
||||||
|
openIds: s.openIds.filter((other) => other !== id),
|
||||||
|
recent: s.recent.filter((other) => other !== id),
|
||||||
|
active: s.active?.id === id ? null : s.active,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
activate: (id, slot) =>
|
||||||
|
set((s) => {
|
||||||
|
if (!s.modules[id]) return s;
|
||||||
|
const { recent, evicted } = touchLru(s.recent, id, CUSTOM_MODULE_KEEP_ALIVE_MAX);
|
||||||
|
const kept = s.openIds.filter((other) => !evicted.includes(other));
|
||||||
|
return {
|
||||||
|
recent,
|
||||||
|
openIds: kept.includes(id) ? kept : [...kept, id],
|
||||||
|
active: { id, slot },
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
deactivate: (slot) => set((s) => (s.active?.slot === slot ? { active: null } : s)),
|
||||||
|
clear: () => set({ modules: {}, openIds: [], recent: [], active: null }),
|
||||||
|
}));
|
||||||
@@ -54,6 +54,7 @@ beforeEach(() => {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
layoutFromNewerVersion: false,
|
layoutFromNewerVersion: false,
|
||||||
|
canvas: null,
|
||||||
});
|
});
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1]);
|
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 });
|
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 { clampWidthToCols, WIDGET_CONSTRAINTS } from '@/components/dashboard/widget-registry';
|
||||||
import type { DashboardTab } from '@/lib/dashboard-api';
|
import type { DashboardTab } from '@/lib/dashboard-api';
|
||||||
import * as api 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 {
|
export interface WidgetInstance {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -28,6 +35,12 @@ interface DashboardState {
|
|||||||
* Bearbeitungsmodus bleibt gesperrt; die Oberflaeche bittet ums Neuladen.
|
* Bearbeitungsmodus bleibt gesperrt; die Oberflaeche bittet ums Neuladen.
|
||||||
*/
|
*/
|
||||||
layoutFromNewerVersion: boolean;
|
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;
|
setEditMode: (mode: boolean) => void;
|
||||||
updateLayouts: (layouts: Record<string, unknown>) => void;
|
updateLayouts: (layouts: Record<string, unknown>) => void;
|
||||||
@@ -38,6 +51,8 @@ interface DashboardState {
|
|||||||
loadDashboard: () => Promise<void>;
|
loadDashboard: () => Promise<void>;
|
||||||
saveLayout: () => Promise<void>;
|
saveLayout: () => Promise<void>;
|
||||||
selectDashboard: (id: string) => 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>;
|
createDashboard: () => Promise<void>;
|
||||||
renameDashboard: (id: string, name: string) => Promise<void>;
|
renameDashboard: (id: string, name: string) => Promise<void>;
|
||||||
deleteDashboard: (id: string) => Promise<void>;
|
deleteDashboard: (id: string) => Promise<void>;
|
||||||
@@ -92,6 +107,14 @@ function loadDashboardsOnce(): Promise<DashboardTab[]> {
|
|||||||
* ist alt), setzt der Store `layoutFromNewerVersion`: `saveLayout` speichert
|
* ist alt), setzt der Store `layoutFromNewerVersion`: `saveLayout` speichert
|
||||||
* dann nie, `setEditMode(true)` wird ignoriert, die Oberflaeche bittet ums
|
* dann nie, `setEditMode(true)` wird ignoriert, die Oberflaeche bittet ums
|
||||||
* Neuladen. Der Merker gilt je geladenem Reiter.
|
* 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) => ({
|
export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
||||||
dashboards: [],
|
dashboards: [],
|
||||||
@@ -104,6 +127,7 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
layoutFromNewerVersion: false,
|
layoutFromNewerVersion: false,
|
||||||
|
canvas: null,
|
||||||
|
|
||||||
setEditMode: (mode: boolean) => {
|
setEditMode: (mode: boolean) => {
|
||||||
// quick-260930: Anordnung eines neueren Programmstands — nicht bearbeiten.
|
// quick-260930: Anordnung eines neueren Programmstands — nicht bearbeiten.
|
||||||
@@ -214,11 +238,12 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
|||||||
api.fetchLayout(first.id),
|
api.fetchLayout(first.id),
|
||||||
api.fetchWidgets(first.id),
|
api.fetchWidgets(first.id),
|
||||||
]);
|
]);
|
||||||
const { layouts: migratedLayouts, migrated, newer } = migrateGridLayouts(rawLayouts);
|
const { layouts: migratedLayouts, migrated, newer, canvas } = migrateGridLayouts(rawLayouts);
|
||||||
set({
|
set({
|
||||||
dashboards,
|
dashboards,
|
||||||
activeDashboardId: first.id,
|
activeDashboardId: first.id,
|
||||||
layouts: migratedLayouts,
|
layouts: migratedLayouts,
|
||||||
|
canvas,
|
||||||
widgets,
|
widgets,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
layoutFromNewerVersion: newer,
|
layoutFromNewerVersion: newer,
|
||||||
@@ -229,7 +254,7 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
|||||||
// try/catch, damit ein Speicherfehler NICHT als Ladefehler erscheint.
|
// try/catch, damit ein Speicherfehler NICHT als Ladefehler erscheint.
|
||||||
if (migrated) {
|
if (migrated) {
|
||||||
try {
|
try {
|
||||||
await api.saveLayout(first.id, withGridVersion(migratedLayouts));
|
await api.saveLayout(first.id, withGridVersion(migratedLayouts, canvas));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to persist migrated layout:', 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.
|
// sie ginge mit der alten Marke zurueck und wuerde doppelt skaliert.
|
||||||
if (get().layoutFromNewerVersion) return;
|
if (get().layoutFromNewerVersion) return;
|
||||||
try {
|
try {
|
||||||
await api.saveLayout(dashboardId, withGridVersion(get().layouts));
|
await api.saveLayout(dashboardId, withGridVersion(get().layouts, get().canvas));
|
||||||
set({ isDirty: false });
|
set({ isDirty: false });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to save layout:', err);
|
console.error('Failed to save layout:', err);
|
||||||
@@ -275,10 +300,11 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
|||||||
set({ isSwitchingDashboard: true, error: null });
|
set({ isSwitchingDashboard: true, error: null });
|
||||||
try {
|
try {
|
||||||
const [rawLayouts, widgets] = await Promise.all([api.fetchLayout(id), api.fetchWidgets(id)]);
|
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({
|
set({
|
||||||
activeDashboardId: id,
|
activeDashboardId: id,
|
||||||
layouts: migratedLayouts,
|
layouts: migratedLayouts,
|
||||||
|
canvas,
|
||||||
widgets,
|
widgets,
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
isSwitchingDashboard: false,
|
isSwitchingDashboard: false,
|
||||||
@@ -287,7 +313,7 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
|||||||
});
|
});
|
||||||
if (migrated) {
|
if (migrated) {
|
||||||
try {
|
try {
|
||||||
await api.saveLayout(id, withGridVersion(migratedLayouts));
|
await api.saveLayout(id, withGridVersion(migratedLayouts, canvas));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to persist migrated layout:', 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). */
|
/** Legt einen neuen, leeren Reiter an und macht ihn aktiv (D-08). */
|
||||||
createDashboard: async () => {
|
createDashboard: async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -205,6 +205,75 @@ Unter „Verwaltung → Eigene Module“ legen Sie **gemeinsame** Seitenleisten-
|
|||||||
|
|
||||||
Daneben kann **jeder Benutzer** – auch Sie selbst – unter „Einstellungen → Eigene Module“ persönliche Einträge anlegen. Diese sieht ausschließlich der Besitzer; Administratoren sehen und ändern sie nicht. Wird ein Benutzer gelöscht, verschwinden seine persönlichen Einträge mit. Die Verwaltungsseite zeigt deshalb nur die gemeinsamen Einträge.
|
Daneben kann **jeder Benutzer** – auch Sie selbst – unter „Einstellungen → Eigene Module“ persönliche Einträge anlegen. Diese sieht ausschließlich der Besitzer; Administratoren sehen und ändern sie nicht. Wird ein Benutzer gelöscht, verschwinden seine persönlichen Einträge mit. Die Verwaltungsseite zeigt deshalb nur die gemeinsamen Einträge.
|
||||||
|
|
||||||
|
### Proxmox-Server anbinden: Zugang nur zum Lesen einrichten
|
||||||
|
|
||||||
|
Tessera schaut bei Proxmox nur zu und ändert dort nichts. Legen Sie deshalb auf jedem Proxmox-Server einen **eigenen Zugang nur für Tessera** an, der ausschließlich lesen darf – nie Ihr persönliches Konto und nie `root`. Die Server tragen Sie danach im Proxmox-Modul unter „Zu den Einstellungen“ ein.
|
||||||
|
|
||||||
|
| Produkt | Zugangsart in Tessera | Rolle am Proxmox-Server |
|
||||||
|
|---|---|---|
|
||||||
|
| PVE (Virtualisierung) | API-Token (empfohlen) | PVEAuditor |
|
||||||
|
| PBS (Backup) | API-Token (empfohlen) | Audit |
|
||||||
|
| PMG (Mail-Gateway) | Benutzer/Passwort | Auditor |
|
||||||
|
|
||||||
|
**Warum bei PMG kein Token?** Das Mail-Gateway kennt keine API-Token – Proxmox hat sie dort bisher nicht eingebaut (Stand September 2026, offene Funktionsanfrage bei Proxmox). Tessera bietet bei PMG deshalb nur Benutzername und Passwort an. Mit einem eigenen Benutzer, der nur die Rolle „Auditor“ hat, ist das genauso sicher: dieses Konto kann nichts verändern.
|
||||||
|
|
||||||
|
Ein Token hat bei PVE und PBS zwei Vorteile gegenüber einem Passwort: Er lässt sich einzeln widerrufen, ohne das Konto anzufassen, und er kann sich nicht an der Proxmox-Oberfläche anmelden. Wichtig bei beiden: Ein Token darf nie mehr als sein Benutzer. Die Leserolle muss deshalb **dem Benutzer und dem Token** zugewiesen werden, sonst meldet Tessera beim Verbindungstest „keine Berechtigung“.
|
||||||
|
|
||||||
|
#### PVE: Benutzer und API-Token anlegen
|
||||||
|
|
||||||
|
In der Proxmox-VE-Oberfläche (Ansicht „Rechenzentrum“ / „Datacenter“):
|
||||||
|
|
||||||
|
1. **Berechtigungen → Benutzer → Hinzufügen:** Benutzername `tessera`, Realm „Proxmox VE authentication server“ (`pve`), ein langes, zufälliges Passwort (es wird nirgends gebraucht), Kommentar z. B. „Tessera, nur lesen“.
|
||||||
|
2. **Berechtigungen → API-Token → Hinzufügen:** Benutzer `tessera@pve`, Token-ID `tessera`, Haken bei „Privilegien-Trennung“ gesetzt lassen. Nach dem Speichern zeigt Proxmox die **Token-ID** (`tessera@pve!tessera`) und das **Geheimnis** – das Geheimnis erscheint **nur dieses eine Mal**, kopieren Sie es sofort.
|
||||||
|
3. **Berechtigungen → Hinzufügen → Benutzer-Berechtigung:** Pfad `/`, Benutzer `tessera@pve`, Rolle `PVEAuditor`, „Weitergeben“ angehakt.
|
||||||
|
4. **Berechtigungen → Hinzufügen → API-Token-Berechtigung:** Pfad `/`, API-Token `tessera@pve!tessera`, Rolle `PVEAuditor`, „Weitergeben“ angehakt.
|
||||||
|
|
||||||
|
Dasselbe auf der Kommandozeile eines PVE-Knotens (als root):
|
||||||
|
|
||||||
|
```
|
||||||
|
pveum user add tessera@pve --comment "Tessera, nur lesen"
|
||||||
|
pveum user token add tessera@pve tessera --privsep 1
|
||||||
|
pveum acl modify / --users tessera@pve --roles PVEAuditor
|
||||||
|
pveum acl modify / --tokens 'tessera@pve!tessera' --roles PVEAuditor
|
||||||
|
```
|
||||||
|
|
||||||
|
Der zweite Befehl gibt das Geheimnis (`value`) einmalig aus. In einem Cluster genügt das auf einem Knoten – Benutzer und Rechte gelten für den ganzen Cluster.
|
||||||
|
|
||||||
|
In Tessera: Typ **PVE**, Adresse z. B. `https://pve01.intern:8006`, Zugangsart **API-Token**, Token-Kennung `tessera@pve!tessera`, Token-Geheimnis = das kopierte Geheimnis.
|
||||||
|
|
||||||
|
#### PBS: Benutzer und API-Token anlegen
|
||||||
|
|
||||||
|
In der Proxmox-Backup-Server-Oberfläche:
|
||||||
|
|
||||||
|
1. **Konfiguration → Zugriffssteuerung → Benutzerverwaltung → Hinzufügen:** Benutzername `tessera`, Realm „Proxmox Backup authentication server“ (`pbs`), ein langes, zufälliges Passwort.
|
||||||
|
2. **Reiter „API-Token“ → Hinzufügen:** Benutzer `tessera@pbs`, Token-Name `tessera`. Proxmox zeigt danach **Token-ID** (`tessera@pbs!tessera`) und **Geheimnis** – das Geheimnis nur dieses eine Mal, sofort kopieren.
|
||||||
|
3. **Reiter „Berechtigungen“ → Hinzufügen → Benutzer-Berechtigung:** Pfad `/`, Benutzer `tessera@pbs`, Rolle `Audit`, „Weitergeben“ angehakt.
|
||||||
|
4. **Reiter „Berechtigungen“ → Hinzufügen → API-Token-Berechtigung:** Pfad `/`, API-Token `tessera@pbs!tessera`, Rolle `Audit`, „Weitergeben“ angehakt.
|
||||||
|
|
||||||
|
Kommandozeile des Backup-Servers (als root):
|
||||||
|
|
||||||
|
```
|
||||||
|
proxmox-backup-manager user create tessera@pbs --comment "Tessera, nur lesen"
|
||||||
|
proxmox-backup-manager user generate-token tessera@pbs tessera
|
||||||
|
proxmox-backup-manager acl update / Audit --auth-id tessera@pbs
|
||||||
|
proxmox-backup-manager acl update / Audit --auth-id 'tessera@pbs!tessera'
|
||||||
|
```
|
||||||
|
|
||||||
|
In Tessera: Typ **PBS**, Adresse z. B. `https://pbs01.intern:8007`, Zugangsart **API-Token**, Token-Kennung `tessera@pbs!tessera`, Token-Geheimnis = das kopierte Geheimnis.
|
||||||
|
|
||||||
|
#### PMG: Benutzer mit Leserolle anlegen
|
||||||
|
|
||||||
|
In der Proxmox-Mail-Gateway-Oberfläche:
|
||||||
|
|
||||||
|
1. **Konfiguration → Benutzerverwaltung → Hinzufügen:** Benutzername `tessera`, Realm „Proxmox Mail Gateway authentication server“ (`pmg`), Rolle **Auditor**, ein langes, zufälliges Passwort, Haken bei „Aktiviert“.
|
||||||
|
2. Das Passwort notieren – Tessera braucht es.
|
||||||
|
|
||||||
|
In Tessera: Typ **PMG**, Adresse z. B. `https://pmg01.intern:8006`, Zugangsart **Benutzer/Passwort** (bei PMG die einzige Auswahl), Benutzername `tessera@pmg`, Passwort = das vergebene Passwort.
|
||||||
|
|
||||||
|
#### Prüfen und später widerrufen
|
||||||
|
|
||||||
|
Nach dem Speichern prüft **„Verbindung testen“** den Zugang sofort. „Keine Berechtigung“ heißt fast immer: Die Rolle fehlt am Benutzer oder am Token, oder „Weitergeben“ ist nicht angehakt. Soll Tessera einen Server nicht mehr sehen, löschen Sie bei PVE/PBS einfach den Token, bei PMG den Benutzer `tessera` – andere Konten sind davon nicht betroffen.
|
||||||
|
|
||||||
## 6. SMTP
|
## 6. SMTP
|
||||||
|
|
||||||
Unter **Administrator → SMTP** wird der Mailversand konfiguriert: Host, Port, Verschlüsselung (Keine, STARTTLS oder SSL-TLS), Benutzername, Passwort, die Absenderadresse und optional das Feld „Fehlermeldungen an“ (siehe unten). Das Passwortfeld wird aus Sicherheitsgründen nie mit dem gespeicherten Wert vorbefüllt – es bleibt beim Laden immer leer und wird nur mitgesendet, wenn tatsächlich ein neuer Wert eingegeben wurde.
|
Unter **Administrator → SMTP** wird der Mailversand konfiguriert: Host, Port, Verschlüsselung (Keine, STARTTLS oder SSL-TLS), Benutzername, Passwort, die Absenderadresse und optional das Feld „Fehlermeldungen an“ (siehe unten). Das Passwortfeld wird aus Sicherheitsgründen nie mit dem gespeicherten Wert vorbefüllt – es bleibt beim Laden immer leer und wird nur mitgesendet, wenn tatsächlich ein neuer Wert eingegeben wurde.
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ Unten in der Seitenleiste begrüßt Tessera Sie je nach Tageszeit mit Ihrem Name
|
|||||||
|
|
||||||
Das Dashboard ist Ihre persönliche Startseite und öffnet sich automatisch nach der Anmeldung. Es zeigt ein Raster aus Kacheln — den **Widgets** —, die genau dort stehen, wo Sie sie im Bearbeitungsmodus platziert haben. Jedes Widget trägt oben ein gelbes Symbol-Feld und blendet beim Laden sanft ein. Ist noch kein Widget platziert, sehen Sie den Hinweis „Ihr Dashboard ist noch leer" mit einigen Vorschlägen für erste Kacheln.
|
Das Dashboard ist Ihre persönliche Startseite und öffnet sich automatisch nach der Anmeldung. Es zeigt ein Raster aus Kacheln — den **Widgets** —, die genau dort stehen, wo Sie sie im Bearbeitungsmodus platziert haben. Jedes Widget trägt oben ein gelbes Symbol-Feld und blendet beim Laden sanft ein. Ist noch kein Widget platziert, sehen Sie den Hinweis „Ihr Dashboard ist noch leer" mit einigen Vorschlägen für erste Kacheln.
|
||||||
|
|
||||||
|
**Gleiches Bild auf jedem Bildschirm:** Tessera merkt sich je Dashboard die Größe der Fläche auf dem Bildschirm, an dem Sie es zum ersten Mal geöffnet haben. Auf jedem anderen Bildschirm – etwa am Laptop statt am großen Monitor – zeigt es dasselbe Dashboard maßstäblich verkleinert oder vergrößert, Kacheln und Schrift gleichermaßen, sodass es vollständig hineinpasst. Hat der Bildschirm eine andere Form, bleibt rechts oder unten etwas Platz frei. Bearbeiten können Sie das Dashboard auf jedem Bildschirm. Auf dem Handy (schmaler als 768 Pixel) ordnet Tessera die Kacheln weiterhin untereinander an.
|
||||||
|
|
||||||
**Mehrere Dashboards (Reiter):** Die Reiter stehen in der Mitte der App-Leiste — Sie können mehrere Dashboards anlegen, die dort nebeneinander stehen. Jeder Reiter trägt seine eigenen Kacheln und seine eigene Anordnung; was auf dem einen Reiter liegt, erscheint nicht auf dem anderen. Ein Klick auf einen Reiter wechselt dorthin. Beim Öffnen wird immer der ERSTE Reiter geladen — Sie legen ihn fest, indem Sie einen Reiter mit der Maus ganz nach vorn ziehen (das geht jederzeit, auch ohne den Bearbeitungsmodus). Im Bearbeitungsmodus können Sie außerdem:
|
**Mehrere Dashboards (Reiter):** Die Reiter stehen in der Mitte der App-Leiste — Sie können mehrere Dashboards anlegen, die dort nebeneinander stehen. Jeder Reiter trägt seine eigenen Kacheln und seine eigene Anordnung; was auf dem einen Reiter liegt, erscheint nicht auf dem anderen. Ein Klick auf einen Reiter wechselt dorthin. Beim Öffnen wird immer der ERSTE Reiter geladen — Sie legen ihn fest, indem Sie einen Reiter mit der Maus ganz nach vorn ziehen (das geht jederzeit, auch ohne den Bearbeitungsmodus). Im Bearbeitungsmodus können Sie außerdem:
|
||||||
- Über den Knopf am Ende der Leiste einen neuen, leeren Reiter anlegen — er heißt automatisch „Dashboard 2", „Dashboard 3" und so weiter.
|
- Über den Knopf am Ende der Leiste einen neuen, leeren Reiter anlegen — er heißt automatisch „Dashboard 2", „Dashboard 3" und so weiter.
|
||||||
- Den Namen des gerade aktiven Reiters ändern: Klicken Sie auf den Stift daneben, geben Sie den neuen Namen ein und bestätigen Sie mit der Eingabetaste (Escape verwirft die Änderung).
|
- Den Namen des gerade aktiven Reiters ändern: Klicken Sie auf den Stift daneben, geben Sie den neuen Namen ein und bestätigen Sie mit der Eingabetaste (Escape verwirft die Änderung).
|
||||||
@@ -160,7 +162,7 @@ Das Modul zeigt den Zustand Ihrer Proxmox-Server auf einen Blick — für die dr
|
|||||||
|
|
||||||
**Einen Server anlegen** (nur für Administratoren, über den Link „Zu den Einstellungen"): Name, Typ (PVE/PBS/PMG), Adresse (z. B. `https://pve.intern:8006`) und Zugang. Beim Zugang wählen Sie zwischen einem API-Token oder Benutzername/Passwort — bei PMG bietet Tessera von vornherein nur Benutzername/Passwort an, weil dieses Proxmox-Produkt keine API-Token kennt. Ein einmal gespeichertes Geheimnis (Token oder Passwort) wird nie wieder im Klartext angezeigt; lassen Sie das Feld beim Bearbeiten leer, um es unverändert zu lassen, oder tragen Sie ein neues ein, um es zu ersetzen.
|
**Einen Server anlegen** (nur für Administratoren, über den Link „Zu den Einstellungen"): Name, Typ (PVE/PBS/PMG), Adresse (z. B. `https://pve.intern:8006`) und Zugang. Beim Zugang wählen Sie zwischen einem API-Token oder Benutzername/Passwort — bei PMG bietet Tessera von vornherein nur Benutzername/Passwort an, weil dieses Proxmox-Produkt keine API-Token kennt. Ein einmal gespeichertes Geheimnis (Token oder Passwort) wird nie wieder im Klartext angezeigt; lassen Sie das Feld beim Bearbeiten leer, um es unverändert zu lassen, oder tragen Sie ein neues ein, um es zu ersetzen.
|
||||||
|
|
||||||
**Zugangsrechte am Proxmox-Server:** Legen Sie dort für den Zugang, den Sie hier eintragen, ausschließlich eine NUR-LESE-Rolle an — Tessera braucht nie mehr. Bei PVE ist das die Rolle **PVEAuditor**, bei PBS **Audit** (beziehungsweise feiner **DatastoreAudit**), bei PMG **Auditor**.
|
**Zugangsrechte am Proxmox-Server:** Legen Sie dort für den Zugang, den Sie hier eintragen, ausschließlich eine NUR-LESE-Rolle an — Tessera braucht nie mehr. Bei PVE ist das die Rolle **PVEAuditor**, bei PBS **Audit** (beziehungsweise feiner **DatastoreAudit**), bei PMG **Auditor**. Wie Sie diesen Zugang Schritt für Schritt anlegen (bei PVE und PBS als API-Token, bei PMG als eigener Benutzer), steht im Administrationshandbuch unter [Proxmox-Server anbinden](anleitung-administration.md#proxmox-server-anbinden-zugang-nur-zum-lesen-einrichten).
|
||||||
|
|
||||||
**Zertifikat prüfen:** Dieser Schalter steht standardmäßig auf „prüfen" (an). Nutzen Sie für einen Server ein selbstsigniertes oder sonst nicht vertrauenswürdiges Zertifikat, können Sie die Prüfung für **genau diesen einen Server** ausschalten — die Ausnahme gilt nie für einen anderen Server.
|
**Zertifikat prüfen:** Dieser Schalter steht standardmäßig auf „prüfen" (an). Nutzen Sie für einen Server ein selbstsigniertes oder sonst nicht vertrauenswürdiges Zertifikat, können Sie die Prüfung für **genau diesen einen Server** ausschalten — die Ausnahme gilt nie für einen anderen Server.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user