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:
@@ -10,6 +10,7 @@
|
|||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@uiw/react-md-editor": "4.1.1",
|
||||||
"jose": "^6.2.3",
|
"jose": "^6.2.3",
|
||||||
"next": "^15.3.0",
|
"next": "^15.3.0",
|
||||||
"next-intl": "^4.13.0",
|
"next-intl": "^4.13.0",
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-grid-layout": "2.2.3",
|
"react-grid-layout": "2.2.3",
|
||||||
"react-resizable": "^4.0.2",
|
"react-resizable": "^4.0.2",
|
||||||
|
"rehype-sanitize": "^6.0.0",
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,13 +5,17 @@ import { useTranslations } from 'next-intl';
|
|||||||
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
|
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
|
||||||
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
|
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
|
||||||
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
|
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 { 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 { useDashboardStore } from '@/lib/stores/dashboard-store';
|
||||||
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
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);
|
wireClockWidget(ClockWidget);
|
||||||
|
wireSearchWidget(SearchWidget);
|
||||||
|
wireNoteWidget(NoteWidget);
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const t = useTranslations('widgets');
|
const t = useTranslations('widgets');
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
|||||||
descriptionKey: 'search.description',
|
descriptionKey: 'search.description',
|
||||||
icon: SearchIcon,
|
icon: SearchIcon,
|
||||||
...WIDGET_CONSTRAINTS.search,
|
...WIDGET_CONSTRAINTS.search,
|
||||||
component: PlaceholderWidget,
|
component: PlaceholderWidget, // Replaced via wireSearchWidget()
|
||||||
},
|
},
|
||||||
calendar: {
|
calendar: {
|
||||||
type: 'calendar',
|
type: 'calendar',
|
||||||
@@ -186,12 +186,12 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
|||||||
descriptionKey: 'note.description',
|
descriptionKey: 'note.description',
|
||||||
icon: NoteIcon,
|
icon: NoteIcon,
|
||||||
...WIDGET_CONSTRAINTS.note,
|
...WIDGET_CONSTRAINTS.note,
|
||||||
component: PlaceholderWidget,
|
component: PlaceholderWidget, // Replaced via wireNoteWidget()
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wire actual ClockWidget lazily to avoid circular deps
|
// Wire actual widget components lazily to avoid circular deps
|
||||||
// (import is deferred so widget-registry can be imported by tests without
|
// (imports are deferred so widget-registry can be imported by tests without
|
||||||
// pulling in the entire React tree)
|
// pulling in the entire React tree)
|
||||||
let clockWired = false;
|
let clockWired = false;
|
||||||
export function wireClockWidget(
|
export function wireClockWidget(
|
||||||
@@ -202,3 +202,23 @@ export function wireClockWidget(
|
|||||||
clockWired = true;
|
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(fetchSpy).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('/api/dashboard/widgets/note-1/config'),
|
expect.stringContaining('/dashboard/widgets/note-1/config'),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: 'PATCH',
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -59,12 +59,54 @@ export async function removeWidget(id: string): Promise<void> {
|
|||||||
export async function updateWidgetConfig(
|
export async function updateWidgetConfig(
|
||||||
id: string,
|
id: string,
|
||||||
config: Record<string, unknown>,
|
config: Record<string, unknown>,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const res = await fetch(`${API_URL}/dashboard/widgets/${id}/config`, {
|
const res = await fetch(`${API_URL}/dashboard/widgets/${id}/config`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ config }),
|
body: JSON.stringify({ config }),
|
||||||
|
signal,
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to update widget config');
|
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');
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default defineConfig({
|
|||||||
css: false,
|
css: false,
|
||||||
server: {
|
server: {
|
||||||
deps: {
|
deps: {
|
||||||
inline: ['react-grid-layout', 'react-resizable'],
|
inline: ['react-grid-layout', 'react-resizable', '@uiw/react-md-editor'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+1260
-6
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user