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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user