feat(quick-260921-qd3): XFrame-Widget - Webseite als Rahmen im Dashboard, Sandbox ohne Top-Navigation, Neuladen-Intervall

- xframe-config.ts: Resolver (https-Pruefung via isHttpsUrl des Bilderrahmens,
  Titel bis 100 Zeichen, Neuladen 0/60/300/600/1800/3600 s geklemmt),
  XFRAME_SANDBOX ohne allow-top-navigation und allow-modals; 12 Tests zuerst rot
- xframe-widget.tsx: genau ein <iframe> (sandbox, allow="", no-referrer, lazy),
  Kopfleiste mit Titel oder Ecksymbol "In neuem Tab oeffnen", Neuladen ueber
  key-Wechsel mit Timer-Raeumung, transparente Flaeche im Bearbeitungsmodus
  damit die Kachel Ziehgriff bleibt; 12 Tests zuerst rot
- xframe-config-form.tsx: Adresse/Titel mit Uebernahme bei Blur/Enter, http wird
  mit Meldung abgewiesen und nicht gespeichert, Intervall-Auswahl, dauerhafter
  Hinweis auf verweigertes Einbetten; 8 Tests
- Panel-Zweig samt "— Titel" in der Kopfzeile, Registry (12x12, Fenster-Symbol),
  Katalog, Seite, DTO @IsIn, de/en widgets.xframe (15 Schluessel), Umlaut-Allowlist
  "neuem"; der Server ruft die Adresse nie ab

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-21 19:18:48 +02:00
parent 8bf3601a18
commit d63d9f5563
18 changed files with 904 additions and 7 deletions
@@ -234,3 +234,32 @@ describe('WidgetSettingsPanel — Favoriten-Titel (quick-260916-iex)', () => {
expect(screen.queryByText('Title')).not.toBeInTheDocument();
});
});
const xframe = (de as { widgets: { xframe: Record<string, string> } }).widgets.xframe;
describe('WidgetSettingsPanel — XFrame (quick-260921-qd3)', () => {
it('Test X1: der Zweig rendert das XFrame-Formular, Kopfzeile zeigt "— Titel", Blur der Adresse ruft updateWidgetConfig/onWidgetUpdate', async () => {
const onWidgetUpdate = vi.fn();
const widget = {
id: 'x1',
widgetType: 'xframe',
config: { url: 'https://intern.example/board', title: 'Board', reloadSeconds: 300 },
};
render(<WidgetSettingsPanel widgets={[widget]} onWidgetUpdate={onWidgetUpdate} />);
const header = screen.getByRole('button', { name: /XFrame #1/ });
expect(header).toHaveTextContent('— Board');
fireEvent.click(header);
const url = screen.getByLabelText(xframe.urlLabel) as HTMLInputElement;
expect(url.value).toBe('https://intern.example/board');
expect((screen.getByLabelText(xframe.titleLabel) as HTMLInputElement).value).toBe('Board');
expect((screen.getByLabelText(xframe.reloadLabel) as HTMLSelectElement).value).toBe('300');
expect(screen.getByText(xframe.embedHint)).toBeInTheDocument();
fireEvent.change(url, { target: { value: 'https://b.de/' } });
fireEvent.blur(url);
await vi.waitFor(() => expect(onWidgetUpdate).toHaveBeenCalledWith('x1', { url: 'https://b.de/' }));
expect(updateWidgetConfig).toHaveBeenCalledTimes(1);
expect(updateWidgetConfig).toHaveBeenCalledWith('x1', { url: 'https://b.de/' });
});
});
@@ -6,6 +6,7 @@ import Link from 'next/link';
import { WIDGET_REGISTRY, type WidgetType } from '@/components/dashboard/widget-registry';
import { PictureFrameConfigForm } from '@/components/settings/picture-frame-config-form';
import { SearchProviderForm } from '@/components/settings/search-provider-form';
import { XframeConfigForm } from '@/components/settings/xframe-config-form';
import { updateWidgetConfig } from '@/lib/dashboard-api';
import {
CLOCK_FONT_SIZE_MAX_PT,
@@ -119,7 +120,8 @@ export function WidgetSettingsPanel({
<span>
{t(def.nameKey)} #{idx + 1}
{(widget.widgetType === 'note' ||
widget.widgetType === 'favorites') &&
widget.widgetType === 'favorites' ||
widget.widgetType === 'xframe') &&
typeof widget.config.title === 'string' &&
widget.config.title.trim() !== '' && (
<span className="ml-2 text-muted-foreground">
@@ -207,6 +209,16 @@ export function WidgetSettingsPanel({
}
/>
)}
{/* XFrame (quick-260921-qd3) */}
{widget.widgetType === 'xframe' && (
<XframeConfigForm
config={widget.config}
onChange={(cfg) =>
handleConfigChange(widget.id, cfg)
}
/>
)}
</div>
)}
</div>
@@ -0,0 +1,141 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import de from '@/messages/de.json';
/**
* xframe-config-form.test — NEU (quick-260921-qd3). Texte aus der echten
* de.json ueber den echten ICU-Uebersetzer (Muster
* picture-frame-config-form.test.tsx): Vorbelegung, Adresse mit Uebernahme
* bei Blur/Enter, http -> Meldung ohne Aufruf (T-QD3-03), Leeren erlaubt,
* Titel, Intervall-Auswahl, dauerhafter Einbett-Hinweis.
*/
vi.mock('next-intl', async (importOriginal) => {
const actual = await importOriginal<typeof import('next-intl')>();
const messages = (await import('@/messages/de.json')).default;
return {
useTranslations: (ns?: string) => actual.createTranslator({ locale: 'de', messages, namespace: ns }),
};
});
import { XframeConfigForm } from './xframe-config-form';
const texts = (de as { widgets: { xframe: Record<string, string> } }).widgets.xframe;
const URL_A = 'https://intern.example/board';
function renderForm(config: Record<string, unknown>) {
const onChange = vi.fn();
render(<XframeConfigForm config={config} onChange={onChange} />);
const url = screen.getByLabelText(texts.urlLabel) as HTMLInputElement;
const title = screen.getByLabelText(texts.titleLabel) as HTMLInputElement;
const reload = screen.getByLabelText(texts.reloadLabel) as HTMLSelectElement;
return { onChange, url, title, reload };
}
afterEach(() => {
cleanup();
});
describe('XframeConfigForm (quick-260921-qd3)', () => {
it('Test 1: Felder aus der Konfiguration vorbelegt, Hinweis dauerhaft sichtbar, Intervall-Optionen beschriftet', () => {
const { url, title, reload } = renderForm({ url: URL_A, title: 'Board', reloadSeconds: 300 });
expect(url.value).toBe(URL_A);
expect(url.type).toBe('url');
expect(url.placeholder).toBe(texts.urlPlaceholder);
expect(title.value).toBe('Board');
expect(title.maxLength).toBe(100);
expect(reload.value).toBe('300');
expect(Array.from(reload.options).map((o) => o.value)).toEqual(['0', '60', '300', '600', '1800', '3600']);
expect(Array.from(reload.options).map((o) => o.textContent)).toEqual([
'Nie',
'Jede Minute',
'Alle 5 Minuten',
'Alle 10 Minuten',
'Alle 30 Minuten',
'Jede Stunde',
]);
expect(screen.getByText(texts.embedHint)).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('Test 2: leere Konfiguration -> Adresse leer, Titel leer, Intervall Nie', () => {
const { url, title, reload } = renderForm({});
expect(url.value).toBe('');
expect(title.value).toBe('');
expect(reload.value).toBe('0');
});
it('Test 3: https-Adresse aendern + Blur -> onChange({ url }) genau einmal; Enter uebernimmt ebenfalls', () => {
const { onChange, url } = renderForm({ url: URL_A });
fireEvent.change(url, { target: { value: 'https://b.de/' } });
expect(onChange).not.toHaveBeenCalled();
fireEvent.blur(url);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith({ url: 'https://b.de/' });
fireEvent.change(url, { target: { value: ' https://c.de/ ' } });
fireEvent.keyDown(url, { key: 'Enter' });
expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange).toHaveBeenLastCalledWith({ url: 'https://c.de/' });
});
it('Test 4: http-Adresse + Enter -> role=alert mit urlInvalid, aria-invalid, KEIN Aufruf; https danach raeumt die Meldung', () => {
const { onChange, url } = renderForm({ url: URL_A });
fireEvent.change(url, { target: { value: 'http://b.de' } });
fireEvent.keyDown(url, { key: 'Enter' });
expect(onChange).not.toHaveBeenCalled();
expect(screen.getByRole('alert')).toHaveTextContent(texts.urlInvalid);
expect(url.getAttribute('aria-invalid')).toBe('true');
fireEvent.change(url, { target: { value: 'javascript:alert(1)' } });
fireEvent.blur(url);
expect(onChange).not.toHaveBeenCalled();
fireEvent.change(url, { target: { value: 'https://b.de/' } });
fireEvent.blur(url);
expect(onChange).toHaveBeenCalledWith({ url: 'https://b.de/' });
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('Test 5: Feld leeren + Blur -> onChange({ url: "" }) (Adresse entfernen erlaubt); bei bereits leerer Adresse kein Aufruf', () => {
const { onChange, url } = renderForm({ url: URL_A });
fireEvent.change(url, { target: { value: '' } });
fireEvent.blur(url);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith({ url: '' });
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
cleanup();
const second = renderForm({});
fireEvent.blur(second.url);
expect(second.onChange).not.toHaveBeenCalled();
});
it('Test 6: gleiche Adresse erneut uebernehmen (Blur ohne Aenderung) -> kein Aufruf', () => {
const { onChange, url } = renderForm({ url: URL_A });
fireEvent.blur(url);
fireEvent.keyDown(url, { key: 'Enter' });
fireEvent.change(url, { target: { value: ` ${URL_A} ` } });
fireEvent.blur(url);
expect(onChange).not.toHaveBeenCalled();
});
it('Test 7: Titel aendern + Blur -> onChange({ title }) getrimmt; unveraendert -> kein Aufruf; Enter uebernimmt', () => {
const { onChange, title } = renderForm({ url: URL_A, title: 'Board' });
fireEvent.blur(title);
expect(onChange).not.toHaveBeenCalled();
fireEvent.change(title, { target: { value: ' Neu ' } });
fireEvent.blur(title);
expect(onChange).toHaveBeenCalledWith({ title: 'Neu' });
fireEvent.change(title, { target: { value: '' } });
fireEvent.keyDown(title, { key: 'Enter' });
expect(onChange).toHaveBeenLastCalledWith({ title: '' });
expect(onChange).toHaveBeenCalledTimes(2);
});
it('Test 8: Intervall-Auswahl 600 -> onChange({ reloadSeconds: 600 }) sofort', () => {
const { onChange, reload } = renderForm({ url: URL_A });
fireEvent.change(reload, { target: { value: '600' } });
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith({ reloadSeconds: 600 });
});
});
@@ -0,0 +1,148 @@
'use client';
import { type KeyboardEvent, useState } from 'react';
import { useTranslations } from 'next-intl';
import {
XFRAME_RELOAD_OPTIONS,
XFRAME_TITLE_MAX,
isHttpsUrl,
resolveXframeConfig,
} from '@/components/dashboard/widgets/xframe-config';
interface XframeConfigFormProps {
config: Record<string, unknown>;
onChange: (partial: Record<string, unknown>) => void;
}
const FIELD_CLASS =
'h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground';
const LABEL_CLASS = 'mb-1 block text-sm text-foreground';
/**
* Einstellungen des XFrame-Widgets im WidgetSettingsPanel
* (quick-260921-qd3, Muster ClockConfig/PictureFrameConfigForm).
*
* Adresse und Titel sind Entwuerfe mit Uebernahme bei Blur/Enter (Muster
* commitFontSize in ClockConfig); jedes Feld sendet nur sich selbst — die
* API fuehrt Widget-Konfigurationen flach zusammen.
*
* https-Pruefung (T-QD3-03): `isHttpsUrl` (echter URL-Parser) — dieselbe
* Funktion, die das Widget beim Rendern erneut anwendet. Eine
* Nicht-https-Adresse wird mit `role="alert"` abgewiesen und NICHT
* gespeichert; Leeren ist erlaubt (Adresse entfernen -> Leerzustand).
*
* Der Hinweis, dass manche Webseiten das Einbetten verweigern, steht
* dauerhaft: verweigertes Einbetten ist cross-origin nicht zuverlaessig
* erkennbar, die Kachel bietet dafuer immer „In neuem Tab öffnen“.
*/
export function XframeConfigForm({ config, onChange }: XframeConfigFormProps) {
const t = useTranslations('widgets');
const { url, title, reloadSeconds } = resolveXframeConfig(config);
const [urlDraft, setUrlDraft] = useState(url ?? '');
const [urlError, setUrlError] = useState(false);
const [titleDraft, setTitleDraft] = useState(title);
const commitUrl = () => {
const raw = urlDraft.trim();
if (raw === '') {
setUrlError(false);
if (url !== null) onChange({ url: '' });
return;
}
if (!isHttpsUrl(raw)) {
setUrlError(true);
return;
}
setUrlError(false);
if (raw !== url) onChange({ url: raw });
};
const commitTitle = () => {
const next = titleDraft.trim().slice(0, XFRAME_TITLE_MAX);
if (next !== title) onChange({ title: next });
};
const onEnter = (commit: () => void) => (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
commit();
}
};
const reloadLabel = (seconds: number): string => {
if (seconds === 0) return t('xframe.reloadOff');
if (seconds === 60) return t('xframe.reloadMinute');
if (seconds === 3600) return t('xframe.reloadHour');
return t('xframe.reloadMinutes', { n: seconds / 60 });
};
return (
<div className="space-y-4">
{/* Adresse (nur https) */}
<div>
<label htmlFor="xframe-url" className={LABEL_CLASS}>
{t('xframe.urlLabel')}
</label>
<input
id="xframe-url"
type="url"
inputMode="url"
placeholder={t('xframe.urlPlaceholder')}
value={urlDraft}
onChange={(e) => setUrlDraft(e.target.value)}
onBlur={commitUrl}
onKeyDown={onEnter(commitUrl)}
aria-invalid={urlError || undefined}
aria-describedby="xframe-url-hint"
className={FIELD_CLASS}
/>
<p id="xframe-url-hint" className="mt-1 text-xs text-muted-foreground">
{t('xframe.embedHint')}
</p>
{urlError && (
<p role="alert" className="mt-1 text-xs text-destructive">
{t('xframe.urlInvalid')}
</p>
)}
</div>
{/* Titel (optional, Kopfleiste der Kachel) */}
<div>
<label htmlFor="xframe-title" className={LABEL_CLASS}>
{t('xframe.titleLabel')}
</label>
<input
id="xframe-title"
type="text"
maxLength={XFRAME_TITLE_MAX}
placeholder={t('xframe.titlePlaceholder')}
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={onEnter(commitTitle)}
className={FIELD_CLASS}
/>
</div>
{/* Neuladen-Intervall */}
<div>
<label htmlFor="xframe-reload" className={LABEL_CLASS}>
{t('xframe.reloadLabel')}
</label>
<select
id="xframe-reload"
value={String(reloadSeconds)}
onChange={(e) => onChange({ reloadSeconds: Number(e.target.value) })}
className={FIELD_CLASS}
>
{XFRAME_RELOAD_OPTIONS.map((seconds) => (
<option key={seconds} value={String(seconds)}>
{reloadLabel(seconds)}
</option>
))}
</select>
</div>
</div>
);
}