feat(custom-modules): besuchte eigene Module offen halten (Keep-Alive, hoechstens 5)
iframes liegen dauerhaft in einem Behaelter im Portal-Rahmen und werden per position: fixed deckungsgleich ueber den Platzhalter der Modulseite gelegt; beim Wegnavigieren nur versteckt (visibility), nie neu eingehaengt. Name/Adresse je Modul im Sitzungsspeicher, Nachladen im Hintergrund, 404 verwirft. Abmelden und Loeschen leeren. Im Browser nachgewiesen: gleiches iframe-Element und kein neuer Seitenabruf nach Dashboard -> Modul B -> Modul A; folgt Seitenleiste ein-/ausgeklappt und Handybreite. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
createCustomModule,
|
||||
updateCustomModule,
|
||||
} from '@/lib/custom-modules-api';
|
||||
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
|
||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||
|
||||
interface CustomModuleFormModalProps {
|
||||
@@ -96,6 +97,10 @@ export function CustomModuleFormModal({
|
||||
const input = { name: trimmedName, url: trimmedUrl, category };
|
||||
if (mod) {
|
||||
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 {
|
||||
await createCustomModule(shared ? { ...input, shared: true } : input);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
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';
|
||||
|
||||
interface DeleteCustomModuleDialogProps {
|
||||
@@ -36,6 +37,8 @@ export function DeleteCustomModuleDialog({
|
||||
setError(null);
|
||||
try {
|
||||
await deleteCustomModule(mod.id);
|
||||
// Offen gehaltenen <iframe> und Sitzungsspeicher des Moduls verwerfen.
|
||||
useCustomModuleCacheStore.getState().forget(mod.id);
|
||||
onDeleted();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
|
||||
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 { Sidebar } from '@/components/layout/sidebar';
|
||||
import { CustomModuleFrameHost } from '@/components/modules/custom-module-frame-host';
|
||||
import { ReleaseNoticeHost } from '@/components/release-notice/release-notice-host';
|
||||
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 }) {
|
||||
const isCollapsed = useSidebarStore((s) => s.isCollapsed);
|
||||
@@ -24,9 +25,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Desktop: sidebar pushes content via margin-left
|
||||
// Mobile: sidebar overlays, no margin needed (handled by app-shell-main class in globals.css)
|
||||
const sidebarWidth = isCollapsed
|
||||
? 'var(--sidebar-width-collapsed)'
|
||||
: 'var(--sidebar-width)';
|
||||
const sidebarWidth = isCollapsed ? 'var(--sidebar-width-collapsed)' : 'var(--sidebar-width)';
|
||||
|
||||
return (
|
||||
<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. */}
|
||||
<main
|
||||
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}
|
||||
</main>
|
||||
@@ -46,6 +47,10 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
Benachrichtigung auf jeder Portalseite ausloest — nicht nur, wenn die
|
||||
Kachel auf dem Dashboard sichtbar ist. Rendert nichts. */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
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 { 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 { useSidebarStore } from '@/lib/stores/sidebar-store';
|
||||
|
||||
export function Header() {
|
||||
const t = useTranslations();
|
||||
@@ -60,13 +61,8 @@ export function Header() {
|
||||
return;
|
||||
}
|
||||
redirectedRef.current = true;
|
||||
const next = buildNextParam(
|
||||
window.location.pathname,
|
||||
window.location.search,
|
||||
);
|
||||
window.location.href = next
|
||||
? `/login?next=${encodeURIComponent(next)}`
|
||||
: '/login';
|
||||
const next = buildNextParam(window.location.pathname, window.location.search);
|
||||
window.location.href = next ? `/login?next=${encodeURIComponent(next)}` : '/login';
|
||||
}
|
||||
// 'unavailable' (T-gyd-02): kein Redirect, bisheriges stilles
|
||||
// Verhalten -- ein API-Ausfall darf keine Abmelde-Schleife ausloesen.
|
||||
@@ -76,10 +72,7 @@ export function Header() {
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
@@ -91,9 +84,7 @@ export function Header() {
|
||||
|
||||
const [avatarError, setAvatarError] = useState(false);
|
||||
|
||||
const userInitial = user
|
||||
? (user.displayName ?? user.username).charAt(0).toUpperCase()
|
||||
: '?';
|
||||
const userInitial = user ? (user.displayName ?? user.username).charAt(0).toUpperCase() : '?';
|
||||
|
||||
const roleLabel = user ? tHeader(`role.${user.role}`) : '';
|
||||
|
||||
@@ -101,11 +92,7 @@ export function Header() {
|
||||
// der Name der aktuellen Seite bzw. des Moduls.
|
||||
const navModules = useNavStore((s) => s.modules);
|
||||
const pageTitle = resolvePageTitle(pathname, navModules);
|
||||
const pageTitleText = pageTitle
|
||||
? 'key' in pageTitle
|
||||
? t(pageTitle.key)
|
||||
: pageTitle.text
|
||||
: null;
|
||||
const pageTitleText = pageTitle ? ('key' in pageTitle ? t(pageTitle.key) : pageTitle.text) : null;
|
||||
|
||||
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)]">
|
||||
@@ -258,7 +245,18 @@ export function Header() {
|
||||
: '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" />
|
||||
</svg>
|
||||
{tHeader('admin.label')}
|
||||
@@ -273,6 +271,9 @@ export function Header() {
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDropdownOpen(false);
|
||||
// Offen gehaltene eigene Module und ihr Sitzungsspeicher
|
||||
// gehoeren zur Sitzung — beim Abmelden alles verwerfen.
|
||||
useCustomModuleCacheStore.getState().clear();
|
||||
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"
|
||||
|
||||
@@ -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 { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
|
||||
import { CustomModuleRequestError } from '@/lib/custom-modules-api';
|
||||
@@ -24,12 +24,33 @@ vi.mock('@/lib/custom-modules-api', async () => {
|
||||
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';
|
||||
|
||||
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 {
|
||||
id: 'abc',
|
||||
name: 'Wiki',
|
||||
id,
|
||||
name,
|
||||
url,
|
||||
category: 'infrastructure',
|
||||
createdAt: '2026-09-29T00:00:00.000Z',
|
||||
@@ -39,6 +60,7 @@ function mod(url: string) {
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetCustomModule.mockReset();
|
||||
useCustomModuleCacheStore.getState().clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -48,7 +70,7 @@ afterEach(() => {
|
||||
describe('CustomModuleView', () => {
|
||||
it('rendert den Rahmen mit Sandbox, ohne Referrer und ohne allow', async () => {
|
||||
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
|
||||
render(<CustomModuleView id="abc" />);
|
||||
renderModule('abc');
|
||||
|
||||
const frame = await screen.findByTitle('Wiki');
|
||||
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 () => {
|
||||
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
|
||||
render(<CustomModuleView id="abc" />);
|
||||
renderModule('abc');
|
||||
|
||||
const link = await screen.findByRole('link', { name: 'In neuem Tab öffnen' });
|
||||
expect(link).toHaveAttribute('href', 'https://example.com/wiki');
|
||||
@@ -77,7 +99,7 @@ describe('CustomModuleView', () => {
|
||||
document.body.appendChild(slot);
|
||||
try {
|
||||
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
|
||||
render(<CustomModuleView id="abc" />);
|
||||
renderModule('abc');
|
||||
const link = await screen.findByRole('link', { name: 'In neuem Tab öffnen' });
|
||||
expect(slot.contains(link)).toBe(true);
|
||||
} finally {
|
||||
@@ -92,7 +114,7 @@ describe('CustomModuleView', () => {
|
||||
'https://user:pw@example.com',
|
||||
])('rendert bei der Adresse %s weder Rahmen noch Link', async (url) => {
|
||||
mockGetCustomModule.mockResolvedValue(mod(url));
|
||||
const { container } = render(<CustomModuleView id="abc" />);
|
||||
const { container } = renderModule('abc');
|
||||
|
||||
await screen.findByText('Ungültige Adresse');
|
||||
expect(container.querySelector('iframe')).toBeNull();
|
||||
@@ -101,7 +123,7 @@ describe('CustomModuleView', () => {
|
||||
|
||||
it('zeigt bei 404 den Nicht-gefunden-Text', async () => {
|
||||
mockGetCustomModule.mockResolvedValue(null);
|
||||
const { container } = render(<CustomModuleView id="weg" />);
|
||||
const { container } = renderModule('weg');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Dieses Modul gibt es nicht mehr.')).toBeInTheDocument();
|
||||
@@ -115,7 +137,7 @@ describe('CustomModuleView', () => {
|
||||
['Netzwerkfehler', new TypeError('Failed to fetch')],
|
||||
])('zeigt bei %s „konnte nicht geladen werden“ statt „nicht gefunden“', async (_, err) => {
|
||||
mockGetCustomModule.mockRejectedValue(err);
|
||||
const { container } = render(<CustomModuleView id="abc" />);
|
||||
const { container } = renderModule('abc');
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Dieses Modul konnte nicht geladen werden.',
|
||||
@@ -124,3 +146,99 @@ describe('CustomModuleView', () => {
|
||||
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';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
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 { 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 =
|
||||
| { status: 'loading' }
|
||||
| { status: 'missing' }
|
||||
| { status: 'failed' }
|
||||
| { status: 'ready'; mod: CustomModule };
|
||||
type LoadStatus = 'loading' | 'missing' | 'failed' | 'ready';
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Nur ein 404 heisst „gibt es nicht mehr“; 403, 500 oder ein Netzwerkfehler
|
||||
* 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 }) {
|
||||
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 slotRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
setActionsSlot(document.getElementById(HEADER_ACTIONS_SLOT_ID));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState({ status: 'loading' });
|
||||
const hasCached = () => Boolean(useCustomModuleCacheStore.getState().modules[id]);
|
||||
setStatus(hasCached() ? 'ready' : 'loading');
|
||||
getCustomModule(id)
|
||||
.then((mod) => {
|
||||
.then((loaded) => {
|
||||
if (cancelled) return;
|
||||
setState(mod ? { status: 'ready', mod } : { status: 'missing' });
|
||||
if (loaded) {
|
||||
remember(loaded);
|
||||
setStatus('ready');
|
||||
} else {
|
||||
forget(id);
|
||||
setStatus('missing');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setState({ status: 'failed' });
|
||||
if (!cancelled) setStatus(hasCached() ? 'ready' : 'failed');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id]);
|
||||
}, [id, remember, forget]);
|
||||
|
||||
if (state.status === 'loading') {
|
||||
return <div className="p-4 text-sm text-muted-foreground">{t('loading')}</div>;
|
||||
}
|
||||
if (state.status === 'missing') {
|
||||
const urlOk = mod ? checkCustomModuleUrl(mod.url) === 'ok' : false;
|
||||
const showFrame = status === 'ready' && urlOk;
|
||||
|
||||
// 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>;
|
||||
}
|
||||
if (state.status === 'failed') {
|
||||
if (status === 'failed') {
|
||||
return (
|
||||
<div role="alert" className="p-4 text-sm text-destructive">
|
||||
{t('loadError')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { mod } = state;
|
||||
const urlOk = checkCustomModuleUrl(mod.url) === 'ok';
|
||||
if (status === 'loading' || !mod) {
|
||||
return <div className="p-4 text-sm text-muted-foreground">{t('loading')}</div>;
|
||||
}
|
||||
|
||||
const openLink = urlOk ? (
|
||||
<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">
|
||||
{openLink && (actionsSlot ? createPortal(openLink, actionsSlot) : openLink)}
|
||||
{urlOk ? (
|
||||
<iframe
|
||||
src={mod.url}
|
||||
title={mod.name}
|
||||
sandbox={XFRAME_SANDBOX}
|
||||
allow=""
|
||||
referrerPolicy="no-referrer"
|
||||
className="w-full flex-1 rounded-lg border-0 bg-background"
|
||||
data-testid="custom-module-frame"
|
||||
<div
|
||||
ref={slotRef}
|
||||
className="w-full flex-1 rounded-lg bg-background"
|
||||
data-testid="custom-module-slot"
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border border-border p-4 text-sm text-muted-foreground">
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
Reference in New Issue
Block a user