Files
stack/apps/web/src/components/widgets/WidgetGrid.tsx
Jason Woltje 14a1e218a5 feat(#41): implement Widget/HUD system
- BaseWidget wrapper with loading/error states
- WidgetRegistry for central widget management
- WidgetGrid with react-grid-layout integration
- TasksWidget, CalendarWidget, QuickCaptureWidget
- useLayouts hooks for layout persistence
- Comprehensive test suite (TDD approach)
2026-01-29 17:54:46 -06:00

146 lines
4.1 KiB
TypeScript

/**
* WidgetGrid - Draggable grid layout for widgets
* Uses react-grid-layout for drag-and-drop functionality
*/
import { useCallback, useMemo } from "react";
import GridLayout from "react-grid-layout";
import type { Layout } from "react-grid-layout";
import type { WidgetPlacement } from "@mosaic/shared";
import { cn } from "@mosaic/ui/lib/utils";
import { getWidgetByName } from "./WidgetRegistry";
import { BaseWidget } from "./BaseWidget";
import "react-grid-layout/css/styles.css";
export interface WidgetGridProps {
layout: WidgetPlacement[];
onLayoutChange: (layout: WidgetPlacement[]) => void;
onRemoveWidget?: (widgetId: string) => void;
isEditing?: boolean;
className?: string;
}
export function WidgetGrid({
layout,
onLayoutChange,
onRemoveWidget,
isEditing = false,
className,
}: WidgetGridProps) {
// Convert WidgetPlacement to react-grid-layout Layout format
const gridLayout: Layout[] = useMemo(
() =>
layout.map((item) => ({
i: item.i,
x: item.x,
y: item.y,
w: item.w,
h: item.h,
minW: item.minW,
maxW: item.maxW,
minH: item.minH,
maxH: item.maxH,
static: !isEditing || item.static,
isDraggable: isEditing && (item.isDraggable !== false),
isResizable: isEditing && (item.isResizable !== false),
})),
[layout, isEditing]
);
const handleLayoutChange = useCallback(
(newLayout: Layout[]) => {
const updatedLayout: WidgetPlacement[] = newLayout.map((item) => ({
i: item.i,
x: item.x,
y: item.y,
w: item.w,
h: item.h,
minW: item.minW,
maxW: item.maxW,
minH: item.minH,
maxH: item.maxH,
static: item.static,
isDraggable: item.isDraggable,
isResizable: item.isResizable,
}));
onLayoutChange(updatedLayout);
},
[onLayoutChange]
);
const handleRemoveWidget = useCallback(
(widgetId: string) => {
if (onRemoveWidget) {
onRemoveWidget(widgetId);
}
},
[onRemoveWidget]
);
// Empty state
if (layout.length === 0) {
return (
<div className="flex items-center justify-center h-full min-h-[400px] bg-gray-50 rounded-lg border-2 border-dashed border-gray-300">
<div className="text-center">
<p className="text-gray-500 text-lg font-medium">No widgets yet</p>
<p className="text-gray-400 text-sm mt-1">
Add widgets to customize your dashboard
</p>
</div>
</div>
);
}
return (
<div className={cn("widget-grid-container", className)}>
<GridLayout
className="layout"
layout={gridLayout}
onLayoutChange={handleLayoutChange}
cols={12}
rowHeight={100}
width={1200}
isDraggable={isEditing}
isResizable={isEditing}
compactType="vertical"
preventCollision={false}
data-testid="grid-layout"
>
{layout.map((item) => {
// Extract widget type from widget ID (format: "WidgetType-uuid")
const widgetType = item.i.split("-")[0];
const widgetDef = getWidgetByName(widgetType);
if (!widgetDef) {
return (
<div key={item.i} data-testid={`widget-${item.i}`}>
<BaseWidget id={item.i} title="Unknown Widget" error="Widget not found" />
</div>
);
}
const WidgetComponent = widgetDef.component;
return (
<div key={item.i} data-testid={`widget-${item.i}`}>
<BaseWidget
id={item.i}
title={widgetDef.displayName}
description={widgetDef.description}
onEdit={isEditing ? undefined : undefined} // TODO: Implement edit
onRemove={
isEditing && onRemoveWidget
? () => handleRemoveWidget(item.i)
: undefined
}
>
<WidgetComponent id={item.i} />
</BaseWidget>
</div>
);
})}
</GridLayout>
</div>
);
}