feat(web): migrate dashboard to WidgetGrid with layout persistence (#497)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #497.
This commit is contained in:
@@ -1,112 +1,154 @@
|
||||
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 => (
|
||||
<div data-testid="dashboard-metrics">Dashboard Metrics</div>
|
||||
// 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 => (
|
||||
<div data-testid="widget-grid" data-editing={isEditing}>
|
||||
{layout.map((item) => (
|
||||
<div key={item.i} data-testid={`widget-${item.i}`}>
|
||||
{item.i}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/dashboard/OrchestratorSessions", () => ({
|
||||
OrchestratorSessions: (): React.JSX.Element => (
|
||||
<div data-testid="orchestrator-sessions">Orchestrator Sessions</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/dashboard/QuickActions", () => ({
|
||||
QuickActions: (): React.JSX.Element => <div data-testid="quick-actions">Quick Actions</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/dashboard/ActivityFeed", () => ({
|
||||
ActivityFeed: (): React.JSX.Element => <div data-testid="activity-feed">Activity Feed</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/dashboard/TokenBudget", () => ({
|
||||
TokenBudget: (): React.JSX.Element => <div data-testid="token-budget">Token Budget</div>,
|
||||
}));
|
||||
|
||||
// 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<void> => {
|
||||
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout);
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
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<void> => {
|
||||
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(<DashboardPage />);
|
||||
|
||||
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<void> => {
|
||||
it("should show loading spinner initially", (): void => {
|
||||
// Never-resolving promise to test loading state
|
||||
vi.mocked(layoutsApi.fetchDefaultLayout).mockReturnValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally never-resolving
|
||||
new Promise(() => {})
|
||||
);
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
expect(screen.getByText("Loading dashboard...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should fall back to default layout on API error", async (): Promise<void> => {
|
||||
vi.mocked(layoutsApi.fetchDefaultLayout).mockRejectedValue(new Error("Network error"));
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByTestId("dashboard-metrics")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("widget-grid")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the OrchestratorSessions widget", async (): Promise<void> => {
|
||||
it("should render Dashboard heading", async (): Promise<void> => {
|
||||
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout);
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByTestId("orchestrator-sessions")).toBeInTheDocument();
|
||||
expect(screen.getByText("Dashboard")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the QuickActions widget", async (): Promise<void> => {
|
||||
it("should render Edit Layout button", async (): Promise<void> => {
|
||||
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout);
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByTestId("quick-actions")).toBeInTheDocument();
|
||||
expect(screen.getByText("Edit Layout")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the ActivityFeed widget", async (): Promise<void> => {
|
||||
render(<DashboardPage />);
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByTestId("activity-feed")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("should toggle edit mode on button click", async (): Promise<void> => {
|
||||
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout);
|
||||
|
||||
it("should render the TokenBudget widget", async (): Promise<void> => {
|
||||
render(<DashboardPage />);
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByTestId("token-budget")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render error state when API fails", async (): Promise<void> => {
|
||||
vi.mocked(fetchDashboardSummary).mockRejectedValueOnce(new Error("Network error"));
|
||||
render(<DashboardPage />);
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<DashboardSummaryResponse | null>(null);
|
||||
const [layout, setLayout] = useState<WidgetPlacement[]>(DEFAULT_LAYOUT);
|
||||
const [layoutId, setLayoutId] = useState<string | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Debounce timer for auto-saving layout changes
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | 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<void> {
|
||||
async function loadLayout(): Promise<void> {
|
||||
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 (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<DashboardMetrics />
|
||||
<div className="dash-grid">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16, minWidth: 0 }}>
|
||||
<OrchestratorSessions />
|
||||
<QuickActions />
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<ActivityFeed />
|
||||
<TokenBudget />
|
||||
</div>
|
||||
<div className="flex items-center justify-center" style={{ minHeight: 400 }}>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div
|
||||
className="w-8 h-8 border-2 border-t-transparent rounded-full animate-spin"
|
||||
style={{ borderColor: "var(--primary)", borderTopColor: "transparent" }}
|
||||
/>
|
||||
<span className="text-sm" style={{ color: "var(--muted)" }}>
|
||||
Loading dashboard...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -95,32 +113,45 @@ export default function DashboardPage(): ReactElement {
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{error && (
|
||||
<div
|
||||
{/* Dashboard header with edit toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
marginBottom: 16,
|
||||
background: "rgba(229,72,77,0.1)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r)",
|
||||
fontSize: "1.5rem",
|
||||
fontWeight: 700,
|
||||
color: "var(--text)",
|
||||
fontSize: "0.85rem",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<DashboardMetrics metrics={data?.metrics} />
|
||||
<div className="dash-grid">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16, minWidth: 0 }}>
|
||||
<OrchestratorSessions jobs={data?.activeJobs} />
|
||||
<QuickActions />
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<ActivityFeed items={data?.recentActivity} />
|
||||
<TokenBudget budgets={data?.tokenBudget} />
|
||||
</div>
|
||||
Dashboard
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsEditing((prev) => !prev);
|
||||
}}
|
||||
style={{
|
||||
padding: "6px 14px",
|
||||
borderRadius: "var(--r)",
|
||||
border: isEditing ? "1px solid var(--primary)" : "1px solid var(--border)",
|
||||
background: isEditing ? "var(--primary)" : "transparent",
|
||||
color: isEditing ? "#fff" : "var(--text-2)",
|
||||
fontSize: "0.83rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
{isEditing ? "Done" : "Edit Layout"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Widget grid */}
|
||||
<WidgetGrid
|
||||
layout={layout}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
{...(isEditing && { onRemoveWidget: handleRemoveWidget })}
|
||||
isEditing={isEditing}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user