diff --git a/apps/web/src/app/(authenticated)/page.test.tsx b/apps/web/src/app/(authenticated)/page.test.tsx index 3d72378..528ec61 100644 --- a/apps/web/src/app/(authenticated)/page.test.tsx +++ b/apps/web/src/app/(authenticated)/page.test.tsx @@ -1,112 +1,150 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest"; +import { render, screen, waitFor, act } from "@testing-library/react"; import DashboardPage from "./page"; -import { fetchDashboardSummary } from "@/lib/api/dashboard"; +import * as layoutsApi from "@/lib/api/layouts"; +import type { UserLayout, WidgetPlacement } from "@mosaic/shared"; -// Mock Phase 3 dashboard widgets -vi.mock("@/components/dashboard/DashboardMetrics", () => ({ - DashboardMetrics: (): React.JSX.Element => ( -
Dashboard Metrics
+// ResizeObserver is not available in jsdom +beforeAll((): void => { + global.ResizeObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + })); +}); + +// Mock WidgetGrid to avoid react-grid-layout dependency in tests +vi.mock("@/components/widgets/WidgetGrid", () => ({ + WidgetGrid: ({ + layout, + isEditing, + }: { + layout: WidgetPlacement[]; + isEditing?: boolean; + }): React.JSX.Element => ( +
+ {layout.map((item) => ( +
+ {item.i} +
+ ))} +
), })); -vi.mock("@/components/dashboard/OrchestratorSessions", () => ({ - OrchestratorSessions: (): React.JSX.Element => ( -
Orchestrator Sessions
- ), -})); - -vi.mock("@/components/dashboard/QuickActions", () => ({ - QuickActions: (): React.JSX.Element =>
Quick Actions
, -})); - -vi.mock("@/components/dashboard/ActivityFeed", () => ({ - ActivityFeed: (): React.JSX.Element =>
Activity Feed
, -})); - -vi.mock("@/components/dashboard/TokenBudget", () => ({ - TokenBudget: (): React.JSX.Element =>
Token Budget
, -})); - -// Mock hooks and API calls +// Mock hooks vi.mock("@/lib/hooks", () => ({ useWorkspaceId: (): string | null => "ws-test-123", })); -vi.mock("@/lib/api/dashboard", () => ({ - fetchDashboardSummary: vi.fn().mockResolvedValue({ - metrics: { - activeAgents: 5, - tasksCompleted: 42, - totalTasks: 100, - tasksInProgress: 10, - activeProjects: 3, - errorRate: 0.5, - }, - recentActivity: [], - activeJobs: [], - tokenBudget: [], - }), -})); +// Mock layout API +vi.mock("@/lib/api/layouts"); + +const mockExistingLayout: UserLayout = { + id: "layout-1", + workspaceId: "ws-test-123", + userId: "user-1", + name: "Default", + isDefault: true, + layout: [ + { i: "TasksWidget-default", x: 0, y: 0, w: 4, h: 2 }, + { i: "CalendarWidget-default", x: 4, y: 0, w: 4, h: 2 }, + ], + metadata: {}, + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), +}; describe("DashboardPage", (): void => { beforeEach((): void => { vi.clearAllMocks(); - vi.mocked(fetchDashboardSummary).mockResolvedValue({ - metrics: { - activeAgents: 5, - tasksCompleted: 42, - totalTasks: 100, - tasksInProgress: 10, - activeProjects: 3, - errorRate: 0.5, - }, - recentActivity: [], - activeJobs: [], - tokenBudget: [], + }); + + it("should render WidgetGrid with saved layout", async (): Promise => { + vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout); + + render(); + + await waitFor((): void => { + expect(screen.getByTestId("widget-grid")).toBeInTheDocument(); + }); + + expect(screen.getByTestId("widget-TasksWidget-default")).toBeInTheDocument(); + expect(screen.getByTestId("widget-CalendarWidget-default")).toBeInTheDocument(); + }); + + it("should create default layout when none exists", async (): Promise => { + vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(null); + vi.mocked(layoutsApi.createLayout).mockResolvedValue({ + ...mockExistingLayout, + layout: [{ i: "TasksWidget-default", x: 0, y: 0, w: 4, h: 2 }], + }); + + render(); + + await waitFor((): void => { + expect(layoutsApi.createLayout).toHaveBeenCalledWith("ws-test-123", { + name: "Default", + isDefault: true, + layout: expect.arrayContaining([ + expect.objectContaining({ i: "TasksWidget-default" }), + ]) as WidgetPlacement[], + }); }); }); - it("should render the DashboardMetrics widget", async (): Promise => { + it("should show loading spinner initially", (): void => { + vi.mocked(layoutsApi.fetchDefaultLayout).mockReturnValue(new Promise(() => {})); + render(); + + expect(screen.getByText("Loading dashboard...")).toBeInTheDocument(); + }); + + it("should fall back to default layout on API error", async (): Promise => { + vi.mocked(layoutsApi.fetchDefaultLayout).mockRejectedValue(new Error("Network error")); + + render(); + await waitFor((): void => { - expect(screen.getByTestId("dashboard-metrics")).toBeInTheDocument(); + expect(screen.getByTestId("widget-grid")).toBeInTheDocument(); }); }); - it("should render the OrchestratorSessions widget", async (): Promise => { + it("should render Dashboard heading", async (): Promise => { + vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout); + render(); + await waitFor((): void => { - expect(screen.getByTestId("orchestrator-sessions")).toBeInTheDocument(); + expect(screen.getByText("Dashboard")).toBeInTheDocument(); }); }); - it("should render the QuickActions widget", async (): Promise => { + it("should render Edit Layout button", async (): Promise => { + vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout); + render(); + await waitFor((): void => { - expect(screen.getByTestId("quick-actions")).toBeInTheDocument(); + expect(screen.getByText("Edit Layout")).toBeInTheDocument(); }); }); - it("should render the ActivityFeed widget", async (): Promise => { - render(); - await waitFor((): void => { - expect(screen.getByTestId("activity-feed")).toBeInTheDocument(); - }); - }); + it("should toggle edit mode on button click", async (): Promise => { + vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout); - it("should render the TokenBudget widget", async (): Promise => { render(); - await waitFor((): void => { - expect(screen.getByTestId("token-budget")).toBeInTheDocument(); - }); - }); - it("should render error state when API fails", async (): Promise => { - vi.mocked(fetchDashboardSummary).mockRejectedValueOnce(new Error("Network error")); - render(); await waitFor((): void => { - expect(screen.getByText("Failed to load dashboard data")).toBeInTheDocument(); + expect(screen.getByText("Edit Layout")).toBeInTheDocument(); }); + + act((): void => { + screen.getByText("Edit Layout").click(); + }); + + expect(screen.getByText("Done")).toBeInTheDocument(); + expect(screen.getByTestId("widget-grid").getAttribute("data-editing")).toBe("true"); }); }); diff --git a/apps/web/src/app/(authenticated)/page.tsx b/apps/web/src/app/(authenticated)/page.tsx index dbaf667..641627e 100644 --- a/apps/web/src/app/(authenticated)/page.tsx +++ b/apps/web/src/app/(authenticated)/page.tsx @@ -1,22 +1,24 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import type { ReactElement } from "react"; -import { DashboardMetrics } from "@/components/dashboard/DashboardMetrics"; -import { OrchestratorSessions } from "@/components/dashboard/OrchestratorSessions"; -import { QuickActions } from "@/components/dashboard/QuickActions"; -import { ActivityFeed } from "@/components/dashboard/ActivityFeed"; -import { TokenBudget } from "@/components/dashboard/TokenBudget"; -import { fetchDashboardSummary } from "@/lib/api/dashboard"; -import type { DashboardSummaryResponse } from "@/lib/api/dashboard"; +import type { WidgetPlacement } from "@mosaic/shared"; +import { WidgetGrid } from "@/components/widgets/WidgetGrid"; +import { DEFAULT_LAYOUT } from "@/components/widgets/defaultLayout"; +import { fetchDefaultLayout, createLayout, updateLayout } from "@/lib/api/layouts"; import { useWorkspaceId } from "@/lib/hooks"; export default function DashboardPage(): ReactElement { const workspaceId = useWorkspaceId(); - const [data, setData] = useState(null); + const [layout, setLayout] = useState(DEFAULT_LAYOUT); + const [layoutId, setLayoutId] = useState(null); + const [isEditing, setIsEditing] = useState(false); const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); + // Debounce timer for auto-saving layout changes + const saveTimerRef = useRef | null>(null); + + // Load the user's default layout (or create one) useEffect(() => { if (!workspaceId) { setIsLoading(false); @@ -24,70 +26,86 @@ export default function DashboardPage(): ReactElement { } const wsId = workspaceId; - let cancelled = false; - setError(null); - setIsLoading(true); + const ac = new AbortController(); - async function loadSummary(): Promise { + async function loadLayout(): Promise { try { - const summary = await fetchDashboardSummary(wsId); - if (!cancelled) { - setData(summary); + const existing = await fetchDefaultLayout(wsId); + if (ac.signal.aborted) return; + + if (existing) { + setLayout(existing.layout); + setLayoutId(existing.id); + } else { + const created = await createLayout(wsId, { + name: "Default", + isDefault: true, + layout: DEFAULT_LAYOUT, + }); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- aborted can change during await + if (ac.signal.aborted) return; + setLayout(created.layout); + setLayoutId(created.id); } } catch (err: unknown) { - console.error("[Dashboard] Failed to fetch summary:", err); - if (!cancelled) { - setError("Failed to load dashboard data"); - } - } finally { - if (!cancelled) { - setIsLoading(false); - } + console.error("[Dashboard] Failed to load layout:", err); } + setIsLoading(false); } - void loadSummary(); + void loadLayout(); return (): void => { - cancelled = true; + ac.abort(); }; }, [workspaceId]); - useEffect(() => { - if (!workspaceId) return; + // Save layout changes with debounce + const saveLayout = useCallback( + (newLayout: WidgetPlacement[]) => { + if (!workspaceId || !layoutId) return; - let cancelled = false; - const wsId = workspaceId; + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + } - const interval = setInterval(() => { - fetchDashboardSummary(wsId) - .then((summary) => { - if (!cancelled) setData(summary); - }) - .catch((err: unknown) => { - console.error("[Dashboard] Refresh failed:", err); + saveTimerRef.current = setTimeout(() => { + void updateLayout(workspaceId, layoutId, { layout: newLayout }).catch((err: unknown) => { + console.error("[Dashboard] Failed to save layout:", err); }); - }, 30_000); + }, 800); + }, + [workspaceId, layoutId] + ); - return (): void => { - cancelled = true; - clearInterval(interval); - }; - }, [workspaceId]); + const handleLayoutChange = useCallback( + (newLayout: WidgetPlacement[]) => { + setLayout(newLayout); + saveLayout(newLayout); + }, + [saveLayout] + ); + + const handleRemoveWidget = useCallback( + (widgetId: string) => { + const updated = layout.filter((item) => item.i !== widgetId); + setLayout(updated); + saveLayout(updated); + }, + [layout, saveLayout] + ); if (isLoading) { return ( -
- -
-
- - -
-
- - -
+
+
+
+ + Loading dashboard... +
); @@ -95,32 +113,45 @@ export default function DashboardPage(): ReactElement { return (
- {error && ( -
+

- {error} -

- )} - -
-
- - -
-
- - -
+ Dashboard + +
+ + {/* Widget grid */} +
); } diff --git a/apps/web/src/components/widgets/BaseWidget.tsx b/apps/web/src/components/widgets/BaseWidget.tsx index aca0b57..04540c6 100644 --- a/apps/web/src/components/widgets/BaseWidget.tsx +++ b/apps/web/src/components/widgets/BaseWidget.tsx @@ -37,16 +37,31 @@ export function BaseWidget({ return (
{/* Widget Header */} -
+
-

{title}

- {description &&

{description}

} +

+ {title} +

+ {description && ( +

+ {description} +

+ )}
{/* Control buttons - only show if handlers provided */} @@ -56,7 +71,8 @@ export function BaseWidget({