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