From 39ea1474a57bcad5e76a3b4024a370732421ae45 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 16 Sep 2026 13:44:09 +0200 Subject: [PATCH] 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) --- CHANGELOG.md | 9 + .../migration.sql | 22 + .../src/dashboard/dto/create-widget.dto.ts | 4 +- apps/api/src/dashboard/widget-module-map.ts | 6 +- apps/web/src/app/(portal)/page.test.tsx | 1 - apps/web/src/app/(portal)/page.tsx | 4 +- .../components/dashboard/dashboard-grid.tsx | 4 +- .../dashboard/widget-catalog-modal.tsx | 1 - .../dashboard/widget-registry.test.tsx | 7 +- .../components/dashboard/widget-registry.tsx | 42 +- .../dashboard/widgets/link-widget.test.tsx | 231 ----------- .../dashboard/widgets/link-widget.tsx | 381 ------------------ .../dashboard/widgets/widget-wrapper.test.tsx | 29 ++ apps/web/src/messages/de.json | 16 - apps/web/src/messages/en.json | 16 - docs/anleitung-anwender.md | 9 +- 16 files changed, 77 insertions(+), 705 deletions(-) create mode 100644 apps/api/prisma/migrations/20260916120000_remove_link_widget/migration.sql delete mode 100644 apps/web/src/components/dashboard/widgets/link-widget.test.tsx delete mode 100644 apps/web/src/components/dashboard/widgets/link-widget.tsx create mode 100644 apps/web/src/components/dashboard/widgets/widget-wrapper.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 69c1e2c..ab6b653 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T - Kalender-Einstellungen: Das Feld „Adresse (URL)“ im Formular für Kalenderquellen zeigt jetzt je nach Typ ein passendes Beispiel (z. B. `https://mail.firma.de/EWS/Exchange.asmx` für Exchange EWS) und bei Exchange EWS einen Hinweis, dass die vollständige Adresse nötig ist – der Servername allein reicht nicht. - Kalender-Widget neu gestaltet: Monatsübersicht mit Terminanzahl je Tag (Termine beim Überfahren sichtbar) und darunter die nächsten Termine. In den Widget-Einstellungen lässt sich die Monatsansicht ein-/ausblenden sowie Anzahl und Zeitraum der angezeigten Termine wählen. +- Favoriten-Widget kann einen Titel bekommen; ohne Titel bleibt die Kopfzeile weg. + +### Entfernt + +- Widget „Link“ (ein einzelner Link) entfernt — Favoriten-Widget übernimmt das; vorhandene Link-Kacheln werden beim Update automatisch entfernt. + +### Behoben + +- Notiz-Widget: Listen zum Abhaken lassen sich jetzt in der Ansicht direkt abhaken. ## 1.1.0 – 2026-09-16 diff --git a/apps/api/prisma/migrations/20260916120000_remove_link_widget/migration.sql b/apps/api/prisma/migrations/20260916120000_remove_link_widget/migration.sql new file mode 100644 index 0000000..0ede4da --- /dev/null +++ b/apps/api/prisma/migrations/20260916120000_remove_link_widget/migration.sql @@ -0,0 +1,22 @@ +-- Widget "Link" entfernt (quick-260916-iex): das Favoriten-Widget deckt den +-- Anwendungsfall "Schnellzugriff auf Links" bereits vollstaendig ab, ein +-- separates Einzel-Link-Widget ist ueberfluessig. Web/API haben den Typ +-- `link` restlos entfernt (Registry, Katalog, DTO); diese Migration raeumt +-- bestehende Link-Kacheln in der Datenbank hinterher. +-- +-- FavoriteLink-Zeilen des jeweiligen Widgets kaskadieren automatisch ueber +-- den bestehenden Fremdschluessel "FavoriteLink_widgetId_fkey ... ON DELETE +-- CASCADE" (Migration 20260708090000_add_favorite_link) -- kein separates +-- DELETE auf "FavoriteLink" noetig. +-- +-- Laeuft als Migrationsrolle "tessera" (POSTGRES_USER, Superuser mit +-- BYPASSRLS, siehe 20260909130000_rls_app_role). Die FORCE ROW LEVEL +-- SECURITY-Regel auf "WidgetInstance" (Migration 20260909140000) greift +-- fuer diese Rolle deshalb nicht -- das DELETE sieht und loescht ueber +-- alle Mandanten hinweg. +-- +-- Idempotent: ein zweiter Lauf loescht 0 Zeilen. Keine Schemaaenderung. +-- Verwaiste Eintraege im Layout-JSON von "DashboardLayout" sind unschaedlich +-- (dashboard-grid.tsx rendert nur ueber widgets.map) und verschwinden beim +-- naechsten Speichern des Dashboards von selbst. +DELETE FROM "WidgetInstance" WHERE "widgetType" = 'link'; diff --git a/apps/api/src/dashboard/dto/create-widget.dto.ts b/apps/api/src/dashboard/dto/create-widget.dto.ts index caad5c6..f12884e 100644 --- a/apps/api/src/dashboard/dto/create-widget.dto.ts +++ b/apps/api/src/dashboard/dto/create-widget.dto.ts @@ -2,12 +2,12 @@ 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. + * widgetType must be one of the seven supported types. * config is optional and defaults to {} on the model. */ export class CreateWidgetDto { @IsString() - @IsIn(['clock', 'search', 'calendar', 'note', 'calculator', 'favorites', 'link', 'stopwatch']) + @IsIn(['clock', 'search', 'calendar', 'note', 'calculator', 'favorites', 'stopwatch']) widgetType!: string; @IsOptional() diff --git a/apps/api/src/dashboard/widget-module-map.ts b/apps/api/src/dashboard/widget-module-map.ts index d9af197..c930dc8 100644 --- a/apps/api/src/dashboard/widget-module-map.ts +++ b/apps/api/src/dashboard/widget-module-map.ts @@ -13,9 +13,9 @@ * für ein Feld, das derzeit für jede Zeile leer wäre, wiegt schwerer als * diese Konstante mit identischer Aussagekraft (15-RESEARCH.md Pitfall 5). * - * Die Tabelle ist am Ende dieser Phase bewusst leer: alle acht heute + * Die Tabelle ist am Ende dieser Phase bewusst leer: alle sieben heute * registrierten Widget-Typen (clock/search/calendar/note/calculator/ - * favorites/link/stopwatch, siehe apps/web/src/components/dashboard/ + * favorites/stopwatch, siehe apps/web/src/components/dashboard/ * widget-registry.tsx) sind Plattform-Widgets ohne Modulbezug. Das * einzige bislang geplante modulgebundene Widget steht in * .planning/REQUIREMENTS.md unter "Future Requirements (deferred)" und @@ -25,7 +25,7 @@ export const WIDGET_MODULE_MAP: Readonly> = {}; /** * Liefert den Modul-Slug für einen Widget-Typ, oder `undefined`, wenn - * der Typ kein Modul-Widget ist (der heutige Zustand für alle acht + * der Typ kein Modul-Widget ist (der heutige Zustand für alle sieben * bestehenden Typen). Einziger Lesezugriff auf die Zuordnungstabelle, * damit Tests sie gezielt mocken können. */ diff --git a/apps/web/src/app/(portal)/page.test.tsx b/apps/web/src/app/(portal)/page.test.tsx index df2851c..7eec2ad 100644 --- a/apps/web/src/app/(portal)/page.test.tsx +++ b/apps/web/src/app/(portal)/page.test.tsx @@ -58,7 +58,6 @@ vi.mock('@/components/dashboard/widgets/note-widget', () => ({ NoteWidget: () => vi.mock('@/components/dashboard/widgets/calculator-widget', () => ({ CalculatorWidget: () => null })); vi.mock('@/components/dashboard/widgets/stopwatch-widget', () => ({ StopwatchWidget: () => null })); vi.mock('@/components/dashboard/widgets/favorites-widget', () => ({ FavoritesWidget: () => null })); -vi.mock('@/components/dashboard/widgets/link-widget', () => ({ LinkWidget: () => null })); beforeEach(() => { mockStore.isEditMode = false; diff --git a/apps/web/src/app/(portal)/page.tsx b/apps/web/src/app/(portal)/page.tsx index b53dab0..55d2bfb 100644 --- a/apps/web/src/app/(portal)/page.tsx +++ b/apps/web/src/app/(portal)/page.tsx @@ -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, wireLinkWidget } from '@/components/dashboard/widget-registry'; +import { wireClockWidget, wireSearchWidget, wireCalendarWidget, wireNoteWidget, wireCalculatorWidget, wireStopwatchWidget, wireFavoritesWidget } 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'; @@ -13,7 +13,6 @@ import { NoteWidget } from '@/components/dashboard/widgets/note-widget'; import { CalculatorWidget } from '@/components/dashboard/widgets/calculator-widget'; import { StopwatchWidget } from '@/components/dashboard/widgets/stopwatch-widget'; import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget'; -import { LinkWidget } from '@/components/dashboard/widgets/link-widget'; import { useDashboardStore } from '@/lib/stores/dashboard-store'; import type { WidgetType } from '@/components/dashboard/widget-registry'; @@ -25,7 +24,6 @@ wireNoteWidget(NoteWidget); wireCalculatorWidget(CalculatorWidget); wireStopwatchWidget(StopwatchWidget); wireFavoritesWidget(FavoritesWidget); -wireLinkWidget(LinkWidget); export default function DashboardPage() { const t = useTranslations('widgets'); diff --git a/apps/web/src/components/dashboard/dashboard-grid.tsx b/apps/web/src/components/dashboard/dashboard-grid.tsx index 5eff727..4571b64 100644 --- a/apps/web/src/components/dashboard/dashboard-grid.tsx +++ b/apps/web/src/components/dashboard/dashboard-grid.tsx @@ -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 diff --git a/apps/web/src/components/dashboard/widget-catalog-modal.tsx b/apps/web/src/components/dashboard/widget-catalog-modal.tsx index 972f0c0..d8e8258 100644 --- a/apps/web/src/components/dashboard/widget-catalog-modal.tsx +++ b/apps/web/src/components/dashboard/widget-catalog-modal.tsx @@ -17,7 +17,6 @@ const WIDGET_TYPES: WidgetType[] = [ 'note', 'calculator', 'favorites', - 'link', 'stopwatch', ]; diff --git a/apps/web/src/components/dashboard/widget-registry.test.tsx b/apps/web/src/components/dashboard/widget-registry.test.tsx index da6c4f2..b74e757 100644 --- a/apps/web/src/components/dashboard/widget-registry.test.tsx +++ b/apps/web/src/components/dashboard/widget-registry.test.tsx @@ -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); }); }); diff --git a/apps/web/src/components/dashboard/widget-registry.tsx b/apps/web/src/components/dashboard/widget-registry.tsx index f8ef88e..8900d15 100644 --- a/apps/web/src/components/dashboard/widget-registry.tsx +++ b/apps/web/src/components/dashboard/widget-registry.tsx @@ -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 ( - - - - - ); -} - function StopwatchIcon({ className }: { className?: string }) { return ( = { ...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) { } } -let linkWired = false; -export function wireLinkWidget(component: ComponentType) { - if (!linkWired) { - WIDGET_REGISTRY.link.component = component; - linkWired = true; - } -} - let stopwatchWired = false; export function wireStopwatchWidget(component: ComponentType) { if (!stopwatchWired) { diff --git a/apps/web/src/components/dashboard/widgets/link-widget.test.tsx b/apps/web/src/components/dashboard/widgets/link-widget.test.tsx deleted file mode 100644 index 59748cc..0000000 --- a/apps/web/src/components/dashboard/widgets/link-widget.test.tsx +++ /dev/null @@ -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; -const mockCreate = createFavorite as ReturnType; -const mockUpdate = updateFavorite as ReturnType; - -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( - , - ); - - // 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( - , - ); - - 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( - , - ); - - 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( - , - ); - - 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( - , - ); - - 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( - , - ); - - 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( - , - ); - - 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'); - }); -}); diff --git a/apps/web/src/components/dashboard/widgets/link-widget.tsx b/apps/web/src/components/dashboard/widgets/link-widget.tsx deleted file mode 100644 index 53fecfd..0000000 --- a/apps/web/src/components/dashboard/widgets/link-widget.tsx +++ /dev/null @@ -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(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(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) { - 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 ( -
- - {getFallbackLetter(link.title)} - - {link.iconUrl && ( - { - (e.currentTarget as HTMLImageElement).style.display = 'none'; - }} - /> - )} -
- ); - } - - // --- Inline edit form --- - function renderEditForm() { - return ( -
- setEditTitle(e.target.value)} - placeholder={t('link.addTitle')} - /> - setEditUrl(e.target.value)} - placeholder={t('link.addUrl')} - /> - setEditIconUrl(e.target.value)} - placeholder="Logo-URL (optional)" - /> -
- - -
-
- ); - } - - // --- Render --- - return ( -
- {/* View mode toggle (edit mode only) */} - {isEditMode && ( -
- - -
- )} - - {/* Status messages */} - {loading && ( -

{t('link.loading')}

- )} - {error && ( -

{error}

- )} - - {/* Empty state */} - {!loading && !link && ( -

{t('link.empty')}

- )} - - {/* Link display — list (row) view */} - {!loading && link && !isEditing && viewMode === 'list' && ( -
- isEditMode && e.preventDefault()} - > - {renderIcon('sm')} - {link.title} - - {isEditMode && ( - - )} -
- )} - - {/* Link display — tile (grid) view */} - {!loading && link && !isEditing && viewMode === 'grid' && ( -
- isEditMode && e.preventDefault()} - > - {renderIcon('md')} - {link.title} - - {isEditMode && ( - - )} -
- )} - - {/* Inline edit form */} - {isEditMode && isEditing && link && renderEditForm()} - - {/* Add form — single-link enforcement: hidden when link exists (D-06) */} - {isEditMode && !link && !loading && ( -
void handleAdd(e)} - className="flex flex-col gap-1 widgetNoDrag" - > - setNewTitle(e.target.value)} - placeholder={t('link.addTitle')} - /> - setNewUrl(e.target.value)} - placeholder={t('link.addUrl')} - /> - -
- )} -
- ); -} diff --git a/apps/web/src/components/dashboard/widgets/widget-wrapper.test.tsx b/apps/web/src/components/dashboard/widgets/widget-wrapper.test.tsx new file mode 100644 index 0000000..29cd11d --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/widget-wrapper.test.tsx @@ -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( + , + ); + + const article = screen.getByRole('article'); + expect(article).toHaveAttribute('aria-label', 'link'); + + const fallback = screen.getByText('link'); + expect(fallback.className).toContain('text-muted-foreground'); + }); +}); diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index e41f7ae..d0732f4 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -285,22 +285,6 @@ "titleLabel": "Titel", "titlePlaceholder": "Titel (optional)" }, - "link": { - "name": "Link", - "description": "Einzelner Schnellzugriff-Link", - "loading": "Link wird geladen...", - "empty": "Noch kein Link gesetzt.", - "addTitle": "Titel", - "addUrl": "URL", - "addButton": "Hinzufügen", - "listView": "Liste", - "gridView": "Kachel", - "editButton": "Link bearbeiten", - "deleteButton": "Link löschen", - "saveButton": "Speichern", - "cancelButton": "Abbrechen", - "error": "Fehler beim Laden des Links" - }, "stopwatch": { "name": "Stoppuhr", "description": "Zeitmessung mit Rundenzeiten", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index da0067f..097d73c 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -285,22 +285,6 @@ "titleLabel": "Title", "titlePlaceholder": "Title (optional)" }, - "link": { - "name": "Link", - "description": "Single quick-access link", - "loading": "Loading link...", - "empty": "No link set yet.", - "addTitle": "Title", - "addUrl": "URL", - "addButton": "Add", - "listView": "List", - "gridView": "Tile", - "editButton": "Edit link", - "deleteButton": "Delete link", - "saveButton": "Save", - "cancelButton": "Cancel", - "error": "Error loading link" - }, "stopwatch": { "name": "Stopwatch", "description": "Time measurement with lap times", diff --git a/docs/anleitung-anwender.md b/docs/anleitung-anwender.md index 09eeb4e..faeb6a0 100644 --- a/docs/anleitung-anwender.md +++ b/docs/anleitung-anwender.md @@ -74,13 +74,12 @@ Ihre Änderungen werden über **„Änderungen speichern"** übernommen. Verlass | Uhr | Zeigt die aktuelle Uhrzeit an (optional mit Datum). Die Uhrzeit wächst und schrumpft mit der Kachel; wer eine feste Größe möchte, stellt sie unter Einstellungen > Dashboard als Schriftgröße in Punkt ein | | Suchleiste | Schnellsuche im Web über frei konfigurierbare Suchanbieter | | Kalender | Monatsübersicht mit der Anzahl der Termine je Tag (die Termine eines Tages erscheinen, wenn Sie mit der Maus darüberfahren) und darunter die nächsten Termine aus Ihren verbundenen Kalenderquellen. Ob die Monatsansicht erscheint, wie viele Termine und welcher Zeitraum gezeigt werden, stellen Sie unter Einstellungen > Dashboard > Widgets ein | -| Notizen | Freitext-Notizen mit Markdown-Formatierung | +| Notizen | Freitext-Notizen mit Markdown-Formatierung; Listen zum Abhaken (`- [ ]`) lassen sich in der Ansicht direkt per Klick abhaken | | Taschenrechner | Grundrechenarten, auch per Tastatur bedienbar | -| Favoriten | Schnellzugriff auf mehrere selbst gepflegte Links, als Liste oder Kachelansicht | -| Link | Schnellzugriff auf genau einen einzelnen Link | +| Favoriten | Schnellzugriff auf mehrere selbst gepflegte Links, als Liste oder Kachelansicht, optional mit eigener Überschrift | | Stoppuhr | Zeitmessung mit Rundenzeiten | -Für Uhr, Suchleiste, Kalender, Favoriten und Link gibt es zusätzliche Einstellungen (z. B. Zeitzone und Schriftgröße der Uhr, eigene Suchanbieter, Kalenderquellen, hinterlegte Links) — diese finden Sie unter **Einstellungen > Dashboard**, siehe [Persönliche Einstellungen](#persönliche-einstellungen). +Für Uhr, Suchleiste, Kalender, Notizen und Favoriten gibt es zusätzliche Einstellungen (z. B. Zeitzone und Schriftgröße der Uhr, eigene Suchanbieter, Kalenderquellen, Überschrift der Notiz- und Favoriten-Kachel) — diese finden Sie unter **Einstellungen > Dashboard**, siehe [Persönliche Einstellungen](#persönliche-einstellungen). ## Marktplatz @@ -150,7 +149,7 @@ Ein einfaches Werkzeug, um zu prüfen, ob eine Internet-Domain verfügbar ist. G - **Akzentfarbe:** Passt die Hauptfarbe der Oberfläche an Ihren Geschmack an; über „Zurücksetzen" kehren Sie zur Standardfarbe zurück. - **Passwort ändern:** Nur sichtbar und nutzbar, wenn Ihr Konto **lokal** in Tessera verwaltet wird. Wird Ihr Konto stattdessen über das Verzeichnis (LDAP/Active Directory) verwaltet, zeigt Tessera stattdessen den Hinweis „Ihr Passwort wird über das Verzeichnis (LDAP) verwaltet. Eine Änderung ist hier nicht möglich." — in diesem Fall ändern Sie Ihr Passwort über die üblichen Firmenwege (z. B. Windows-Anmeldung), nicht in Tessera. -**Dashboard > Widgets:** Hier finden Sie für jedes auf Ihrem Dashboard platzierte Widget die zugehörigen Einstellungen, zum Beispiel eigene Suchanbieter für die Suchleiste oder beim Kalender die Monatsansicht (ein/aus), die Anzahl der angezeigten Termine (bis zu zehn, oder ausgeblendet) und den Zeitraum (7 bis 90 Tage). +**Dashboard > Widgets:** Hier finden Sie für jedes auf Ihrem Dashboard platzierte Widget die zugehörigen Einstellungen, zum Beispiel eigene Suchanbieter für die Suchleiste, beim Kalender die Monatsansicht (ein/aus), die Anzahl der angezeigten Termine (bis zu zehn, oder ausgeblendet) und den Zeitraum (7 bis 90 Tage), oder bei Notizen und Favoriten die Überschrift der Kachel. **Dashboard > Kalender:** Hier verwalten Sie die Kalenderquellen, aus denen das Kalender-Widget seine Termine bezieht — Quellen hinzufügen, die Verbindung testen und nicht mehr benötigte Quellen wieder entfernen.