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)
This commit is contained in:
Jason Woltje
2026-01-29 17:54:46 -06:00
parent 95833fb4ea
commit 14a1e218a5
14 changed files with 2142 additions and 0 deletions

View 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);
});
});
});

View 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,
};
}

View 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));
});
});

View 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,
};
}