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:
99
apps/web/src/components/widgets/BaseWidget.tsx
Normal file
99
apps/web/src/components/widgets/BaseWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
apps/web/src/components/widgets/WidgetGrid.tsx
Normal file
145
apps/web/src/components/widgets/WidgetGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
apps/web/src/components/widgets/WidgetRegistry.tsx
Normal file
95
apps/web/src/components/widgets/WidgetRegistry.tsx
Normal 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;
|
||||
}
|
||||
145
apps/web/src/components/widgets/__tests__/BaseWidget.test.tsx
Normal file
145
apps/web/src/components/widgets/__tests__/BaseWidget.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
127
apps/web/src/components/widgets/__tests__/TasksWidget.test.tsx
Normal file
127
apps/web/src/components/widgets/__tests__/TasksWidget.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
135
apps/web/src/components/widgets/__tests__/WidgetGrid.test.tsx
Normal file
135
apps/web/src/components/widgets/__tests__/WidgetGrid.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
215
apps/web/src/hooks/__tests__/useLayouts.test.tsx
Normal file
215
apps/web/src/hooks/__tests__/useLayouts.test.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* useLayouts Hook Tests
|
||||
* Following TDD principles
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// We'll implement this hook
|
||||
import { useLayouts, useCreateLayout, useUpdateLayout, useDeleteLayout } from "../useLayouts";
|
||||
|
||||
global.fetch = vi.fn();
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe("useLayouts", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should fetch layouts on mount", async () => {
|
||||
const mockLayouts = [
|
||||
{ id: "1", name: "Default", isDefault: true, layout: [] },
|
||||
{ id: "2", name: "Custom", isDefault: false, layout: [] },
|
||||
];
|
||||
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockLayouts,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useLayouts(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual(mockLayouts);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle fetch errors", async () => {
|
||||
(global.fetch as any).mockRejectedValueOnce(new Error("API Error"));
|
||||
|
||||
const { result } = renderHook(() => useLayouts(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should show loading state", () => {
|
||||
(global.fetch as any).mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
const { result } = renderHook(() => useLayouts(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCreateLayout", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should create a new layout", async () => {
|
||||
const mockLayout = {
|
||||
id: "3",
|
||||
name: "New Layout",
|
||||
isDefault: false,
|
||||
layout: [],
|
||||
};
|
||||
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockLayout,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCreateLayout(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
name: "New Layout",
|
||||
layout: [],
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
expect(result.current.data).toEqual(mockLayout);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle creation errors", async () => {
|
||||
(global.fetch as any).mockRejectedValueOnce(new Error("API Error"));
|
||||
|
||||
const { result } = renderHook(() => useCreateLayout(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
name: "New Layout",
|
||||
layout: [],
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useUpdateLayout", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should update an existing layout", async () => {
|
||||
const mockLayout = {
|
||||
id: "1",
|
||||
name: "Updated Layout",
|
||||
isDefault: false,
|
||||
layout: [{ i: "widget-1", x: 0, y: 0, w: 2, h: 2 }],
|
||||
};
|
||||
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockLayout,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUpdateLayout(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
id: "1",
|
||||
name: "Updated Layout",
|
||||
layout: [{ i: "widget-1", x: 0, y: 0, w: 2, h: 2 }],
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
expect(result.current.data).toEqual(mockLayout);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle update errors", async () => {
|
||||
(global.fetch as any).mockRejectedValueOnce(new Error("API Error"));
|
||||
|
||||
const { result } = renderHook(() => useUpdateLayout(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
id: "1",
|
||||
name: "Updated Layout",
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDeleteLayout", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should delete a layout", async () => {
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDeleteLayout(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate("1");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle deletion errors", async () => {
|
||||
(global.fetch as any).mockRejectedValueOnce(new Error("API Error"));
|
||||
|
||||
const { result } = renderHook(() => useDeleteLayout(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate("1");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
176
apps/web/src/hooks/useLayouts.ts
Normal file
176
apps/web/src/hooks/useLayouts.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* React Query hooks for layout management
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { UserLayout, WidgetPlacement } from "@mosaic/shared";
|
||||
|
||||
const LAYOUTS_KEY = ["layouts"];
|
||||
|
||||
interface CreateLayoutData {
|
||||
name: string;
|
||||
layout: WidgetPlacement[];
|
||||
isDefault?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface UpdateLayoutData {
|
||||
id: string;
|
||||
name?: string;
|
||||
layout?: WidgetPlacement[];
|
||||
isDefault?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all layouts for the current user
|
||||
*/
|
||||
export function useLayouts() {
|
||||
return useQuery<UserLayout[]>({
|
||||
queryKey: LAYOUTS_KEY,
|
||||
queryFn: async () => {
|
||||
const response = await fetch("/api/layouts");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch layouts");
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single layout by ID
|
||||
*/
|
||||
export function useLayout(id: string) {
|
||||
return useQuery<UserLayout>({
|
||||
queryKey: [...LAYOUTS_KEY, id],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`/api/layouts/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch layout");
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the default layout
|
||||
*/
|
||||
export function useDefaultLayout() {
|
||||
return useQuery<UserLayout>({
|
||||
queryKey: [...LAYOUTS_KEY, "default"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch("/api/layouts/default");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch default layout");
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new layout
|
||||
*/
|
||||
export function useCreateLayout() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: CreateLayoutData) => {
|
||||
const response = await fetch("/api/layouts", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to create layout");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate layouts cache to refetch
|
||||
queryClient.invalidateQueries({ queryKey: LAYOUTS_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing layout
|
||||
*/
|
||||
export function useUpdateLayout() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, ...data }: UpdateLayoutData) => {
|
||||
const response = await fetch(`/api/layouts/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to update layout");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
// Invalidate affected queries
|
||||
queryClient.invalidateQueries({ queryKey: LAYOUTS_KEY });
|
||||
queryClient.invalidateQueries({ queryKey: [...LAYOUTS_KEY, variables.id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a layout
|
||||
*/
|
||||
export function useDeleteLayout() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const response = await fetch(`/api/layouts/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to delete layout");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate layouts cache to refetch
|
||||
queryClient.invalidateQueries({ queryKey: LAYOUTS_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper hook to save layout changes with debouncing
|
||||
*/
|
||||
export function useSaveLayout(layoutId: string) {
|
||||
const updateLayout = useUpdateLayout();
|
||||
|
||||
const saveLayout = (layout: WidgetPlacement[]) => {
|
||||
updateLayout.mutate({
|
||||
id: layoutId,
|
||||
layout,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
saveLayout,
|
||||
isSaving: updateLayout.isPending,
|
||||
error: updateLayout.error,
|
||||
};
|
||||
}
|
||||
231
apps/web/src/hooks/useWebSocket.test.tsx
Normal file
231
apps/web/src/hooks/useWebSocket.test.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useWebSocket } from './useWebSocket';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
|
||||
// Mock socket.io-client
|
||||
vi.mock('socket.io-client');
|
||||
|
||||
describe('useWebSocket', () => {
|
||||
let mockSocket: Partial<Socket>;
|
||||
let eventHandlers: Record<string, (data: unknown) => void>;
|
||||
|
||||
beforeEach(() => {
|
||||
eventHandlers = {};
|
||||
|
||||
mockSocket = {
|
||||
on: vi.fn((event: string, handler: (data: unknown) => void) => {
|
||||
eventHandlers[event] = handler;
|
||||
return mockSocket as Socket;
|
||||
}),
|
||||
off: vi.fn((event: string) => {
|
||||
delete eventHandlers[event];
|
||||
return mockSocket as Socket;
|
||||
}),
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
connected: false,
|
||||
};
|
||||
|
||||
(io as unknown as ReturnType<typeof vi.fn>).mockReturnValue(mockSocket);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should connect to WebSocket server on mount', () => {
|
||||
const workspaceId = 'workspace-123';
|
||||
const token = 'auth-token';
|
||||
|
||||
renderHook(() => useWebSocket(workspaceId, token));
|
||||
|
||||
expect(io).toHaveBeenCalledWith(expect.any(String), {
|
||||
auth: { token },
|
||||
query: { workspaceId },
|
||||
});
|
||||
});
|
||||
|
||||
it('should disconnect on unmount', () => {
|
||||
const { unmount } = renderHook(() => useWebSocket('workspace-123', 'token'));
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockSocket.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update connection status on connect event', async () => {
|
||||
mockSocket.connected = false;
|
||||
const { result } = renderHook(() => useWebSocket('workspace-123', 'token'));
|
||||
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
|
||||
act(() => {
|
||||
mockSocket.connected = true;
|
||||
eventHandlers['connect']?.(undefined);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should update connection status on disconnect event', async () => {
|
||||
mockSocket.connected = true;
|
||||
const { result } = renderHook(() => useWebSocket('workspace-123', 'token'));
|
||||
|
||||
act(() => {
|
||||
eventHandlers['connect']?.(undefined);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
mockSocket.connected = false;
|
||||
eventHandlers['disconnect']?.(undefined);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle task:created events', async () => {
|
||||
const onTaskCreated = vi.fn();
|
||||
renderHook(() => useWebSocket('workspace-123', 'token', { onTaskCreated }));
|
||||
|
||||
const task = { id: 'task-1', title: 'New Task' };
|
||||
|
||||
act(() => {
|
||||
eventHandlers['task:created']?.(task);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onTaskCreated).toHaveBeenCalledWith(task);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle task:updated events', async () => {
|
||||
const onTaskUpdated = vi.fn();
|
||||
renderHook(() => useWebSocket('workspace-123', 'token', { onTaskUpdated }));
|
||||
|
||||
const task = { id: 'task-1', title: 'Updated Task' };
|
||||
|
||||
act(() => {
|
||||
eventHandlers['task:updated']?.(task);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onTaskUpdated).toHaveBeenCalledWith(task);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle task:deleted events', async () => {
|
||||
const onTaskDeleted = vi.fn();
|
||||
renderHook(() => useWebSocket('workspace-123', 'token', { onTaskDeleted }));
|
||||
|
||||
const payload = { id: 'task-1' };
|
||||
|
||||
act(() => {
|
||||
eventHandlers['task:deleted']?.(payload);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onTaskDeleted).toHaveBeenCalledWith(payload);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle event:created events', async () => {
|
||||
const onEventCreated = vi.fn();
|
||||
renderHook(() => useWebSocket('workspace-123', 'token', { onEventCreated }));
|
||||
|
||||
const event = { id: 'event-1', title: 'New Event' };
|
||||
|
||||
act(() => {
|
||||
eventHandlers['event:created']?.(event);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onEventCreated).toHaveBeenCalledWith(event);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle event:updated events', async () => {
|
||||
const onEventUpdated = vi.fn();
|
||||
renderHook(() => useWebSocket('workspace-123', 'token', { onEventUpdated }));
|
||||
|
||||
const event = { id: 'event-1', title: 'Updated Event' };
|
||||
|
||||
act(() => {
|
||||
eventHandlers['event:updated']?.(event);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onEventUpdated).toHaveBeenCalledWith(event);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle event:deleted events', async () => {
|
||||
const onEventDeleted = vi.fn();
|
||||
renderHook(() => useWebSocket('workspace-123', 'token', { onEventDeleted }));
|
||||
|
||||
const payload = { id: 'event-1' };
|
||||
|
||||
act(() => {
|
||||
eventHandlers['event:deleted']?.(payload);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onEventDeleted).toHaveBeenCalledWith(payload);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle project:updated events', async () => {
|
||||
const onProjectUpdated = vi.fn();
|
||||
renderHook(() => useWebSocket('workspace-123', 'token', { onProjectUpdated }));
|
||||
|
||||
const project = { id: 'project-1', name: 'Updated Project' };
|
||||
|
||||
act(() => {
|
||||
eventHandlers['project:updated']?.(project);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onProjectUpdated).toHaveBeenCalledWith(project);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reconnect with new workspace ID', () => {
|
||||
const { rerender } = renderHook(
|
||||
({ workspaceId }: { workspaceId: string }) => useWebSocket(workspaceId, 'token'),
|
||||
{ initialProps: { workspaceId: 'workspace-1' } }
|
||||
);
|
||||
|
||||
expect(io).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({ workspaceId: 'workspace-2' });
|
||||
|
||||
expect(mockSocket.disconnect).toHaveBeenCalled();
|
||||
expect(io).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should clean up all event listeners on unmount', () => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useWebSocket('workspace-123', 'token', {
|
||||
onTaskCreated: vi.fn(),
|
||||
onTaskUpdated: vi.fn(),
|
||||
onTaskDeleted: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockSocket.off).toHaveBeenCalledWith('connect', expect.any(Function));
|
||||
expect(mockSocket.off).toHaveBeenCalledWith('disconnect', expect.any(Function));
|
||||
expect(mockSocket.off).toHaveBeenCalledWith('task:created', expect.any(Function));
|
||||
expect(mockSocket.off).toHaveBeenCalledWith('task:updated', expect.any(Function));
|
||||
expect(mockSocket.off).toHaveBeenCalledWith('task:deleted', expect.any(Function));
|
||||
});
|
||||
});
|
||||
142
apps/web/src/hooks/useWebSocket.ts
Normal file
142
apps/web/src/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DeletePayload {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface WebSocketCallbacks {
|
||||
onTaskCreated?: (task: Task) => void;
|
||||
onTaskUpdated?: (task: Task) => void;
|
||||
onTaskDeleted?: (payload: DeletePayload) => void;
|
||||
onEventCreated?: (event: Event) => void;
|
||||
onEventUpdated?: (event: Event) => void;
|
||||
onEventDeleted?: (payload: DeletePayload) => void;
|
||||
onProjectUpdated?: (project: Project) => void;
|
||||
}
|
||||
|
||||
interface UseWebSocketReturn {
|
||||
isConnected: boolean;
|
||||
socket: Socket | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing WebSocket connections and real-time updates
|
||||
*
|
||||
* @param workspaceId - The workspace ID to subscribe to
|
||||
* @param token - Authentication token
|
||||
* @param callbacks - Event callbacks for real-time updates
|
||||
* @returns Connection status and socket instance
|
||||
*/
|
||||
export function useWebSocket(
|
||||
workspaceId: string,
|
||||
token: string,
|
||||
callbacks: WebSocketCallbacks = {}
|
||||
): UseWebSocketReturn {
|
||||
const [socket, setSocket] = useState<Socket | null>(null);
|
||||
const [isConnected, setIsConnected] = useState<boolean>(false);
|
||||
|
||||
const {
|
||||
onTaskCreated,
|
||||
onTaskUpdated,
|
||||
onTaskDeleted,
|
||||
onEventCreated,
|
||||
onEventUpdated,
|
||||
onEventDeleted,
|
||||
onProjectUpdated,
|
||||
} = callbacks;
|
||||
|
||||
useEffect(() => {
|
||||
// Get WebSocket URL from environment or default to API URL
|
||||
const wsUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
// Create socket connection
|
||||
const newSocket = io(wsUrl, {
|
||||
auth: { token },
|
||||
query: { workspaceId },
|
||||
});
|
||||
|
||||
setSocket(newSocket);
|
||||
|
||||
// Connection event handlers
|
||||
const handleConnect = (): void => {
|
||||
setIsConnected(true);
|
||||
};
|
||||
|
||||
const handleDisconnect = (): void => {
|
||||
setIsConnected(false);
|
||||
};
|
||||
|
||||
newSocket.on('connect', handleConnect);
|
||||
newSocket.on('disconnect', handleDisconnect);
|
||||
|
||||
// Real-time event handlers
|
||||
if (onTaskCreated) {
|
||||
newSocket.on('task:created', onTaskCreated);
|
||||
}
|
||||
if (onTaskUpdated) {
|
||||
newSocket.on('task:updated', onTaskUpdated);
|
||||
}
|
||||
if (onTaskDeleted) {
|
||||
newSocket.on('task:deleted', onTaskDeleted);
|
||||
}
|
||||
if (onEventCreated) {
|
||||
newSocket.on('event:created', onEventCreated);
|
||||
}
|
||||
if (onEventUpdated) {
|
||||
newSocket.on('event:updated', onEventUpdated);
|
||||
}
|
||||
if (onEventDeleted) {
|
||||
newSocket.on('event:deleted', onEventDeleted);
|
||||
}
|
||||
if (onProjectUpdated) {
|
||||
newSocket.on('project:updated', onProjectUpdated);
|
||||
}
|
||||
|
||||
// Cleanup on unmount or dependency change
|
||||
return (): void => {
|
||||
newSocket.off('connect', handleConnect);
|
||||
newSocket.off('disconnect', handleDisconnect);
|
||||
|
||||
if (onTaskCreated) newSocket.off('task:created', onTaskCreated);
|
||||
if (onTaskUpdated) newSocket.off('task:updated', onTaskUpdated);
|
||||
if (onTaskDeleted) newSocket.off('task:deleted', onTaskDeleted);
|
||||
if (onEventCreated) newSocket.off('event:created', onEventCreated);
|
||||
if (onEventUpdated) newSocket.off('event:updated', onEventUpdated);
|
||||
if (onEventDeleted) newSocket.off('event:deleted', onEventDeleted);
|
||||
if (onProjectUpdated) newSocket.off('project:updated', onProjectUpdated);
|
||||
|
||||
newSocket.disconnect();
|
||||
};
|
||||
}, [
|
||||
workspaceId,
|
||||
token,
|
||||
onTaskCreated,
|
||||
onTaskUpdated,
|
||||
onTaskDeleted,
|
||||
onEventCreated,
|
||||
onEventUpdated,
|
||||
onEventDeleted,
|
||||
onProjectUpdated,
|
||||
]);
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
socket,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user