Merge feature/41-widget-hud-system (#41) into develop

Implements Widget/HUD system:
- BaseWidget, WidgetRegistry, WidgetGrid
- TasksWidget, CalendarWidget, QuickCaptureWidget
- Layout persistence with useLayouts hooks
- Comprehensive test suite
This commit is contained in:
Jason Woltje
2026-01-29 17:54:50 -06:00
14 changed files with 2142 additions and 0 deletions

View File

@@ -0,0 +1,99 @@
/**
* BaseWidget - Wrapper component for all widgets
* Provides consistent styling, controls, and error/loading states
*/
import type { ReactNode } from "react";
import { Settings, X } from "lucide-react";
import { cn } from "@mosaic/ui/lib/utils";
export interface BaseWidgetProps {
id: string;
title: string;
description?: string;
children: ReactNode;
onEdit?: () => void;
onRemove?: () => void;
className?: string;
isLoading?: boolean;
error?: string;
}
export function BaseWidget({
id,
title,
description,
children,
onEdit,
onRemove,
className,
isLoading = false,
error,
}: BaseWidgetProps) {
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
)}
>
{/* Widget Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 bg-gray-50">
<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>
)}
</div>
{/* Control buttons - only show if handlers provided */}
{(onEdit || onRemove) && (
<div className="flex items-center gap-1 ml-2">
{onEdit && (
<button
onClick={onEdit}
aria-label="Edit widget"
className="p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded transition-colors"
title="Edit widget"
>
<Settings className="w-4 h-4" />
</button>
)}
{onRemove && (
<button
onClick={onRemove}
aria-label="Remove widget"
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
title="Remove widget"
>
<X className="w-4 h-4" />
</button>
)}
</div>
)}
</div>
{/* Widget Content */}
<div className="flex-1 p-4 overflow-auto">
{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>
</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>
</div>
) : (
children
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,145 @@
/**
* 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>
);
}

View File

@@ -0,0 +1,95 @@
/**
* Widget Registry - Central registry for all available widgets
*/
import type { ComponentType } from "react";
import type { WidgetProps } from "@mosaic/shared";
import { TasksWidget } from "./TasksWidget";
import { CalendarWidget } from "./CalendarWidget";
import { QuickCaptureWidget } from "./QuickCaptureWidget";
import { AgentStatusWidget } from "./AgentStatusWidget";
export interface WidgetDefinition {
name: string;
displayName: string;
description: string;
component: ComponentType<WidgetProps>;
defaultWidth: number;
defaultHeight: number;
minWidth: number;
minHeight: number;
maxWidth?: number;
maxHeight?: number;
}
/**
* Registry of all available widgets
*/
export const widgetRegistry: Record<string, WidgetDefinition> = {
TasksWidget: {
name: "TasksWidget",
displayName: "Tasks",
description: "View and manage your tasks",
component: TasksWidget,
defaultWidth: 2,
defaultHeight: 2,
minWidth: 1,
minHeight: 2,
maxWidth: 4,
},
CalendarWidget: {
name: "CalendarWidget",
displayName: "Calendar",
description: "View upcoming events and schedule",
component: CalendarWidget,
defaultWidth: 2,
defaultHeight: 2,
minWidth: 2,
minHeight: 2,
maxWidth: 4,
},
QuickCaptureWidget: {
name: "QuickCaptureWidget",
displayName: "Quick Capture",
description: "Quickly capture notes and tasks",
component: QuickCaptureWidget,
defaultWidth: 2,
defaultHeight: 1,
minWidth: 2,
minHeight: 1,
maxWidth: 4,
maxHeight: 2,
},
AgentStatusWidget: {
name: "AgentStatusWidget",
displayName: "Agent Status",
description: "Monitor agent activity and status",
component: AgentStatusWidget,
defaultWidth: 2,
defaultHeight: 2,
minWidth: 1,
minHeight: 2,
maxWidth: 3,
},
};
/**
* Get widget definition by name
*/
export function getWidgetByName(name: string): WidgetDefinition | undefined {
return widgetRegistry[name];
}
/**
* Get all available widgets as an array
*/
export function getAllWidgets(): WidgetDefinition[] {
return Object.values(widgetRegistry);
}
/**
* Check if a widget name is valid
*/
export function isValidWidget(name: string): boolean {
return name in widgetRegistry;
}

View File

@@ -0,0 +1,145 @@
/**
* BaseWidget Component Tests
* Following TDD - write tests first!
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { BaseWidget } from "../BaseWidget";
describe("BaseWidget", () => {
const mockOnEdit = vi.fn();
const mockOnRemove = vi.fn();
it("should render children content", () => {
render(
<BaseWidget
id="test-widget"
title="Test Widget"
onEdit={mockOnEdit}
onRemove={mockOnRemove}
>
<div>Widget Content</div>
</BaseWidget>
);
expect(screen.getByText("Widget Content")).toBeInTheDocument();
});
it("should render title", () => {
render(
<BaseWidget
id="test-widget"
title="My Custom Widget"
onEdit={mockOnEdit}
onRemove={mockOnRemove}
>
<div>Content</div>
</BaseWidget>
);
expect(screen.getByText("My Custom Widget")).toBeInTheDocument();
});
it("should call onEdit when edit button clicked", async () => {
const user = userEvent.setup();
render(
<BaseWidget
id="test-widget"
title="Test Widget"
onEdit={mockOnEdit}
onRemove={mockOnRemove}
>
<div>Content</div>
</BaseWidget>
);
const editButton = screen.getByRole("button", { name: /edit/i });
await user.click(editButton);
expect(mockOnEdit).toHaveBeenCalledTimes(1);
});
it("should call onRemove when remove button clicked", async () => {
const user = userEvent.setup();
render(
<BaseWidget
id="test-widget"
title="Test Widget"
onEdit={mockOnEdit}
onRemove={mockOnRemove}
>
<div>Content</div>
</BaseWidget>
);
const removeButton = screen.getByRole("button", { name: /remove/i });
await user.click(removeButton);
expect(mockOnRemove).toHaveBeenCalledTimes(1);
});
it("should not show control buttons when handlers not provided", () => {
render(
<BaseWidget id="test-widget" title="Test Widget">
<div>Content</div>
</BaseWidget>
);
expect(screen.queryByRole("button", { name: /edit/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /remove/i })).not.toBeInTheDocument();
});
it("should render with description when provided", () => {
render(
<BaseWidget
id="test-widget"
title="Test Widget"
description="This is a test description"
>
<div>Content</div>
</BaseWidget>
);
expect(screen.getByText("This is a test description")).toBeInTheDocument();
});
it("should apply custom className", () => {
const { container } = render(
<BaseWidget
id="test-widget"
title="Test Widget"
className="custom-class"
>
<div>Content</div>
</BaseWidget>
);
expect(container.querySelector(".custom-class")).toBeInTheDocument();
});
it("should render loading state", () => {
render(
<BaseWidget id="test-widget" title="Test Widget" isLoading={true}>
<div>Content</div>
</BaseWidget>
);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it("should render error state", () => {
render(
<BaseWidget
id="test-widget"
title="Test Widget"
error="Something went wrong"
>
<div>Content</div>
</BaseWidget>
);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,117 @@
/**
* CalendarWidget Component Tests
* Following TDD principles
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { CalendarWidget } from "../CalendarWidget";
global.fetch = vi.fn();
describe("CalendarWidget", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should render loading state initially", () => {
(global.fetch as any).mockImplementation(() => new Promise(() => {}));
render(<CalendarWidget id="calendar-1" />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it("should render upcoming events", async () => {
const mockEvents = [
{
id: "1",
title: "Team Meeting",
startTime: new Date(Date.now() + 3600000).toISOString(),
endTime: new Date(Date.now() + 7200000).toISOString(),
},
{
id: "2",
title: "Project Review",
startTime: new Date(Date.now() + 86400000).toISOString(),
endTime: new Date(Date.now() + 90000000).toISOString(),
},
];
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockEvents,
});
render(<CalendarWidget id="calendar-1" />);
await waitFor(() => {
expect(screen.getByText("Team Meeting")).toBeInTheDocument();
expect(screen.getByText("Project Review")).toBeInTheDocument();
});
});
it("should handle empty event list", async () => {
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => [],
});
render(<CalendarWidget id="calendar-1" />);
await waitFor(() => {
expect(screen.getByText(/no upcoming events/i)).toBeInTheDocument();
});
});
it("should handle API errors gracefully", async () => {
(global.fetch as any).mockRejectedValueOnce(new Error("API Error"));
render(<CalendarWidget id="calendar-1" />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
it("should format event times correctly", async () => {
const now = new Date();
const startTime = new Date(now.getTime() + 3600000); // 1 hour from now
const mockEvents = [
{
id: "1",
title: "Meeting",
startTime: startTime.toISOString(),
endTime: new Date(startTime.getTime() + 3600000).toISOString(),
},
];
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockEvents,
});
render(<CalendarWidget id="calendar-1" />);
await waitFor(() => {
expect(screen.getByText("Meeting")).toBeInTheDocument();
// Should show time in readable format
});
});
it("should display current date", async () => {
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => [],
});
render(<CalendarWidget id="calendar-1" />);
await waitFor(() => {
const currentDate = new Date().toLocaleDateString();
// Widget should display current date or month
expect(screen.getByTestId("calendar-header")).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,148 @@
/**
* QuickCaptureWidget Component Tests
* Following TDD principles
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QuickCaptureWidget } from "../QuickCaptureWidget";
global.fetch = vi.fn();
describe("QuickCaptureWidget", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should render input field", () => {
render(<QuickCaptureWidget id="quick-capture-1" />);
expect(screen.getByRole("textbox")).toBeInTheDocument();
});
it("should render submit button", () => {
render(<QuickCaptureWidget id="quick-capture-1" />);
expect(screen.getByRole("button", { name: /add|capture|submit/i })).toBeInTheDocument();
});
it("should allow text input", async () => {
const user = userEvent.setup();
render(<QuickCaptureWidget id="quick-capture-1" />);
const input = screen.getByRole("textbox");
await user.type(input, "Quick note for later");
expect(input).toHaveValue("Quick note for later");
});
it("should submit note when button clicked", async () => {
const user = userEvent.setup();
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true }),
});
render(<QuickCaptureWidget id="quick-capture-1" />);
const input = screen.getByRole("textbox");
const button = screen.getByRole("button", { name: /add|capture|submit/i });
await user.type(input, "New quick note");
await user.click(button);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining("/api"),
expect.objectContaining({
method: "POST",
})
);
});
});
it("should clear input after successful submission", async () => {
const user = userEvent.setup();
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true }),
});
render(<QuickCaptureWidget id="quick-capture-1" />);
const input = screen.getByRole("textbox");
const button = screen.getByRole("button", { name: /add|capture|submit/i });
await user.type(input, "Test note");
await user.click(button);
await waitFor(() => {
expect(input).toHaveValue("");
});
});
it("should handle submission errors", async () => {
const user = userEvent.setup();
(global.fetch as any).mockRejectedValueOnce(new Error("API Error"));
render(<QuickCaptureWidget id="quick-capture-1" />);
const input = screen.getByRole("textbox");
const button = screen.getByRole("button", { name: /add|capture|submit/i });
await user.type(input, "Test note");
await user.click(button);
await waitFor(() => {
expect(screen.getByText(/error|failed/i)).toBeInTheDocument();
});
});
it("should not submit empty notes", async () => {
const user = userEvent.setup();
render(<QuickCaptureWidget id="quick-capture-1" />);
const button = screen.getByRole("button", { name: /add|capture|submit/i });
await user.click(button);
expect(global.fetch).not.toHaveBeenCalled();
});
it("should support keyboard shortcut (Enter)", async () => {
const user = userEvent.setup();
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true }),
});
render(<QuickCaptureWidget id="quick-capture-1" />);
const input = screen.getByRole("textbox");
await user.type(input, "Quick note{Enter}");
await waitFor(() => {
expect(global.fetch).toHaveBeenCalled();
});
});
it("should show success feedback after submission", async () => {
const user = userEvent.setup();
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true }),
});
render(<QuickCaptureWidget id="quick-capture-1" />);
const input = screen.getByRole("textbox");
const button = screen.getByRole("button", { name: /add|capture|submit/i });
await user.type(input, "Test note");
await user.click(button);
await waitFor(() => {
expect(screen.getByText(/success|saved|captured/i)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,127 @@
/**
* TasksWidget Component Tests
* Following TDD principles
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { TasksWidget } from "../TasksWidget";
// Mock fetch for API calls
global.fetch = vi.fn();
describe("TasksWidget", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should render loading state initially", () => {
(global.fetch as any).mockImplementation(() => new Promise(() => {}));
render(<TasksWidget id="tasks-1" />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it("should render task statistics", async () => {
const mockTasks = [
{ id: "1", title: "Task 1", status: "IN_PROGRESS", priority: "HIGH" },
{ id: "2", title: "Task 2", status: "COMPLETED", priority: "MEDIUM" },
{ id: "3", title: "Task 3", status: "NOT_STARTED", priority: "LOW" },
];
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockTasks,
});
render(<TasksWidget id="tasks-1" />);
await waitFor(() => {
expect(screen.getByText("3")).toBeInTheDocument(); // Total
expect(screen.getByText("1")).toBeInTheDocument(); // In Progress
expect(screen.getByText("1")).toBeInTheDocument(); // Completed
});
});
it("should render task list", async () => {
const mockTasks = [
{ id: "1", title: "Complete documentation", status: "IN_PROGRESS", priority: "HIGH" },
{ id: "2", title: "Review PRs", status: "NOT_STARTED", priority: "MEDIUM" },
];
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockTasks,
});
render(<TasksWidget id="tasks-1" />);
await waitFor(() => {
expect(screen.getByText("Complete documentation")).toBeInTheDocument();
expect(screen.getByText("Review PRs")).toBeInTheDocument();
});
});
it("should handle empty task list", async () => {
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => [],
});
render(<TasksWidget id="tasks-1" />);
await waitFor(() => {
expect(screen.getByText(/no tasks/i)).toBeInTheDocument();
});
});
it("should handle API errors gracefully", async () => {
(global.fetch as any).mockRejectedValueOnce(new Error("API Error"));
render(<TasksWidget id="tasks-1" />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
it("should display priority indicators", async () => {
const mockTasks = [
{ id: "1", title: "High priority task", status: "IN_PROGRESS", priority: "HIGH" },
];
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockTasks,
});
render(<TasksWidget id="tasks-1" />);
await waitFor(() => {
expect(screen.getByText("High priority task")).toBeInTheDocument();
// Priority icon should be rendered (high priority = red)
});
});
it("should limit displayed tasks to 5", async () => {
const mockTasks = Array.from({ length: 10 }, (_, i) => ({
id: `${i + 1}`,
title: `Task ${i + 1}`,
status: "NOT_STARTED",
priority: "MEDIUM",
}));
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockTasks,
});
render(<TasksWidget id="tasks-1" />);
await waitFor(() => {
const taskElements = screen.getAllByText(/Task \d+/);
expect(taskElements.length).toBeLessThanOrEqual(5);
});
});
});

View File

@@ -0,0 +1,135 @@
/**
* WidgetGrid Component Tests
* Following TDD - write tests first!
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { WidgetGrid } from "../WidgetGrid";
import type { WidgetPlacement } from "@mosaic/shared";
// Mock react-grid-layout
vi.mock("react-grid-layout", () => ({
default: ({ children }: any) => <div data-testid="grid-layout">{children}</div>,
Responsive: ({ children }: any) => <div data-testid="responsive-grid-layout">{children}</div>,
}));
describe("WidgetGrid", () => {
const mockLayout: WidgetPlacement[] = [
{ i: "tasks-1", x: 0, y: 0, w: 2, h: 2 },
{ i: "calendar-1", x: 2, y: 0, w: 2, h: 2 },
];
const mockOnLayoutChange = vi.fn();
it("should render grid layout", () => {
render(
<WidgetGrid
layout={mockLayout}
onLayoutChange={mockOnLayoutChange}
/>
);
expect(screen.getByTestId("grid-layout")).toBeInTheDocument();
});
it("should render widgets from layout", () => {
render(
<WidgetGrid
layout={mockLayout}
onLayoutChange={mockOnLayoutChange}
/>
);
// Should render correct number of widgets
const widgets = screen.getAllByTestId(/widget-/);
expect(widgets).toHaveLength(mockLayout.length);
});
it("should call onLayoutChange when layout changes", () => {
const { rerender } = render(
<WidgetGrid
layout={mockLayout}
onLayoutChange={mockOnLayoutChange}
/>
);
const newLayout: WidgetPlacement[] = [
{ i: "tasks-1", x: 1, y: 0, w: 2, h: 2 },
{ i: "calendar-1", x: 2, y: 0, w: 2, h: 2 },
];
rerender(
<WidgetGrid
layout={newLayout}
onLayoutChange={mockOnLayoutChange}
/>
);
// Layout change handler should be set up (actual calls handled by react-grid-layout)
expect(mockOnLayoutChange).toBeDefined();
});
it("should support edit mode", () => {
render(
<WidgetGrid
layout={mockLayout}
onLayoutChange={mockOnLayoutChange}
isEditing={true}
/>
);
// In edit mode, widgets should have edit controls
expect(screen.getByTestId("grid-layout")).toBeInTheDocument();
});
it("should support read-only mode", () => {
render(
<WidgetGrid
layout={mockLayout}
onLayoutChange={mockOnLayoutChange}
isEditing={false}
/>
);
expect(screen.getByTestId("grid-layout")).toBeInTheDocument();
});
it("should render empty state when no widgets", () => {
render(
<WidgetGrid
layout={[]}
onLayoutChange={mockOnLayoutChange}
/>
);
expect(screen.getByText(/no widgets/i)).toBeInTheDocument();
});
it("should handle widget removal", async () => {
const mockOnRemoveWidget = vi.fn();
render(
<WidgetGrid
layout={mockLayout}
onLayoutChange={mockOnLayoutChange}
onRemoveWidget={mockOnRemoveWidget}
isEditing={true}
/>
);
// Widget removal should be supported
expect(mockOnRemoveWidget).toBeDefined();
});
it("should apply custom className", () => {
const { container } = render(
<WidgetGrid
layout={mockLayout}
onLayoutChange={mockOnLayoutChange}
className="custom-grid"
/>
);
expect(container.querySelector(".custom-grid")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,91 @@
/**
* Widget Registry Tests
* Following TDD - write tests first!
*/
import { describe, it, expect } from "vitest";
import { widgetRegistry } from "../WidgetRegistry";
import { TasksWidget } from "../TasksWidget";
import { CalendarWidget } from "../CalendarWidget";
import { QuickCaptureWidget } from "../QuickCaptureWidget";
describe("WidgetRegistry", () => {
it("should have a registry of widgets", () => {
expect(widgetRegistry).toBeDefined();
expect(typeof widgetRegistry).toBe("object");
});
it("should include TasksWidget in registry", () => {
expect(widgetRegistry.TasksWidget).toBeDefined();
expect(widgetRegistry.TasksWidget.component).toBe(TasksWidget);
});
it("should include CalendarWidget in registry", () => {
expect(widgetRegistry.CalendarWidget).toBeDefined();
expect(widgetRegistry.CalendarWidget.component).toBe(CalendarWidget);
});
it("should include QuickCaptureWidget in registry", () => {
expect(widgetRegistry.QuickCaptureWidget).toBeDefined();
expect(widgetRegistry.QuickCaptureWidget.component).toBe(QuickCaptureWidget);
});
it("should have correct metadata for TasksWidget", () => {
const tasksWidget = widgetRegistry.TasksWidget;
expect(tasksWidget.name).toBe("TasksWidget");
expect(tasksWidget.displayName).toBe("Tasks");
expect(tasksWidget.description).toBeDefined();
expect(tasksWidget.defaultWidth).toBeGreaterThan(0);
expect(tasksWidget.defaultHeight).toBeGreaterThan(0);
expect(tasksWidget.minWidth).toBeGreaterThan(0);
expect(tasksWidget.minHeight).toBeGreaterThan(0);
});
it("should have correct metadata for CalendarWidget", () => {
const calendarWidget = widgetRegistry.CalendarWidget;
expect(calendarWidget.name).toBe("CalendarWidget");
expect(calendarWidget.displayName).toBe("Calendar");
expect(calendarWidget.description).toBeDefined();
expect(calendarWidget.defaultWidth).toBeGreaterThan(0);
expect(calendarWidget.defaultHeight).toBeGreaterThan(0);
});
it("should have correct metadata for QuickCaptureWidget", () => {
const quickCaptureWidget = widgetRegistry.QuickCaptureWidget;
expect(quickCaptureWidget.name).toBe("QuickCaptureWidget");
expect(quickCaptureWidget.displayName).toBe("Quick Capture");
expect(quickCaptureWidget.description).toBeDefined();
expect(quickCaptureWidget.defaultWidth).toBeGreaterThan(0);
expect(quickCaptureWidget.defaultHeight).toBeGreaterThan(0);
});
it("should export getWidgetByName helper", async () => {
const { getWidgetByName } = await import("../WidgetRegistry");
expect(typeof getWidgetByName).toBe("function");
});
it("getWidgetByName should return correct widget", async () => {
const { getWidgetByName } = await import("../WidgetRegistry");
const widget = getWidgetByName("TasksWidget");
expect(widget).toBeDefined();
expect(widget?.component).toBe(TasksWidget);
});
it("getWidgetByName should return undefined for invalid name", async () => {
const { getWidgetByName } = await import("../WidgetRegistry");
const widget = getWidgetByName("InvalidWidget");
expect(widget).toBeUndefined();
});
it("should export getAllWidgets helper", async () => {
const { getAllWidgets } = await import("../WidgetRegistry");
expect(typeof getAllWidgets).toBe("function");
});
it("getAllWidgets should return array of all widgets", async () => {
const { getAllWidgets } = await import("../WidgetRegistry");
const widgets = getAllWidgets();
expect(Array.isArray(widgets)).toBe(true);
expect(widgets.length).toBeGreaterThanOrEqual(3);
});
});