fix(dashboard): noCompactor + note edit/preview toggle + widget border
Tessera CI/CD / Tests (push) Has been cancelled
Tessera CI/CD / Build & Publish Images (push) Has been cancelled
Tessera CI/CD / Lint & Type Check (push) Has been cancelled

- dashboard-grid: add noCompactor to prevent auto-compaction on drag
- note-widget: edit/preview toggle button (pencil icon), isEditing state,
  hideToolbar in preview mode
- widget-wrapper: border-primary/20 accent border
- dashboard-store: console.error on widget add/remove/layout-save failures

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 08:57:37 +02:00
parent be3680d0df
commit c8f3361816
4 changed files with 41 additions and 40 deletions
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Responsive } from 'react-grid-layout'; import { Responsive, noCompactor } from 'react-grid-layout';
import type { ResponsiveLayouts } from 'react-grid-layout'; import type { ResponsiveLayouts } from 'react-grid-layout';
import 'react-grid-layout/css/styles.css'; import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css'; import 'react-resizable/css/styles.css';
@@ -97,6 +97,7 @@ export function DashboardGrid({
resizeConfig={{ resizeConfig={{
enabled: isEditMode, enabled: isEditMode,
}} }}
compactor={noCompactor}
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)} onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
> >
{widgets.map((widget) => { {widgets.map((widget) => {
@@ -9,10 +9,6 @@ import { updateWidgetConfig } from '@/lib/dashboard-api';
const DEBOUNCE_MS = 1500; const DEBOUNCE_MS = 1500;
/**
* Compact toolbar commands (D-16).
* Bold, Italic, Strikethrough, divider, Unordered List, Checkbox, divider, Link, Code
*/
const NOTE_COMMANDS = [ const NOTE_COMMANDS = [
commands.bold, commands.bold,
commands.italic, commands.italic,
@@ -25,12 +21,6 @@ const NOTE_COMMANDS = [
commands.code, 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) { export function NoteWidget({ instanceId, config }: WidgetProps) {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const [title, setTitle] = useState<string>( const [title, setTitle] = useState<string>(
@@ -39,12 +29,12 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
const [content, setContent] = useState<string>( const [content, setContent] = useState<string>(
(config.content as string) || '', (config.content as string) || '',
); );
const [isEditing, setIsEditing] = useState(false);
const [saveError, setSaveError] = useState(false); const [saveError, setSaveError] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const abortRef = useRef<AbortController | undefined>(undefined); const abortRef = useRef<AbortController | undefined>(undefined);
// Cleanup on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
clearTimeout(timerRef.current); clearTimeout(timerRef.current);
@@ -54,21 +44,13 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
const save = useCallback( const save = useCallback(
async (newContent: string, newTitle: string) => { async (newContent: string, newTitle: string) => {
// Abort any in-flight request (Pitfall 7)
abortRef.current?.abort(); abortRef.current?.abort();
abortRef.current = new AbortController(); abortRef.current = new AbortController();
try { try {
await updateWidgetConfig(instanceId, { await updateWidgetConfig(instanceId, { content: newContent, title: newTitle }, abortRef.current.signal);
content: newContent,
title: newTitle,
}, abortRef.current.signal);
setSaveError(false); setSaveError(false);
} catch (err: unknown) { } catch (err: unknown) {
// AbortError is expected — swallow it if (err instanceof DOMException && err.name === 'AbortError') return;
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
setSaveError(true); setSaveError(true);
} }
}, },
@@ -78,9 +60,7 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
const scheduleSave = useCallback( const scheduleSave = useCallback(
(newContent: string, newTitle: string) => { (newContent: string, newTitle: string) => {
clearTimeout(timerRef.current); clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => { timerRef.current = setTimeout(() => save(newContent, newTitle), DEBOUNCE_MS);
save(newContent, newTitle);
}, DEBOUNCE_MS);
}, },
[save], [save],
); );
@@ -105,32 +85,52 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
return ( return (
<div className="flex h-full flex-col overflow-hidden" data-color-mode="auto"> <div className="flex h-full flex-col overflow-hidden" data-color-mode="auto">
{/* Editable title (D-17, Body 14px weight 600) */} {/* Header: title + pencil toggle */}
<div className="relative flex items-center border-b border-border px-3 py-1.5"> <div className="relative flex items-center border-b border-border px-3 py-1.5 gap-2">
<input <input
type="text" type="text"
className="w-full bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground" className="flex-1 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground"
value={title} value={title}
onChange={handleTitleChange} onChange={handleTitleChange}
placeholder={t('note.defaultTitle')} placeholder={t('note.defaultTitle')}
/> />
{/* Autosave error indicator (small red dot top-right) */}
{saveError && ( {saveError && (
<span <span
className="absolute right-2 top-1/2 h-2 w-2 -translate-y-1/2 rounded-full bg-destructive" className="h-2 w-2 shrink-0 rounded-full bg-destructive"
title={t('note.autosaveError')} title={t('note.autosaveError')}
/> />
)} )}
<button
onClick={() => setIsEditing((v) => !v)}
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded transition-colors hover:bg-muted ${isEditing ? 'text-destructive' : 'text-muted-foreground'}`}
title={isEditing ? t('note.viewMode') : t('note.editMode')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
</div> </div>
{/* Markdown editor (D-16, D-18) */} {/* Content: preview or editor */}
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
<MDEditor <MDEditor
data-testid="md-editor" data-testid="md-editor"
value={content} value={content}
onChange={handleContentChange} onChange={isEditing ? handleContentChange : undefined}
commands={NOTE_COMMANDS} commands={NOTE_COMMANDS}
preview="edit" preview={isEditing ? 'edit' : 'preview'}
hideToolbar={!isEditing}
height="100%" height="100%"
visibleDragbar={false} visibleDragbar={false}
previewOptions={{ previewOptions={{
@@ -25,7 +25,7 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
return ( return (
<div <div
className="relative h-full w-full overflow-hidden rounded-lg border bg-card shadow-sm" className="relative h-full w-full overflow-hidden rounded-lg border border-primary/20 bg-card shadow-sm"
data-widget-id={widget.id} data-widget-id={widget.id}
role="article" role="article"
aria-label={definition ? t(definition.nameKey) : widget.widgetType} aria-label={definition ? t(definition.nameKey) : widget.widgetType}
+6 -6
View File
@@ -81,8 +81,8 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
isDirty: true, isDirty: true,
}; };
}); });
} catch { } catch (err) {
set({ error: 'Failed to add widget' }); console.error('Failed to add widget:', err);
} }
}, },
@@ -100,8 +100,8 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
isDirty: true, isDirty: true,
}; };
}); });
} catch { } catch (err) {
set({ error: 'Failed to remove widget' }); console.error('Failed to remove widget:', err);
} }
}, },
@@ -129,8 +129,8 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
try { try {
await api.saveLayout(get().layouts); await api.saveLayout(get().layouts);
set({ isDirty: false }); set({ isDirty: false });
} catch { } catch (err) {
set({ error: 'Failed to save layout' }); console.error('Failed to save layout:', err);
} }
}, },
})); }));