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 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -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