From 14a1e218a5a068a2a01159753a90ce67a42a1ba8 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 29 Jan 2026 17:54:46 -0600 Subject: [PATCH] feat(#41): implement Widget/HUD system - BaseWidget wrapper with loading/error states - WidgetRegistry for central widget management - WidgetGrid with react-grid-layout integration - TasksWidget, CalendarWidget, QuickCaptureWidget - useLayouts hooks for layout persistence - Comprehensive test suite (TDD approach) --- .../layouts/__tests__/layouts.service.spec.ts | 276 ++++++++++++++++++ .../web/src/components/widgets/BaseWidget.tsx | 99 +++++++ .../web/src/components/widgets/WidgetGrid.tsx | 145 +++++++++ .../src/components/widgets/WidgetRegistry.tsx | 95 ++++++ .../widgets/__tests__/BaseWidget.test.tsx | 145 +++++++++ .../widgets/__tests__/CalendarWidget.test.tsx | 117 ++++++++ .../__tests__/QuickCaptureWidget.test.tsx | 148 ++++++++++ .../widgets/__tests__/TasksWidget.test.tsx | 127 ++++++++ .../widgets/__tests__/WidgetGrid.test.tsx | 135 +++++++++ .../widgets/__tests__/WidgetRegistry.test.tsx | 91 ++++++ .../src/hooks/__tests__/useLayouts.test.tsx | 215 ++++++++++++++ apps/web/src/hooks/useLayouts.ts | 176 +++++++++++ apps/web/src/hooks/useWebSocket.test.tsx | 231 +++++++++++++++ apps/web/src/hooks/useWebSocket.ts | 142 +++++++++ 14 files changed, 2142 insertions(+) create mode 100644 apps/api/src/layouts/__tests__/layouts.service.spec.ts create mode 100644 apps/web/src/components/widgets/BaseWidget.tsx create mode 100644 apps/web/src/components/widgets/WidgetGrid.tsx create mode 100644 apps/web/src/components/widgets/WidgetRegistry.tsx create mode 100644 apps/web/src/components/widgets/__tests__/BaseWidget.test.tsx create mode 100644 apps/web/src/components/widgets/__tests__/CalendarWidget.test.tsx create mode 100644 apps/web/src/components/widgets/__tests__/QuickCaptureWidget.test.tsx create mode 100644 apps/web/src/components/widgets/__tests__/TasksWidget.test.tsx create mode 100644 apps/web/src/components/widgets/__tests__/WidgetGrid.test.tsx create mode 100644 apps/web/src/components/widgets/__tests__/WidgetRegistry.test.tsx create mode 100644 apps/web/src/hooks/__tests__/useLayouts.test.tsx create mode 100644 apps/web/src/hooks/useLayouts.ts create mode 100644 apps/web/src/hooks/useWebSocket.test.tsx create mode 100644 apps/web/src/hooks/useWebSocket.ts diff --git a/apps/api/src/layouts/__tests__/layouts.service.spec.ts b/apps/api/src/layouts/__tests__/layouts.service.spec.ts new file mode 100644 index 0000000..5bf261e --- /dev/null +++ b/apps/api/src/layouts/__tests__/layouts.service.spec.ts @@ -0,0 +1,276 @@ +/** + * LayoutsService Unit Tests + * Following TDD principles + */ + +import { Test, TestingModule } from "@nestjs/testing"; +import { NotFoundException } from "@nestjs/common"; +import { LayoutsService } from "../layouts.service"; +import { PrismaService } from "../../prisma/prisma.service"; + +describe("LayoutsService", () => { + let service: LayoutsService; + let prisma: jest.Mocked; + + const mockWorkspaceId = "workspace-123"; + const mockUserId = "user-123"; + + const mockLayout = { + id: "layout-1", + workspaceId: mockWorkspaceId, + userId: mockUserId, + name: "Default Layout", + isDefault: true, + layout: [ + { i: "tasks-1", x: 0, y: 0, w: 2, h: 2 }, + { i: "calendar-1", x: 2, y: 0, w: 2, h: 2 }, + ], + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LayoutsService, + { + provide: PrismaService, + useValue: { + userLayout: { + findMany: jest.fn(), + findFirst: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + updateMany: jest.fn(), + delete: jest.fn(), + }, + $transaction: jest.fn((callback) => callback(prisma)), + }, + }, + ], + }).compile(); + + service = module.get(LayoutsService); + prisma = module.get(PrismaService) as jest.Mocked; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("findAll", () => { + it("should return all layouts for a user", async () => { + const mockLayouts = [mockLayout]; + prisma.userLayout.findMany.mockResolvedValue(mockLayouts); + + const result = await service.findAll(mockWorkspaceId, mockUserId); + + expect(result).toEqual(mockLayouts); + expect(prisma.userLayout.findMany).toHaveBeenCalledWith({ + where: { + workspaceId: mockWorkspaceId, + userId: mockUserId, + }, + orderBy: { + isDefault: "desc", + createdAt: "desc", + }, + }); + }); + }); + + describe("findDefault", () => { + it("should return default layout", async () => { + prisma.userLayout.findFirst.mockResolvedValueOnce(mockLayout); + + const result = await service.findDefault(mockWorkspaceId, mockUserId); + + expect(result).toEqual(mockLayout); + expect(prisma.userLayout.findFirst).toHaveBeenCalledWith({ + where: { + workspaceId: mockWorkspaceId, + userId: mockUserId, + isDefault: true, + }, + }); + }); + + it("should return most recent layout if no default exists", async () => { + prisma.userLayout.findFirst + .mockResolvedValueOnce(null) // No default + .mockResolvedValueOnce(mockLayout); // Most recent + + const result = await service.findDefault(mockWorkspaceId, mockUserId); + + expect(result).toEqual(mockLayout); + expect(prisma.userLayout.findFirst).toHaveBeenCalledTimes(2); + }); + + it("should throw NotFoundException if no layouts exist", async () => { + prisma.userLayout.findFirst + .mockResolvedValueOnce(null) // No default + .mockResolvedValueOnce(null); // No layouts + + await expect( + service.findDefault(mockWorkspaceId, mockUserId) + ).rejects.toThrow(NotFoundException); + }); + }); + + describe("findOne", () => { + it("should return a layout by ID", async () => { + prisma.userLayout.findUnique.mockResolvedValue(mockLayout); + + const result = await service.findOne("layout-1", mockWorkspaceId, mockUserId); + + expect(result).toEqual(mockLayout); + expect(prisma.userLayout.findUnique).toHaveBeenCalledWith({ + where: { + id: "layout-1", + workspaceId: mockWorkspaceId, + userId: mockUserId, + }, + }); + }); + + it("should throw NotFoundException if layout not found", async () => { + prisma.userLayout.findUnique.mockResolvedValue(null); + + await expect( + service.findOne("invalid-id", mockWorkspaceId, mockUserId) + ).rejects.toThrow(NotFoundException); + }); + }); + + describe("create", () => { + it("should create a new layout", async () => { + const createDto = { + name: "New Layout", + layout: [], + isDefault: false, + }; + + prisma.$transaction.mockImplementation((callback) => + callback({ + userLayout: { + create: jest.fn().mockResolvedValue(mockLayout), + updateMany: jest.fn(), + }, + }) + ); + + const result = await service.create(mockWorkspaceId, mockUserId, createDto); + + expect(result).toBeDefined(); + }); + + it("should unset other defaults when creating default layout", async () => { + const createDto = { + name: "New Default", + layout: [], + isDefault: true, + }; + + const mockUpdateMany = jest.fn(); + const mockCreate = jest.fn().mockResolvedValue(mockLayout); + + prisma.$transaction.mockImplementation((callback) => + callback({ + userLayout: { + updateMany: mockUpdateMany, + create: mockCreate, + }, + }) + ); + + await service.create(mockWorkspaceId, mockUserId, createDto); + + expect(mockUpdateMany).toHaveBeenCalledWith({ + where: { + workspaceId: mockWorkspaceId, + userId: mockUserId, + isDefault: true, + }, + data: { + isDefault: false, + }, + }); + }); + }); + + describe("update", () => { + it("should update a layout", async () => { + const updateDto = { + name: "Updated Layout", + layout: [{ i: "tasks-1", x: 1, y: 0, w: 2, h: 2 }], + }; + + const mockUpdate = jest.fn().mockResolvedValue({ ...mockLayout, ...updateDto }); + const mockFindUnique = jest.fn().mockResolvedValue(mockLayout); + + prisma.$transaction.mockImplementation((callback) => + callback({ + userLayout: { + findUnique: mockFindUnique, + update: mockUpdate, + updateMany: jest.fn(), + }, + }) + ); + + const result = await service.update( + "layout-1", + mockWorkspaceId, + mockUserId, + updateDto + ); + + expect(result).toBeDefined(); + expect(mockFindUnique).toHaveBeenCalled(); + expect(mockUpdate).toHaveBeenCalled(); + }); + + it("should throw NotFoundException if layout not found", async () => { + const mockFindUnique = jest.fn().mockResolvedValue(null); + + prisma.$transaction.mockImplementation((callback) => + callback({ + userLayout: { + findUnique: mockFindUnique, + }, + }) + ); + + await expect( + service.update("invalid-id", mockWorkspaceId, mockUserId, {}) + ).rejects.toThrow(NotFoundException); + }); + }); + + describe("remove", () => { + it("should delete a layout", async () => { + prisma.userLayout.findUnique.mockResolvedValue(mockLayout); + prisma.userLayout.delete.mockResolvedValue(mockLayout); + + await service.remove("layout-1", mockWorkspaceId, mockUserId); + + expect(prisma.userLayout.delete).toHaveBeenCalledWith({ + where: { + id: "layout-1", + workspaceId: mockWorkspaceId, + userId: mockUserId, + }, + }); + }); + + it("should throw NotFoundException if layout not found", async () => { + prisma.userLayout.findUnique.mockResolvedValue(null); + + await expect( + service.remove("invalid-id", mockWorkspaceId, mockUserId) + ).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/apps/web/src/components/widgets/BaseWidget.tsx b/apps/web/src/components/widgets/BaseWidget.tsx new file mode 100644 index 0000000..529e8e5 --- /dev/null +++ b/apps/web/src/components/widgets/BaseWidget.tsx @@ -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 ( +
+ {/* Widget Header */} +
+
+

{title}

+ {description && ( +

{description}

+ )} +
+ + {/* Control buttons - only show if handlers provided */} + {(onEdit || onRemove) && ( +
+ {onEdit && ( + + )} + {onRemove && ( + + )} +
+ )} +
+ + {/* Widget Content */} +
+ {isLoading ? ( +
+
+
+ Loading... +
+
+ ) : error ? ( +
+
+
Error
+
{error}
+
+
+ ) : ( + children + )} +
+
+ ); +} diff --git a/apps/web/src/components/widgets/WidgetGrid.tsx b/apps/web/src/components/widgets/WidgetGrid.tsx new file mode 100644 index 0000000..5cd7f5b --- /dev/null +++ b/apps/web/src/components/widgets/WidgetGrid.tsx @@ -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 ( +
+
+

No widgets yet

+

+ Add widgets to customize your dashboard +

+
+
+ ); + } + + return ( +
+ + {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 ( +
+ +
+ ); + } + + const WidgetComponent = widgetDef.component; + + return ( +
+ handleRemoveWidget(item.i) + : undefined + } + > + + +
+ ); + })} +
+
+ ); +} diff --git a/apps/web/src/components/widgets/WidgetRegistry.tsx b/apps/web/src/components/widgets/WidgetRegistry.tsx new file mode 100644 index 0000000..1cfe759 --- /dev/null +++ b/apps/web/src/components/widgets/WidgetRegistry.tsx @@ -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; + defaultWidth: number; + defaultHeight: number; + minWidth: number; + minHeight: number; + maxWidth?: number; + maxHeight?: number; +} + +/** + * Registry of all available widgets + */ +export const widgetRegistry: Record = { + 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; +} diff --git a/apps/web/src/components/widgets/__tests__/BaseWidget.test.tsx b/apps/web/src/components/widgets/__tests__/BaseWidget.test.tsx new file mode 100644 index 0000000..e370291 --- /dev/null +++ b/apps/web/src/components/widgets/__tests__/BaseWidget.test.tsx @@ -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( + +
Widget Content
+
+ ); + + expect(screen.getByText("Widget Content")).toBeInTheDocument(); + }); + + it("should render title", () => { + render( + +
Content
+
+ ); + + expect(screen.getByText("My Custom Widget")).toBeInTheDocument(); + }); + + it("should call onEdit when edit button clicked", async () => { + const user = userEvent.setup(); + render( + +
Content
+
+ ); + + 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( + +
Content
+
+ ); + + 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( + +
Content
+
+ ); + + 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( + +
Content
+
+ ); + + expect(screen.getByText("This is a test description")).toBeInTheDocument(); + }); + + it("should apply custom className", () => { + const { container } = render( + +
Content
+
+ ); + + expect(container.querySelector(".custom-class")).toBeInTheDocument(); + }); + + it("should render loading state", () => { + render( + +
Content
+
+ ); + + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + }); + + it("should render error state", () => { + render( + +
Content
+
+ ); + + expect(screen.getByText("Something went wrong")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/widgets/__tests__/CalendarWidget.test.tsx b/apps/web/src/components/widgets/__tests__/CalendarWidget.test.tsx new file mode 100644 index 0000000..033727d --- /dev/null +++ b/apps/web/src/components/widgets/__tests__/CalendarWidget.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + await waitFor(() => { + const currentDate = new Date().toLocaleDateString(); + // Widget should display current date or month + expect(screen.getByTestId("calendar-header")).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/src/components/widgets/__tests__/QuickCaptureWidget.test.tsx b/apps/web/src/components/widgets/__tests__/QuickCaptureWidget.test.tsx new file mode 100644 index 0000000..e6f193b --- /dev/null +++ b/apps/web/src/components/widgets/__tests__/QuickCaptureWidget.test.tsx @@ -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(); + + expect(screen.getByRole("textbox")).toBeInTheDocument(); + }); + + it("should render submit button", () => { + render(); + + expect(screen.getByRole("button", { name: /add|capture|submit/i })).toBeInTheDocument(); + }); + + it("should allow text input", async () => { + const user = userEvent.setup(); + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); + }); +}); diff --git a/apps/web/src/components/widgets/__tests__/TasksWidget.test.tsx b/apps/web/src/components/widgets/__tests__/TasksWidget.test.tsx new file mode 100644 index 0000000..dea16e6 --- /dev/null +++ b/apps/web/src/components/widgets/__tests__/TasksWidget.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + await waitFor(() => { + const taskElements = screen.getAllByText(/Task \d+/); + expect(taskElements.length).toBeLessThanOrEqual(5); + }); + }); +}); diff --git a/apps/web/src/components/widgets/__tests__/WidgetGrid.test.tsx b/apps/web/src/components/widgets/__tests__/WidgetGrid.test.tsx new file mode 100644 index 0000000..3de73e7 --- /dev/null +++ b/apps/web/src/components/widgets/__tests__/WidgetGrid.test.tsx @@ -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) =>
{children}
, + Responsive: ({ children }: any) =>
{children}
, +})); + +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( + + ); + + expect(screen.getByTestId("grid-layout")).toBeInTheDocument(); + }); + + it("should render widgets from layout", () => { + render( + + ); + + // 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( + + ); + + 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( + + ); + + // Layout change handler should be set up (actual calls handled by react-grid-layout) + expect(mockOnLayoutChange).toBeDefined(); + }); + + it("should support edit mode", () => { + render( + + ); + + // In edit mode, widgets should have edit controls + expect(screen.getByTestId("grid-layout")).toBeInTheDocument(); + }); + + it("should support read-only mode", () => { + render( + + ); + + expect(screen.getByTestId("grid-layout")).toBeInTheDocument(); + }); + + it("should render empty state when no widgets", () => { + render( + + ); + + expect(screen.getByText(/no widgets/i)).toBeInTheDocument(); + }); + + it("should handle widget removal", async () => { + const mockOnRemoveWidget = vi.fn(); + render( + + ); + + // Widget removal should be supported + expect(mockOnRemoveWidget).toBeDefined(); + }); + + it("should apply custom className", () => { + const { container } = render( + + ); + + expect(container.querySelector(".custom-grid")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/widgets/__tests__/WidgetRegistry.test.tsx b/apps/web/src/components/widgets/__tests__/WidgetRegistry.test.tsx new file mode 100644 index 0000000..bcc5624 --- /dev/null +++ b/apps/web/src/components/widgets/__tests__/WidgetRegistry.test.tsx @@ -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); + }); +}); diff --git a/apps/web/src/hooks/__tests__/useLayouts.test.tsx b/apps/web/src/hooks/__tests__/useLayouts.test.tsx new file mode 100644 index 0000000..f68c179 --- /dev/null +++ b/apps/web/src/hooks/__tests__/useLayouts.test.tsx @@ -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 }) => ( + {children} + ); +}; + +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); + }); + }); +}); diff --git a/apps/web/src/hooks/useLayouts.ts b/apps/web/src/hooks/useLayouts.ts new file mode 100644 index 0000000..4c2f5cc --- /dev/null +++ b/apps/web/src/hooks/useLayouts.ts @@ -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; +} + +interface UpdateLayoutData { + id: string; + name?: string; + layout?: WidgetPlacement[]; + isDefault?: boolean; + metadata?: Record; +} + +/** + * Fetch all layouts for the current user + */ +export function useLayouts() { + return useQuery({ + 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({ + 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({ + 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, + }; +} diff --git a/apps/web/src/hooks/useWebSocket.test.tsx b/apps/web/src/hooks/useWebSocket.test.tsx new file mode 100644 index 0000000..7e7f620 --- /dev/null +++ b/apps/web/src/hooks/useWebSocket.test.tsx @@ -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; + let eventHandlers: Record 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).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)); + }); +}); diff --git a/apps/web/src/hooks/useWebSocket.ts b/apps/web/src/hooks/useWebSocket.ts new file mode 100644 index 0000000..a99f19b --- /dev/null +++ b/apps/web/src/hooks/useWebSocket.ts @@ -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(null); + const [isConnected, setIsConnected] = useState(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, + }; +}