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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user