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
@@ -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>
);
}