From cc6f5ae8935c8f186f519d5d11be365c348e155b Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 1 Jul 2026 10:34:11 +0200 Subject: [PATCH] feat(08-03): FavoritesWidget frontend + API client + page.tsx wiring (GREEN) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - favorites-api.ts: FavoriteLink type + fetchFavorites/createFavorite/updateFavorite/deleteFavorite all use credentials: include and API_URL/favorites - favorites-widget.tsx: list/grid view, inline add/edit/delete in edit mode, icon + letter fallback, rel=noreferrer + target=_blank, no dangerouslySetInnerHTML (T-08-07) - useEffect deps fixed to [instanceId] only — excludes t() to prevent re-fetch on each render - page.tsx: wireFavoritesWidget(FavoritesWidget) wired - Full test suite: 81/81 pass (17 test files) - Web TypeScript: clean --- apps/web/src/app/(portal)/page.tsx | 4 +- .../widgets/favorites-widget.test.tsx | 53 +- .../dashboard/widgets/favorites-widget.tsx | 462 ++++++++++++++++++ apps/web/src/lib/favorites-api.ts | 81 +++ 4 files changed, 579 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/components/dashboard/widgets/favorites-widget.tsx create mode 100644 apps/web/src/lib/favorites-api.ts diff --git a/apps/web/src/app/(portal)/page.tsx b/apps/web/src/app/(portal)/page.tsx index d371c93..14a51cd 100644 --- a/apps/web/src/app/(portal)/page.tsx +++ b/apps/web/src/app/(portal)/page.tsx @@ -5,13 +5,14 @@ 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 } 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'; 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 { useDashboardStore } from '@/lib/stores/dashboard-store'; import type { WidgetType } from '@/components/dashboard/widget-registry'; @@ -22,6 +23,7 @@ wireCalendarWidget(CalendarWidget); wireNoteWidget(NoteWidget); wireCalculatorWidget(CalculatorWidget); wireStopwatchWidget(StopwatchWidget); +wireFavoritesWidget(FavoritesWidget); export default function DashboardPage() { const t = useTranslations('widgets'); 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 c8ee1e9..628d19d 100644 --- a/apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx +++ b/apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx @@ -132,25 +132,28 @@ describe('FavoritesWidget', () => { const titleInput = screen.getByPlaceholderText('favorites.addTitle'); const urlInput = screen.getByPlaceholderText('favorites.addUrl'); - await act(async () => { + // Use sync fireEvent — RTL will flush state updates automatically + act(() => { fireEvent.change(titleInput, { target: { value: 'New Link' } }); fireEvent.change(urlInput, { target: { value: 'https://example.com' } }); }); - // Submit the form (button with text 'favorites.addButton') + // Submit the form const addButton = screen.getByRole('button', { name: /favorites\.addButton/i }); - await act(async () => { + act(() => { fireEvent.click(addButton); }); - // createFavorite must be called with the widgetId and the entered values - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - widgetId: 'fav-1', - title: 'New Link', - url: 'https://example.com', - }), - ); + // waitFor polls until the async chain (createFavorite → setFavorites) completes + await waitFor(() => { + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + widgetId: 'fav-1', + title: 'New Link', + url: 'https://example.com', + }), + ); + }); // The new link title should appear in the list await waitFor(() => { @@ -175,29 +178,37 @@ describe('FavoritesWidget', () => { const editButtons = screen.getAllByRole('button', { name: /favorites\.editButton/i, }); - await act(async () => { + act(() => { fireEvent.click(editButtons[0]); }); // Change the title in the inline edit form + await waitFor(() => { + expect(screen.getByDisplayValue('GitHub')).toBeInTheDocument(); + }); const editTitleInput = screen.getByDisplayValue('GitHub'); - await act(async () => { + act(() => { fireEvent.change(editTitleInput, { target: { value: 'GitHub Updated' } }); }); // Click Save + await waitFor(() => { + expect(screen.getByRole('button', { name: /favorites\.saveButton/i })).toBeInTheDocument(); + }); const saveButton = screen.getByRole('button', { name: /favorites\.saveButton/i, }); - await act(async () => { + act(() => { fireEvent.click(saveButton); }); // updateFavorite called with the favorite id and new title - expect(mockUpdate).toHaveBeenCalledWith( - 'fav-id-1', - expect.objectContaining({ title: 'GitHub Updated' }), - ); + await waitFor(() => { + expect(mockUpdate).toHaveBeenCalledWith( + 'fav-id-1', + expect.objectContaining({ title: 'GitHub Updated' }), + ); + }); }); it('delete: clicking delete calls deleteFavorite with id and removes the row', async () => { @@ -217,12 +228,14 @@ describe('FavoritesWidget', () => { const deleteButtons = screen.getAllByRole('button', { name: /favorites\.deleteButton/i, }); - await act(async () => { + act(() => { fireEvent.click(deleteButtons[0]); }); // deleteFavorite called with the correct id - expect(mockDelete).toHaveBeenCalledWith('fav-id-1'); + await waitFor(() => { + expect(mockDelete).toHaveBeenCalledWith('fav-id-1'); + }); // The deleted favorite is removed from the list await waitFor(() => { diff --git a/apps/web/src/components/dashboard/widgets/favorites-widget.tsx b/apps/web/src/components/dashboard/widgets/favorites-widget.tsx new file mode 100644 index 0000000..9332cad --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/favorites-widget.tsx @@ -0,0 +1,462 @@ +'use client'; + +import { FormEvent, useEffect, useMemo, 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'; + +/** + * FavoritesWidget — persistent per-widget list of favorite links (DASH-09). + * + * Features: + * - Loads favorites scoped to this widget instance via instanceId (Pitfall 3) + * - Add / edit / delete in edit mode with inline forms + * - List view (default, D-03) and grid view switchable in edit mode + * - 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) + */ +export function FavoritesWidget({ + instanceId, + config, + isEditMode, +}: WidgetProps) { + const t = useTranslations('widgets'); + + const [favorites, setFavorites] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // View mode: read from config, default 'list' (D-03) + const [viewMode, setViewMode] = useState<'list' | 'grid'>( + (config.viewMode as 'list' | 'grid') ?? 'list', + ); + + // Add-form state + const [newTitle, setNewTitle] = useState(''); + const [newUrl, setNewUrl] = useState(''); + const [newIconUrl, setNewIconUrl] = useState(''); + + // Inline edit state + const [editingId, setEditingId] = useState(null); + const [editTitle, setEditTitle] = useState(''); + const [editUrl, setEditUrl] = useState(''); + const [editIconUrl, setEditIconUrl] = useState(''); + + // Sorted favorites: position asc, then title asc + const sortedFavorites = useMemo( + () => + [...favorites].sort((a, b) => { + if (a.position !== b.position) return a.position - b.position; + return a.title.localeCompare(b.title); + }), + [favorites], + ); + + useEffect(() => { + let cancelled = false; + + async function load() { + setLoading(true); + setError(null); + try { + const data = await fetchFavorites(instanceId); + if (!cancelled) setFavorites(data); + } catch { + // Use a stable error key — t is excluded from deps intentionally + if (!cancelled) setError('favorites.error'); + } finally { + if (!cancelled) setLoading(false); + } + } + + void load(); + + return () => { + cancelled = true; + }; + // instanceId is the only meaningful dependency — t is stable at runtime + // and excluded to prevent re-fetching on every render (mock creates new fn) + // 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, D-03) --- + function handleViewMode(next: 'list' | 'grid') { + setViewMode(next); + void updateWidgetConfig(instanceId, { viewMode: next }); + } + + // --- Add favorite --- + async function handleAdd(e: FormEvent) { + e.preventDefault(); + const cleanTitle = newTitle.trim(); + const cleanUrl = newUrl.trim(); + const cleanIconUrl = newIconUrl.trim(); + + if (!cleanTitle || !cleanUrl) return; + + try { + const created = await createFavorite({ + widgetId: instanceId, + title: cleanTitle, + url: cleanUrl, + iconUrl: cleanIconUrl || undefined, + }); + setFavorites((prev) => [...prev, created]); + setNewTitle(''); + setNewUrl(''); + setNewIconUrl(''); + } catch { + setError(t('favorites.error')); + } + } + + // --- Edit favorite --- + function startEdit(fav: FavoriteLink) { + setEditingId(fav.id); + setEditTitle(fav.title); + setEditUrl(fav.url); + setEditIconUrl(fav.iconUrl ?? ''); + setError(null); + } + + function cancelEdit() { + setEditingId(null); + setEditTitle(''); + setEditUrl(''); + setEditIconUrl(''); + } + + async function handleSaveEdit(id: string) { + const cleanTitle = editTitle.trim(); + const cleanUrl = editUrl.trim(); + const cleanIconUrl = editIconUrl.trim(); + + if (!cleanTitle || !cleanUrl) return; + + try { + const updated = await updateFavorite(id, { + title: cleanTitle, + url: cleanUrl, + iconUrl: cleanIconUrl || null, + }); + setFavorites((prev) => prev.map((f) => (f.id === id ? updated : f))); + cancelEdit(); + } catch { + setError(t('favorites.error')); + } + } + + // --- Delete favorite --- + async function handleDelete(id: string) { + try { + await deleteFavorite(id); + setFavorites((prev) => prev.filter((f) => f.id !== id)); + if (editingId === id) cancelEdit(); + } catch { + setError(t('favorites.error')); + } + } + + // --- Render --- + return ( +
+ {/* View mode toggle (edit mode only) */} + {isEditMode && ( +
+ + +
+ )} + + {/* Status messages */} + {loading && ( +

{t('favorites.loading')}

+ )} + {error && ( +

{error}

+ )} + + {/* Favorites list / grid */} + {!loading && sortedFavorites.length === 0 && ( +

{t('favorites.empty')}

+ )} + + {!loading && sortedFavorites.length > 0 && ( + viewMode === 'grid' ? ( +
+ {sortedFavorites.map((fav) => ( + + ))} +
+ ) : ( +
+ {sortedFavorites.map((fav) => ( + + ))} +
+ ) + )} + + {/* Add form (edit mode only, D-04) */} + {isEditMode && ( +
void handleAdd(e)} + className="flex flex-col gap-1 pt-1 border-t border-border widgetNoDrag" + > + setNewTitle(e.target.value)} + placeholder={t('favorites.addTitle')} + /> + setNewUrl(e.target.value)} + placeholder={t('favorites.addUrl')} + /> + +
+ )} +
+ ); +} + +// --- FavoriteTile sub-component --- + +type FavoriteTileProps = { + fav: FavoriteLink; + isEditMode: boolean; + editingId: string | null; + editTitle: string; + editUrl: string; + editIconUrl: string; + getFallbackLetter: (title: string) => string; + onStartEdit: (fav: FavoriteLink) => void; + onCancelEdit: () => void; + onSaveEdit: (id: string) => Promise; + onDelete: (id: string) => Promise; + onEditTitleChange: (v: string) => void; + onEditUrlChange: (v: string) => void; + onEditIconUrlChange: (v: string) => void; + t: (key: string) => string; +}; + +function FavoriteTile({ + fav, + isEditMode, + editingId, + editTitle, + editUrl, + editIconUrl, + getFallbackLetter, + onStartEdit, + onCancelEdit, + onSaveEdit, + onDelete, + onEditTitleChange, + onEditUrlChange, + onEditIconUrlChange, + t, +}: FavoriteTileProps) { + const isEditing = editingId === fav.id; + + return ( +
+ {/* Main link row */} +
+ {/* Anchor — opens in new tab, no server-side redirect (T-08-08) */} + isEditMode && e.preventDefault()} // block navigation in edit mode + > + {/* Icon container with letter fallback (T-08-07 — no dangerouslySetInnerHTML) */} +
+ + {getFallbackLetter(fav.title)} + + {fav.iconUrl && ( + { + (e.currentTarget as HTMLImageElement).style.display = 'none'; + }} + /> + )} +
+ + {fav.title} +
+ + {/* Action buttons (edit mode only, D-04) */} + {isEditMode && !isEditing && ( +
+ + +
+ )} +
+ + {/* Inline edit form */} + {isEditMode && isEditing && ( +
+ onEditTitleChange(e.target.value)} + placeholder={t('favorites.addTitle')} + /> + onEditUrlChange(e.target.value)} + placeholder={t('favorites.addUrl')} + /> + onEditIconUrlChange(e.target.value)} + placeholder="Logo-URL (optional)" + /> +
+ + +
+
+ )} +
+ ); +} diff --git a/apps/web/src/lib/favorites-api.ts b/apps/web/src/lib/favorites-api.ts new file mode 100644 index 0000000..bbef3c7 --- /dev/null +++ b/apps/web/src/lib/favorites-api.ts @@ -0,0 +1,81 @@ +/** + * Favorites API client functions. + * Mirrors the NestJS FavoritesController routes (08-03). + * All calls use credentials: 'include' for cookie-based auth. + */ + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +export interface FavoriteLink { + id: string; + widgetId: string; + title: string; + url: string; + iconUrl: string | null; + position: number; +} + +/** + * Fetch all favorite links for a specific widget instance. + * The widgetId parameter scopes the query to the correct widget (Pitfall 3). + */ +export async function fetchFavorites(widgetId: string): Promise { + const res = await fetch( + `${API_URL}/favorites?widgetId=${encodeURIComponent(widgetId)}`, + { credentials: 'include' }, + ); + if (!res.ok) throw new Error('Failed to fetch favorites'); + + return res.json(); +} + +/** + * Create a new favorite link. + * Server-side icon discovery runs automatically if iconUrl is not provided. + */ +export async function createFavorite(payload: { + widgetId: string; + title: string; + url: string; + iconUrl?: string; +}): Promise { + const res = await fetch(`${API_URL}/favorites`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error('Failed to create favorite'); + + return res.json(); +} + +/** + * Update an existing favorite link. + * Pass iconUrl: null to clear a stored icon. + */ +export async function updateFavorite( + id: string, + payload: Partial<{ title: string; url: string; iconUrl: string | null; position: number }>, +): Promise { + const res = await fetch(`${API_URL}/favorites/${encodeURIComponent(id)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error('Failed to update favorite'); + + return res.json(); +} + +/** + * Delete a favorite link by id. + */ +export async function deleteFavorite(id: string): Promise { + const res = await fetch(`${API_URL}/favorites/${encodeURIComponent(id)}`, { + method: 'DELETE', + credentials: 'include', + }); + if (!res.ok) throw new Error('Failed to delete favorite'); +}