From 7f1ee3b1f0349b263e1684bebf8d48077a6bbed1 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 16 Sep 2026 13:39:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20Favoriten-Widget=20=E2=80=94=20opt?= =?UTF-8?q?ionaler=20Titel=20(Kopfzeile,=20FavoritesConfig,=20i18n)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Favoriten-Widget: config.title (nur typeof string) steuert eine Kopfzeile im Notiz-Look; leer -> keine Kopfzeile; im Bearbeitungsmodus immer ein Titelfeld (widgetNoDrag, 1500 ms entprellt, { title }) - Einstellungen -> Dashboard -> Widgets: FavoritesConfig (Muster NoteConfig) mit uebersetzter Beschriftung, Instanz-Kopfzeile zeigt "— {title}" jetzt fuer note UND favorites - NoteConfig: hart kodiertes "Title" durch uebersetzten Schluessel ersetzt - 3 neue Uebersetzungsschluessel (note.titleLabel, favorites.titleLabel, favorites.titlePlaceholder) in de/en - favorites-Tests 7+4, Panel-Tests 7+3, Umlaut-Waechter 3/3 gruen, tsc Exit 0 Co-Authored-By: Claude Opus 5 (1M context) --- .../widgets/favorites-widget.test.tsx | 127 ++++++++++++++++++ .../dashboard/widgets/favorites-widget.tsx | 58 +++++++- .../settings/widget-settings-panel.test.tsx | 52 +++++++ .../settings/widget-settings-panel.tsx | 53 +++++++- apps/web/src/messages/de.json | 7 +- apps/web/src/messages/en.json | 7 +- 6 files changed, 294 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx b/apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx index 628d19d..b2c8888 100644 --- a/apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx +++ b/apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx @@ -25,6 +25,7 @@ import { updateFavorite, deleteFavorite, } from '@/lib/favorites-api'; +import { updateWidgetConfig } from '@/lib/dashboard-api'; // Must import AFTER mocks import { FavoritesWidget } from './favorites-widget'; @@ -34,6 +35,7 @@ const mockFetch = fetchFavorites as ReturnType; const mockCreate = createFavorite as ReturnType; const mockUpdate = updateFavorite as ReturnType; const mockDelete = deleteFavorite as ReturnType; +const mockUpdateWidgetConfig = updateWidgetConfig as ReturnType; const BASE_FAVORITES = [ { @@ -287,4 +289,129 @@ describe('FavoritesWidget', () => { // 'Notion' has iconUrl null — the letter fallback span should show 'N' expect(screen.getByTestId('letter-fallback-fav-id-2')).toHaveTextContent('N'); }); + + // quick-260916-iex: optionaler Titel + describe('optionaler Titel (quick-260916-iex)', () => { + it('Ansicht ohne (leeren/ungueltigen) Titel zeigt keine Kopfzeile', async () => { + const r1 = render( + , + ); + await waitFor(() => { + expect(screen.getByText('GitHub')).toBeInTheDocument(); + }); + expect(screen.queryByRole('heading')).not.toBeInTheDocument(); + expect( + screen.queryByPlaceholderText('favorites.titlePlaceholder'), + ).not.toBeInTheDocument(); + r1.unmount(); + + const r2 = render( + , + ); + await waitFor(() => { + expect(screen.getByText('GitHub')).toBeInTheDocument(); + }); + expect(screen.queryByRole('heading')).not.toBeInTheDocument(); + r2.unmount(); + + render( + , + ); + await waitFor(() => { + expect(screen.getByText('GitHub')).toBeInTheDocument(); + }); + expect(screen.queryByRole('heading')).not.toBeInTheDocument(); + }); + + it('Ansicht mit Titel zeigt eine H2-Kopfzeile', async () => { + render( + , + ); + await waitFor(() => { + expect(screen.getByText('GitHub')).toBeInTheDocument(); + }); + const heading = screen.getByRole('heading', { name: 'Werkzeuge' }); + expect(heading.tagName).toBe('H2'); + expect(heading.className).toContain('text-sm'); + expect(heading.className).toContain('font-semibold'); + expect( + screen.queryByPlaceholderText('favorites.titlePlaceholder'), + ).not.toBeInTheDocument(); + }); + + it('Bearbeitungsmodus ohne Titel zeigt ein Titelfeld statt einer Kopfzeile', async () => { + render( + , + ); + await waitFor(() => { + expect(screen.getByText('GitHub')).toBeInTheDocument(); + }); + + const titleField = screen.getByPlaceholderText( + 'favorites.titlePlaceholder', + ) as HTMLInputElement; + expect(titleField.value).toBe(''); + expect(titleField.className).toContain('widgetNoDrag'); + expect(screen.queryByRole('heading')).not.toBeInTheDocument(); + + expect( + screen.getByRole('button', { name: /favorites\.listView/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /favorites\.gridView/i }), + ).toBeInTheDocument(); + }); + + it('entprellt das Speichern des Titels um 1500 ms', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + try { + render( + , + ); + await act(async () => {}); + + const titleField = screen.getByPlaceholderText( + 'favorites.titlePlaceholder', + ); + + for (const value of ['W', 'We', 'Werkzeuge']) { + await act(async () => { + fireEvent.change(titleField, { target: { value } }); + vi.advanceTimersByTime(200); + }); + } + expect(mockUpdateWidgetConfig).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(1500); + }); + expect(mockUpdateWidgetConfig).toHaveBeenCalledTimes(1); + expect(mockUpdateWidgetConfig).toHaveBeenCalledWith('fav-1', { + title: 'Werkzeuge', + }); + + await act(async () => { + fireEvent.change(titleField, { target: { value: '' } }); + vi.advanceTimersByTime(1500); + }); + expect(mockUpdateWidgetConfig).toHaveBeenCalledWith('fav-1', { + title: '', + }); + } finally { + vi.useRealTimers(); + } + }); + }); }); diff --git a/apps/web/src/components/dashboard/widgets/favorites-widget.tsx b/apps/web/src/components/dashboard/widgets/favorites-widget.tsx index 22fb8ab..19b179d 100644 --- a/apps/web/src/components/dashboard/widgets/favorites-widget.tsx +++ b/apps/web/src/components/dashboard/widgets/favorites-widget.tsx @@ -1,6 +1,6 @@ 'use client'; -import { FormEvent, useEffect, useMemo, useState } from 'react'; +import { FormEvent, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslations } from 'next-intl'; import { updateWidgetConfig } from '@/lib/dashboard-api'; import { @@ -12,6 +12,9 @@ import { } from '@/lib/favorites-api'; import type { WidgetProps } from '@/components/dashboard/widget-registry'; +// quick-260916-iex: wie DEBOUNCE_MS im Notiz-Widget +const TITLE_DEBOUNCE_MS = 1500; + /** * FavoritesWidget — persistent per-widget list of favorite links (DASH-09). * @@ -22,6 +25,9 @@ import type { WidgetProps } from '@/components/dashboard/widget-registry'; * - Server-side icon discovery (letter fallback when iconUrl is null) * - Links open in new tab with rel="noreferrer" (T-08-08 open-redirect mitigation) * - No dangerouslySetInnerHTML (T-08-07 XSS mitigation) + * - Optionaler Titel (quick-260916-iex): leer -> keine Kopfzeile, sonst + * Kopfzeile im Notiz-Look; im Bearbeitungsmodus immer ein Titelfeld, + * entprellt gespeichert (Muster note-widget.tsx) */ export function FavoritesWidget({ instanceId, @@ -39,6 +45,30 @@ export function FavoritesWidget({ (config.viewMode as 'list' | 'grid') ?? 'list', ); + // Optionaler Titel (quick-260916-iex) + const [title, setTitle] = useState( + typeof config.title === 'string' ? config.title : '', + ); + const titleTimerRef = useRef | undefined>( + undefined, + ); + + useEffect(() => { + return () => clearTimeout(titleTimerRef.current); + }, []); + + function handleTitleChange(e: React.ChangeEvent) { + const value = e.target.value; + setTitle(value); + clearTimeout(titleTimerRef.current); + titleTimerRef.current = setTimeout(() => { + void updateWidgetConfig(instanceId, { title: value }); + }, TITLE_DEBOUNCE_MS); + } + + const hasTitle = title.trim() !== ''; + const showHeader = isEditMode || hasTitle; + // Add-form state const [newTitle, setNewTitle] = useState(''); const [newUrl, setNewUrl] = useState(''); @@ -171,7 +201,30 @@ export function FavoritesWidget({ // --- Render --- return ( -
+
+ {/* Header: optional title (quick-260916-iex) — mirrors note-widget.tsx look */} + {showHeader && ( +
+ {isEditMode ? ( + + ) : ( + hasTitle && ( +

+ {title.trim()} +

+ ) + )} +
+ )} + +
{/* View mode toggle (edit mode only) */} {isEditMode && (
@@ -295,6 +348,7 @@ export function FavoritesWidget({ )} +
); } diff --git a/apps/web/src/components/settings/widget-settings-panel.test.tsx b/apps/web/src/components/settings/widget-settings-panel.test.tsx index c869c28..8acefdd 100644 --- a/apps/web/src/components/settings/widget-settings-panel.test.tsx +++ b/apps/web/src/components/settings/widget-settings-panel.test.tsx @@ -182,3 +182,55 @@ describe('WidgetSettingsPanel — Kalender-Einstellungen (quick-260916-htc)', () expect((screen.getByLabelText(cal.configLookahead) as HTMLSelectElement).value).toBe('30'); }); }); + +const fav = (de as { widgets: { favorites: Record } }).widgets.favorites; +const note = (de as { widgets: { note: Record } }).widgets.note; + +describe('WidgetSettingsPanel — Favoriten-Titel (quick-260916-iex)', () => { + function renderFavoritesExpanded(config: Record = {}) { + const onWidgetUpdate = vi.fn(); + const widget = { id: 'f1', widgetType: 'favorites', config }; + render(); + fireEvent.click(screen.getByRole('button', { name: /Favoriten #1/ })); + return { onWidgetUpdate }; + } + + it('Test B1: Instanz-Kopfzeile zeigt "— Titel", Feld vorbelegt, Aenderung ruft updateWidgetConfig/onWidgetUpdate auf', async () => { + const { onWidgetUpdate } = renderFavoritesExpanded({ title: 'Werkzeuge' }); + + expect(screen.getByRole('button', { name: /Favoriten #1/ })).toHaveTextContent('— Werkzeuge'); + + const input = screen.getByLabelText(fav.titleLabel) as HTMLInputElement; + expect(input.value).toBe('Werkzeuge'); + + fireEvent.change(input, { target: { value: 'Werkzeuge 2' } }); + + await vi.waitFor(() => + expect(onWidgetUpdate).toHaveBeenCalledWith('f1', { title: 'Werkzeuge 2' }), + ); + expect(updateWidgetConfig).toHaveBeenCalledWith('f1', { title: 'Werkzeuge 2' }); + }); + + it('Test B2: ohne Titel bzw. mit Leerraum-Titel keine "—" in der Kopfzeile, Feld leer', () => { + renderFavoritesExpanded({}); + expect(screen.getByRole('button', { name: /Favoriten #1/ })).not.toHaveTextContent('—'); + expect((screen.getByLabelText(fav.titleLabel) as HTMLInputElement).value).toBe(''); + + cleanup(); + + renderFavoritesExpanded({ title: ' ' }); + expect(screen.getByRole('button', { name: /Favoriten #1/ })).not.toHaveTextContent('—'); + expect((screen.getByLabelText(fav.titleLabel) as HTMLInputElement).value).toBe(''); + }); + + it('Test B3: Notiz-Instanz zeigt uebersetzte Beschriftung "Titel" statt hart kodiertem "Title"', () => { + const onWidgetUpdate = vi.fn(); + const widget = { id: 'n1', widgetType: 'note', config: { title: 'Einkauf' } }; + render(); + fireEvent.click(screen.getByRole('button', { name: /Notizen #1/ })); + + const input = screen.getByLabelText(note.titleLabel) as HTMLInputElement; + expect(input.value).toBe('Einkauf'); + expect(screen.queryByText('Title')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/settings/widget-settings-panel.tsx b/apps/web/src/components/settings/widget-settings-panel.tsx index 7b14f3d..9773f42 100644 --- a/apps/web/src/components/settings/widget-settings-panel.tsx +++ b/apps/web/src/components/settings/widget-settings-panel.tsx @@ -117,11 +117,12 @@ export function WidgetSettingsPanel({ > {t(def.nameKey)} #{idx + 1} - {widget.widgetType === 'note' && + {(widget.widgetType === 'note' || + widget.widgetType === 'favorites') && typeof widget.config.title === 'string' && - widget.config.title !== '' && ( + widget.config.title.trim() !== '' && ( - — {widget.config.title} + — {widget.config.title.trim()} )} @@ -184,6 +185,16 @@ export function WidgetSettingsPanel({ } /> )} + + {/* Favorites config (quick-260916-iex) */} + {widget.widgetType === 'favorites' && ( + + handleConfigChange(widget.id, cfg) + } + /> + )}
)}
@@ -413,7 +424,7 @@ function NoteConfig({ return (
); } + +// Favorites config (quick-260916-iex, Muster NoteConfig) +function FavoritesConfig({ + config, + onChange, +}: { + config: Record; + onChange: (cfg: Record) => void; +}) { + const t = useTranslations('widgets'); + const rawTitle = typeof config.title === 'string' ? config.title : ''; + // Anzeige getrimmt (Leerraum-only-Titel sind fachlich "kein Titel"), + // gesendet wird der rohe Tippwert (onChange trimmt nicht selbst). + const title = rawTitle.trim() === '' ? '' : rawTitle; + + return ( +
+ + onChange({ title: e.target.value })} + /> +
+ ); +} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 329c343..e41f7ae 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -260,7 +260,8 @@ "defaultTitle": "Notiz", "autosaveError": "Speichern fehlgeschlagen", "editMode": "Bearbeitungsmodus", - "viewMode": "Vorschaumodus" + "viewMode": "Vorschaumodus", + "titleLabel": "Titel" }, "calculator": { "name": "Taschenrechner", @@ -280,7 +281,9 @@ "deleteButton": "Favorit löschen", "saveButton": "Speichern", "cancelButton": "Abbrechen", - "error": "Fehler beim Laden der Favoriten" + "error": "Fehler beim Laden der Favoriten", + "titleLabel": "Titel", + "titlePlaceholder": "Titel (optional)" }, "link": { "name": "Link", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index ccc6bed..da0067f 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -260,7 +260,8 @@ "defaultTitle": "Note", "autosaveError": "Save failed", "editMode": "Edit mode", - "viewMode": "Preview mode" + "viewMode": "Preview mode", + "titleLabel": "Title" }, "calculator": { "name": "Calculator", @@ -280,7 +281,9 @@ "deleteButton": "Delete favorite", "saveButton": "Save", "cancelButton": "Cancel", - "error": "Error loading favorites" + "error": "Error loading favorites", + "titleLabel": "Title", + "titlePlaceholder": "Title (optional)" }, "link": { "name": "Link",