feat(05-01): dashboard grid, clock widget, widget registry, store, and tests

- Install react-grid-layout@2.2.3 and react-resizable
- Create widget-registry.tsx with all 4 widget types, WIDGET_CONSTRAINTS, WidgetProps
- Create dashboard-api.ts with fetch/save layout and widget CRUD functions
- Create dashboard-store.ts (Zustand, NO persist — D-05) with edit mode and auto-save on exit
- Create DashboardGrid with react-grid-layout v2 Responsive, ResizeObserver width
- Create ClockWidget using Intl.DateTimeFormat (no manual UTC offsets)
- Create WidgetWrapper with drag handle and delete button in edit mode
- Create EditModeToggle (pencil/checkmark), WidgetCatalogModal (2x2 grid)
- Rewrite portal page.tsx as dashboard with grid, edit toggle, widget catalog
- Add ResizeObserver polyfill in test setup, CSS mock support in vitest config
- All 5 tests green (dashboard grid + clock widget)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 11:21:43 +02:00
parent 792b7661d8
commit d5e1c42e64
14 changed files with 1115 additions and 29 deletions
+3
View File
@@ -16,6 +16,8 @@
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-grid-layout": "2.2.3",
"react-resizable": "^4.0.2",
"zod": "^4.4.3",
"zustand": "^5.0.14"
},
@@ -26,6 +28,7 @@
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/react-grid-layout": "^2.1.0",
"@vitejs/plugin-react": "^6.0.2",
"jsdom": "^29.1.1",
"postcss": "^8.0.0",
+98 -29
View File
@@ -1,42 +1,111 @@
'use client';
import { useEffect, useState } from 'react';
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 { ClockWidget } from '@/components/dashboard/widgets/clock-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)
wireClockWidget(ClockWidget);
export default function DashboardPage() {
const t = useTranslations('dashboard');
const t = useTranslations('widgets');
const [catalogOpen, setCatalogOpen] = useState(false);
const {
layouts,
widgets,
isEditMode,
isLoading,
error,
setEditMode,
updateLayouts,
addWidget,
removeWidget,
loadDashboard,
} = useDashboardStore();
// Load dashboard data on mount
useEffect(() => {
loadDashboard();
}, [loadDashboard]);
if (isLoading) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<p className="text-sm text-muted-foreground">Loading...</p>
</div>
);
}
if (error) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<p className="text-sm text-destructive">{t('layoutLoadError')}</p>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
{/* Grid icon */}
<div className="rounded-lg bg-muted p-4">
<svg
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-muted-foreground"
>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
</svg>
<div className="relative p-4">
{/* Edit mode toggle — top right */}
<div className="absolute right-4 top-4 z-10">
<EditModeToggle
isEditMode={isEditMode}
onToggle={() => setEditMode(!isEditMode)}
/>
</div>
{/* Empty state text (D-16) */}
<p className="text-lg text-muted-foreground">
{t('empty')}
</p>
{/* Dashboard grid */}
<div className="mt-8">
<DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={isEditMode}
onLayoutChange={updateLayouts}
onRemoveWidget={removeWidget}
/>
</div>
{/* Add widget button */}
<button className="mt-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity">
{t('addWidget')}
</button>
{/* Add widget button (edit mode only) */}
{isEditMode && (
<div className="fixed bottom-6 right-6 z-20">
<button
onClick={() => setCatalogOpen(true)}
className="flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
>
<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"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
{t('addWidget')}
</button>
</div>
)}
{/* Widget catalog modal */}
<WidgetCatalogModal
isOpen={catalogOpen}
onClose={() => setCatalogOpen(false)}
onAddWidget={(type: WidgetType) => {
addWidget(type);
}}
/>
</div>
);
}
@@ -1,6 +1,10 @@
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
// Mock CSS imports that vitest cannot resolve
vi.mock('react-grid-layout/css/styles.css', () => ({}));
vi.mock('react-resizable/css/styles.css', () => ({}));
// Mock next-intl
vi.mock('next-intl', () => ({
useTranslations: (ns: string) => (key: string) => {
@@ -0,0 +1,129 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Responsive } from 'react-grid-layout';
import type { ResponsiveLayouts } from 'react-grid-layout';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import { useTranslations } from 'next-intl';
import { WIDGET_CONSTRAINTS, type WidgetType } from './widget-registry';
import { WidgetWrapper } from './widgets/widget-wrapper';
const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 };
const COLS = { lg: 12, md: 10, sm: 6, xs: 4, xxs: 1 };
interface DashboardGridProps {
layouts: Record<string, Array<{ i: string; x: number; y: number; w: number; h: number }>>;
widgets: Array<{ id: string; widgetType: string; config: Record<string, unknown> }>;
isEditMode: boolean;
onLayoutChange: (allLayouts: ResponsiveLayouts) => void;
onRemoveWidget: (id: string) => void;
}
/**
* Dashboard grid wrapper using react-grid-layout v2 Responsive component.
* Renders widgets in a draggable/resizable grid when in edit mode.
* Shows empty state when no widgets exist.
*
* v2 API: uses dragConfig/resizeConfig instead of isDraggable/isResizable.
*/
export function DashboardGrid({
layouts,
widgets,
isEditMode,
onLayoutChange,
onRemoveWidget,
}: DashboardGridProps) {
const t = useTranslations('widgets');
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(1200);
// Measure container width (v2 requires explicit width — Pitfall 1)
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
setWidth(entries[0].contentRect.width);
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
// Empty state (D-02)
if (widgets.length === 0) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
<div className="rounded-lg bg-muted p-4">
<svg
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-muted-foreground"
>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
</svg>
</div>
<h2 className="text-lg font-semibold text-muted-foreground">
{t('emptyHeading')}
</h2>
<p className="text-sm text-muted-foreground">
{t('emptyBody')}
</p>
</div>
);
}
return (
<div ref={containerRef}>
<Responsive
width={width}
breakpoints={BREAKPOINTS}
cols={COLS}
layouts={layouts as ResponsiveLayouts}
rowHeight={40}
margin={[16, 16] as [number, number]}
dragConfig={{
enabled: isEditMode,
handle: '.widget-drag-handle',
}}
resizeConfig={{
enabled: isEditMode,
}}
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
>
{widgets.map((widget) => {
const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType];
return (
<div
key={widget.id}
data-grid={{
...(layouts.lg?.find((l) => l.i === widget.id) ?? {
x: 0,
y: 0,
w: constraints?.defaultW ?? 2,
h: constraints?.defaultH ?? 2,
}),
minW: constraints?.minW ?? 2,
minH: constraints?.minH ?? 2,
}}
>
<WidgetWrapper
widget={widget}
isEditMode={isEditMode}
onRemove={onRemoveWidget}
/>
</div>
);
})}
</Responsive>
</div>
);
}
@@ -0,0 +1,63 @@
'use client';
import { useTranslations } from 'next-intl';
interface EditModeToggleProps {
isEditMode: boolean;
onToggle: () => void;
}
/**
* Pencil / Checkmark icon button to toggle dashboard edit mode (D-01).
* Active state (edit mode) uses primary background.
*/
export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
const t = useTranslations('widgets');
return (
<button
onClick={onToggle}
className={`inline-flex items-center justify-center rounded-md p-2 transition-colors ${
isEditMode
? 'bg-primary text-primary-foreground hover:opacity-90'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
aria-pressed={isEditMode}
aria-label={isEditMode ? t('saveChanges') : t('editMode')}
title={isEditMode ? t('saveChanges') : t('editMode')}
>
{isEditMode ? (
// Checkmark icon
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
// Pencil icon
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
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>
);
}
@@ -0,0 +1,124 @@
'use client';
import { useEffect, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { WIDGET_REGISTRY, type WidgetType } from './widget-registry';
interface WidgetCatalogModalProps {
isOpen: boolean;
onClose: () => void;
onAddWidget: (type: WidgetType) => void;
}
const WIDGET_TYPES: WidgetType[] = ['clock', 'search', 'calendar', 'note'];
/**
* Modal dialog showing available widget types as selectable cards.
* Click on a card adds the widget to the dashboard and closes the modal.
* Escape to close, click outside to close, focus trap (D-01 flow).
*/
export function WidgetCatalogModal({
isOpen,
onClose,
onAddWidget,
}: WidgetCatalogModalProps) {
const t = useTranslations('widgets');
const dialogRef = useRef<HTMLDivElement>(null);
// Focus trap + Escape handler
useEffect(() => {
if (!isOpen) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
document.addEventListener('keydown', handleKeyDown);
// Focus the dialog on open
dialogRef.current?.focus();
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={onClose}
aria-hidden="true"
>
{/* Backdrop */}
<div className="fixed inset-0 bg-black/50" />
{/* Dialog */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={t('catalogTitle')}
className="relative z-50 w-full max-w-[480px] rounded-lg border bg-card p-6 shadow-xl"
onClick={(e) => e.stopPropagation()}
tabIndex={-1}
>
{/* Header */}
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">
{t('catalogTitle')}
</h2>
<button
onClick={onClose}
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Close"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{/* 2x2 grid of widget type cards */}
<div className="grid grid-cols-2 gap-3">
{WIDGET_TYPES.map((type) => {
const def = WIDGET_REGISTRY[type];
const Icon = def.icon;
return (
<button
key={type}
onClick={() => {
onAddWidget(type);
onClose();
}}
className="flex flex-col items-center gap-2 rounded-lg border bg-card p-4 text-center transition-colors hover:border-primary hover:bg-muted"
aria-label={t(def.nameKey)}
>
<div className="flex h-14 w-14 items-center justify-center rounded-lg bg-muted">
<Icon className="h-6 w-6 text-muted-foreground" />
</div>
<span className="text-sm font-semibold text-foreground">
{t(def.nameKey)}
</span>
<span className="text-xs text-muted-foreground">
{t(def.descriptionKey)}
</span>
</button>
);
})}
</div>
</div>
</div>
);
}
@@ -0,0 +1,204 @@
import type { ComponentType } from 'react';
/**
* Supported widget types for the dashboard.
* clock is fully implemented in this plan; search/calendar/note are
* declared here so the catalog shows all four types immediately.
*/
export type WidgetType = 'clock' | 'search' | 'calendar' | 'note';
/**
* Props contract that every widget component must accept.
*/
export interface WidgetProps {
instanceId: string;
config: Record<string, unknown>;
isEditMode: boolean;
}
/**
* Per-type size constraints for the react-grid-layout grid.
* Values from UI-SPEC size table (D-06).
*/
export const WIDGET_CONSTRAINTS: Record<
WidgetType,
{ minW: number; minH: number; defaultW: number; defaultH: number }
> = {
clock: { minW: 2, minH: 2, defaultW: 2, defaultH: 2 },
search: { minW: 3, minH: 2, defaultW: 6, defaultH: 2 },
calendar: { minW: 3, minH: 3, defaultW: 4, defaultH: 6 },
note: { minW: 2, minH: 3, defaultW: 3, defaultH: 4 },
};
/**
* Definition for a widget type in the catalog.
*/
export interface WidgetDefinition {
type: WidgetType;
nameKey: string;
descriptionKey: string;
/** Inline SVG icon as React component */
icon: ComponentType<{ className?: string }>;
minW: number;
minH: number;
defaultW: number;
defaultH: number;
/** Lazy-loaded widget component */
component: ComponentType<WidgetProps>;
}
/**
* Placeholder widget for types not yet implemented.
* Renders the widget type name as a centered label.
*/
function PlaceholderWidget({ config }: WidgetProps) {
const label = (config as Record<string, string>).__widgetType ?? 'Widget';
return (
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
{label}
</div>
);
}
// --- Icon components (inline SVG, established project pattern) ---
function ClockIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
);
}
function SearchIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
);
}
function CalendarIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
);
}
function NoteIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
);
}
/**
* Registry of all widget types. Used by the widget catalog modal
* and the grid renderer to look up components and constraints.
*
* clock: Fully implemented this plan.
* search, calendar, note: Placeholder components — implemented in later plans.
*/
export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
clock: {
type: 'clock',
nameKey: 'clock.name',
descriptionKey: 'clock.description',
icon: ClockIcon,
...WIDGET_CONSTRAINTS.clock,
// Lazy import replaced at module level for clock
component: PlaceholderWidget, // Replaced below
},
search: {
type: 'search',
nameKey: 'search.name',
descriptionKey: 'search.description',
icon: SearchIcon,
...WIDGET_CONSTRAINTS.search,
component: PlaceholderWidget,
},
calendar: {
type: 'calendar',
nameKey: 'calendar.name',
descriptionKey: 'calendar.description',
icon: CalendarIcon,
...WIDGET_CONSTRAINTS.calendar,
component: PlaceholderWidget,
},
note: {
type: 'note',
nameKey: 'note.name',
descriptionKey: 'note.description',
icon: NoteIcon,
...WIDGET_CONSTRAINTS.note,
component: PlaceholderWidget,
},
};
// Wire actual ClockWidget lazily to avoid circular deps
// (import is deferred so widget-registry can be imported by tests without
// pulling in the entire React tree)
let clockWired = false;
export function wireClockWidget(
component: ComponentType<WidgetProps>,
) {
if (!clockWired) {
WIDGET_REGISTRY.clock.component = component;
clockWired = true;
}
}
@@ -0,0 +1,61 @@
'use client';
import { useEffect, useState } from 'react';
import type { WidgetProps } from '../widget-registry';
/**
* Digital clock widget displaying time in a configurable timezone.
* Uses Intl.DateTimeFormat — never computes UTC offsets manually (RESEARCH).
*
* Config:
* - timezone: IANA timezone string (default 'Europe/Berlin')
* - showDate: boolean (default false) — show date below time
*/
export function ClockWidget({ config }: WidgetProps) {
const timezone = (config.timezone as string) || 'Europe/Berlin';
const showDate = config.showDate === true;
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const timer = setInterval(() => {
setNow(new Date());
}, 1000);
return () => clearInterval(timer);
}, []);
const timeFormatter = new Intl.DateTimeFormat('de-DE', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZone: timezone,
});
const dateFormatter = new Intl.DateTimeFormat('de-DE', {
day: '2-digit',
month: 'long',
year: 'numeric',
timeZone: timezone,
});
return (
<div className="flex h-full flex-col items-center justify-center gap-1 p-2">
<time
role="time"
className="font-semibold tabular-nums text-foreground"
style={{ fontSize: 'clamp(28px, 4vw, 40px)' }}
dateTime={now.toISOString()}
>
{timeFormatter.format(now)}
</time>
{showDate && (
<span
data-testid="clock-date"
className="text-xs text-muted-foreground"
>
{dateFormatter.format(now)}
</span>
)}
</div>
);
}
@@ -0,0 +1,84 @@
'use client';
import { useTranslations } from 'next-intl';
import { WIDGET_REGISTRY, type WidgetType } from '../widget-registry';
interface WidgetWrapperProps {
widget: {
id: string;
widgetType: string;
config: Record<string, unknown>;
};
isEditMode: boolean;
onRemove: (id: string) => void;
}
/**
* Wrapper card for all dashboard widgets.
* In edit mode: shows drag handle (top) and delete button (top-right).
* Renders the actual widget component via WIDGET_REGISTRY lookup.
*/
export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperProps) {
const t = useTranslations('widgets');
const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType];
const WidgetComponent = definition?.component;
return (
<div
className="relative h-full w-full overflow-hidden rounded-lg border bg-card shadow-sm"
data-widget-id={widget.id}
role="article"
aria-label={definition ? t(definition.nameKey) : widget.widgetType}
>
{/* Edit mode: drag handle */}
{isEditMode && (
<div className="widget-drag-handle flex h-[6px] w-full cursor-grab items-center justify-center bg-muted active:cursor-grabbing">
<div className="h-[2px] w-8 rounded-full bg-muted-foreground/40" />
</div>
)}
{/* Edit mode: delete button */}
{isEditMode && (
<button
onClick={(e) => {
e.stopPropagation();
onRemove(widget.id);
}}
className="absolute right-1 top-1 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-card text-muted-foreground transition-colors hover:bg-destructive hover:text-destructive-foreground"
aria-label={t('deleteTooltip')}
title={t('deleteTooltip')}
>
<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"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
{/* Widget body */}
<div className={`h-full ${isEditMode ? 'pt-0' : ''}`}>
{WidgetComponent ? (
<WidgetComponent
instanceId={widget.id}
config={widget.config}
isEditMode={isEditMode}
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{widget.widgetType}
</div>
)}
</div>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
/**
* Dashboard API client functions.
* All calls use credentials: 'include' for cookie-based auth.
*/
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
export async function fetchLayout(): Promise<Record<string, unknown>> {
const res = await fetch(`${API_URL}/dashboard/layout`, {
credentials: 'include',
});
if (!res.ok) throw new Error('Failed to fetch layout');
return res.json();
}
export async function saveLayout(
layouts: Record<string, unknown>,
): Promise<void> {
const res = await fetch(`${API_URL}/dashboard/layout`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ layouts }),
});
if (!res.ok) throw new Error('Failed to save layout');
}
export async function fetchWidgets(): Promise<
Array<{ id: string; widgetType: string; config: Record<string, unknown> }>
> {
const res = await fetch(`${API_URL}/dashboard/widgets`, {
credentials: 'include',
});
if (!res.ok) throw new Error('Failed to fetch widgets');
return res.json();
}
export async function addWidget(
widgetType: string,
): Promise<{ id: string; widgetType: string; config: Record<string, unknown> }> {
const res = await fetch(`${API_URL}/dashboard/widgets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ widgetType }),
});
if (!res.ok) throw new Error('Failed to add widget');
return res.json();
}
export async function removeWidget(id: string): Promise<void> {
const res = await fetch(`${API_URL}/dashboard/widgets/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (!res.ok) throw new Error('Failed to remove widget');
}
export async function updateWidgetConfig(
id: string,
config: Record<string, unknown>,
): 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 }),
});
if (!res.ok) throw new Error('Failed to update widget config');
}
+136
View File
@@ -0,0 +1,136 @@
import { create } from 'zustand';
import * as api from '@/lib/dashboard-api';
import type { WidgetType } from '@/components/dashboard/widget-registry';
import { WIDGET_CONSTRAINTS } from '@/components/dashboard/widget-registry';
export interface WidgetInstance {
id: string;
widgetType: string;
config: Record<string, unknown>;
}
interface DashboardState {
layouts: Record<string, Array<{ i: string; x: number; y: number; w: number; h: number }>>;
widgets: WidgetInstance[];
isEditMode: boolean;
isDirty: boolean;
isLoading: boolean;
error: string | null;
setEditMode: (mode: boolean) => void;
updateLayouts: (layouts: Record<string, unknown>) => void;
addWidget: (widgetType: WidgetType) => Promise<void>;
removeWidget: (id: string) => Promise<void>;
loadDashboard: () => Promise<void>;
saveLayout: () => Promise<void>;
}
/**
* Dashboard Zustand store. NO persist middleware — layout comes from DB (D-05).
* Save only on exiting edit mode (D-01), not on every drag.
*/
export const useDashboardStore = create<DashboardState>()((set, get) => ({
layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] },
widgets: [],
isEditMode: false,
isDirty: false,
isLoading: false,
error: null,
setEditMode: (mode: boolean) => {
const prev = get().isEditMode;
set({ isEditMode: mode });
// On exit edit mode, auto-save if dirty (D-01)
if (prev && !mode && get().isDirty) {
get().saveLayout();
}
},
updateLayouts: (layouts) => {
set({
layouts: layouts as DashboardState['layouts'],
isDirty: true,
});
},
addWidget: async (widgetType: WidgetType) => {
try {
const newWidget = await api.addWidget(widgetType);
const constraints = WIDGET_CONSTRAINTS[widgetType];
set((state) => {
// Add default layout entry for all breakpoints
const newLayouts = { ...state.layouts };
for (const bp of Object.keys(newLayouts)) {
const arr = [...(newLayouts[bp] || [])];
// Place at next available y position
const maxY = arr.reduce((max, item) => Math.max(max, item.y + item.h), 0);
arr.push({
i: newWidget.id,
x: 0,
y: maxY,
w: constraints.defaultW,
h: constraints.defaultH,
});
newLayouts[bp] = arr;
}
return {
widgets: [...state.widgets, newWidget],
layouts: newLayouts,
isDirty: true,
};
});
} catch {
set({ error: 'Failed to add widget' });
}
},
removeWidget: async (id: string) => {
try {
await api.removeWidget(id);
set((state) => {
const newLayouts = { ...state.layouts };
for (const bp of Object.keys(newLayouts)) {
newLayouts[bp] = newLayouts[bp].filter((item) => item.i !== id);
}
return {
widgets: state.widgets.filter((w) => w.id !== id),
layouts: newLayouts,
isDirty: true,
};
});
} catch {
set({ error: 'Failed to remove widget' });
}
},
loadDashboard: async () => {
set({ isLoading: true, error: null });
try {
const [layouts, widgets] = await Promise.all([
api.fetchLayout(),
api.fetchWidgets(),
]);
set({
layouts: layouts as DashboardState['layouts'],
widgets,
isLoading: false,
});
} catch {
set({
error: 'Failed to load dashboard',
isLoading: false,
});
}
},
saveLayout: async () => {
try {
await api.saveLayout(get().layouts);
set({ isDirty: false });
} catch {
set({ error: 'Failed to save layout' });
}
},
}));
+19
View File
@@ -1 +1,20 @@
import '@testing-library/jest-dom/vitest';
// Polyfill ResizeObserver for jsdom (needed by react-grid-layout)
if (typeof globalThis.ResizeObserver === 'undefined') {
globalThis.ResizeObserver = class ResizeObserver {
private callback: ResizeObserverCallback;
constructor(callback: ResizeObserverCallback) {
this.callback = callback;
}
observe() {
// Fire callback immediately with a mock entry
this.callback(
[{ contentRect: { width: 1200, height: 800 } } as unknown as ResizeObserverEntry],
this,
);
}
unobserve() {}
disconnect() {}
};
}
+6
View File
@@ -13,5 +13,11 @@ export default defineConfig({
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
css: false,
server: {
deps: {
inline: ['react-grid-layout', 'react-resizable'],
},
},
},
});
+114
View File
@@ -135,6 +135,12 @@ importers:
react-dom:
specifier: ^19.0.0
version: 19.2.7(react@19.2.7)
react-grid-layout:
specifier: 2.2.3
version: 2.2.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react-resizable:
specifier: ^4.0.2
version: 4.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
zod:
specifier: ^4.4.3
version: 4.4.3
@@ -160,6 +166,9 @@ importers:
'@types/react-dom':
specifier: ^19.0.0
version: 19.2.3(@types/react@19.2.17)
'@types/react-grid-layout':
specifier: ^2.1.0
version: 2.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@vitejs/plugin-react':
specifier: ^6.0.2
version: 6.0.2(vite@8.0.16(@types/node@22.19.21)(jiti@2.7.0)(terser@5.48.0))
@@ -1527,6 +1536,10 @@ packages:
peerDependencies:
'@types/react': ^19.2.0
'@types/react-grid-layout@2.1.0':
resolution: {integrity: sha512-pHEjVg9ert6BDFHFQ1IEdLUkd2gasJvyti5lV2kE46N/R07ZiaSZpAXeXJAA1MXy/Qby23fZmiuEgZkITxPXug==}
deprecated: This is a stub types definition. react-grid-layout provides its own type definitions, so you do not need this installed.
'@types/react@19.2.17':
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
@@ -1928,6 +1941,10 @@ packages:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -2403,6 +2420,9 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-equals@4.0.3:
resolution: {integrity: sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==}
fast-json-stable-stringify@2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
@@ -2926,6 +2946,10 @@ packages:
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
engines: {node: '>=10'}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@@ -3606,6 +3630,9 @@ packages:
promise@7.3.1:
resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
proto-list@1.2.4:
resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==}
@@ -3684,9 +3711,36 @@ packages:
peerDependencies:
react: ^19.2.7
react-draggable@4.7.0:
resolution: {integrity: sha512-kTpANmKWVnFXiZ76Ag2ZowiFStuBYnJ606PI1TbUsOg29/400/JNIxI9+CuenhiAqFuXWJffz6F4UI3R51kUug==}
peerDependencies:
react: '>= 16.3.0'
react-dom: '>= 16.3.0'
react-grid-layout@2.2.3:
resolution: {integrity: sha512-OAEJHBxmfuxQfVtZwRzmsokijGlBgzYIJ7MUlLk/VSa43SaGzu15w5D0P2RDrfX5EvP9POMbL6bFrai/huDzbQ==}
peerDependencies:
react: '>= 16.3.0'
react-dom: '>= 16.3.0'
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react-resizable@3.2.0:
resolution: {integrity: sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==}
peerDependencies:
react: '>= 16.3'
react-dom: '>= 16.3'
react-resizable@4.0.2:
resolution: {integrity: sha512-jZD9ghYRmyJCw0+awYctSZ+9pmX1WXQvzDrTovELYc8obC/BShTI2r4c14LIVzeQ+vJZNb0yKM7bG2eqv7Vfyg==}
peerDependencies:
react: '>= 16.3'
react-dom: '>= 16.3'
react@19.2.7:
resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==}
engines: {node: '>=0.10.0'}
@@ -3714,6 +3768,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
resize-observer-polyfill@1.5.1:
resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -5562,6 +5619,13 @@ snapshots:
dependencies:
'@types/react': 19.2.17
'@types/react-grid-layout@2.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
react-grid-layout: 2.2.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
transitivePeerDependencies:
- react
- react-dom
'@types/react@19.2.17':
dependencies:
csstype: 3.2.3
@@ -6029,6 +6093,8 @@ snapshots:
clone@1.0.4: {}
clsx@2.1.1: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -6537,6 +6603,8 @@ snapshots:
fast-deep-equal@3.1.3: {}
fast-equals@4.0.3: {}
fast-json-stable-stringify@2.1.0: {}
fast-safe-stringify@2.1.1: {}
@@ -7111,6 +7179,10 @@ snapshots:
chalk: 4.1.2
is-unicode-supported: 0.1.0
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
lru-cache@10.4.3:
optional: true
@@ -8216,6 +8288,12 @@ snapshots:
asap: 2.0.6
optional: true
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
object-assign: 4.1.1
react-is: 16.13.1
proto-list@1.2.4:
optional: true
@@ -8341,8 +8419,42 @@ snapshots:
react: 19.2.7
scheduler: 0.27.0
react-draggable@4.7.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
clsx: 2.1.1
prop-types: 15.8.1
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
react-grid-layout@2.2.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
clsx: 2.1.1
fast-equals: 4.0.3
prop-types: 15.8.1
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
react-draggable: 4.7.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react-resizable: 3.2.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
resize-observer-polyfill: 1.5.1
react-is@16.13.1: {}
react-is@17.0.2: {}
react-resizable@3.2.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
prop-types: 15.8.1
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
react-draggable: 4.7.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react-resizable@4.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
prop-types: 15.8.1
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
react-draggable: 4.7.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react@19.2.7: {}
readable-stream@3.6.2:
@@ -8365,6 +8477,8 @@ snapshots:
require-from-string@2.0.2: {}
resize-observer-polyfill@1.5.1: {}
resolve-from@4.0.0: {}
resolve@1.22.12: