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:
@@ -2,8 +2,8 @@ import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for creating a new widget instance on a user's dashboard.
|
||||
* widgetType must be one of the eight supported types
|
||||
* ('picture-frame' seit quick-260921-pi9).
|
||||
* widgetType must be one of the nine supported types
|
||||
* ('picture-frame' seit quick-260921-pi9, 'xframe' seit quick-260921-qd3).
|
||||
* config is optional and defaults to {} on the model.
|
||||
*/
|
||||
export class CreateWidgetDto {
|
||||
@@ -17,6 +17,7 @@ export class CreateWidgetDto {
|
||||
'favorites',
|
||||
'stopwatch',
|
||||
'picture-frame',
|
||||
'xframe',
|
||||
])
|
||||
widgetType!: string;
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ vi.mock('@/components/dashboard/widgets/calculator-widget', () => ({ CalculatorW
|
||||
vi.mock('@/components/dashboard/widgets/stopwatch-widget', () => ({ StopwatchWidget: () => null }));
|
||||
vi.mock('@/components/dashboard/widgets/favorites-widget', () => ({ FavoritesWidget: () => null }));
|
||||
vi.mock('@/components/dashboard/widgets/picture-frame-widget', () => ({ PictureFrameWidget: () => null }));
|
||||
vi.mock('@/components/dashboard/widgets/xframe-widget', () => ({ XframeWidget: () => null }));
|
||||
|
||||
beforeEach(() => {
|
||||
mockStore.isEditMode = false;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl';
|
||||
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
|
||||
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
|
||||
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
|
||||
import { wireClockWidget, wireSearchWidget, wireCalendarWidget, wireNoteWidget, wireCalculatorWidget, wireStopwatchWidget, wireFavoritesWidget, wirePictureFrameWidget } from '@/components/dashboard/widget-registry';
|
||||
import { wireClockWidget, wireSearchWidget, wireCalendarWidget, wireNoteWidget, wireCalculatorWidget, wireStopwatchWidget, wireFavoritesWidget, wirePictureFrameWidget, wireXframeWidget } from '@/components/dashboard/widget-registry';
|
||||
import { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
|
||||
import { SearchWidget } from '@/components/dashboard/widgets/search-widget';
|
||||
import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget';
|
||||
@@ -14,6 +14,7 @@ import { CalculatorWidget } from '@/components/dashboard/widgets/calculator-widg
|
||||
import { StopwatchWidget } from '@/components/dashboard/widgets/stopwatch-widget';
|
||||
import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget';
|
||||
import { PictureFrameWidget } from '@/components/dashboard/widgets/picture-frame-widget';
|
||||
import { XframeWidget } from '@/components/dashboard/widgets/xframe-widget';
|
||||
import { useDashboardStore } from '@/lib/stores/dashboard-store';
|
||||
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
||||
|
||||
@@ -26,6 +27,7 @@ wireCalculatorWidget(CalculatorWidget);
|
||||
wireStopwatchWidget(StopwatchWidget);
|
||||
wireFavoritesWidget(FavoritesWidget);
|
||||
wirePictureFrameWidget(PictureFrameWidget);
|
||||
wireXframeWidget(XframeWidget);
|
||||
|
||||
export default function DashboardPage() {
|
||||
const t = useTranslations('widgets');
|
||||
|
||||
@@ -25,6 +25,8 @@ vi.mock('next-intl', () => ({
|
||||
'stopwatch.description': 'Zeit messen',
|
||||
'pictureFrame.name': 'Bilderrahmen',
|
||||
'pictureFrame.description': 'Bilder als Diashow',
|
||||
'xframe.name': 'XFrame',
|
||||
'xframe.description': 'Webseite einbetten',
|
||||
},
|
||||
common: {
|
||||
close: 'Schließen',
|
||||
|
||||
@@ -19,6 +19,7 @@ const WIDGET_TYPES: WidgetType[] = [
|
||||
'favorites',
|
||||
'stopwatch',
|
||||
'picture-frame',
|
||||
'xframe',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,6 +18,8 @@ const ALL_WIDGET_TYPES: WidgetType[] = [
|
||||
'stopwatch',
|
||||
// Bilderrahmen (quick-260921-pi9)
|
||||
'picture-frame',
|
||||
// XFrame (quick-260921-qd3)
|
||||
'xframe',
|
||||
];
|
||||
|
||||
describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
@@ -53,7 +55,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('stopwatch');
|
||||
});
|
||||
|
||||
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu, acht Typen)', () => {
|
||||
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu, neun Typen)', () => {
|
||||
// Raster 24 Spalten / 20 px (quick-260916-bwo). Die Minima sind seit
|
||||
// quick-260916-dyv inhaltsgetrieben (Rechnung im Plan), die Vorgaben
|
||||
// (defaultW/defaultH) bleiben das Doppelte der alten 12-Spalten-Werte.
|
||||
@@ -67,6 +69,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 },
|
||||
stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 },
|
||||
'picture-frame': { minW: 4, minH: 4, defaultW: 8, defaultH: 12 },
|
||||
xframe: { minW: 4, minH: 4, defaultW: 12, defaultH: 12 },
|
||||
});
|
||||
|
||||
let counted = 0;
|
||||
@@ -76,6 +79,6 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
counted++;
|
||||
}
|
||||
}
|
||||
expect(counted).toBe(32);
|
||||
expect(counted).toBe(36);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ComponentType } from 'react';
|
||||
* Einzel-Schnellzugriffs-Typ wurde in quick-260916-iex entfernt — Favoriten
|
||||
* decken den Fall ab).
|
||||
* picture-frame: Bilderrahmen (quick-260921-pi9).
|
||||
* xframe: Webseite als Rahmen (quick-260921-qd3).
|
||||
*/
|
||||
export type WidgetType =
|
||||
| 'clock'
|
||||
@@ -16,7 +17,8 @@ export type WidgetType =
|
||||
| 'calculator'
|
||||
| 'favorites'
|
||||
| 'stopwatch'
|
||||
| 'picture-frame';
|
||||
| 'picture-frame'
|
||||
| 'xframe';
|
||||
|
||||
/**
|
||||
* Props contract that every widget component must accept.
|
||||
@@ -60,6 +62,11 @@ export const WIDGET_CONSTRAINTS: Record<
|
||||
// Browser-Rundgang mit 216 px Hoehe zu flach. 4x4 ist die kleinste Kachel,
|
||||
// in der Bild und Unterschrift-Streifen noch erkennbar sind.
|
||||
'picture-frame': { minW: 4, minH: 4, defaultW: 8, defaultH: 12 },
|
||||
// quick-260921-qd3: eine Webseite braucht Breite UND Hoehe — 12x12 = halbe
|
||||
// Rasterbreite bei rund 330 px Hoehe (8 Zeilen waeren nur 216 px, zu flach
|
||||
// fuer eine Seite; vgl. Bilderrahmen 8x12). 4x4 kleinste Kachel, in der ein
|
||||
// Rahmen noch Sinn hat.
|
||||
xframe: { minW: 4, minH: 4, defaultW: 12, defaultH: 12 },
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -277,6 +284,29 @@ function PictureFrameIcon({ className }: { className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function XframeIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||
<line x1="3" y1="9" x2="21" y2="9" />
|
||||
<circle cx="6.5" cy="6.5" r="0.5" />
|
||||
<circle cx="9.5" cy="6.5" r="0.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of all widget types. Used by the widget catalog modal
|
||||
* and the grid renderer to look up components and constraints.
|
||||
@@ -346,6 +376,14 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
||||
...WIDGET_CONSTRAINTS['picture-frame'],
|
||||
component: PlaceholderWidget, // Replaced via wirePictureFrameWidget()
|
||||
},
|
||||
xframe: {
|
||||
type: 'xframe',
|
||||
nameKey: 'xframe.name',
|
||||
descriptionKey: 'xframe.description',
|
||||
icon: XframeIcon,
|
||||
...WIDGET_CONSTRAINTS.xframe,
|
||||
component: PlaceholderWidget, // Replaced via wireXframeWidget()
|
||||
},
|
||||
};
|
||||
|
||||
// Wire actual widget components lazily to avoid circular deps
|
||||
@@ -415,3 +453,11 @@ export function wirePictureFrameWidget(component: ComponentType<WidgetProps>) {
|
||||
pictureFrameWired = true;
|
||||
}
|
||||
}
|
||||
|
||||
let xframeWired = false;
|
||||
export function wireXframeWidget(component: ComponentType<WidgetProps>) {
|
||||
if (!xframeWired) {
|
||||
WIDGET_REGISTRY.xframe.component = component;
|
||||
xframeWired = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
XFRAME_RELOAD_MAX,
|
||||
XFRAME_RELOAD_MIN,
|
||||
XFRAME_RELOAD_OPTIONS,
|
||||
XFRAME_SANDBOX,
|
||||
XFRAME_TITLE_MAX,
|
||||
isHttpsUrl,
|
||||
resolveXframeConfig,
|
||||
} from './xframe-config';
|
||||
|
||||
/**
|
||||
* xframe-config.test — NEU (quick-260921-qd3, XFrame).
|
||||
*
|
||||
* Reiner Resolver ohne React: die API prueft Config-Felder nicht — allein
|
||||
* diese Funktion entscheidet, was zum `<iframe src>` wird (T-QD3-03). Dazu
|
||||
* die Sandbox-Konstante (T-QD3-01: keine Top-Navigation, keine Modals) und
|
||||
* die Klemmung des Neulade-Intervalls (T-QD3-08).
|
||||
*/
|
||||
describe('resolveXframeConfig (quick-260921-qd3)', () => {
|
||||
it('Test 1: leere Konfiguration -> keine Adresse, leerer Titel, nie neu laden', () => {
|
||||
expect(resolveXframeConfig({})).toEqual({ url: null, title: '', reloadSeconds: 0 });
|
||||
});
|
||||
|
||||
it('Test 2: https-Adresse bleibt (getrimmt)', () => {
|
||||
expect(resolveXframeConfig({ url: 'https://intern.example/board' }).url).toBe('https://intern.example/board');
|
||||
expect(resolveXframeConfig({ url: ' https://a.de/x ' }).url).toBe('https://a.de/x');
|
||||
});
|
||||
|
||||
it('Test 3: http, javascript, data, ftp, Unparsbares, Zahl und leer -> null', () => {
|
||||
for (const url of ['http://a.de', 'javascript:alert(1)', 'data:text/html,x', 'ftp://a.de', 'kein-url', 42, '']) {
|
||||
expect(resolveXframeConfig({ url }).url, `url=${String(url)}`).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('Test 4: Titel getrimmt, auf 100 Zeichen gekuerzt, Nicht-String -> leer', () => {
|
||||
expect(resolveXframeConfig({ title: ' Board ' }).title).toBe('Board');
|
||||
expect(resolveXframeConfig({ title: 'x'.repeat(150) }).title).toHaveLength(XFRAME_TITLE_MAX);
|
||||
expect(XFRAME_TITLE_MAX).toBe(100);
|
||||
expect(resolveXframeConfig({ title: 7 }).title).toBe('');
|
||||
});
|
||||
|
||||
it('Test 5: reloadSeconds fehlt / "abc" / -5 / 30 -> 0 (unter der kleinsten Stufe heisst nie)', () => {
|
||||
expect(resolveXframeConfig({}).reloadSeconds).toBe(0);
|
||||
expect(resolveXframeConfig({ reloadSeconds: 'abc' }).reloadSeconds).toBe(0);
|
||||
expect(resolveXframeConfig({ reloadSeconds: -5 }).reloadSeconds).toBe(0);
|
||||
expect(resolveXframeConfig({ reloadSeconds: 30 }).reloadSeconds).toBe(0);
|
||||
expect(resolveXframeConfig({ reloadSeconds: Number.NaN }).reloadSeconds).toBe(0);
|
||||
});
|
||||
|
||||
it('Test 6: reloadSeconds 60 -> 60, 90 -> 60, 599 -> 300, 600 -> 600 (groesste Stufe <= n)', () => {
|
||||
expect(resolveXframeConfig({ reloadSeconds: 60 }).reloadSeconds).toBe(60);
|
||||
expect(resolveXframeConfig({ reloadSeconds: 90 }).reloadSeconds).toBe(60);
|
||||
expect(resolveXframeConfig({ reloadSeconds: 599 }).reloadSeconds).toBe(300);
|
||||
expect(resolveXframeConfig({ reloadSeconds: 600 }).reloadSeconds).toBe(600);
|
||||
});
|
||||
|
||||
it('Test 7: reloadSeconds 99999 -> 3600 (Obergrenze)', () => {
|
||||
expect(resolveXframeConfig({ reloadSeconds: 99999 }).reloadSeconds).toBe(XFRAME_RELOAD_MAX);
|
||||
expect(XFRAME_RELOAD_MAX).toBe(3600);
|
||||
expect(XFRAME_RELOAD_MIN).toBe(60);
|
||||
});
|
||||
|
||||
it('Test 8: alle drei Felder zusammen', () => {
|
||||
expect(
|
||||
resolveXframeConfig({ url: 'https://a.de/', title: ' Board ', reloadSeconds: 1800 }),
|
||||
).toEqual({ url: 'https://a.de/', title: 'Board', reloadSeconds: 1800 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('XFRAME_SANDBOX / XFRAME_RELOAD_OPTIONS / isHttpsUrl (quick-260921-qd3)', () => {
|
||||
it('Test 9: Sandbox enthaelt scripts, same-origin, forms, popups, popups-to-escape-sandbox', () => {
|
||||
const tokens = XFRAME_SANDBOX.split(' ');
|
||||
for (const token of [
|
||||
'allow-scripts',
|
||||
'allow-same-origin',
|
||||
'allow-forms',
|
||||
'allow-popups',
|
||||
'allow-popups-to-escape-sandbox',
|
||||
]) {
|
||||
expect(tokens).toContain(token);
|
||||
}
|
||||
});
|
||||
|
||||
it('Test 10: Sandbox enthaelt KEIN top-navigation und KEIN modals (T-QD3-01)', () => {
|
||||
expect(XFRAME_SANDBOX).not.toMatch(/top-navigation/);
|
||||
expect(XFRAME_SANDBOX).not.toMatch(/modals/);
|
||||
});
|
||||
|
||||
it('Test 11: Auswahlstufen des Neuladens sind 0, 60, 300, 600, 1800, 3600', () => {
|
||||
expect(XFRAME_RELOAD_OPTIONS).toEqual([0, 60, 300, 600, 1800, 3600]);
|
||||
});
|
||||
|
||||
it('Test 12: isHttpsUrl ist re-exportiert — HTTPS://A.DE true, http://a.de false', () => {
|
||||
expect(isHttpsUrl('HTTPS://A.DE')).toBe(true);
|
||||
expect(isHttpsUrl('http://a.de')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* xframe-config — Typen, Grenzen und reiner Resolver des XFrame-Widgets
|
||||
* (quick-260921-qd3). Ohne React-Import, damit der Test schlank bleibt
|
||||
* (Muster picture-frame-config.ts).
|
||||
*
|
||||
* T-QD3-03: Die API prueft Widget-Konfigurationen nicht inhaltlich
|
||||
* (`@IsObject()`, flache Zusammenfuehrung). Deshalb liegt die https-Pruefung
|
||||
* ALLEIN hier und im Formular: das Formular weist eine Nicht-https-Adresse
|
||||
* mit Meldung ab und speichert sie nicht; `resolveXframeConfig` laesst beim
|
||||
* Rendern jede Adresse auf `null` fallen, die der echte `URL`-Parser nicht
|
||||
* als `https:` erkennt (`http:`, `javascript:`, `data:`, `ftp:`, Unparsbares)
|
||||
* — ein von Hand manipulierter Config-Wert wird auf dem eigenen Dashboard
|
||||
* nicht einmal als `<iframe>` gerendert.
|
||||
*
|
||||
* T-QD3-01, Sandbox: `allow-same-origin` bleibt bewusst drin — ohne dieses
|
||||
* Token laeuft die Fremdseite in einem opaken Origin, ihre Cookies, ihr
|
||||
* `localStorage` und ihre Same-Origin-Aufrufe brechen, die meisten Seiten
|
||||
* sind dann unbenutzbar. Der Origin ist der der FREMDSEITE, nicht Tesseras;
|
||||
* die Sandbox hat hier allein die Aufgabe, die Navigation des obersten
|
||||
* Fensters und Modaldialoge zu unterbinden. Bewusst FEHLEN deshalb
|
||||
* `allow-top-navigation`, `allow-top-navigation-by-user-activation` (die
|
||||
* eingebettete Seite kann den Tessera-Tab nicht umlenken) und `allow-modals`
|
||||
* (keine `alert`/`confirm`/`prompt`, die als Tessera-Dialoge missdeutet
|
||||
* werden koennten). `allow-popups-to-escape-sandbox` betrifft nur NEUE
|
||||
* Fenster (z. B. Anmelde-Popups der Fremdseite), nie den Tessera-Tab.
|
||||
*/
|
||||
|
||||
import { isHttpsUrl } from './picture-frame-config';
|
||||
|
||||
/** Auswahlstufen des Formulars in Sekunden; 0 = nie. */
|
||||
export const XFRAME_RELOAD_OPTIONS: number[] = [0, 60, 300, 600, 1800, 3600];
|
||||
/** Unter der kleinsten Stufe heisst „nie“ — schuetzt die Fremdseite vor Sekundentakt (T-QD3-08). */
|
||||
export const XFRAME_RELOAD_MIN = 60;
|
||||
export const XFRAME_RELOAD_MAX = 3600;
|
||||
export const XFRAME_TITLE_MAX = 100;
|
||||
/** Exakter Sandbox-Wert des <iframe> — Begruendung im Dateikopf. */
|
||||
export const XFRAME_SANDBOX =
|
||||
'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox';
|
||||
|
||||
// Bewusst geteilt mit dem Bilderrahmen: EINE Regel fuer „https-Adresse“ im
|
||||
// ganzen Dashboard (echter URL-Parser, Protokoll kleingeschrieben verglichen).
|
||||
export { isHttpsUrl };
|
||||
|
||||
export interface XframeConfig {
|
||||
/** null = keine gueltige https-Adresse -> Leerzustand. */
|
||||
url: string | null;
|
||||
title: string;
|
||||
/** 0 = nie, sonst eine Stufe aus XFRAME_RELOAD_OPTIONS. */
|
||||
reloadSeconds: number;
|
||||
}
|
||||
|
||||
function resolveUrl(raw: unknown): string | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const trimmed = raw.trim();
|
||||
return isHttpsUrl(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
function resolveTitle(raw: unknown): string {
|
||||
if (typeof raw !== 'string') return '';
|
||||
return raw.trim().slice(0, XFRAME_TITLE_MAX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Groesste Auswahlstufe <= n, damit das <select> im Formular immer eine
|
||||
* passende Option zeigt; unter der kleinsten Stufe -> 0 (nie).
|
||||
*/
|
||||
function resolveReload(raw: unknown): number {
|
||||
if (typeof raw !== 'number' || !Number.isFinite(raw)) return 0;
|
||||
if (raw < XFRAME_RELOAD_MIN) return 0;
|
||||
if (raw >= XFRAME_RELOAD_MAX) return XFRAME_RELOAD_MAX;
|
||||
let best = 0;
|
||||
for (const option of XFRAME_RELOAD_OPTIONS) {
|
||||
if (option <= raw && option > best) best = option;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function resolveXframeConfig(config: Record<string, unknown>): XframeConfig {
|
||||
return {
|
||||
url: resolveUrl(config.url),
|
||||
title: resolveTitle(config.title),
|
||||
reloadSeconds: resolveReload(config.reloadSeconds),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { act, cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// next-intl — Durchreiche t(key) => key (Muster picture-frame-widget.test.tsx)
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
import { XFRAME_SANDBOX } from './xframe-config';
|
||||
import { XframeWidget } from './xframe-widget';
|
||||
|
||||
/**
|
||||
* xframe-widget.test — NEU (quick-260921-qd3).
|
||||
*
|
||||
* Kachel: Leerzustand (auch bei http), genau ein <iframe> mit exakter
|
||||
* Sandbox (T-QD3-01), no-referrer (T-QD3-05), lazy, leerem allow
|
||||
* (T-QD3-06); Link „In neuem Tab öffnen“ als Ecksymbol oder in der
|
||||
* Kopfleiste; Titel wird escaped (T-QD3-09); Neuladen per Timer haengt den
|
||||
* Rahmen neu ein und raeumt beim Aushaengen (T-QD3-08); Bearbeitungs-Overlay
|
||||
* nach dem Rahmen im DOM (T-QD3-07).
|
||||
*
|
||||
* jsdom laedt keine Unterressourcen — ein <iframe src="https://…"> erzeugt
|
||||
* hier keinen Netzabruf.
|
||||
*/
|
||||
const URL_A = 'https://intern.example/board';
|
||||
|
||||
function renderWidget(config: Record<string, unknown>, isEditMode = false) {
|
||||
return render(<XframeWidget instanceId="xf-1" config={config} isEditMode={isEditMode} />);
|
||||
}
|
||||
|
||||
function frame(): HTMLIFrameElement {
|
||||
return screen.getByTestId('xframe-frame') as HTMLIFrameElement;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('XframeWidget (quick-260921-qd3)', () => {
|
||||
it('Test 1: leere Konfiguration -> Leerhinweis, kein <iframe>, kein Link', () => {
|
||||
renderWidget({});
|
||||
expect(screen.getByText('xframe.empty')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('xframe-frame')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 2: http-Adresse -> ebenfalls Leerzustand (Resolver weist ab)', () => {
|
||||
renderWidget({ url: 'http://a.de' });
|
||||
expect(screen.getByText('xframe.empty')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('xframe-frame')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 3: https-Adresse -> genau ein <iframe> mit src, title = Adresse, exakter Sandbox, no-referrer, lazy, allow leer, Nonce 0', () => {
|
||||
const { container } = renderWidget({ url: URL_A });
|
||||
expect(container.querySelectorAll('iframe')).toHaveLength(1);
|
||||
const f = frame();
|
||||
expect(f.getAttribute('src')).toBe(URL_A);
|
||||
expect(f.getAttribute('title')).toBe(URL_A);
|
||||
expect(f.getAttribute('sandbox')).toBe(XFRAME_SANDBOX);
|
||||
expect(f.getAttribute('sandbox')).not.toMatch(/top-navigation/);
|
||||
expect(f.getAttribute('referrerpolicy')).toBe('no-referrer');
|
||||
expect(f.getAttribute('loading')).toBe('lazy');
|
||||
expect(f.getAttribute('allow')).toBe('');
|
||||
expect(f.getAttribute('data-reload-nonce')).toBe('0');
|
||||
});
|
||||
|
||||
it('Test 4: ohne Titel -> kein <h2>, Link als Ecksymbol (absolute) mit href, _blank, noopener noreferrer', () => {
|
||||
const { container } = renderWidget({ url: URL_A });
|
||||
expect(container.querySelector('h2')).toBeNull();
|
||||
const link = screen.getByRole('link', { name: 'xframe.openInNewTab' });
|
||||
expect(link.getAttribute('href')).toBe(URL_A);
|
||||
expect(link.getAttribute('target')).toBe('_blank');
|
||||
expect(link.getAttribute('rel')).toContain('noopener');
|
||||
expect(link.getAttribute('rel')).toContain('noreferrer');
|
||||
expect(link.className).toContain('absolute');
|
||||
});
|
||||
|
||||
it('Test 5: mit Titel -> <h2> Board, <iframe title="Board">, Link in der Kopfleiste (Vorfahre border-b), nicht absolute', () => {
|
||||
const { container } = renderWidget({ url: URL_A, title: 'Board' });
|
||||
expect(container.querySelector('h2')).toHaveTextContent('Board');
|
||||
expect(frame().getAttribute('title')).toBe('Board');
|
||||
const link = screen.getByRole('link', { name: 'xframe.openInNewTab' });
|
||||
expect(link.closest('.border-b')).not.toBeNull();
|
||||
expect(link.className).not.toContain('absolute');
|
||||
});
|
||||
|
||||
it('Test 6: Titel <b>x</b> erscheint woertlich, kein <b>-Element (React-Escaping, T-QD3-09)', () => {
|
||||
const { container } = renderWidget({ url: URL_A, title: '<b>x</b>' });
|
||||
expect(screen.getByText('<b>x</b>')).toBeInTheDocument();
|
||||
expect(container.querySelector('b')).toBeNull();
|
||||
});
|
||||
|
||||
it('Test 7: reloadSeconds 60 -> ein Timer; nach 60 s neues <iframe>-Objekt mit Nonce 1, nach 120 s Nonce 2; unmount raeumt', () => {
|
||||
const { unmount } = renderWidget({ url: URL_A, reloadSeconds: 60 });
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
const before = frame();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60_000);
|
||||
});
|
||||
const after = frame();
|
||||
expect(after).not.toBe(before);
|
||||
expect(after.getAttribute('data-reload-nonce')).toBe('1');
|
||||
expect(after.getAttribute('src')).toBe(URL_A);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60_000);
|
||||
});
|
||||
expect(frame().getAttribute('data-reload-nonce')).toBe('2');
|
||||
unmount();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('Test 8: reloadSeconds 0 -> kein Timer, Nonce bleibt 0', () => {
|
||||
renderWidget({ url: URL_A, reloadSeconds: 0 });
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3_600_000);
|
||||
});
|
||||
expect(frame().getAttribute('data-reload-nonce')).toBe('0');
|
||||
});
|
||||
|
||||
it('Test 9: Bearbeitungsmodus + reloadSeconds 60 -> kein Timer (kein Neuladen beim Bearbeiten)', () => {
|
||||
renderWidget({ url: URL_A, reloadSeconds: 60 }, true);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('Test 10: Bearbeitungsmodus -> Overlay aria-hidden, absolute inset-0, im DOM NACH dem <iframe>', () => {
|
||||
renderWidget({ url: URL_A }, true);
|
||||
const overlay = screen.getByTestId('xframe-edit-overlay');
|
||||
expect(overlay.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(overlay.className).toContain('absolute');
|
||||
expect(overlay.className).toContain('inset-0');
|
||||
// FOLLOWING = 4: das Overlay folgt dem Rahmen in Dokumentreihenfolge
|
||||
expect(frame().compareDocumentPosition(overlay) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Test 11: Ansichtsmodus -> kein Overlay', () => {
|
||||
renderWidget({ url: URL_A }, false);
|
||||
expect(screen.queryByTestId('xframe-edit-overlay')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 12: Ecksymbol sitzt im Bearbeitungsmodus tiefer (top-6) als im Ansichtsmodus (top-1)', () => {
|
||||
renderWidget({ url: URL_A }, false);
|
||||
expect(screen.getByRole('link', { name: 'xframe.openInNewTab' }).className).toContain('top-1');
|
||||
cleanup();
|
||||
renderWidget({ url: URL_A }, true);
|
||||
expect(screen.getByRole('link', { name: 'xframe.openInNewTab' }).className).toContain('top-6');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import type { WidgetProps } from '@/components/dashboard/widget-registry';
|
||||
import { XFRAME_SANDBOX, resolveXframeConfig } from './xframe-config';
|
||||
|
||||
/**
|
||||
* XframeWidget — eine Webseite per https-Adresse als eingebetteter Rahmen
|
||||
* in der Kachel (quick-260921-qd3).
|
||||
*
|
||||
* Sandbox: `XFRAME_SANDBOX` (Begruendung in xframe-config.ts, T-QD3-01) —
|
||||
* keine Freigabe der Navigation des obersten Fensters, keine Modaldialoge;
|
||||
* `allow=""` delegiert keine Berechtigungen (Kamera, Mikrofon, Standort,
|
||||
* T-QD3-06); `referrerPolicy="no-referrer"`, damit der Fremdhost die
|
||||
* Tessera-Adresse nicht erfaehrt (T-QD3-05).
|
||||
*
|
||||
* Woher die Seite kommt: AUSSCHLIESSLICH der Browser des Benutzers laedt
|
||||
* die Adresse — der Server ruft sie nie ab (T-QD3-04: keine SSRF-Flaeche,
|
||||
* kein Proxy-Endpunkt; interne Hosts sieht damit nur, wer sie ohnehin
|
||||
* erreicht). Was gerendert wird, entscheidet `resolveXframeConfig`
|
||||
* (T-QD3-03): nur eine https-Adresse, sonst der Leerzustand.
|
||||
*
|
||||
* Bearbeitungsmodus (T-QD3-07): ein <iframe> schluckt Mausereignisse und
|
||||
* bricht das Ziehen. Deshalb liegt bei `isEditMode` eine transparente
|
||||
* Flaeche NACH dem Rahmen im DOM ueber ihm, damit `mousedown` zur Karte
|
||||
* `.widget-drag-handle` aufsteigt (dashboard-grid.tsx: Griff = ganze Karte,
|
||||
* eine div startet das Ziehen). Im Ansichtsmodus gibt es die Flaeche nicht,
|
||||
* die Seite bleibt bedienbar.
|
||||
*
|
||||
* Neuladen (T-QD3-08): bei `reloadSeconds > 0` bumpt ein Intervall einen
|
||||
* Zaehler, der Teil des `key` des <iframe> ist — der Rahmen wird neu
|
||||
* eingehaengt. Aufraeumfunktion raeumt den Timer; im Bearbeitungsmodus
|
||||
* laeuft kein Timer.
|
||||
*
|
||||
* Verweigertes Einbetten (`X-Frame-Options`/`frame-ancestors` der
|
||||
* Fremdseite) ist cross-origin nicht zuverlaessig erkennbar — wird nicht
|
||||
* versucht. Stattdessen bietet die Kachel immer „In neuem Tab öffnen“
|
||||
* (echter Link, per Tastatur erreichbar; `a` steht im cancel-Selektor des
|
||||
* Rasters und startet im Bearbeitungsmodus kein Ziehen).
|
||||
*/
|
||||
export function XframeWidget({ config, isEditMode }: WidgetProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const { url, title, reloadSeconds } = useMemo(() => resolveXframeConfig(config), [config]);
|
||||
const [reloadNonce, setReloadNonce] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (url === null || reloadSeconds === 0 || isEditMode) return;
|
||||
const timer = setInterval(() => setReloadNonce((n) => n + 1), reloadSeconds * 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [url, reloadSeconds, isEditMode]);
|
||||
|
||||
if (url === null) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center px-2 text-center text-sm text-muted-foreground">
|
||||
{t('xframe.empty')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const openLabel = t('xframe.openInNewTab');
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
{title !== '' && (
|
||||
// Bekannt und akzeptiert (wie die Titelzeile der Favoriten): im
|
||||
// Bearbeitungsmodus ueberdeckt die 20-px-Griffleiste der Karte den
|
||||
// oberen Teil der Kopfleiste — Bearbeiten ist Anordnen, nicht Lesen.
|
||||
<div className="flex items-center gap-2 border-b border-border px-1.5 py-1.5">
|
||||
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{title}</h2>
|
||||
<NewTabLink
|
||||
url={url}
|
||||
label={openLabel}
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<iframe
|
||||
key={`${url}#${reloadNonce}`}
|
||||
src={url}
|
||||
title={title !== '' ? title : url}
|
||||
sandbox={XFRAME_SANDBOX}
|
||||
allow=""
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
data-testid="xframe-frame"
|
||||
data-reload-nonce={reloadNonce}
|
||||
className="h-full w-full border-0 bg-background"
|
||||
/>
|
||||
{isEditMode && (
|
||||
<div className="absolute inset-0" aria-hidden="true" data-testid="xframe-edit-overlay" />
|
||||
)}
|
||||
{title === '' && (
|
||||
<NewTabLink
|
||||
url={url}
|
||||
label={openLabel}
|
||||
className={`absolute right-1 z-10 rounded bg-card/80 p-1 text-muted-foreground shadow-sm hover:text-foreground ${
|
||||
isEditMode ? 'top-6' : 'top-1'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** „In neuem Tab öffnen“ — echter Link (Tastatur), ohne opener und ohne Referrer (T-QD3-05). */
|
||||
function NewTabLink({ url, label, className }: { url: string; label: string; className: string }) {
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={className}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<line x1="10" y1="14" x2="21" y2="3" />
|
||||
</svg>
|
||||
{/* Biome useAnchorContent zaehlt aria-label nicht als Inhalt — der
|
||||
sr-only-Text traegt denselben Namen fuer Vorleser. */}
|
||||
<span className="sr-only">{label}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -363,6 +363,23 @@
|
||||
"moveDownButton": "Nach unten",
|
||||
"limitReached": "Die Höchstzahl von 30 Bildern ist erreicht.",
|
||||
"uploadFailed": "Das Bild konnte nicht hochgeladen werden."
|
||||
},
|
||||
"xframe": {
|
||||
"name": "XFrame",
|
||||
"description": "Webseite einbetten",
|
||||
"empty": "Keine Adresse eingestellt — über die Einstellungen festlegen",
|
||||
"openInNewTab": "In neuem Tab öffnen",
|
||||
"urlLabel": "Adresse (https)",
|
||||
"urlPlaceholder": "https://…",
|
||||
"urlInvalid": "Bitte geben Sie eine vollständige https-Adresse ein.",
|
||||
"titleLabel": "Titel",
|
||||
"titlePlaceholder": "Titel (optional)",
|
||||
"reloadLabel": "Automatisch neu laden",
|
||||
"reloadOff": "Nie",
|
||||
"reloadMinute": "Jede Minute",
|
||||
"reloadMinutes": "Alle {n} Minuten",
|
||||
"reloadHour": "Jede Stunde",
|
||||
"embedHint": "Manche Webseiten lassen sich nicht einbetten — dann bleibt der Rahmen leer. Über „In neuem Tab öffnen“ erreichen Sie die Seite trotzdem."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -363,6 +363,23 @@
|
||||
"moveDownButton": "Move down",
|
||||
"limitReached": "The maximum of 30 images has been reached.",
|
||||
"uploadFailed": "The image could not be uploaded."
|
||||
},
|
||||
"xframe": {
|
||||
"name": "XFrame",
|
||||
"description": "Embed a web page",
|
||||
"empty": "No address set — configure it in the settings",
|
||||
"openInNewTab": "Open in a new tab",
|
||||
"urlLabel": "Address (https)",
|
||||
"urlPlaceholder": "https://…",
|
||||
"urlInvalid": "Please enter a complete https address.",
|
||||
"titleLabel": "Title",
|
||||
"titlePlaceholder": "Title (optional)",
|
||||
"reloadLabel": "Reload automatically",
|
||||
"reloadOff": "Never",
|
||||
"reloadMinute": "Every minute",
|
||||
"reloadMinutes": "Every {n} minutes",
|
||||
"reloadHour": "Every hour",
|
||||
"embedHint": "Some web pages refuse to be embedded — the frame then stays empty. “Open in a new tab” still takes you to the page."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -134,6 +134,8 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
||||
// Bilderrahmen-Widget (quick-260921-pi9): korrektes Deutsch mit „ss“
|
||||
'Webadresse',
|
||||
'Bildausschnitt',
|
||||
// XFrame-Widget (quick-260921-qd3): „In neuem Tab öffnen“, korrektes Deutsch wie neuen
|
||||
'neuem',
|
||||
'Absenderadresse',
|
||||
'muss',
|
||||
'musste',
|
||||
|
||||
Reference in New Issue
Block a user