feat(web): migrate dashboard to WidgetGrid with layout persistence (#497)
All checks were successful
ci/woodpecker/push/web Pipeline was successful

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #497.
This commit is contained in:
2026-02-24 00:50:24 +00:00
committed by jason.woltje
parent f9cccd6965
commit cc56f2cbe1
8 changed files with 399 additions and 176 deletions

View File

@@ -37,16 +37,31 @@ export function BaseWidget({
return (
<div
data-widget-id={id}
className={cn(
"flex flex-col h-full bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden",
className
)}
className={cn("flex flex-col h-full overflow-hidden", className)}
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--r-lg)",
boxShadow: "var(--shadow-sm)",
}}
>
{/* Widget Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 bg-gray-50">
<div
className="flex items-center justify-between px-4 py-3"
style={{
borderBottom: "1px solid var(--border)",
background: "var(--surface-2)",
}}
>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 truncate">{title}</h3>
{description && <p className="text-xs text-gray-500 truncate mt-0.5">{description}</p>}
<h3 className="text-sm font-semibold truncate" style={{ color: "var(--text)" }}>
{title}
</h3>
{description && (
<p className="text-xs truncate mt-0.5" style={{ color: "var(--muted)" }}>
{description}
</p>
)}
</div>
{/* Control buttons - only show if handlers provided */}
@@ -56,7 +71,8 @@ export function BaseWidget({
<button
onClick={onEdit}
aria-label="Edit widget"
className="p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded transition-colors"
className="p-1 rounded transition-colors"
style={{ color: "var(--muted)" }}
title="Edit widget"
>
<Settings className="w-4 h-4" />
@@ -66,7 +82,8 @@ export function BaseWidget({
<button
onClick={onRemove}
aria-label="Remove widget"
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
className="p-1 rounded transition-colors"
style={{ color: "var(--muted)" }}
title="Remove widget"
>
<X className="w-4 h-4" />
@@ -81,15 +98,24 @@ export function BaseWidget({
{isLoading ? (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-2">
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-gray-500">Loading...</span>
<div
className="w-8 h-8 border-2 border-t-transparent rounded-full animate-spin"
style={{ borderColor: "var(--primary)", borderTopColor: "transparent" }}
/>
<span className="text-sm" style={{ color: "var(--muted)" }}>
Loading...
</span>
</div>
</div>
) : error ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="text-red-500 text-sm font-medium mb-1">Error</div>
<div className="text-xs text-gray-600">{error}</div>
<div className="text-sm font-medium mb-1" style={{ color: "var(--danger)" }}>
Error
</div>
<div className="text-xs" style={{ color: "var(--muted)" }}>
{error}
</div>
</div>
</div>
) : (

View File

@@ -5,7 +5,7 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useRef, useState, useEffect } from "react";
import GridLayout from "react-grid-layout";
import type { Layout, LayoutItem } from "react-grid-layout";
import type { WidgetPlacement } from "@mosaic/shared";
@@ -33,6 +33,30 @@ export function WidgetGrid({
isEditing = false,
className,
}: WidgetGridProps): React.JSX.Element {
// Measure container width for responsive grid
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(1200);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver((entries): void => {
const entry = entries[0];
if (entry) {
setContainerWidth(entry.contentRect.width);
}
});
observer.observe(el);
// Set initial width
setContainerWidth(el.clientWidth);
return (): void => {
observer.disconnect();
};
}, []);
// Convert WidgetPlacement to react-grid-layout Layout format
const gridLayout: Layout = useMemo(
() =>
@@ -96,22 +120,34 @@ export function WidgetGrid({
// 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
ref={containerRef}
className="flex items-center justify-center h-full min-h-[400px]"
style={{
background: "var(--surface-2)",
borderRadius: "var(--r-lg)",
border: "2px dashed var(--border)",
}}
>
<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>
<p className="text-lg font-medium" style={{ color: "var(--muted)" }}>
No widgets yet
</p>
<p className="text-sm mt-1" style={{ color: "var(--muted)", opacity: 0.7 }}>
Add widgets to customize your dashboard
</p>
</div>
</div>
);
}
return (
<div className={cn("widget-grid-container", className)}>
<div ref={containerRef} className={cn("widget-grid-container", className)}>
<GridLayout
className="layout"
layout={gridLayout}
onLayoutChange={handleLayoutChange}
width={1200}
width={containerWidth}
gridConfig={{
cols: 12,
rowHeight: 100,

View File

@@ -3,11 +3,20 @@
* Following TDD - write tests first!
*/
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect, vi, beforeAll } from "vitest";
import { render, screen } from "@testing-library/react";
import { WidgetGrid } from "../WidgetGrid";
import type { WidgetPlacement } from "@mosaic/shared";
// ResizeObserver is not available in jsdom
beforeAll((): void => {
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
});
// Mock react-grid-layout
vi.mock("react-grid-layout", () => ({
default: ({ children }: { children: React.ReactNode }): React.JSX.Element => (

View File

@@ -0,0 +1,25 @@
/**
* Default dashboard layout — used when a user has no saved layout.
*
* Widget ID format: "WidgetType-default" where the prefix before the
* first "-" must match a key in WidgetRegistry.
*
* Grid: 12 columns, 100px row height.
*/
import type { WidgetPlacement } from "@mosaic/shared";
export const DEFAULT_LAYOUT: WidgetPlacement[] = [
// Row 0 — top row (3 widgets, 4 cols each)
{ i: "TasksWidget-default", x: 0, y: 0, w: 4, h: 2, minW: 1, minH: 2, maxW: 4 },
{ i: "CalendarWidget-default", x: 4, y: 0, w: 4, h: 2, minW: 2, minH: 2, maxW: 4 },
{ i: "AgentStatusWidget-default", x: 8, y: 0, w: 4, h: 2, minW: 1, minH: 2, maxW: 3 },
// Row 2 — middle row
{ i: "ActiveProjectsWidget-default", x: 0, y: 2, w: 4, h: 3, minW: 2, minH: 2, maxW: 4 },
{ i: "TaskProgressWidget-default", x: 4, y: 2, w: 4, h: 2, minW: 1, minH: 2, maxW: 3 },
{ i: "OrchestratorEventsWidget-default", x: 8, y: 2, w: 4, h: 2, minW: 1, minH: 2, maxW: 4 },
// Row 4 — bottom
{ i: "QuickCaptureWidget-default", x: 4, y: 4, w: 4, h: 1, minW: 2, minH: 1, maxW: 4, maxH: 2 },
];