feat(05-02): add search and note widgets with tests

- SearchWidget: provider dropdown, text input, button; opens search in new tab via window.open (D-14/D-15)
- NoteWidget: MDEditor with compact toolbar, debounced autosave (1500ms), AbortController for in-flight cancellation (D-16/D-17/D-18)
- rehype-sanitize enabled for Markdown XSS prevention (T-05-05)
- Widget registry updated with wireSearchWidget/wireNoteWidget (no more placeholders)
- dashboard-api.ts: added fetchSearchProviders, addSearchProvider, removeSearchProvider, signal support on updateWidgetConfig
- 9 new tests passing (search: 5, note: 4)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 11:33:01 +02:00
parent 97c13809f5
commit dd0209898b
9 changed files with 1616 additions and 14 deletions
+2
View File
@@ -10,6 +10,7 @@
"type-check": "tsc --noEmit"
},
"dependencies": {
"@uiw/react-md-editor": "4.1.1",
"jose": "^6.2.3",
"next": "^15.3.0",
"next-intl": "^4.13.0",
@@ -18,6 +19,7 @@
"react-dom": "^19.0.0",
"react-grid-layout": "2.2.3",
"react-resizable": "^4.0.2",
"rehype-sanitize": "^6.0.0",
"zod": "^4.4.3",
"zustand": "^5.0.14"
},
+6 -2
View File
@@ -5,13 +5,17 @@ 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 } from '@/components/dashboard/widget-registry';
import { wireClockWidget, wireSearchWidget, wireNoteWidget } from '@/components/dashboard/widget-registry';
import { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
import { SearchWidget } from '@/components/dashboard/widgets/search-widget';
import { NoteWidget } from '@/components/dashboard/widgets/note-widget';
import { useDashboardStore } from '@/lib/stores/dashboard-store';
import type { WidgetType } from '@/components/dashboard/widget-registry';
// Wire ClockWidget into the registry (deferred to avoid circular deps)
// Wire widget components into the registry (deferred to avoid circular deps)
wireClockWidget(ClockWidget);
wireSearchWidget(SearchWidget);
wireNoteWidget(NoteWidget);
export default function DashboardPage() {
const t = useTranslations('widgets');
@@ -170,7 +170,7 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
descriptionKey: 'search.description',
icon: SearchIcon,
...WIDGET_CONSTRAINTS.search,
component: PlaceholderWidget,
component: PlaceholderWidget, // Replaced via wireSearchWidget()
},
calendar: {
type: 'calendar',
@@ -186,12 +186,12 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
descriptionKey: 'note.description',
icon: NoteIcon,
...WIDGET_CONSTRAINTS.note,
component: PlaceholderWidget,
component: PlaceholderWidget, // Replaced via wireNoteWidget()
},
};
// Wire actual ClockWidget lazily to avoid circular deps
// (import is deferred so widget-registry can be imported by tests without
// Wire actual widget components lazily to avoid circular deps
// (imports are deferred so widget-registry can be imported by tests without
// pulling in the entire React tree)
let clockWired = false;
export function wireClockWidget(
@@ -202,3 +202,23 @@ export function wireClockWidget(
clockWired = true;
}
}
let searchWired = false;
export function wireSearchWidget(
component: ComponentType<WidgetProps>,
) {
if (!searchWired) {
WIDGET_REGISTRY.search.component = component;
searchWired = true;
}
}
let noteWired = false;
export function wireNoteWidget(
component: ComponentType<WidgetProps>,
) {
if (!noteWired) {
WIDGET_REGISTRY.note.component = component;
noteWired = true;
}
}
@@ -114,7 +114,7 @@ describe('NoteWidget', () => {
});
expect(fetchSpy).toHaveBeenCalledWith(
expect.stringContaining('/api/dashboard/widgets/note-1/config'),
expect.stringContaining('/dashboard/widgets/note-1/config'),
expect.objectContaining({
method: 'PATCH',
}),
@@ -0,0 +1,143 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import MDEditor, { commands } from '@uiw/react-md-editor';
import rehypeSanitize from 'rehype-sanitize';
import type { WidgetProps } from '../widget-registry';
import { updateWidgetConfig } from '@/lib/dashboard-api';
const DEBOUNCE_MS = 1500;
/**
* Compact toolbar commands (D-16).
* Bold, Italic, Strikethrough, divider, Unordered List, Checkbox, divider, Link, Code
*/
const NOTE_COMMANDS = [
commands.bold,
commands.italic,
commands.strikethrough,
commands.divider,
commands.unorderedListCommand,
commands.checkedListCommand,
commands.divider,
commands.link,
commands.code,
];
/**
* Notes widget (DASH-06, D-16/D-17/D-18).
* Markdown editor with compact toolbar and debounced autosave.
* Uses AbortController to cancel in-flight requests before issuing new ones (Pitfall 7).
* rehype-sanitize is enabled for XSS prevention (T-05-05).
*/
export function NoteWidget({ instanceId, config }: WidgetProps) {
const t = useTranslations('widgets');
const [title, setTitle] = useState<string>(
(config.title as string) || t('note.defaultTitle'),
);
const [content, setContent] = useState<string>(
(config.content as string) || '',
);
const [saveError, setSaveError] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
const abortRef = useRef<AbortController>();
// Cleanup on unmount
useEffect(() => {
return () => {
clearTimeout(timerRef.current);
abortRef.current?.abort();
};
}, []);
const save = useCallback(
async (newContent: string, newTitle: string) => {
// Abort any in-flight request (Pitfall 7)
abortRef.current?.abort();
abortRef.current = new AbortController();
try {
await updateWidgetConfig(instanceId, {
content: newContent,
title: newTitle,
}, abortRef.current.signal);
setSaveError(false);
} catch (err: unknown) {
// AbortError is expected — swallow it
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
setSaveError(true);
}
},
[instanceId],
);
const scheduleSave = useCallback(
(newContent: string, newTitle: string) => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
save(newContent, newTitle);
}, DEBOUNCE_MS);
},
[save],
);
const handleContentChange = useCallback(
(val?: string) => {
const newContent = val ?? '';
setContent(newContent);
scheduleSave(newContent, title);
},
[scheduleSave, title],
);
const handleTitleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newTitle = e.target.value;
setTitle(newTitle);
scheduleSave(content, newTitle);
},
[scheduleSave, content],
);
return (
<div className="flex h-full flex-col overflow-hidden" data-color-mode="auto">
{/* Editable title (D-17, Body 14px weight 600) */}
<div className="relative flex items-center border-b border-border px-3 py-1.5">
<input
type="text"
className="w-full bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground"
value={title}
onChange={handleTitleChange}
placeholder={t('note.defaultTitle')}
/>
{/* Autosave error indicator (small red dot top-right) */}
{saveError && (
<span
className="absolute right-2 top-1/2 h-2 w-2 -translate-y-1/2 rounded-full bg-destructive"
title={t('note.autosaveError')}
/>
)}
</div>
{/* Markdown editor (D-16, D-18) */}
<div className="flex-1 overflow-auto">
<MDEditor
data-testid="md-editor"
value={content}
onChange={handleContentChange}
commands={NOTE_COMMANDS}
preview="edit"
height="100%"
visibleDragbar={false}
previewOptions={{
rehypePlugins: [[rehypeSanitize]],
}}
/>
</div>
</div>
);
}
@@ -0,0 +1,137 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import type { WidgetProps } from '../widget-registry';
import { fetchSearchProviders, updateWidgetConfig } from '@/lib/dashboard-api';
/**
* Default hardcoded search providers (D-15).
* Used as fallback if the API fetch fails so the widget always works.
*/
const DEFAULT_PROVIDERS = [
{ id: 'google', name: 'Google', urlTemplate: 'https://www.google.com/search?q={query}', isDefault: true },
{ id: 'bing', name: 'Bing', urlTemplate: 'https://www.bing.com/search?q={query}', isDefault: true },
{ id: 'ddg', name: 'DuckDuckGo', urlTemplate: 'https://duckduckgo.com/?q={query}', isDefault: true },
];
export interface SearchProvider {
id: string;
name: string;
urlTemplate: string;
isDefault: boolean;
}
/**
* Search widget (DASH-04, D-14/D-15).
* Horizontal layout: provider dropdown left, text input center, search button right.
* Opens web search in a new tab via window.open with noopener,noreferrer.
*/
export function SearchWidget({ instanceId, config }: WidgetProps) {
const t = useTranslations('widgets');
const [providers, setProviders] = useState<SearchProvider[]>(DEFAULT_PROVIDERS);
const [selectedProviderId, setSelectedProviderId] = useState<string>(
(config.providerId as string) || 'google',
);
const [query, setQuery] = useState('');
// Fetch providers from API, fallback to defaults on error
useEffect(() => {
let cancelled = false;
fetchSearchProviders()
.then((data) => {
if (!cancelled && data.length > 0) {
setProviders(data);
}
})
.catch(() => {
// Keep default providers — widget always works
});
return () => {
cancelled = true;
};
}, []);
// Persist selected provider to widget config
const handleProviderChange = useCallback(
(providerId: string) => {
setSelectedProviderId(providerId);
updateWidgetConfig(instanceId, { providerId }).catch(() => {
// Silently fail — provider selection is non-critical
});
},
[instanceId],
);
const handleSearch = useCallback(() => {
if (!query.trim()) return;
const provider = providers.find((p) => p.id === selectedProviderId) ?? providers[0];
if (!provider) return;
const url = provider.urlTemplate.replace(
'{query}',
encodeURIComponent(query.trim()),
);
window.open(url, '_blank', 'noopener,noreferrer');
}, [query, selectedProviderId, providers]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSearch();
}
},
[handleSearch],
);
return (
<div className="flex h-full items-center gap-2 px-3">
{/* Provider dropdown (D-14: ~120px left) */}
<select
role="combobox"
className="h-8 w-[120px] shrink-0 rounded border border-border bg-card px-2 text-sm text-foreground"
value={selectedProviderId}
onChange={(e) => handleProviderChange(e.target.value)}
>
{providers.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
{/* Search input (flex-1 center) */}
<input
type="text"
className="h-8 flex-1 rounded border border-border bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder={t('search.placeholder')}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
/>
{/* Search button (right) */}
<button
type="button"
className="flex h-8 shrink-0 items-center justify-center rounded bg-primary px-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
onClick={handleSearch}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</button>
</div>
);
}
+42
View File
@@ -59,12 +59,54 @@ export async function removeWidget(id: string): Promise<void> {
export async function updateWidgetConfig(
id: string,
config: Record<string, unknown>,
signal?: AbortSignal,
): Promise<void> {
const res = await fetch(`${API_URL}/dashboard/widgets/${id}/config`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ config }),
signal,
});
if (!res.ok) throw new Error('Failed to update widget config');
}
// --- Search Provider API (05-02) ---
export interface SearchProviderResponse {
id: string;
name: string;
urlTemplate: string;
isDefault: boolean;
userId?: string | null;
}
export async function fetchSearchProviders(): Promise<SearchProviderResponse[]> {
const res = await fetch(`${API_URL}/dashboard/search-providers`, {
credentials: 'include',
});
if (!res.ok) throw new Error('Failed to fetch search providers');
return res.json();
}
export async function addSearchProvider(payload: {
name: string;
urlTemplate: string;
}): Promise<SearchProviderResponse> {
const res = await fetch(`${API_URL}/dashboard/search-providers`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error('Failed to add search provider');
return res.json();
}
export async function removeSearchProvider(id: string): Promise<void> {
const res = await fetch(`${API_URL}/dashboard/search-providers/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (!res.ok) throw new Error('Failed to remove search provider');
}
+1 -1
View File
@@ -16,7 +16,7 @@ export default defineConfig({
css: false,
server: {
deps: {
inline: ['react-grid-layout', 'react-resizable'],
inline: ['react-grid-layout', 'react-resizable', '@uiw/react-md-editor'],
},
},
},