diff --git a/apps/web/src/app/(portal)/settings/custom-modules/custom-modules-settings.test.tsx b/apps/web/src/app/(portal)/settings/custom-modules/custom-modules-settings.test.tsx index 93980c2..c21ad61 100644 --- a/apps/web/src/app/(portal)/settings/custom-modules/custom-modules-settings.test.tsx +++ b/apps/web/src/app/(portal)/settings/custom-modules/custom-modules-settings.test.tsx @@ -99,6 +99,13 @@ vi.mock('@/lib/stores/marketplace-store', () => ({ 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'; const mine = { diff --git a/apps/web/src/app/(portal)/settings/custom-modules/page.tsx b/apps/web/src/app/(portal)/settings/custom-modules/page.tsx index 6ed6e7d..30c513f 100644 --- a/apps/web/src/app/(portal)/settings/custom-modules/page.tsx +++ b/apps/web/src/app/(portal)/settings/custom-modules/page.tsx @@ -2,6 +2,7 @@ import { useTranslations } from 'next-intl'; 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. @@ -13,15 +14,21 @@ export default function CustomModulesSettingsPage() { const t = useTranslations('settings.customModules'); return ( - + <> + + {/* quick-261008-who: Vorladen je Modul, auch fuer gemeinsame Eintraege. */} +
+ +
+ ); } diff --git a/apps/web/src/components/custom-modules/custom-module-preload-settings.test.tsx b/apps/web/src/components/custom-modules/custom-module-preload-settings.test.tsx new file mode 100644 index 0000000..003cccf --- /dev/null +++ b/apps/web/src/components/custom-modules/custom-module-preload-settings.test.tsx @@ -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) => { + const map: Record = { + 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( + '@/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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + expect(await screen.findByText('Keine Module')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/custom-modules/custom-module-preload-settings.tsx b/apps/web/src/components/custom-modules/custom-module-preload-settings.tsx new file mode 100644 index 0000000..d9b1dd0 --- /dev/null +++ b/apps/web/src/components/custom-modules/custom-module-preload-settings.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [loadFailed, setLoadFailed] = useState(false); + const [busyId, setBusyId] = useState(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 ( + + {loading ? ( +

{tCommon('loading')}

+ ) : loadFailed ? ( +

+ {t('loadError')} +

+ ) : modules.length === 0 ? ( +

{t('empty')}

+ ) : ( +
+

+ {t('count', { count, max: CUSTOM_MODULE_PRELOAD_MAX })} +

+ {full && ( +

+ {t('limitReached', { max: CUSTOM_MODULE_PRELOAD_MAX })} +

+ )} +
    + {modules.map((mod) => { + const checked = mod.preload === true; + const inputId = `preload-${mod.id}`; + return ( +
  • + toggle(mod)} + /> + + + {mod.personal ? t('personal') : t('shared')} + +
  • + ); + })} +
+ {notice && ( +

+ {notice.text} +

+ )} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/modules/custom-module-view.test.tsx b/apps/web/src/components/modules/custom-module-view.test.tsx index a07165e..df6b4e8 100644 --- a/apps/web/src/components/modules/custom-module-view.test.tsx +++ b/apps/web/src/components/modules/custom-module-view.test.tsx @@ -11,17 +11,27 @@ vi.mock('next-intl', () => ({ loadError: 'Dieses Modul konnte nicht geladen werden.', invalidUrl: 'Ungültige Adresse', loading: 'Wird geladen', + 'preload.toggle': 'Beim Start vorladen', + 'preload.limitReached': 'Grenze erreicht', + 'preload.saveError': 'Speichern fehlgeschlagen', }; 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 () => { const actual = await vi.importActual( '@/lib/custom-modules-api', ); - return { ...actual, getCustomModule: mockGetCustomModule }; + return { + ...actual, + getCustomModule: mockGetCustomModule, + setCustomModulePreload: mockSetPreload, + }; }); import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store'; @@ -60,6 +70,7 @@ function mod(url: string, id = 'abc', name = 'Wiki') { beforeEach(() => { mockGetCustomModule.mockReset(); + mockSetPreload.mockReset(); useCustomModuleCacheStore.getState().clear(); }); @@ -216,18 +227,18 @@ describe('Eigene Module offen halten', () => { 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) => mod(`https://example.com/${id}`, id, `Modul ${id}`), ); const { rerender } = render(); 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(); 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.getByTitle('Modul m2')).toBeInTheDocument(); }); @@ -242,3 +253,69 @@ describe('Eigene Module offen halten', () => { 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'); + }); +}); diff --git a/apps/web/src/components/modules/custom-module-view.tsx b/apps/web/src/components/modules/custom-module-view.tsx index d5f6904..bacd5a5 100644 --- a/apps/web/src/components/modules/custom-module-view.tsx +++ b/apps/web/src/components/modules/custom-module-view.tsx @@ -4,7 +4,13 @@ import { useTranslations } from 'next-intl'; import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; 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'; type LoadStatus = 'loading' | 'missing' | 'failed' | 'ready'; @@ -32,6 +38,13 @@ type LoadStatus = 'loading' | 'missing' | 'failed' | 'ready'; * Eintrag samt