feat(web): Favoriten-Widget — optionaler Titel (Kopfzeile, FavoritesConfig, i18n)

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-09-16 13:39:48 +02:00
parent 684f063a8e
commit 7f1ee3b1f0
6 changed files with 294 additions and 10 deletions
@@ -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<typeof vi.fn>;
const mockCreate = createFavorite as ReturnType<typeof vi.fn>;
const mockUpdate = updateFavorite as ReturnType<typeof vi.fn>;
const mockDelete = deleteFavorite as ReturnType<typeof vi.fn>;
const mockUpdateWidgetConfig = updateWidgetConfig as ReturnType<typeof vi.fn>;
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(
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
);
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(
<FavoritesWidget
instanceId="fav-1"
config={{ title: ' ' }}
isEditMode={false}
/>,
);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
});
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
r2.unmount();
render(
<FavoritesWidget
instanceId="fav-1"
config={{ title: 42 }}
isEditMode={false}
/>,
);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
});
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
});
it('Ansicht mit Titel zeigt eine H2-Kopfzeile', async () => {
render(
<FavoritesWidget
instanceId="fav-1"
config={{ title: 'Werkzeuge' }}
isEditMode={false}
/>,
);
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(
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
);
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(
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
);
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();
}
});
});
});
@@ -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<string>(
typeof config.title === 'string' ? config.title : '',
);
const titleTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
useEffect(() => {
return () => clearTimeout(titleTimerRef.current);
}, []);
function handleTitleChange(e: React.ChangeEvent<HTMLInputElement>) {
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 (
<div className="flex flex-col h-full overflow-auto p-1 gap-2">
<div className="flex h-full flex-col overflow-hidden">
{/* Header: optional title (quick-260916-iex) — mirrors note-widget.tsx look */}
{showHeader && (
<div className="flex items-center gap-2 border-b border-border px-1.5 py-1.5">
{isEditMode ? (
<input
type="text"
className="flex-1 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground widgetNoDrag"
value={title}
onChange={handleTitleChange}
placeholder={t('favorites.titlePlaceholder')}
aria-label={t('favorites.titleLabel')}
/>
) : (
hasTitle && (
<h2 className="truncate text-sm font-semibold text-foreground">
{title.trim()}
</h2>
)
)}
</div>
)}
<div className="flex flex-1 flex-col gap-2 overflow-auto p-1">
{/* View mode toggle (edit mode only) */}
{isEditMode && (
<div className="flex gap-1 widgetNoDrag" aria-label={t('favorites.name')}>
@@ -296,6 +349,7 @@ export function FavoritesWidget({
</form>
)}
</div>
</div>
);
}
@@ -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<string, string> } }).widgets.favorites;
const note = (de as { widgets: { note: Record<string, string> } }).widgets.note;
describe('WidgetSettingsPanel — Favoriten-Titel (quick-260916-iex)', () => {
function renderFavoritesExpanded(config: Record<string, unknown> = {}) {
const onWidgetUpdate = vi.fn();
const widget = { id: 'f1', widgetType: 'favorites', config };
render(<WidgetSettingsPanel widgets={[widget]} onWidgetUpdate={onWidgetUpdate} />);
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(<WidgetSettingsPanel widgets={[widget]} onWidgetUpdate={onWidgetUpdate} />);
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();
});
});
@@ -117,11 +117,12 @@ export function WidgetSettingsPanel({
>
<span>
{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() !== '' && (
<span className="ml-2 text-muted-foreground">
— {widget.config.title}
— {widget.config.title.trim()}
</span>
)}
</span>
@@ -184,6 +185,16 @@ export function WidgetSettingsPanel({
}
/>
)}
{/* Favorites config (quick-260916-iex) */}
{widget.widgetType === 'favorites' && (
<FavoritesConfig
config={widget.config}
onChange={(cfg) =>
handleConfigChange(widget.id, cfg)
}
/>
)}
</div>
)}
</div>
@@ -413,7 +424,7 @@ function NoteConfig({
return (
<div>
<label htmlFor="note-title" className="mb-1 block text-sm text-foreground">
Title
{t('note.titleLabel')}
</label>
<input
id="note-title"
@@ -425,3 +436,37 @@ function NoteConfig({
</div>
);
}
// Favorites config (quick-260916-iex, Muster NoteConfig)
function FavoritesConfig({
config,
onChange,
}: {
config: Record<string, unknown>;
onChange: (cfg: Record<string, unknown>) => 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 (
<div>
<label
htmlFor="favorites-title"
className="mb-1 block text-sm text-foreground"
>
{t('favorites.titleLabel')}
</label>
<input
id="favorites-title"
type="text"
className="h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground"
value={title}
placeholder={t('favorites.titlePlaceholder')}
onChange={(e) => onChange({ title: e.target.value })}
/>
</div>
);
}
+5 -2
View File
@@ -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",
+5 -2
View File
@@ -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",