feat: Link-Widget restlos entfernt (Web, API, Migration, Handbuch, Changelog)

- Web: Union-Mitglied, Constraints, Icon, Registry-Eintrag, wire-Funktion,
  Katalog, Seiten-Verdrahtung, Tests und i18n (de/en) fuer den Typ link
  entfernt; link-widget.tsx/.test.tsx geloescht
- API: create-widget.dto.ts (@IsIn-Liste) und widget-module-map.ts
  (Kommentare) auf sieben Typen angepasst; schema.prisma unveraendert
- Neue Migration 20260916120000_remove_link_widget: eine idempotente
  DELETE-Anweisung auf WidgetInstance, FavoriteLink kaskadiert ueber den
  bestehenden FK; wird in diesem Auftrag NICHT ausgefuehrt
- Neuer widget-wrapper-Test belegt den grauen Fallback fuer unbekannte
  Widget-Typen (Bestandsverhalten, jetzt festgeschrieben)
- Handbuch (Widget-Tabelle, Einstellungs-Hinweise) und CHANGELOG
  (Geaendert/Entfernt/Behoben) aktualisiert
- Web: 52 Dateien / 344 Tests gruen, tsc Exit 0; API: tsc Exit 0,
  dashboard-Spec 31 Tests gruen

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-16 13:44:09 +02:00
parent 7f1ee3b1f0
commit 39ea1474a5
16 changed files with 77 additions and 705 deletions
@@ -20,8 +20,8 @@ const COLS = { lg: 24, md: 20, sm: 12, xs: 8, xxs: 2 };
// quick-260916-dyv: Ziehen zuverlaessig.
// - Griff ist die GANZE Karte (`widget-drag-handle` in widget-wrapper.tsx).
// - `cancel` verhindert den Drag-Start an Eingabefeldern, Knoepfen, Links,
// [contenteditable], [data-no-drag] und der Klasse `widgetNoDrag` (Favoriten/
// Link-Widget, bisher nirgends verdrahtet). react-draggable 4.7.0 prueft
// [contenteditable], [data-no-drag] und der Klasse `widgetNoDrag`
// (Favoriten-Widget). react-draggable 4.7.0 prueft
// `cancel` NACH `handle` und vom Ereignisziel aufwaerts bis zum RGL-Element
// (Draggable.js:417 + matchesSelectorAndParentsTo) — cancel gewinnt also auch
// INNERHALB des Griffs. RGL haengt `.react-resizable-handle` selbst voran
@@ -17,7 +17,6 @@ const WIDGET_TYPES: WidgetType[] = [
'note',
'calculator',
'favorites',
'link',
'stopwatch',
];
@@ -15,7 +15,6 @@ const ALL_WIDGET_TYPES: WidgetType[] = [
// New types (Phase 8)
'calculator',
'favorites',
'link',
'stopwatch',
];
@@ -49,11 +48,10 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
it('WIDGET_CONSTRAINTS contains new Phase-8 keys', () => {
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('calculator');
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('favorites');
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('link');
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('stopwatch');
});
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert', () => {
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt, sieben 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.
@@ -65,7 +63,6 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 },
calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 },
favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 },
link: { minW: 3, minH: 2, defaultW: 4, defaultH: 4 },
stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 },
});
@@ -76,6 +73,6 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
counted++;
}
}
expect(counted).toBe(32);
expect(counted).toBe(28);
});
});
@@ -3,7 +3,9 @@ import type { ComponentType } from 'react';
/**
* Supported widget types for the dashboard.
* clock/search/calendar/note: implemented in previous plans.
* calculator/favorites/link/stopwatch: Phase 8 additions.
* calculator/favorites/stopwatch: Phase 8 additions (der fruehere
* Einzel-Schnellzugriffs-Typ wurde in quick-260916-iex entfernt — Favoriten
* decken den Fall ab).
*/
export type WidgetType =
| 'clock'
@@ -12,7 +14,6 @@ export type WidgetType =
| 'note'
| 'calculator'
| 'favorites'
| 'link'
| 'stopwatch';
/**
@@ -51,7 +52,6 @@ export const WIDGET_CONSTRAINTS: Record<
// Inhalt 268 px; bei 9 Zeilen (244 px) fehlte die unterste Reihe um 25 px.
calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 },
favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 },
link: { minW: 3, minH: 2, defaultW: 4, defaultH: 4 },
stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 },
};
@@ -219,26 +219,6 @@ function FavoritesIcon({ className }: { className?: string }) {
);
}
function LinkIcon({ className }: { className?: string }) {
return (
<svg
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}
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
);
}
function StopwatchIcon({ className }: { className?: string }) {
return (
<svg
@@ -314,14 +294,6 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
...WIDGET_CONSTRAINTS.favorites,
component: PlaceholderWidget, // Replaced via wireFavoritesWidget()
},
link: {
type: 'link',
nameKey: 'link.name',
descriptionKey: 'link.description',
icon: LinkIcon,
...WIDGET_CONSTRAINTS.link,
component: PlaceholderWidget, // Replaced via wireLinkWidget()
},
stopwatch: {
type: 'stopwatch',
nameKey: 'stopwatch.name',
@@ -384,14 +356,6 @@ export function wireFavoritesWidget(component: ComponentType<WidgetProps>) {
}
}
let linkWired = false;
export function wireLinkWidget(component: ComponentType<WidgetProps>) {
if (!linkWired) {
WIDGET_REGISTRY.link.component = component;
linkWired = true;
}
}
let stopwatchWired = false;
export function wireStopwatchWidget(component: ComponentType<WidgetProps>) {
if (!stopwatchWired) {
@@ -1,231 +0,0 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock next-intl — passthrough t(key) => key (must be before imports)
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
// Mock favorites-api — all 4 functions as controllable vi.fn() spies
vi.mock('@/lib/favorites-api', () => ({
fetchFavorites: vi.fn(),
createFavorite: vi.fn(),
updateFavorite: vi.fn(),
deleteFavorite: vi.fn(),
}));
// Mock dashboard-api for updateWidgetConfig (view mode persistence)
vi.mock('@/lib/dashboard-api', () => ({
updateWidgetConfig: vi.fn().mockResolvedValue(undefined),
}));
import {
fetchFavorites,
createFavorite,
updateFavorite,
} from '@/lib/favorites-api';
// Must import AFTER mocks
import { LinkWidget } from './link-widget';
// Typed mock helpers
const mockFetch = fetchFavorites as ReturnType<typeof vi.fn>;
const mockCreate = createFavorite as ReturnType<typeof vi.fn>;
const mockUpdate = updateFavorite as ReturnType<typeof vi.fn>;
const SINGLE_LINK = {
id: 'link-id-1',
widgetId: 'link-1',
title: 'GitHub',
url: 'https://github.com',
iconUrl: 'https://github.com/favicon.ico',
position: 0,
};
describe('LinkWidget', () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetch.mockResolvedValue([SINGLE_LINK]);
mockCreate.mockResolvedValue({
id: 'link-id-new',
widgetId: 'link-1',
title: 'New Site',
url: 'https://example.com',
iconUrl: null,
position: 0,
});
mockUpdate.mockResolvedValue({ ...SINGLE_LINK, title: 'GitHub Updated' });
});
afterEach(() => {
vi.restoreAllMocks();
});
it('calls fetchFavorites with instanceId on mount and renders the link as an anchor with target="_blank" rel="noreferrer"', async () => {
render(
<LinkWidget instanceId="link-1" config={{}} isEditMode={false} />,
);
// fetchFavorites must be called with the instanceId (widgetId scope — Pitfall 3)
expect(mockFetch).toHaveBeenCalledWith('link-1');
// After async resolution, link title should be visible
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
});
// Link must open in new tab with noreferrer (T-08-12)
const anchor = screen.getByText('GitHub').closest('a');
expect(anchor).toHaveAttribute('target', '_blank');
expect(anchor).toHaveAttribute('rel', 'noreferrer');
});
it('shows empty message in non-edit mode when no link exists', async () => {
mockFetch.mockResolvedValue([]);
render(
<LinkWidget instanceId="link-1" config={{}} isEditMode={false} />,
);
await waitFor(() => {
expect(screen.getByText('link.empty')).toBeInTheDocument();
});
});
it('shows add form in edit mode when no link exists; submitting calls createFavorite with widgetId, title, url', async () => {
mockFetch.mockResolvedValue([]);
render(
<LinkWidget instanceId="link-1" config={{}} isEditMode={true} />,
);
await waitFor(() => {
expect(screen.getByPlaceholderText('link.addTitle')).toBeInTheDocument();
});
const titleInput = screen.getByPlaceholderText('link.addTitle');
const urlInput = screen.getByPlaceholderText('link.addUrl');
act(() => {
fireEvent.change(titleInput, { target: { value: 'New Site' } });
fireEvent.change(urlInput, { target: { value: 'https://example.com' } });
});
const addButton = screen.getByRole('button', { name: /link\.addButton/i });
act(() => {
fireEvent.click(addButton);
});
await waitFor(() => {
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
widgetId: 'link-1',
title: 'New Site',
url: 'https://example.com',
}),
);
});
// New link title should appear after creation
await waitFor(() => {
expect(screen.getByText('New Site')).toBeInTheDocument();
});
});
it('single-link enforcement: add form is hidden when a link already exists', async () => {
render(
<LinkWidget instanceId="link-1" config={{}} isEditMode={true} />,
);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
});
// Add form must NOT be present — single-link constraint (D-06)
expect(screen.queryByPlaceholderText('link.addTitle')).not.toBeInTheDocument();
});
it('edit: clicking edit, changing title, saving calls updateFavorite with id and new title', async () => {
render(
<LinkWidget instanceId="link-1" config={{}} isEditMode={true} />,
);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
});
// Click the edit button (aria-label is the translation key)
const editButton = screen.getByRole('button', { name: /link\.editButton/i });
act(() => {
fireEvent.click(editButton);
});
// Inline edit form appears with current title pre-filled
await waitFor(() => {
expect(screen.getByDisplayValue('GitHub')).toBeInTheDocument();
});
const editTitleInput = screen.getByDisplayValue('GitHub');
act(() => {
fireEvent.change(editTitleInput, { target: { value: 'GitHub Updated' } });
});
await waitFor(() => {
expect(screen.getByRole('button', { name: /link\.saveButton/i })).toBeInTheDocument();
});
const saveButton = screen.getByRole('button', { name: /link\.saveButton/i });
act(() => {
fireEvent.click(saveButton);
});
// updateFavorite called with the link id and new title
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith(
'link-id-1',
expect.objectContaining({ title: 'GitHub Updated' }),
);
});
});
it('view toggle: list is default; switching to tile in edit mode renders tile container', async () => {
render(
<LinkWidget
instanceId="link-1"
config={{ viewMode: 'list' }}
isEditMode={true}
/>,
);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
});
// Default list view — list container should be present
expect(screen.getByTestId('link-list')).toBeInTheDocument();
// Click the tile (grid) toggle button
const tileButton = screen.getByRole('button', { name: /link\.gridView/i });
await act(async () => {
fireEvent.click(tileButton);
});
// Tile container should now be rendered
expect(screen.getByTestId('link-tile')).toBeInTheDocument();
});
it('letter fallback: link with iconUrl null renders first uppercase letter of title', async () => {
mockFetch.mockResolvedValue([{ ...SINGLE_LINK, iconUrl: null, title: 'Notion' }]);
render(
<LinkWidget instanceId="link-1" config={{}} isEditMode={false} />,
);
await waitFor(() => {
expect(screen.getByText('Notion')).toBeInTheDocument();
});
// Letter fallback span should show 'N' (first letter of 'Notion')
expect(screen.getByTestId('link-letter-fallback')).toHaveTextContent('N');
});
});
@@ -1,381 +0,0 @@
'use client';
import { FormEvent, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { updateWidgetConfig } from '@/lib/dashboard-api';
import {
createFavorite,
deleteFavorite,
fetchFavorites,
updateFavorite,
type FavoriteLink,
} from '@/lib/favorites-api';
import type { WidgetProps } from '@/components/dashboard/widget-registry';
/**
* LinkWidget — single-link quick-access tile (DASH-09 / D-06).
*
* Features:
* - Displays exactly one link, scoped to this widget instance via instanceId
* - Shares the FavoriteLink backend from Plan 03 (widgetId = instanceId)
* - Single-link enforcement: add form hidden when a link already exists
* - Add / edit / delete in edit mode (inline forms)
* - List view (row: icon + title, default) and tile view, switchable in edit mode
* - Server-side icon discovery (letter fallback when iconUrl is null)
* - Link opens in new tab with rel="noreferrer" (T-08-12 open-redirect mitigation)
* - No dangerouslySetInnerHTML (T-08-11 XSS mitigation)
*/
export function LinkWidget({
instanceId,
config,
isEditMode,
}: WidgetProps) {
const t = useTranslations('widgets');
const [link, setLink] = useState<FavoriteLink | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// View mode: read from config, default 'list' (D-06)
const [viewMode, setViewMode] = useState<'list' | 'grid'>(
(config.viewMode as 'list' | 'grid') ?? 'list',
);
// Add-form state
const [newTitle, setNewTitle] = useState('');
const [newUrl, setNewUrl] = useState('');
// Inline edit state
const [isEditing, setIsEditing] = useState(false);
const [editTitle, setEditTitle] = useState('');
const [editUrl, setEditUrl] = useState('');
const [editIconUrl, setEditIconUrl] = useState('');
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
setError(null);
try {
const data = await fetchFavorites(instanceId);
// Single-link: take only the first entry (D-06)
if (!cancelled) setLink(data[0] ?? null);
} catch {
// Stable error key — t excluded from deps to prevent re-fetch loops
if (!cancelled) setError(t('link.error'));
} finally {
if (!cancelled) setLoading(false);
}
}
void load();
return () => {
cancelled = true;
};
// instanceId is the only meaningful dependency — t is excluded intentionally
// (mock creates new fn ref on each render, causing re-fetch loops in tests)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [instanceId]);
function getFallbackLetter(title: string): string {
const clean = title.trim();
return clean ? clean.slice(0, 1).toUpperCase() : '?';
}
// --- View mode toggle (edit mode only) ---
function handleViewMode(next: 'list' | 'grid') {
setViewMode(next);
void updateWidgetConfig(instanceId, { viewMode: next });
}
// --- Add link ---
async function handleAdd(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const cleanTitle = newTitle.trim();
const cleanUrl = newUrl.trim();
if (!cleanTitle || !cleanUrl) return;
try {
const created = await createFavorite({
widgetId: instanceId,
title: cleanTitle,
url: cleanUrl,
});
setLink(created);
setNewTitle('');
setNewUrl('');
} catch {
setError(t('link.error'));
}
}
// --- Edit link ---
function startEdit() {
if (!link) return;
setIsEditing(true);
setEditTitle(link.title);
setEditUrl(link.url);
setEditIconUrl(link.iconUrl ?? '');
setError(null);
}
function cancelEdit() {
setIsEditing(false);
setEditTitle('');
setEditUrl('');
setEditIconUrl('');
}
async function handleSaveEdit() {
if (!link) return;
const cleanTitle = editTitle.trim();
const cleanUrl = editUrl.trim();
const cleanIconUrl = editIconUrl.trim();
if (!cleanTitle || !cleanUrl) return;
try {
const updated = await updateFavorite(link.id, {
title: cleanTitle,
url: cleanUrl,
iconUrl: cleanIconUrl || null,
});
setLink(updated);
cancelEdit();
} catch {
setError(t('link.error'));
}
}
// --- Delete link ---
async function handleDelete() {
if (!link) return;
try {
await deleteFavorite(link.id);
setLink(null);
cancelEdit();
} catch {
setError(t('link.error'));
}
}
// --- Shared icon element ---
function renderIcon(size: 'sm' | 'md') {
if (!link) return null;
const wh = size === 'sm' ? 'w-5 h-5' : 'w-8 h-8';
const textSize = size === 'sm' ? 'text-xs' : 'text-sm';
const imgSize = size === 'sm' ? 20 : 32;
return (
<div className={`relative flex-shrink-0 ${wh}`}>
<span
data-testid="link-letter-fallback"
className={`absolute inset-0 flex items-center justify-center ${textSize} font-semibold bg-muted rounded`}
>
{getFallbackLetter(link.title)}
</span>
{link.iconUrl && (
<img
src={link.iconUrl}
alt=""
width={imgSize}
height={imgSize}
loading="lazy"
className={`absolute inset-0 ${wh} rounded`}
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
)}
</div>
);
}
// --- Inline edit form ---
function renderEditForm() {
return (
<div className="flex flex-col gap-1 widgetNoDrag">
<input
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
placeholder={t('link.addTitle')}
/>
<input
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
value={editUrl}
onChange={(e) => setEditUrl(e.target.value)}
placeholder={t('link.addUrl')}
/>
<input
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
value={editIconUrl}
onChange={(e) => setEditIconUrl(e.target.value)}
placeholder="Logo-URL (optional)"
/>
<div className="flex gap-1">
<button
type="button"
className="flex-1 rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:opacity-90 transition-opacity"
onClick={() => void handleSaveEdit()}
>
{t('link.saveButton')}
</button>
<button
type="button"
className="flex-1 rounded bg-muted px-2 py-1 text-xs text-muted-foreground hover:bg-muted/80 transition-colors"
onClick={cancelEdit}
>
{t('link.cancelButton')}
</button>
</div>
</div>
);
}
// --- Render ---
return (
<div className="flex flex-col h-full overflow-auto p-1 gap-2">
{/* View mode toggle (edit mode only) */}
{isEditMode && (
<div className="flex gap-1 widgetNoDrag">
<button
type="button"
onClick={() => handleViewMode('list')}
className={`px-2 py-1 text-xs rounded ${
viewMode === 'list'
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
}`}
>
{t('link.listView')}
</button>
<button
type="button"
onClick={() => handleViewMode('grid')}
className={`px-2 py-1 text-xs rounded ${
viewMode === 'grid'
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
}`}
>
{t('link.gridView')}
</button>
</div>
)}
{/* Status messages */}
{loading && (
<p className="text-xs text-muted-foreground">{t('link.loading')}</p>
)}
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
{/* Empty state */}
{!loading && !link && (
<p className="text-xs text-muted-foreground">{t('link.empty')}</p>
)}
{/* Link display — list (row) view */}
{!loading && link && !isEditing && viewMode === 'list' && (
<div data-testid="link-list" className="flex items-center gap-2">
<a
href={link.url}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 flex-1 min-w-0 hover:underline widgetNoDrag"
onClick={(e) => isEditMode && e.preventDefault()}
>
{renderIcon('sm')}
<span className="text-xs truncate">{link.title}</span>
</a>
{isEditMode && (
<button
type="button"
aria-label={t('link.editButton')}
title={t('link.editButton')}
className="p-0.5 text-muted-foreground hover:text-foreground flex-shrink-0 widgetNoDrag"
onClick={startEdit}
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="currentColor"
aria-hidden="true"
>
<path d="M4 20h4.6L19.2 9.4a2.1 2.1 0 0 0 0-3l-1.6-1.6a2.1 2.1 0 0 0-3 0L4 15.4V20Zm2-2v-1.8L16.1 6.1l1.8 1.8L7.8 18H6Zm9.1-12.9 1.8-1.8 1.8 1.8-1.8 1.8-1.8-1.8Z" />
</svg>
</button>
)}
</div>
)}
{/* Link display — tile (grid) view */}
{!loading && link && !isEditing && viewMode === 'grid' && (
<div data-testid="link-tile" className="flex flex-col items-center gap-1 p-1">
<a
href={link.url}
target="_blank"
rel="noreferrer"
className="flex flex-col items-center gap-1 widgetNoDrag"
onClick={(e) => isEditMode && e.preventDefault()}
>
{renderIcon('md')}
<span className="text-xs text-center truncate max-w-full">{link.title}</span>
</a>
{isEditMode && (
<button
type="button"
aria-label={t('link.editButton')}
title={t('link.editButton')}
className="p-0.5 text-muted-foreground hover:text-foreground widgetNoDrag"
onClick={startEdit}
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="currentColor"
aria-hidden="true"
>
<path d="M4 20h4.6L19.2 9.4a2.1 2.1 0 0 0 0-3l-1.6-1.6a2.1 2.1 0 0 0-3 0L4 15.4V20Zm2-2v-1.8L16.1 6.1l1.8 1.8L7.8 18H6Zm9.1-12.9 1.8-1.8 1.8 1.8-1.8 1.8-1.8-1.8Z" />
</svg>
</button>
)}
</div>
)}
{/* Inline edit form */}
{isEditMode && isEditing && link && renderEditForm()}
{/* Add form — single-link enforcement: hidden when link exists (D-06) */}
{isEditMode && !link && !loading && (
<form
onSubmit={(e) => void handleAdd(e)}
className="flex flex-col gap-1 widgetNoDrag"
>
<input
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder={t('link.addTitle')}
/>
<input
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
placeholder={t('link.addUrl')}
/>
<button
type="submit"
className="w-full rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:opacity-90 transition-opacity"
>
{t('link.addButton')}
</button>
</form>
)}
</div>
);
}
@@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
// quick-260916-iex: Link-Widget entfernt — unbekannte Widget-Typen (z. B.
// eine alte Link-Kachel vor dem Einspielen der Migration) muessen weiterhin
// ohne Absturz als grauer Text gerendert werden.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
import { WidgetWrapper } from './widget-wrapper';
describe('WidgetWrapper', () => {
it('unbekannter Widget-Typ (z. B. eine alte Link-Kachel vor der Migration) rendert als grauer Text ohne Absturz', () => {
render(
<WidgetWrapper
widget={{ id: 'w-alt', widgetType: 'link', config: {} }}
isEditMode={false}
onRemove={vi.fn()}
/>,
);
const article = screen.getByRole('article');
expect(article).toHaveAttribute('aria-label', 'link');
const fallback = screen.getByText('link');
expect(fallback.className).toContain('text-muted-foreground');
});
});