feat(quick-261008-who): Vorladen schalten, Grenze acht mit gepinnten Modulen
- Offenhalten-Grenze fuenf auf acht, gepinnte Module nie per LRU verworfen - Schalter in der App-Leiste der Modulansicht, Karte in Einstellungen -> Eigene Module - Texte de/en Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -99,6 +99,13 @@ vi.mock('@/lib/stores/marketplace-store', () => ({
|
|||||||
selector({ bumpSidebarRefresh: mockBump }),
|
selector({ bumpSidebarRefresh: mockBump }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Die Karte „Beim Start vorladen“ (quick-261008-who) hat einen eigenen Test und
|
||||||
|
// ruft ebenfalls listCustomModules auf — hier ausgeblendet, damit die Zaehlung
|
||||||
|
// der Listenaufrufe die Verwaltungsliste betrifft.
|
||||||
|
vi.mock('@/components/custom-modules/custom-module-preload-settings', () => ({
|
||||||
|
CustomModulePreloadSettings: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
import CustomModulesSettingsPage from './page';
|
import CustomModulesSettingsPage from './page';
|
||||||
|
|
||||||
const mine = {
|
const mine = {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { CustomModuleManager } from '@/components/custom-modules/custom-module-manager';
|
import { CustomModuleManager } from '@/components/custom-modules/custom-module-manager';
|
||||||
|
import { CustomModulePreloadSettings } from '@/components/custom-modules/custom-module-preload-settings';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Einstellungen > Eigene Module (quick-260929-dzu) — /settings/custom-modules.
|
* Einstellungen > Eigene Module (quick-260929-dzu) — /settings/custom-modules.
|
||||||
@@ -13,6 +14,7 @@ export default function CustomModulesSettingsPage() {
|
|||||||
const t = useTranslations('settings.customModules');
|
const t = useTranslations('settings.customModules');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<CustomModuleManager
|
<CustomModuleManager
|
||||||
scope="personal"
|
scope="personal"
|
||||||
texts={{
|
texts={{
|
||||||
@@ -23,5 +25,10 @@ export default function CustomModulesSettingsPage() {
|
|||||||
noModulesBody: t('noModulesBody'),
|
noModulesBody: t('noModulesBody'),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{/* quick-261008-who: Vorladen je Modul, auch fuer gemeinsame Eintraege. */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<CustomModulePreloadSettings />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { act, cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { CustomModuleRequestError } from '@/lib/custom-modules-api';
|
||||||
|
|
||||||
|
vi.mock('next-intl', () => ({
|
||||||
|
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
settingsTitle: 'Beim Start vorladen',
|
||||||
|
count: `${values?.count} von ${values?.max} ausgewählt`,
|
||||||
|
limitReached: 'Grenze erreicht',
|
||||||
|
saveError: 'Speichern fehlgeschlagen',
|
||||||
|
shared: 'Für alle',
|
||||||
|
personal: 'Persönlich',
|
||||||
|
empty: 'Keine Module',
|
||||||
|
loading: 'Wird geladen',
|
||||||
|
};
|
||||||
|
return map[key] ?? key;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockList, mockSet } = vi.hoisted(() => ({ mockList: vi.fn(), mockSet: vi.fn() }));
|
||||||
|
vi.mock('@/lib/custom-modules-api', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/lib/custom-modules-api')>(
|
||||||
|
'@/lib/custom-modules-api',
|
||||||
|
);
|
||||||
|
return { ...actual, listCustomModules: mockList, setCustomModulePreload: mockSet };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
|
||||||
|
import { CustomModulePreloadSettings } from './custom-module-preload-settings';
|
||||||
|
|
||||||
|
function mod(id: string, preload: boolean, personal = true) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: `Modul ${id}`,
|
||||||
|
url: `https://example.com/${id}`,
|
||||||
|
category: 'infrastructure',
|
||||||
|
personal,
|
||||||
|
preload,
|
||||||
|
createdAt: '2026-10-08T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-10-08T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockList.mockReset();
|
||||||
|
mockSet.mockReset();
|
||||||
|
useCustomModuleCacheStore.getState().clear();
|
||||||
|
});
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe('CustomModulePreloadSettings', () => {
|
||||||
|
it('listet alle Module mit Kennzeichnung und Zaehler', async () => {
|
||||||
|
mockList.mockResolvedValue([mod('a', true), mod('b', false, false)]);
|
||||||
|
render(<CustomModulePreloadSettings />);
|
||||||
|
expect(await screen.findByLabelText('Modul a')).toBeChecked();
|
||||||
|
expect(screen.getByLabelText('Modul b')).not.toBeChecked();
|
||||||
|
expect(screen.getByText('Persönlich')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Für alle')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('preload-count')).toHaveTextContent('1 von 8 ausgewählt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schaltet ein: ruft die API, aktualisiert Zaehler und Speicher (vorladen)', async () => {
|
||||||
|
mockList.mockResolvedValue([mod('a', false)]);
|
||||||
|
mockSet.mockResolvedValue({ id: 'a', preload: true, preloadCount: 1 });
|
||||||
|
render(<CustomModulePreloadSettings />);
|
||||||
|
const box = await screen.findByLabelText('Modul a');
|
||||||
|
await act(async () => {
|
||||||
|
box.click();
|
||||||
|
});
|
||||||
|
expect(mockSet).toHaveBeenCalledWith('a', true);
|
||||||
|
await waitFor(() => expect(box).toBeChecked());
|
||||||
|
expect(screen.getByTestId('preload-count')).toHaveTextContent('1 von 8');
|
||||||
|
expect(useCustomModuleCacheStore.getState().pinned).toEqual(['a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schaltet aus: Speicher gibt das Pinnen frei', async () => {
|
||||||
|
mockList.mockResolvedValue([mod('a', true)]);
|
||||||
|
mockSet.mockResolvedValue({ id: 'a', preload: false, preloadCount: 0 });
|
||||||
|
useCustomModuleCacheStore.getState().preload({ id: 'a', name: 'a', url: 'https://x.test/a' });
|
||||||
|
render(<CustomModulePreloadSettings />);
|
||||||
|
const box = await screen.findByLabelText('Modul a');
|
||||||
|
await act(async () => {
|
||||||
|
box.click();
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(box).not.toBeChecked());
|
||||||
|
expect(useCustomModuleCacheStore.getState().pinned).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sperrt bei acht gewaehlten die uebrigen Kaestchen und zeigt den Hinweis', async () => {
|
||||||
|
const eight = Array.from({ length: 8 }, (_, i) => mod(`p${i}`, true));
|
||||||
|
mockList.mockResolvedValue([...eight, mod('x', false)]);
|
||||||
|
render(<CustomModulePreloadSettings />);
|
||||||
|
const other = await screen.findByLabelText('Modul x');
|
||||||
|
expect(other).toBeDisabled();
|
||||||
|
expect(screen.getByLabelText('Modul p0')).not.toBeDisabled();
|
||||||
|
expect(screen.getByText('Grenze erreicht')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('preload-count')).toHaveTextContent('8 von 8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setzt das Kaestchen bei 409 zurueck und zeigt den Grenz-Text', async () => {
|
||||||
|
mockList.mockResolvedValue([mod('a', false)]);
|
||||||
|
mockSet.mockRejectedValue(new CustomModuleRequestError(409, 'x'));
|
||||||
|
render(<CustomModulePreloadSettings />);
|
||||||
|
const box = await screen.findByLabelText('Modul a');
|
||||||
|
await act(async () => {
|
||||||
|
box.click();
|
||||||
|
});
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent('Grenze erreicht');
|
||||||
|
expect(box).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt bei anderen Fehlern den Speicherfehler-Text', async () => {
|
||||||
|
mockList.mockResolvedValue([mod('a', false)]);
|
||||||
|
mockSet.mockRejectedValue(new CustomModuleRequestError(500, 'x'));
|
||||||
|
render(<CustomModulePreloadSettings />);
|
||||||
|
const box = await screen.findByLabelText('Modul a');
|
||||||
|
await act(async () => {
|
||||||
|
box.click();
|
||||||
|
});
|
||||||
|
const status = await screen.findByRole('status');
|
||||||
|
expect(within(status).getByText('Speichern fehlgeschlagen')).toBeInTheDocument();
|
||||||
|
expect(box).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt den Leer-Text ohne Module', async () => {
|
||||||
|
mockList.mockResolvedValue([]);
|
||||||
|
render(<CustomModulePreloadSettings />);
|
||||||
|
expect(await screen.findByText('Keine Module')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { CUSTOM_MODULE_PRELOAD_MAX } from '@tessera/shared';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { SettingsSection } from '@/components/control-center/settings-section';
|
||||||
|
import {
|
||||||
|
checkCustomModuleUrl,
|
||||||
|
type CustomModule,
|
||||||
|
CustomModuleRequestError,
|
||||||
|
listCustomModules,
|
||||||
|
setCustomModulePreload,
|
||||||
|
} from '@/lib/custom-modules-api';
|
||||||
|
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
|
||||||
|
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Karte „Beim Start vorladen“ in Einstellungen → Eigene Module
|
||||||
|
* (quick-261008-who). Zeigt ALLE fuer den Benutzer sichtbaren Module
|
||||||
|
* (gemeinsame und persoenliche) mit je einem Kontrollkaestchen und dem Zaehler
|
||||||
|
* „x von 8“ — die Verwaltungsliste darueber zeigt nur persoenliche Eintraege,
|
||||||
|
* beim Erreichen der Grenze muss man aber sehen, welche Module schon vorgeladen
|
||||||
|
* werden. Die Wahl liegt serverseitig pro Benutzer (PUT .../preload); bei acht
|
||||||
|
* gewaehlten sind die uebrigen Kaestchen gesperrt. Zieht nach Anlegen, Aendern
|
||||||
|
* oder Loeschen (`sidebarRefreshKey`) neu.
|
||||||
|
*/
|
||||||
|
export function CustomModulePreloadSettings() {
|
||||||
|
const t = useTranslations('customModules.preload');
|
||||||
|
const tCommon = useTranslations('common');
|
||||||
|
const refreshKey = useMarketplaceStore((s) => s.sidebarRefreshKey);
|
||||||
|
const [modules, setModules] = useState<CustomModule[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadFailed, setLoadFailed] = useState(false);
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<{ text: string; error: boolean } | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setModules(await listCustomModules());
|
||||||
|
setLoadFailed(false);
|
||||||
|
} catch {
|
||||||
|
setLoadFailed(true);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey ist der Ausloeser fuer das Neuladen.
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load, refreshKey]);
|
||||||
|
|
||||||
|
const count = modules.filter((m) => m.preload === true).length;
|
||||||
|
const full = count >= CUSTOM_MODULE_PRELOAD_MAX;
|
||||||
|
|
||||||
|
const toggle = async (mod: CustomModule) => {
|
||||||
|
const next = mod.preload !== true;
|
||||||
|
setNotice(null);
|
||||||
|
setBusyId(mod.id);
|
||||||
|
try {
|
||||||
|
await setCustomModulePreload(mod.id, next);
|
||||||
|
setModules((all) => all.map((m) => (m.id === mod.id ? { ...m, preload: next } : m)));
|
||||||
|
const store = useCustomModuleCacheStore.getState();
|
||||||
|
if (!next) store.setPinned(mod.id, false);
|
||||||
|
else if (checkCustomModuleUrl(mod.url) === 'ok') {
|
||||||
|
store.preload({ id: mod.id, name: mod.name, url: mod.url });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const limit = err instanceof CustomModuleRequestError && err.status === 409;
|
||||||
|
setNotice({
|
||||||
|
text: limit ? t('limitReached', { max: CUSTOM_MODULE_PRELOAD_MAX }) : t('saveError'),
|
||||||
|
error: !limit,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
title={t('settingsTitle')}
|
||||||
|
description={t('settingsDescription', { max: CUSTOM_MODULE_PRELOAD_MAX })}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{tCommon('loading')}</p>
|
||||||
|
) : loadFailed ? (
|
||||||
|
<p role="alert" className="text-sm text-destructive">
|
||||||
|
{t('loadError')}
|
||||||
|
</p>
|
||||||
|
) : modules.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('empty')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground" data-testid="preload-count">
|
||||||
|
{t('count', { count, max: CUSTOM_MODULE_PRELOAD_MAX })}
|
||||||
|
</p>
|
||||||
|
{full && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('limitReached', { max: CUSTOM_MODULE_PRELOAD_MAX })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<ul className="flex flex-col divide-y divide-border">
|
||||||
|
{modules.map((mod) => {
|
||||||
|
const checked = mod.preload === true;
|
||||||
|
const inputId = `preload-${mod.id}`;
|
||||||
|
return (
|
||||||
|
<li key={mod.id} className="flex items-center gap-3 py-2">
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
disabled={busyId !== null || (full && !checked)}
|
||||||
|
onChange={() => toggle(mod)}
|
||||||
|
/>
|
||||||
|
<label htmlFor={inputId} className="min-w-0 flex-1 truncate text-sm">
|
||||||
|
{mod.name}
|
||||||
|
</label>
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{mod.personal ? t('personal') : t('shared')}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{notice && (
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
className={`text-sm ${notice.error ? 'text-destructive' : 'text-muted-foreground'}`}
|
||||||
|
>
|
||||||
|
{notice.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,17 +11,27 @@ vi.mock('next-intl', () => ({
|
|||||||
loadError: 'Dieses Modul konnte nicht geladen werden.',
|
loadError: 'Dieses Modul konnte nicht geladen werden.',
|
||||||
invalidUrl: 'Ungültige Adresse',
|
invalidUrl: 'Ungültige Adresse',
|
||||||
loading: 'Wird geladen',
|
loading: 'Wird geladen',
|
||||||
|
'preload.toggle': 'Beim Start vorladen',
|
||||||
|
'preload.limitReached': 'Grenze erreicht',
|
||||||
|
'preload.saveError': 'Speichern fehlgeschlagen',
|
||||||
};
|
};
|
||||||
return map[key] ?? key;
|
return map[key] ?? key;
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { mockGetCustomModule } = vi.hoisted(() => ({ mockGetCustomModule: vi.fn() }));
|
const { mockGetCustomModule, mockSetPreload } = vi.hoisted(() => ({
|
||||||
|
mockGetCustomModule: vi.fn(),
|
||||||
|
mockSetPreload: vi.fn(),
|
||||||
|
}));
|
||||||
vi.mock('@/lib/custom-modules-api', async () => {
|
vi.mock('@/lib/custom-modules-api', async () => {
|
||||||
const actual = await vi.importActual<typeof import('@/lib/custom-modules-api')>(
|
const actual = await vi.importActual<typeof import('@/lib/custom-modules-api')>(
|
||||||
'@/lib/custom-modules-api',
|
'@/lib/custom-modules-api',
|
||||||
);
|
);
|
||||||
return { ...actual, getCustomModule: mockGetCustomModule };
|
return {
|
||||||
|
...actual,
|
||||||
|
getCustomModule: mockGetCustomModule,
|
||||||
|
setCustomModulePreload: mockSetPreload,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
|
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
|
||||||
@@ -60,6 +70,7 @@ function mod(url: string, id = 'abc', name = 'Wiki') {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockGetCustomModule.mockReset();
|
mockGetCustomModule.mockReset();
|
||||||
|
mockSetPreload.mockReset();
|
||||||
useCustomModuleCacheStore.getState().clear();
|
useCustomModuleCacheStore.getState().clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -216,18 +227,18 @@ describe('Eigene Module offen halten', () => {
|
|||||||
expect(state.openIds).toEqual([]);
|
expect(state.openIds).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('haelt hoechstens fuenf Module offen und verwirft das am laengsten unbenutzte', async () => {
|
it('haelt hoechstens acht Module offen und verwirft das am laengsten unbenutzte', async () => {
|
||||||
mockGetCustomModule.mockImplementation(async (id: string) =>
|
mockGetCustomModule.mockImplementation(async (id: string) =>
|
||||||
mod(`https://example.com/${id}`, id, `Modul ${id}`),
|
mod(`https://example.com/${id}`, id, `Modul ${id}`),
|
||||||
);
|
);
|
||||||
const { rerender } = render(<Portal route="m1" />);
|
const { rerender } = render(<Portal route="m1" />);
|
||||||
await screen.findByTitle('Modul m1');
|
await screen.findByTitle('Modul m1');
|
||||||
for (const id of ['m2', 'm3', 'm4', 'm5', 'm6']) {
|
for (const id of ['m2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9']) {
|
||||||
rerender(<Portal route={id} />);
|
rerender(<Portal route={id} />);
|
||||||
await screen.findByTitle(`Modul ${id}`);
|
await screen.findByTitle(`Modul ${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(document.querySelectorAll('iframe')).toHaveLength(5);
|
expect(document.querySelectorAll('iframe')).toHaveLength(8);
|
||||||
expect(screen.queryByTitle('Modul m1')).toBeNull();
|
expect(screen.queryByTitle('Modul m1')).toBeNull();
|
||||||
expect(screen.getByTitle('Modul m2')).toBeInTheDocument();
|
expect(screen.getByTitle('Modul m2')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -242,3 +253,69 @@ describe('Eigene Module offen halten', () => {
|
|||||||
expect(document.querySelector('iframe')).toBeNull();
|
expect(document.querySelector('iframe')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Schalter „Beim Start vorladen“ (quick-261008-who)', () => {
|
||||||
|
const toggleName = 'Beim Start vorladen';
|
||||||
|
let slot: HTMLElement;
|
||||||
|
beforeEach(() => {
|
||||||
|
slot = document.createElement('div');
|
||||||
|
slot.id = 'header-actions-slot';
|
||||||
|
document.body.appendChild(slot);
|
||||||
|
});
|
||||||
|
afterEach(() => slot.remove());
|
||||||
|
|
||||||
|
it('steht in der App-Leiste neben dem Link und zeigt aria-pressed aus der Antwort', async () => {
|
||||||
|
mockGetCustomModule.mockResolvedValue({ ...mod('https://example.com/wiki'), preload: true });
|
||||||
|
renderModule('abc');
|
||||||
|
const button = await screen.findByRole('button', { name: toggleName });
|
||||||
|
expect(slot.contains(button)).toBe(true);
|
||||||
|
expect(slot.contains(screen.getByRole('link', { name: 'In neuem Tab öffnen' }))).toBe(true);
|
||||||
|
await waitFor(() => expect(button).toHaveAttribute('aria-pressed', 'true'));
|
||||||
|
expect(useCustomModuleCacheStore.getState().pinned).toContain('abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schaltet per Klick ein, ruft die API und pinnt das Modul', async () => {
|
||||||
|
mockGetCustomModule.mockResolvedValue({ ...mod('https://example.com/wiki'), preload: false });
|
||||||
|
mockSetPreload.mockResolvedValue({ id: 'abc', preload: true, preloadCount: 1 });
|
||||||
|
renderModule('abc');
|
||||||
|
const button = await screen.findByRole('button', { name: toggleName });
|
||||||
|
expect(button).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
await act(async () => {
|
||||||
|
button.click();
|
||||||
|
});
|
||||||
|
expect(mockSetPreload).toHaveBeenCalledWith('abc', true);
|
||||||
|
await waitFor(() => expect(button).toHaveAttribute('aria-pressed', 'true'));
|
||||||
|
expect(useCustomModuleCacheStore.getState().pinned).toContain('abc');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
button.click();
|
||||||
|
});
|
||||||
|
expect(mockSetPreload).toHaveBeenLastCalledWith('abc', false);
|
||||||
|
await waitFor(() => expect(button).toHaveAttribute('aria-pressed', 'false'));
|
||||||
|
expect(useCustomModuleCacheStore.getState().pinned).not.toContain('abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt bei 409 den Grenz-Hinweis und laesst den Schalter aus', async () => {
|
||||||
|
mockGetCustomModule.mockResolvedValue({ ...mod('https://example.com/wiki'), preload: false });
|
||||||
|
mockSetPreload.mockRejectedValue(new CustomModuleRequestError(409, 'x'));
|
||||||
|
renderModule('abc');
|
||||||
|
const button = await screen.findByRole('button', { name: toggleName });
|
||||||
|
await act(async () => {
|
||||||
|
button.click();
|
||||||
|
});
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent('Grenze erreicht');
|
||||||
|
expect(button).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt bei anderen Fehlern den Speicherfehler-Text', async () => {
|
||||||
|
mockGetCustomModule.mockResolvedValue({ ...mod('https://example.com/wiki'), preload: false });
|
||||||
|
mockSetPreload.mockRejectedValue(new CustomModuleRequestError(500, 'x'));
|
||||||
|
renderModule('abc');
|
||||||
|
const button = await screen.findByRole('button', { name: toggleName });
|
||||||
|
await act(async () => {
|
||||||
|
button.click();
|
||||||
|
});
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent('Speichern fehlgeschlagen');
|
||||||
|
expect(button).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import { useTranslations } from 'next-intl';
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot';
|
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot';
|
||||||
import { checkCustomModuleUrl, getCustomModule } from '@/lib/custom-modules-api';
|
import { CUSTOM_MODULE_PRELOAD_MAX } from '@tessera/shared';
|
||||||
|
import {
|
||||||
|
checkCustomModuleUrl,
|
||||||
|
CustomModuleRequestError,
|
||||||
|
getCustomModule,
|
||||||
|
setCustomModulePreload,
|
||||||
|
} from '@/lib/custom-modules-api';
|
||||||
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
|
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
|
||||||
|
|
||||||
type LoadStatus = 'loading' | 'missing' | 'failed' | 'ready';
|
type LoadStatus = 'loading' | 'missing' | 'failed' | 'ready';
|
||||||
@@ -32,6 +38,13 @@ type LoadStatus = 'loading' | 'missing' | 'failed' | 'ready';
|
|||||||
* Eintrag samt <iframe> und zeigt „nicht gefunden“. Scheitert NUR das
|
* Eintrag samt <iframe> und zeigt „nicht gefunden“. Scheitert NUR das
|
||||||
* Nachladen (403, 500, Netz) bei vorhandenem Speicherstand, bleibt das Modul
|
* Nachladen (403, 500, Netz) bei vorhandenem Speicherstand, bleibt das Modul
|
||||||
* sichtbar — ohne Speicherstand gilt wie bisher „konnte nicht geladen werden“.
|
* sichtbar — ohne Speicherstand gilt wie bisher „konnte nicht geladen werden“.
|
||||||
|
*
|
||||||
|
* Vorladen (quick-261008-who): Neben „In neuem Tab oeffnen“ sitzt der Schalter
|
||||||
|
* „Beim Start vorladen“ (`aria-pressed`, gilt fuer persoenliche UND gemeinsame
|
||||||
|
* Module). Die Wahl liegt serverseitig pro Benutzer (PUT .../preload); der
|
||||||
|
* Startwert kommt aus dem Speicher (`pinned`) und wird von der Antwort des
|
||||||
|
* Hintergrund-Ladens uebernommen. 409 (Grenze erreicht) und andere Fehler
|
||||||
|
* zeigen eine Hinweiszeile (`role="status"`), der Schalter bleibt unveraendert.
|
||||||
*/
|
*/
|
||||||
export function CustomModuleView({ id }: { id: string }) {
|
export function CustomModuleView({ id }: { id: string }) {
|
||||||
const t = useTranslations('customModules');
|
const t = useTranslations('customModules');
|
||||||
@@ -40,6 +53,13 @@ export function CustomModuleView({ id }: { id: string }) {
|
|||||||
const forget = useCustomModuleCacheStore((s) => s.forget);
|
const forget = useCustomModuleCacheStore((s) => s.forget);
|
||||||
const activate = useCustomModuleCacheStore((s) => s.activate);
|
const activate = useCustomModuleCacheStore((s) => s.activate);
|
||||||
const deactivate = useCustomModuleCacheStore((s) => s.deactivate);
|
const deactivate = useCustomModuleCacheStore((s) => s.deactivate);
|
||||||
|
const setPinned = useCustomModuleCacheStore((s) => s.setPinned);
|
||||||
|
const preloadInStore = useCustomModuleCacheStore((s) => s.preload);
|
||||||
|
const [preload, setPreload] = useState(() =>
|
||||||
|
useCustomModuleCacheStore.getState().pinned.includes(id),
|
||||||
|
);
|
||||||
|
const [preloadSaving, setPreloadSaving] = useState(false);
|
||||||
|
const [preloadNotice, setPreloadNotice] = useState<{ text: string; error: boolean } | null>(null);
|
||||||
const [status, setStatus] = useState<LoadStatus>(() =>
|
const [status, setStatus] = useState<LoadStatus>(() =>
|
||||||
useCustomModuleCacheStore.getState().modules[id] ? 'ready' : 'loading',
|
useCustomModuleCacheStore.getState().modules[id] ? 'ready' : 'loading',
|
||||||
);
|
);
|
||||||
@@ -51,6 +71,12 @@ export function CustomModuleView({ id }: { id: string }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
// An: Rahmen mit anlegen und pinnen (nur bei gueltiger Adresse); aus: nur
|
||||||
|
// das Pinnen loesen.
|
||||||
|
const applyPinned = (m: { id: string; name: string; url: string }, on: boolean) => {
|
||||||
|
if (!on) setPinned(m.id, false);
|
||||||
|
else if (checkCustomModuleUrl(m.url) === 'ok') preloadInStore(m);
|
||||||
|
};
|
||||||
const hasCached = () => Boolean(useCustomModuleCacheStore.getState().modules[id]);
|
const hasCached = () => Boolean(useCustomModuleCacheStore.getState().modules[id]);
|
||||||
setStatus(hasCached() ? 'ready' : 'loading');
|
setStatus(hasCached() ? 'ready' : 'loading');
|
||||||
getCustomModule(id)
|
getCustomModule(id)
|
||||||
@@ -58,6 +84,10 @@ export function CustomModuleView({ id }: { id: string }) {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
remember(loaded);
|
remember(loaded);
|
||||||
|
if (typeof loaded.preload === 'boolean') {
|
||||||
|
setPreload(loaded.preload);
|
||||||
|
applyPinned(loaded, loaded.preload);
|
||||||
|
}
|
||||||
setStatus('ready');
|
setStatus('ready');
|
||||||
} else {
|
} else {
|
||||||
forget(id);
|
forget(id);
|
||||||
@@ -70,7 +100,7 @@ export function CustomModuleView({ id }: { id: string }) {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [id, remember, forget]);
|
}, [id, remember, forget, setPinned, preloadInStore]);
|
||||||
|
|
||||||
const urlOk = mod ? checkCustomModuleUrl(mod.url) === 'ok' : false;
|
const urlOk = mod ? checkCustomModuleUrl(mod.url) === 'ok' : false;
|
||||||
const showFrame = status === 'ready' && urlOk;
|
const showFrame = status === 'ready' && urlOk;
|
||||||
@@ -85,6 +115,28 @@ export function CustomModuleView({ id }: { id: string }) {
|
|||||||
return () => deactivate(slot);
|
return () => deactivate(slot);
|
||||||
}, [showFrame, id, activate, deactivate]);
|
}, [showFrame, id, activate, deactivate]);
|
||||||
|
|
||||||
|
const togglePreload = async () => {
|
||||||
|
const next = !preload;
|
||||||
|
setPreloadNotice(null);
|
||||||
|
setPreloadSaving(true);
|
||||||
|
try {
|
||||||
|
await setCustomModulePreload(id, next);
|
||||||
|
setPreload(next);
|
||||||
|
if (next) preloadInStore({ id, name: mod.name, url: mod.url });
|
||||||
|
else setPinned(id, false);
|
||||||
|
} catch (err) {
|
||||||
|
const limit = err instanceof CustomModuleRequestError && err.status === 409;
|
||||||
|
setPreloadNotice({
|
||||||
|
text: limit
|
||||||
|
? t('preload.limitReached', { max: CUSTOM_MODULE_PRELOAD_MAX })
|
||||||
|
: t('preload.saveError'),
|
||||||
|
error: !limit,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setPreloadSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (status === 'missing') {
|
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>;
|
||||||
}
|
}
|
||||||
@@ -126,9 +178,50 @@ export function CustomModuleView({ id }: { id: string }) {
|
|||||||
</a>
|
</a>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
|
const preloadButton = urlOk ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={togglePreload}
|
||||||
|
disabled={preloadSaving}
|
||||||
|
aria-pressed={preload}
|
||||||
|
className={actionsSlot ? 'btn btn-appbar' : 'btn btn-secondary self-end'}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill={preload ? 'currentColor' : 'none'}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z" />
|
||||||
|
</svg>
|
||||||
|
{t('preload.toggle')}
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
|
const headerActions = (
|
||||||
|
<>
|
||||||
|
{preloadButton}
|
||||||
|
{openLink}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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 || preloadButton) &&
|
||||||
|
(actionsSlot ? createPortal(headerActions, actionsSlot) : headerActions)}
|
||||||
|
{preloadNotice && (
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
className={`text-xs ${preloadNotice.error ? 'text-destructive' : 'text-muted-foreground'}`}
|
||||||
|
>
|
||||||
|
{preloadNotice.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{urlOk ? (
|
{urlOk ? (
|
||||||
<div
|
<div
|
||||||
ref={slotRef}
|
ref={slotRef}
|
||||||
|
|||||||
@@ -43,12 +43,14 @@ describe('useCustomModuleCacheStore', () => {
|
|||||||
for (const id of ids.slice(0, CUSTOM_MODULE_KEEP_ALIVE_MAX)) activate(id, slot());
|
for (const id of ids.slice(0, CUSTOM_MODULE_KEEP_ALIVE_MAX)) activate(id, slot());
|
||||||
// m0 erneut benutzen -> m1 ist jetzt am laengsten unbenutzt.
|
// m0 erneut benutzen -> m1 ist jetzt am laengsten unbenutzt.
|
||||||
activate('m0', slot());
|
activate('m0', slot());
|
||||||
expect(useCustomModuleCacheStore.getState().openIds).toEqual(['m0', 'm1', 'm2', 'm3', 'm4']);
|
expect(useCustomModuleCacheStore.getState().openIds).toEqual(
|
||||||
|
ids.slice(0, CUSTOM_MODULE_KEEP_ALIVE_MAX),
|
||||||
|
);
|
||||||
|
|
||||||
activate('m5', slot());
|
activate(ids[CUSTOM_MODULE_KEEP_ALIVE_MAX], slot());
|
||||||
const state = useCustomModuleCacheStore.getState();
|
const state = useCustomModuleCacheStore.getState();
|
||||||
expect(state.openIds).toEqual(['m0', 'm2', 'm3', 'm4', 'm5']);
|
expect(state.openIds).toEqual(['m0', ...ids.slice(2)]);
|
||||||
expect(state.recent[0]).toBe('m5');
|
expect(state.recent[0]).toBe(ids[CUSTOM_MODULE_KEEP_ALIVE_MAX]);
|
||||||
// Metadaten bleiben — nur der offene Rahmen wird verworfen.
|
// Metadaten bleiben — nur der offene Rahmen wird verworfen.
|
||||||
expect(state.modules.m1).toBeDefined();
|
expect(state.modules.m1).toBeDefined();
|
||||||
});
|
});
|
||||||
@@ -134,3 +136,94 @@ describe('useCustomModuleCacheStore — preload (quick-261008-who)', () => {
|
|||||||
expect(s.active?.id).toBe('a');
|
expect(s.active?.id).toBe('a');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Grenze acht mit gepinnten Modulen (quick-261008-who)', () => {
|
||||||
|
const mod = (id: string) => ({ id, name: id, url: `https://x.test/${id}` });
|
||||||
|
const slot = () => document.createElement('div');
|
||||||
|
const st = () => useCustomModuleCacheStore.getState();
|
||||||
|
const open = (id: string) => {
|
||||||
|
st().remember(mod(id));
|
||||||
|
st().activate(id, slot());
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
st().clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Grenzwerte: Offenhalten ist acht und deckt die Vorlade-Grenze', () => {
|
||||||
|
expect(CUSTOM_MODULE_KEEP_ALIVE_MAX).toBe(8);
|
||||||
|
expect(CUSTOM_MODULE_PRELOAD_MAX).toBeLessThanOrEqual(CUSTOM_MODULE_KEEP_ALIVE_MAX);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('touchLru verwirft ohne Gepinnte das neunte Modul nach LRU', () => {
|
||||||
|
const recent = ['h', 'g', 'f', 'e', 'd', 'c', 'b', 'a'];
|
||||||
|
expect(touchLru(recent, 'i', 8)).toEqual({
|
||||||
|
recent: ['i', ...recent.slice(0, 7)],
|
||||||
|
evicted: ['a'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('touchLru verwirft mit Gepinnten nur uebrige: 3 gepinnt + 5 uebrige, ein sechstes', () => {
|
||||||
|
const pinned = ['p1', 'p2', 'p3'];
|
||||||
|
const recent = ['o5', 'o4', 'o3', 'o2', 'o1', 'p3', 'p2', 'p1'];
|
||||||
|
const result = touchLru(recent, 'o6', 8, pinned);
|
||||||
|
expect(result.evicted).toEqual(['o1']);
|
||||||
|
expect(result.recent).toEqual(['o6', 'o5', 'o4', 'o3', 'o2', 'p3', 'p2', 'p1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('touchLru: gepinnte Module aktivieren zaehlt nicht unter den uebrigen', () => {
|
||||||
|
const pinned = ['p1', 'p2', 'p3'];
|
||||||
|
const recent = ['o5', 'o4', 'o3', 'o2', 'o1', 'p3', 'p2', 'p1'];
|
||||||
|
expect(touchLru(recent, 'p1', 8, pinned).evicted).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('acht gepinnt: ein uebriges bleibt als aktives offen (neun), ein weiteres verwirft es', () => {
|
||||||
|
for (let i = 0; i < 8; i++) st().preload(mod(`p${i}`));
|
||||||
|
open('x');
|
||||||
|
expect(st().openIds).toHaveLength(9);
|
||||||
|
open('y');
|
||||||
|
const s = st();
|
||||||
|
expect(s.openIds).toHaveLength(9);
|
||||||
|
expect(s.openIds).not.toContain('x');
|
||||||
|
expect(s.openIds).toContain('y');
|
||||||
|
for (let i = 0; i < 8; i++) expect(s.pinned).toContain(`p${i}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('neu gepinnt verdraengt ueberzaehlige uebrige, nie das aktive', () => {
|
||||||
|
for (const id of ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']) open(id);
|
||||||
|
st().preload(mod('p'));
|
||||||
|
const s = st();
|
||||||
|
expect(s.openIds).not.toContain('a');
|
||||||
|
expect(s.openIds).toContain('h');
|
||||||
|
expect(s.openIds).toHaveLength(8);
|
||||||
|
expect(s.openIds).toContain('p');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('activate eines gepinnten Moduls laesst es gepinnt', () => {
|
||||||
|
st().preload(mod('a'));
|
||||||
|
st().activate('a', slot());
|
||||||
|
expect(st().pinned).toEqual(['a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setPinned(false) macht das Modul wieder verwerfbar, ohne den Rahmen zu schliessen', () => {
|
||||||
|
st().preload(mod('a'));
|
||||||
|
st().setPinned('a', false);
|
||||||
|
expect(st().pinned).toEqual([]);
|
||||||
|
expect(st().openIds).toEqual(['a']);
|
||||||
|
expect(st().recent).toContain('a');
|
||||||
|
// beim naechsten Aktivieren gilt die Grenze: a ist das aelteste
|
||||||
|
for (const id of ['b', 'c', 'd', 'e', 'f', 'g', 'h', 'i']) open(id);
|
||||||
|
expect(st().openIds).not.toContain('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setPinned(true) nur fuer offene Module und bis zur Grenze; Reihenfolge bleibt stabil', () => {
|
||||||
|
st().setPinned('unbekannt', true);
|
||||||
|
expect(st().pinned).toEqual([]);
|
||||||
|
for (let i = 0; i < 8; i++) st().preload(mod(`p${i}`));
|
||||||
|
open('x');
|
||||||
|
const before = st().openIds;
|
||||||
|
st().setPinned('x', true);
|
||||||
|
expect(st().pinned).not.toContain('x');
|
||||||
|
expect(st().openIds).toEqual(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,15 +17,26 @@ import type { CustomModule } from '@/lib/custom-modules-api';
|
|||||||
* die <iframe>-Knoten im DOM, und ein verschobenes <iframe> laedt neu.
|
* die <iframe>-Knoten im DOM, und ein verschobenes <iframe> laedt neu.
|
||||||
* Die Nutzungsreihenfolge fuer die Begrenzung steht deshalb getrennt in
|
* Die Nutzungsreihenfolge fuer die Begrenzung steht deshalb getrennt in
|
||||||
* `recent` (zuletzt benutzt vorn).
|
* `recent` (zuletzt benutzt vorn).
|
||||||
|
*
|
||||||
|
* Vorladen/Pinnen (quick-261008-who): `pinned` sind die Module, die der
|
||||||
|
* Benutzer beim Start vorladen laesst. Sie zaehlen zu den offenen Rahmen, werden
|
||||||
|
* aber NIE per LRU verworfen; die uebrigen Module teilen sich den Rest bis
|
||||||
|
* `CUSTOM_MODULE_KEEP_ALIVE_MAX` (mindestens das gerade geoeffnete). Verhalten
|
||||||
|
* bei mehr als acht: Vorladen ist serverseitig auf acht begrenzt (HTTP 409 mit
|
||||||
|
* Hinweis in der Oberflaeche), der Speicher ignoriert weitere Pins. Extremfall:
|
||||||
|
* acht gepinnt plus ein gerade geoeffnetes, nicht gepinntes Modul = neun Rahmen;
|
||||||
|
* oeffnet man ein weiteres nicht gepinntes, faellt das vorige heraus.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hoechstens so viele eigene Module halten ihren <iframe> offen. Jede offene
|
* Hoechstens so viele eigene Module halten ihren <iframe> offen. Jede offene
|
||||||
* Fremdseite belegt weiter Speicher und laeuft im Hintergrund mit (Skripte,
|
* Fremdseite belegt weiter Speicher und laeuft im Hintergrund mit (Skripte,
|
||||||
* Abfragen); fuenf deckt den ueblichen Wechsel zwischen wenigen Werkzeugen ab,
|
* Abfragen). Acht (Nutzerentscheidung 08.10.2026, vorher fuenf) deckt den
|
||||||
* ohne den Browser bei vielen Eintraegen in der Seitenleiste zu belasten.
|
* Wechsel zwischen den taeglichen Werkzeugen ab und passt zur Hoechstzahl der
|
||||||
|
* vorgeladenen Module (`CUSTOM_MODULE_PRELOAD_MAX`), ohne den Browser bei vielen
|
||||||
|
* Eintraegen in der Seitenleiste zu belasten.
|
||||||
*/
|
*/
|
||||||
export const CUSTOM_MODULE_KEEP_ALIVE_MAX = 5;
|
export const CUSTOM_MODULE_KEEP_ALIVE_MAX = 8;
|
||||||
|
|
||||||
export type CachedCustomModule = Pick<CustomModule, 'id' | 'name' | 'url'>;
|
export type CachedCustomModule = Pick<CustomModule, 'id' | 'name' | 'url'>;
|
||||||
|
|
||||||
@@ -36,11 +47,48 @@ export interface LruResult {
|
|||||||
evicted: string[];
|
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 {
|
* Gemeinsame Begrenzung: gepinnte Kennungen bleiben immer; von den uebrigen
|
||||||
|
* bleiben hoechstens `max - Anzahl gepinnter` (mindestens eine), in der
|
||||||
|
* Reihenfolge von `order`. `keepId` wird zuerst behandelt und nie verworfen.
|
||||||
|
*/
|
||||||
|
function trimUnpinned(
|
||||||
|
order: readonly string[],
|
||||||
|
keepId: string | null,
|
||||||
|
max: number,
|
||||||
|
pinned: readonly string[],
|
||||||
|
): LruResult {
|
||||||
|
const budget = Math.max(1, max - pinned.length);
|
||||||
|
const sorted =
|
||||||
|
keepId && order.includes(keepId) ? [keepId, ...order.filter((o) => o !== keepId)] : order;
|
||||||
|
const kept = new Set<string>();
|
||||||
|
const evicted: string[] = [];
|
||||||
|
let used = 0;
|
||||||
|
for (const other of sorted) {
|
||||||
|
if (pinned.includes(other)) {
|
||||||
|
kept.add(other);
|
||||||
|
} else if (used < budget) {
|
||||||
|
used++;
|
||||||
|
kept.add(other);
|
||||||
|
} else {
|
||||||
|
evicted.push(other);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { recent: order.filter((o) => kept.has(o)), evicted };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reine LRU-Regel: `id` nach vorn, alles jenseits der Grenze wird verworfen —
|
||||||
|
* gepinnte (`pinned`) nie, `id` selbst nie.
|
||||||
|
*/
|
||||||
|
export function touchLru(
|
||||||
|
recent: readonly string[],
|
||||||
|
id: string,
|
||||||
|
max: number,
|
||||||
|
pinned: readonly string[] = [],
|
||||||
|
): LruResult {
|
||||||
const next = [id, ...recent.filter((other) => other !== id)];
|
const next = [id, ...recent.filter((other) => other !== id)];
|
||||||
const limit = Math.max(1, max);
|
return trimUnpinned(next, id, max, pinned);
|
||||||
return { recent: next.slice(0, limit), evicted: next.slice(limit) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActiveCustomModuleSlot {
|
export interface ActiveCustomModuleSlot {
|
||||||
@@ -69,6 +117,12 @@ interface CustomModuleCacheState {
|
|||||||
* Mehrfachaufruf und Aufrufe jenseits von `CUSTOM_MODULE_PRELOAD_MAX` bewirken nichts.
|
* Mehrfachaufruf und Aufrufe jenseits von `CUSTOM_MODULE_PRELOAD_MAX` bewirken nichts.
|
||||||
*/
|
*/
|
||||||
preload: (mod: CachedCustomModule) => void;
|
preload: (mod: CachedCustomModule) => void;
|
||||||
|
/**
|
||||||
|
* Vorladen fuer ein schon bekanntes Modul ein- oder ausschalten, ohne den
|
||||||
|
* Rahmen zu schliessen. Ein: nur fuer offene Module und bis zur Grenze
|
||||||
|
* `CUSTOM_MODULE_PRELOAD_MAX`. Aus: das Modul wird wieder verwerfbar.
|
||||||
|
*/
|
||||||
|
setPinned: (id: string, on: boolean) => void;
|
||||||
/** Die Modulseite zeigt `id` im Platzhalter `slot`: offen halten und nach vorn. */
|
/** Die Modulseite zeigt `id` im Platzhalter `slot`: offen halten und nach vorn. */
|
||||||
activate: (id: string, slot: HTMLElement) => void;
|
activate: (id: string, slot: HTMLElement) => void;
|
||||||
/** Die Modulseite verschwindet: <iframe> nur verstecken, nicht verwerfen. */
|
/** Die Modulseite verschwindet: <iframe> nur verstecken, nicht verwerfen. */
|
||||||
@@ -104,16 +158,52 @@ export const useCustomModuleCacheStore = create<CustomModuleCacheState>()((set)
|
|||||||
set((s) => {
|
set((s) => {
|
||||||
if (s.pinned.includes(mod.id)) return s;
|
if (s.pinned.includes(mod.id)) return s;
|
||||||
if (s.pinned.length >= CUSTOM_MODULE_PRELOAD_MAX) return s;
|
if (s.pinned.length >= CUSTOM_MODULE_PRELOAD_MAX) return s;
|
||||||
|
const pinned = [...s.pinned, mod.id];
|
||||||
|
// Ein neu gepinntes Modul verkleinert den Platz der uebrigen: ueberzaehlige
|
||||||
|
// (nie das aktive) fallen heraus.
|
||||||
|
const { recent, evicted } = trimUnpinned(
|
||||||
|
s.recent,
|
||||||
|
s.active?.id ?? null,
|
||||||
|
CUSTOM_MODULE_KEEP_ALIVE_MAX,
|
||||||
|
pinned,
|
||||||
|
);
|
||||||
|
const openIds = s.openIds.filter((other) => !evicted.includes(other));
|
||||||
return {
|
return {
|
||||||
modules: { ...s.modules, [mod.id]: { id: mod.id, name: mod.name, url: mod.url } },
|
modules: { ...s.modules, [mod.id]: { id: mod.id, name: mod.name, url: mod.url } },
|
||||||
openIds: s.openIds.includes(mod.id) ? s.openIds : [...s.openIds, mod.id],
|
openIds: openIds.includes(mod.id) ? openIds : [...openIds, mod.id],
|
||||||
pinned: [...s.pinned, mod.id],
|
recent,
|
||||||
|
pinned,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
setPinned: (id, on) =>
|
||||||
|
set((s) => {
|
||||||
|
const isPinned = s.pinned.includes(id);
|
||||||
|
if (on === isPinned) return s;
|
||||||
|
if (!on) {
|
||||||
|
// Wieder verwerfbar: als am laengsten unbenutzt in die LRU-Reihenfolge.
|
||||||
|
return {
|
||||||
|
pinned: s.pinned.filter((other) => other !== id),
|
||||||
|
recent: s.recent.includes(id) ? s.recent : [...s.recent, id],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!s.openIds.includes(id) || s.pinned.length >= CUSTOM_MODULE_PRELOAD_MAX) return s;
|
||||||
|
const pinned = [...s.pinned, id];
|
||||||
|
const { recent, evicted } = trimUnpinned(
|
||||||
|
s.recent,
|
||||||
|
s.active?.id ?? null,
|
||||||
|
CUSTOM_MODULE_KEEP_ALIVE_MAX,
|
||||||
|
pinned,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
pinned,
|
||||||
|
recent,
|
||||||
|
openIds: s.openIds.filter((other) => !evicted.includes(other)),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
activate: (id, slot) =>
|
activate: (id, slot) =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
if (!s.modules[id]) return s;
|
if (!s.modules[id]) return s;
|
||||||
const { recent, evicted } = touchLru(s.recent, id, CUSTOM_MODULE_KEEP_ALIVE_MAX);
|
const { recent, evicted } = touchLru(s.recent, id, CUSTOM_MODULE_KEEP_ALIVE_MAX, s.pinned);
|
||||||
const kept = s.openIds.filter((other) => !evicted.includes(other));
|
const kept = s.openIds.filter((other) => !evicted.includes(other));
|
||||||
return {
|
return {
|
||||||
recent,
|
recent,
|
||||||
|
|||||||
@@ -1661,6 +1661,18 @@
|
|||||||
"loadError": "Dieses Modul konnte nicht geladen werden. Bitte versuchen Sie es später erneut.",
|
"loadError": "Dieses Modul konnte nicht geladen werden. Bitte versuchen Sie es später erneut.",
|
||||||
"invalidUrl": "Die Adresse dieses Moduls ist keine gültige https-Adresse und wird deshalb nicht angezeigt.",
|
"invalidUrl": "Die Adresse dieses Moduls ist keine gültige https-Adresse und wird deshalb nicht angezeigt.",
|
||||||
"loading": "Wird geladen …",
|
"loading": "Wird geladen …",
|
||||||
|
"preload": {
|
||||||
|
"toggle": "Beim Start vorladen",
|
||||||
|
"limitReached": "Es können höchstens {max} eigene Module beim Start vorgeladen werden. Schalten Sie das Vorladen zuerst bei einem anderen Modul aus.",
|
||||||
|
"saveError": "Die Einstellung konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.",
|
||||||
|
"settingsTitle": "Beim Start vorladen",
|
||||||
|
"settingsDescription": "Ausgewählte Module lädt Tessera kurz nach dem Start unsichtbar im Hintergrund, damit sie beim ersten Öffnen sofort da sind. Es können höchstens {max} Module ausgewählt werden. Die Auswahl gilt nur für Sie, im Browser und in der Desktop-App.",
|
||||||
|
"count": "{count} von {max} ausgewählt",
|
||||||
|
"shared": "Für alle",
|
||||||
|
"personal": "Persönlich",
|
||||||
|
"empty": "Es gibt noch keine eigenen Module.",
|
||||||
|
"loadError": "Die Module konnten nicht geladen werden. Bitte laden Sie die Seite neu."
|
||||||
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"create": "Eigenes Modul anlegen",
|
"create": "Eigenes Modul anlegen",
|
||||||
"edit": "Eigenes Modul bearbeiten",
|
"edit": "Eigenes Modul bearbeiten",
|
||||||
|
|||||||
@@ -1661,6 +1661,18 @@
|
|||||||
"loadError": "This module could not be loaded. Please try again later.",
|
"loadError": "This module could not be loaded. Please try again later.",
|
||||||
"invalidUrl": "This module's address is not a valid https address and is therefore not shown.",
|
"invalidUrl": "This module's address is not a valid https address and is therefore not shown.",
|
||||||
"loading": "Loading …",
|
"loading": "Loading …",
|
||||||
|
"preload": {
|
||||||
|
"toggle": "Preload at startup",
|
||||||
|
"limitReached": "At most {max} custom modules can be preloaded at startup. Turn off preloading for another module first.",
|
||||||
|
"saveError": "The setting could not be saved. Please try again.",
|
||||||
|
"settingsTitle": "Preload at startup",
|
||||||
|
"settingsDescription": "Tessera loads the selected modules invisibly in the background shortly after startup so they are ready the first time you open them. You can select at most {max} modules. The selection applies only to you, in the browser and in the desktop app.",
|
||||||
|
"count": "{count} of {max} selected",
|
||||||
|
"shared": "For everyone",
|
||||||
|
"personal": "Personal",
|
||||||
|
"empty": "There are no custom modules yet.",
|
||||||
|
"loadError": "The modules could not be loaded. Please reload the page."
|
||||||
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"create": "Add custom module",
|
"create": "Add custom module",
|
||||||
"edit": "Edit custom module",
|
"edit": "Edit custom module",
|
||||||
|
|||||||
Reference in New Issue
Block a user