fix(custom-modules): null beim Aendern ablehnen, Ladefehler statt 404, Fehlertexte, Seitenleiste eingeklappt

- PATCH mit null fuer name/url/category ergibt 400 statt 500
- Modulansicht unterscheidet Ladefehler von "nicht gefunden"
- Formular/Loeschdialog nennen 403 und 400 eigens
- eingeklappte Seitenleiste folgt der Gruppenreihenfolge der ausgeklappten
- neue Eintraege sind mit "Eigene Module" vorbelegt
- Verwaltung zeigt bei Ladefehler nicht zusaetzlich "keine Eintraege"

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 03:20:16 +02:00
parent f7213f5e45
commit be1e0035e0
11 changed files with 401 additions and 104 deletions
@@ -1,10 +1,11 @@
'use client';
import { useState } from 'react';
import { CUSTOM_MODULE_CATEGORIES, CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
import { useTranslations } from 'next-intl';
import { CUSTOM_MODULE_CATEGORIES } from '@tessera/shared';
import { useState } from 'react';
import {
type CustomModule,
CustomModuleRequestError,
checkCustomModuleUrl,
createCustomModule,
updateCustomModule,
@@ -27,6 +28,23 @@ interface CustomModuleFormModalProps {
const NAME_MAX = 100;
const URL_MAX = 2048;
/**
* Fehlertext fuer Speichern und Loeschen: 403 = keine Berechtigung, 400 = die
* API hat die Angaben abgelehnt (mit ihrer Meldung), alles andere (500,
* Netzwerk) = der allgemeine „bitte erneut versuchen“-Text `fallbackKey`.
*/
export function customModuleErrorText(
err: unknown,
t: (key: string, values?: Record<string, string>) => string,
fallbackKey: 'saveError' | 'deleteError',
): string {
if (err instanceof CustomModuleRequestError) {
if (err.status === 403) return t('forbidden');
if (err.status === 400) return t('invalidInput');
}
return t(fallbackKey);
}
/**
* Formular „Eigenes Modul“ (quick-260929-9wc, Vorbild `GroupFormModal`;
* quick-260929-dzu: ein Formular, zwei Aufrufer — Verwaltung legt gemeinsame,
@@ -47,7 +65,9 @@ export function CustomModuleFormModal({
const [name, setName] = useState(mod?.name ?? '');
const [url, setUrl] = useState(mod?.url ?? '');
const [category, setCategory] = useState<string>(mod?.category ?? 'infrastructure');
// Neue Eintraege landen vorbelegt in der Gruppe „Eigene Module“; beim
// Bearbeiten bleibt die gespeicherte Kategorie.
const [category, setCategory] = useState<string>(mod?.category ?? CUSTOM_MODULE_CATEGORY);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -81,8 +101,8 @@ export function CustomModuleFormModal({
}
onSaved();
onClose();
} catch {
setError(t('saveError'));
} catch (err) {
setError(customModuleErrorText(err, t, 'saveError'));
} finally {
setSaving(false);
}
@@ -1,8 +1,8 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { useCategoryLabel } from '@/lib/use-category-label';
@@ -86,9 +86,7 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1
className={`${shared ? 'text-2xl' : 'text-lg'} font-semibold text-foreground`}
>
<h1 className={`${shared ? 'text-2xl' : 'text-lg'} font-semibold text-foreground`}>
{texts.title}
</h1>
<button type="button" onClick={openCreate} className="btn btn-primary">
@@ -104,9 +102,11 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
</div>
)}
{/* Bei einem Ladefehler ohne Eintraege steht nur die Fehlermeldung da —
„keine Eintraege“ waere dann falsch. */}
{loading ? (
<p className="text-muted-foreground">{tCommon('loading')}</p>
) : modules.length === 0 ? (
) : modules.length === 0 && loadFailed ? null : modules.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<h2 className="mb-2 text-lg font-semibold text-foreground">{texts.noModules}</h2>
<p className="mb-6 text-sm text-muted-foreground">{texts.noModulesBody}</p>
@@ -119,9 +119,15 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('name')}</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('url')}</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('category')}</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('name')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('url')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('category')}
</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
{tManage('actions')}
</th>
@@ -1,8 +1,9 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { type CustomModule, deleteCustomModule } from '@/lib/custom-modules-api';
import { customModuleErrorText } from './custom-module-form-modal';
interface DeleteCustomModuleDialogProps {
mod: CustomModule;
@@ -37,8 +38,8 @@ export function DeleteCustomModuleDialog({
await deleteCustomModule(mod.id);
onDeleted();
onClose();
} catch {
setError(t('deleteError'));
} catch (err) {
setError(customModuleErrorText(err, t, 'deleteError'));
} finally {
setDeleting(false);
}
@@ -55,7 +56,11 @@ export function DeleteCustomModuleDialog({
<h2 id="delete-custom-module-title" className="mb-4 text-lg font-semibold text-foreground">
{t('deleteConfirm.title')}
</h2>
<p className="mb-4 text-sm text-foreground">{t(shared ? 'deleteConfirm.bodyShared' : 'deleteConfirm.bodyPersonal', { name: mod.name })}</p>
<p className="mb-4 text-sm text-foreground">
{t(shared ? 'deleteConfirm.bodyShared' : 'deleteConfirm.bodyPersonal', {
name: mod.name,
})}
</p>
{error && (
<div
+54 -10
View File
@@ -8,8 +8,18 @@ vi.mock('next/navigation', () => ({
}));
vi.mock('next/link', () => ({
default: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => (
<a href={href} className={className}>{children}</a>
default: ({
children,
href,
className,
}: {
children: React.ReactNode;
href: string;
className?: string;
}) => (
<a href={href} className={className}>
{children}
</a>
),
}));
@@ -100,9 +110,7 @@ const mockActiveModules = [
function stubFetch() {
vi.stubGlobal(
'fetch',
vi.fn(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) }),
),
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) })),
);
}
@@ -316,7 +324,9 @@ describe('Sidebar', () => {
expect(screen.getByText('Mein Wiki')).toBeInTheDocument();
});
const groups = Array.from(
screen.getByRole('group', { name: 'Kategorien' }).querySelectorAll(':scope > ul > li > button span.truncate'),
screen
.getByRole('group', { name: 'Kategorien' })
.querySelectorAll(':scope > ul > li > button span.truncate'),
).map((n) => n.textContent);
expect(groups.at(-1)).toBe('custom-modules');
expect(groups).toContain('fleet');
@@ -340,12 +350,40 @@ describe('Sidebar', () => {
render(<Sidebar />);
const hrefs = () =>
Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) => a.getAttribute('href'));
Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) =>
a.getAttribute('href'),
);
await waitFor(() => {
expect(hrefs()).toContain('/modules/custom/cm-1');
});
expect(hrefs().at(-1)).toBe('/modules/custom/cm-1');
expect(hrefs().indexOf('/modules/custom/cm-2')).toBeLessThan(hrefs().indexOf('/modules/custom/cm-1'));
expect(hrefs().indexOf('/modules/custom/cm-2')).toBeLessThan(
hrefs().indexOf('/modules/custom/cm-1'),
);
});
it('eingeklappt dieselbe Gruppenfolge wie ausgeklappt (eigenes Modul bei seiner Kategorie)', async () => {
mockCustomModules = [
customModule({ id: 'cm-1', category: 'Domain-Tools', name: 'Mein Tool' }),
customModule({ id: 'cm-2', category: 'custom-modules', name: 'Mein Wiki' }),
];
mockSidebarCollapsed(true);
const Sidebar = await importSidebar();
render(<Sidebar />);
const hrefs = () =>
Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) =>
a.getAttribute('href'),
);
await waitFor(() => {
expect(hrefs()).toContain('/modules/custom/cm-2');
});
expect(hrefs()).toEqual([
'/modules/Domain-Tools/domaincheck',
'/modules/custom/cm-1',
'/modules/Utilities/converter',
'/modules/custom/cm-2',
]);
});
it('eingebaute Module stehen innerhalb einer Kategorie vor eigenen', async () => {
@@ -360,7 +398,10 @@ describe('Sidebar', () => {
.getByText('Domain-Tools')
.closest('li')
?.querySelectorAll('a span.truncate');
expect(Array.from(items ?? []).map((n) => n.textContent)).toEqual(['Domaincheck', 'Aaa Eigenes']);
expect(Array.from(items ?? []).map((n) => n.textContent)).toEqual([
'Domaincheck',
'Aaa Eigenes',
]);
});
it('auf /modules/custom/<id> traegt genau dieser Eintrag die Auswahlmarke', async () => {
@@ -379,7 +420,10 @@ describe('Sidebar', () => {
screen.getByText('Anderes').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
).toBeNull();
expect(
screen.getByText('Domaincheck').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
screen
.getByText('Domaincheck')
.closest('a')
?.querySelector('[data-testid="nav-selection-pill"]'),
).toBeNull();
});
+120 -45
View File
@@ -1,20 +1,20 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { TesseraLogo } from '@/components/brand/tessera-logo';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { SidebarSearch } from '@/components/layout/sidebar-search';
import { AppVersionBadge } from '@/components/layout/app-version-badge';
import { ModuleTile } from '@/components/modules/module-tile';
import { useCategoryLabel } from '@/lib/use-category-label';
import { useNavStore } from '@/lib/stores/nav-store';
import { DashboardGreeting } from '@/components/dashboard/dashboard-greeting';
import { AppVersionBadge } from '@/components/layout/app-version-badge';
import { SidebarSearch } from '@/components/layout/sidebar-search';
import { ModuleTile } from '@/components/modules/module-tile';
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { useNavStore } from '@/lib/stores/nav-store';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { useCategoryLabel } from '@/lib/use-category-label';
/** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */
function SelectionPill() {
@@ -109,13 +109,17 @@ export function Sidebar() {
const linkClass = (href: string) => itemClass(isActive(href));
const isEntryActive = (entry: SidebarEntry) =>
isActive(entry.href) || (entry.extraActiveHref !== undefined && isActive(entry.extraActiveHref));
isActive(entry.href) ||
(entry.extraActiveHref !== undefined && isActive(entry.extraActiveHref));
// Moduleintraege unter einer Kategorie eine Stufe kleiner als die
// Hauptpunkte (quick-260928-wtt): 32 statt 36 px hoch, 13 statt 14 px Schrift;
// die 20-px-Modulkachel passt weiter hinein.
const moduleItemClass = (active: boolean) =>
itemClass(active).replace('h-9', 'h-8').replace('gap-3', 'gap-2.5').replace('text-sm', 'text-[13px]');
itemClass(active)
.replace('h-9', 'h-8')
.replace('gap-3', 'gap-2.5')
.replace('text-sm', 'text-[13px]');
// Eingeklappt (Nutzerwunsch 29.09.): Symbole etwas groesser (Kachel 24 statt
// 20 px, Navigationssymbole 22 statt 20 px), Eintraege dichter — 34 px hoch
@@ -186,28 +190,29 @@ export function Sidebar() {
});
};
// Eingeklappt in derselben Gruppenfolge wie ausgeklappt — „Eigene Module“
// also auch hier immer zuletzt.
// Eintraege nach Kategorie gruppiert: „Eigene Module“ steht immer zuletzt,
// die uebrigen in Fundreihenfolge. Grundlage fuer BEIDE Ansichten.
const orderedCategories = useMemo(() => {
const categories = new Map<string, SidebarEntry[]>();
for (const entry of entries) {
const list = categories.get(entry.category);
if (list) list.push(entry);
else categories.set(entry.category, [entry]);
}
return Array.from(categories.entries()).sort(
([a], [b]) => Number(a === CUSTOM_MODULE_CATEGORY) - Number(b === CUSTOM_MODULE_CATEGORY),
);
}, [entries]);
// Eingeklappt in derselben Gruppenfolge wie ausgeklappt — die Eintraege
// stehen gruppenweise hintereinander, „Eigene Module“ also auch hier zuletzt.
const collapsedEntries = useMemo(
() =>
[...entries].sort(
(a, b) =>
Number(a.category === CUSTOM_MODULE_CATEGORY) - Number(b.category === CUSTOM_MODULE_CATEGORY),
),
[entries],
() => orderedCategories.flatMap(([, categoryEntries]) => categoryEntries),
[orderedCategories],
);
const filteredCategories = useMemo(() => {
const categories = new Map<string, SidebarEntry[]>();
for (const entry of entries) {
if (!categories.has(entry.category)) categories.set(entry.category, []);
categories.get(entry.category)!.push(entry);
}
// „Eigene Module“ steht immer zuletzt, die uebrigen in Fundreihenfolge.
const ordered = Array.from(categories.entries()).sort(
([a], [b]) => Number(a === CUSTOM_MODULE_CATEGORY) - Number(b === CUSTOM_MODULE_CATEGORY),
);
const ordered = orderedCategories;
if (!searchQuery) return ordered;
const q = searchQuery.toLowerCase();
@@ -221,10 +226,22 @@ export function Sidebar() {
}
}
return result;
}, [entries, searchQuery, categoryLabel]);
}, [orderedCategories, searchQuery, categoryLabel]);
const navIcon = (children: React.ReactNode) => (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width={isCollapsed ? 22 : 20} height={isCollapsed ? 22 : 20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width={isCollapsed ? 22 : 20}
height={isCollapsed ? 22 : 20}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0"
>
{children}
</svg>
);
@@ -234,7 +251,12 @@ export function Sidebar() {
<nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}>
<ul className={`flex flex-col ${isCollapsed ? 'gap-px' : 'gap-0.5'}`}>
<li>
<Link href="/" className={isCollapsed ? collapsedItemClass(isActive('/')) : linkClass('/')} aria-label={t('dashboard')} title={isCollapsed ? t('dashboard') : undefined}>
<Link
href="/"
className={isCollapsed ? collapsedItemClass(isActive('/')) : linkClass('/')}
aria-label={t('dashboard')}
title={isCollapsed ? t('dashboard') : undefined}
>
{isActive('/') && <SelectionPill />}
{navIcon(
<>
@@ -248,7 +270,16 @@ export function Sidebar() {
</Link>
</li>
<li>
<Link href="/marketplace" className={isCollapsed ? collapsedItemClass(isActive('/marketplace')) : linkClass('/marketplace')} aria-label={t('marketplace')} title={isCollapsed ? t('marketplace') : undefined}>
<Link
href="/marketplace"
className={
isCollapsed
? collapsedItemClass(isActive('/marketplace'))
: linkClass('/marketplace')
}
aria-label={t('marketplace')}
title={isCollapsed ? t('marketplace') : undefined}
>
{isActive('/marketplace') && <SelectionPill />}
{navIcon(
<>
@@ -268,9 +299,18 @@ export function Sidebar() {
const active = isEntryActive(entry);
return (
<li key={entry.key}>
<Link href={entry.href} className={collapsedItemClass(active)} aria-label={entry.name} title={entry.name}>
<Link
href={entry.href}
className={collapsedItemClass(active)}
aria-label={entry.name}
title={entry.name}
>
{active && <SelectionPill />}
<ModuleTile slug={entry.tileSlug} size={24} tone={active ? 'accent' : 'neutral'} />
<ModuleTile
slug={entry.tileSlug}
size={24}
tone={active ? 'accent' : 'neutral'}
/>
</Link>
</li>
);
@@ -290,9 +330,7 @@ export function Sidebar() {
{t('categories.empty')}
</div>
) : filteredCategories.length === 0 ? (
<div className="px-2.5 py-2 text-sm text-muted-foreground">
{t('noResults')}
</div>
<div className="px-2.5 py-2 text-sm text-muted-foreground">{t('noResults')}</div>
) : (
<ul className="flex flex-col gap-2">
{filteredCategories.map(([category, categoryEntries]) => {
@@ -307,7 +345,19 @@ export function Sidebar() {
className="group flex h-8 w-full items-center gap-1.5 rounded-md px-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
<span className="truncate">{categoryLabel(category)}</span>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" className={`ml-auto shrink-0 opacity-70 transition-transform duration-150 ${open ? '' : '-rotate-90'}`}>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.25"
strokeLinecap="round"
strokeLinejoin="round"
className={`ml-auto shrink-0 opacity-70 transition-transform duration-150 ${open ? '' : '-rotate-90'}`}
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
@@ -319,7 +369,11 @@ export function Sidebar() {
<li key={entry.key}>
<Link href={entry.href} className={moduleItemClass(active)}>
{active && <SelectionPill />}
<ModuleTile slug={entry.tileSlug} size={20} tone={active ? 'accent' : 'neutral'} />
<ModuleTile
slug={entry.tileSlug}
size={20}
tone={active ? 'accent' : 'neutral'}
/>
<span className="truncate">{entry.name}</span>
</Link>
</li>
@@ -353,7 +407,19 @@ export function Sidebar() {
aria-label={isCollapsed ? t('expand') : t('collapse')}
title={isCollapsed ? t('expand') : undefined}
>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" />
<path d="m16 15-3-3 3-3" />
@@ -377,9 +443,7 @@ export function Sidebar() {
<aside
className="fixed left-0 top-[var(--header-height)] z-30 hidden h-[calc(100vh-var(--header-height))] border-r border-sidebar-border transition-[width] duration-200 md:block"
style={{
width: isCollapsed
? 'var(--sidebar-width-collapsed)'
: 'var(--sidebar-width)',
width: isCollapsed ? 'var(--sidebar-width-collapsed)' : 'var(--sidebar-width)',
}}
>
{sidebarContent}
@@ -408,7 +472,18 @@ export function Sidebar() {
aria-label={tCommon('close')}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-appbar-muted transition-colors hover:bg-appbar-hover hover:text-appbar-foreground"
>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20" 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="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
@@ -1,12 +1,14 @@
import { 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';
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => {
const map: Record<string, string> = {
openInNewTab: 'In neuem Tab öffnen',
notFound: 'Dieses Modul gibt es nicht mehr.',
loadError: 'Dieses Modul konnte nicht geladen werden.',
invalidUrl: 'Ungültige Adresse',
loading: 'Wird geladen',
};
@@ -106,4 +108,19 @@ describe('CustomModuleView', () => {
});
expect(container.querySelector('iframe')).toBeNull();
});
it.each([
['500', new CustomModuleRequestError(500, 'Internal server error')],
['403', new CustomModuleRequestError(403, 'Forbidden')],
['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" />);
expect(await screen.findByRole('alert')).toHaveTextContent(
'Dieses Modul konnte nicht geladen werden.',
);
expect(screen.queryByText('Dieses Modul gibt es nicht mehr.')).toBeNull();
expect(container.querySelector('iframe')).toBeNull();
});
});
@@ -1,13 +1,17 @@
'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslations } from 'next-intl';
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot';
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';
type LoadState = { status: 'loading' } | { status: 'missing' } | { status: 'ready'; mod: CustomModule };
type LoadState =
| { status: 'loading' }
| { status: 'missing' }
| { status: 'failed' }
| { status: 'ready'; mod: CustomModule };
/**
* Rahmen-Ansicht eines eigenen Moduls (quick-260929-9wc, D-06): der Rahmen
@@ -19,6 +23,8 @@ type LoadState = { status: 'loading' } | { status: 'missing' } | { status: 'read
* Referrer). Iframe und Link entstehen NUR bei einer https-Adresse ohne
* Zugangsdaten — auch eine von Hand veraenderte Datenbankzeile mit
* `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.
*/
export function CustomModuleView({ id }: { id: string }) {
const t = useTranslations('customModules');
@@ -37,7 +43,7 @@ export function CustomModuleView({ id }: { id: string }) {
setState(mod ? { status: 'ready', mod } : { status: 'missing' });
})
.catch(() => {
if (!cancelled) setState({ status: 'missing' });
if (!cancelled) setState({ status: 'failed' });
});
return () => {
cancelled = true;
@@ -50,6 +56,13 @@ export function CustomModuleView({ id }: { id: string }) {
if (state.status === 'missing') {
return <div className="p-4 text-sm text-muted-foreground">{t('notFound')}</div>;
}
if (state.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';