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:
@@ -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')}>
|
||||
@@ -295,6 +348,7 @@ export function FavoritesWidget({
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user