feat(web): migrate dashboard to WidgetGrid with layout persistence #497
@@ -1,112 +1,154 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||||
import DashboardPage from "./page";
|
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
|
// ResizeObserver is not available in jsdom
|
||||||
vi.mock("@/components/dashboard/DashboardMetrics", () => ({
|
beforeAll((): void => {
|
||||||
DashboardMetrics: (): React.JSX.Element => (
|
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||||
<div data-testid="dashboard-metrics">Dashboard Metrics</div>
|
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", () => ({
|
// Mock hooks
|
||||||
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
|
|
||||||
vi.mock("@/lib/hooks", () => ({
|
vi.mock("@/lib/hooks", () => ({
|
||||||
useWorkspaceId: (): string | null => "ws-test-123",
|
useWorkspaceId: (): string | null => "ws-test-123",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/api/dashboard", () => ({
|
// Mock layout API
|
||||||
fetchDashboardSummary: vi.fn().mockResolvedValue({
|
vi.mock("@/lib/api/layouts");
|
||||||
metrics: {
|
|
||||||
activeAgents: 5,
|
const mockExistingLayout: UserLayout = {
|
||||||
tasksCompleted: 42,
|
id: "layout-1",
|
||||||
totalTasks: 100,
|
workspaceId: "ws-test-123",
|
||||||
tasksInProgress: 10,
|
userId: "user-1",
|
||||||
activeProjects: 3,
|
name: "Default",
|
||||||
errorRate: 0.5,
|
isDefault: true,
|
||||||
},
|
layout: [
|
||||||
recentActivity: [],
|
{ i: "TasksWidget-default", x: 0, y: 0, w: 4, h: 2 },
|
||||||
activeJobs: [],
|
{ i: "CalendarWidget-default", x: 4, y: 0, w: 4, h: 2 },
|
||||||
tokenBudget: [],
|
],
|
||||||
}),
|
metadata: {},
|
||||||
}));
|
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||||
|
};
|
||||||
|
|
||||||
describe("DashboardPage", (): void => {
|
describe("DashboardPage", (): void => {
|
||||||
beforeEach((): void => {
|
beforeEach((): void => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(fetchDashboardSummary).mockResolvedValue({
|
});
|
||||||
metrics: {
|
|
||||||
activeAgents: 5,
|
it("should render WidgetGrid with saved layout", async (): Promise<void> => {
|
||||||
tasksCompleted: 42,
|
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout);
|
||||||
totalTasks: 100,
|
|
||||||
tasksInProgress: 10,
|
render(<DashboardPage />);
|
||||||
activeProjects: 3,
|
|
||||||
errorRate: 0.5,
|
await waitFor((): void => {
|
||||||
},
|
expect(screen.getByTestId("widget-grid")).toBeInTheDocument();
|
||||||
recentActivity: [],
|
});
|
||||||
activeJobs: [],
|
|
||||||
tokenBudget: [],
|
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 />);
|
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 => {
|
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 />);
|
render(<DashboardPage />);
|
||||||
|
|
||||||
await waitFor((): void => {
|
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 />);
|
render(<DashboardPage />);
|
||||||
|
|
||||||
await waitFor((): void => {
|
await waitFor((): void => {
|
||||||
expect(screen.getByTestId("quick-actions")).toBeInTheDocument();
|
expect(screen.getByText("Edit Layout")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render the ActivityFeed widget", async (): Promise<void> => {
|
it("should toggle edit mode on button click", async (): Promise<void> => {
|
||||||
render(<DashboardPage />);
|
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout);
|
||||||
await waitFor((): void => {
|
|
||||||
expect(screen.getByTestId("activity-feed")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render the TokenBudget widget", async (): Promise<void> => {
|
|
||||||
render(<DashboardPage />);
|
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 => {
|
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";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { DashboardMetrics } from "@/components/dashboard/DashboardMetrics";
|
import type { WidgetPlacement } from "@mosaic/shared";
|
||||||
import { OrchestratorSessions } from "@/components/dashboard/OrchestratorSessions";
|
import { WidgetGrid } from "@/components/widgets/WidgetGrid";
|
||||||
import { QuickActions } from "@/components/dashboard/QuickActions";
|
import { DEFAULT_LAYOUT } from "@/components/widgets/defaultLayout";
|
||||||
import { ActivityFeed } from "@/components/dashboard/ActivityFeed";
|
import { fetchDefaultLayout, createLayout, updateLayout } from "@/lib/api/layouts";
|
||||||
import { TokenBudget } from "@/components/dashboard/TokenBudget";
|
|
||||||
import { fetchDashboardSummary } from "@/lib/api/dashboard";
|
|
||||||
import type { DashboardSummaryResponse } from "@/lib/api/dashboard";
|
|
||||||
import { useWorkspaceId } from "@/lib/hooks";
|
import { useWorkspaceId } from "@/lib/hooks";
|
||||||
|
|
||||||
export default function DashboardPage(): ReactElement {
|
export default function DashboardPage(): ReactElement {
|
||||||
const workspaceId = useWorkspaceId();
|
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 [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(() => {
|
useEffect(() => {
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -24,70 +26,86 @@ export default function DashboardPage(): ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const wsId = workspaceId;
|
const wsId = workspaceId;
|
||||||
let cancelled = false;
|
const ac = new AbortController();
|
||||||
setError(null);
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
async function loadSummary(): Promise<void> {
|
async function loadLayout(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const summary = await fetchDashboardSummary(wsId);
|
const existing = await fetchDefaultLayout(wsId);
|
||||||
if (!cancelled) {
|
if (ac.signal.aborted) return;
|
||||||
setData(summary);
|
|
||||||
|
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) {
|
} catch (err: unknown) {
|
||||||
console.error("[Dashboard] Failed to fetch summary:", err);
|
console.error("[Dashboard] Failed to load layout:", err);
|
||||||
if (!cancelled) {
|
|
||||||
setError("Failed to load dashboard data");
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadSummary();
|
void loadLayout();
|
||||||
|
|
||||||
return (): void => {
|
return (): void => {
|
||||||
cancelled = true;
|
ac.abort();
|
||||||
};
|
};
|
||||||
}, [workspaceId]);
|
}, [workspaceId]);
|
||||||
|
|
||||||
useEffect(() => {
|
// Save layout changes with debounce
|
||||||
if (!workspaceId) return;
|
const saveLayout = useCallback(
|
||||||
|
(newLayout: WidgetPlacement[]) => {
|
||||||
|
if (!workspaceId || !layoutId) return;
|
||||||
|
|
||||||
let cancelled = false;
|
if (saveTimerRef.current) {
|
||||||
const wsId = workspaceId;
|
clearTimeout(saveTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
saveTimerRef.current = setTimeout(() => {
|
||||||
fetchDashboardSummary(wsId)
|
void updateLayout(workspaceId, layoutId, { layout: newLayout }).catch((err: unknown) => {
|
||||||
.then((summary) => {
|
console.error("[Dashboard] Failed to save layout:", err);
|
||||||
if (!cancelled) setData(summary);
|
|
||||||
})
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
console.error("[Dashboard] Refresh failed:", err);
|
|
||||||
});
|
});
|
||||||
}, 30_000);
|
}, 800);
|
||||||
|
},
|
||||||
|
[workspaceId, layoutId]
|
||||||
|
);
|
||||||
|
|
||||||
return (): void => {
|
const handleLayoutChange = useCallback(
|
||||||
cancelled = true;
|
(newLayout: WidgetPlacement[]) => {
|
||||||
clearInterval(interval);
|
setLayout(newLayout);
|
||||||
};
|
saveLayout(newLayout);
|
||||||
}, [workspaceId]);
|
},
|
||||||
|
[saveLayout]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRemoveWidget = useCallback(
|
||||||
|
(widgetId: string) => {
|
||||||
|
const updated = layout.filter((item) => item.i !== widgetId);
|
||||||
|
setLayout(updated);
|
||||||
|
saveLayout(updated);
|
||||||
|
},
|
||||||
|
[layout, saveLayout]
|
||||||
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
<div className="flex items-center justify-center" style={{ minHeight: 400 }}>
|
||||||
<DashboardMetrics />
|
<div className="flex flex-col items-center gap-2">
|
||||||
<div className="dash-grid">
|
<div
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 16, minWidth: 0 }}>
|
className="w-8 h-8 border-2 border-t-transparent rounded-full animate-spin"
|
||||||
<OrchestratorSessions />
|
style={{ borderColor: "var(--primary)", borderTopColor: "transparent" }}
|
||||||
<QuickActions />
|
/>
|
||||||
</div>
|
<span className="text-sm" style={{ color: "var(--muted)" }}>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
Loading dashboard...
|
||||||
<ActivityFeed />
|
</span>
|
||||||
<TokenBudget />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -95,32 +113,45 @@ export default function DashboardPage(): ReactElement {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
{error && (
|
{/* Dashboard header with edit toggle */}
|
||||||
<div
|
<div className="flex items-center justify-between">
|
||||||
|
<h1
|
||||||
style={{
|
style={{
|
||||||
padding: "12px 16px",
|
fontSize: "1.5rem",
|
||||||
marginBottom: 16,
|
fontWeight: 700,
|
||||||
background: "rgba(229,72,77,0.1)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
color: "var(--text)",
|
color: "var(--text)",
|
||||||
fontSize: "0.85rem",
|
margin: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{error}
|
Dashboard
|
||||||
</div>
|
</h1>
|
||||||
)}
|
<button
|
||||||
<DashboardMetrics metrics={data?.metrics} />
|
onClick={() => {
|
||||||
<div className="dash-grid">
|
setIsEditing((prev) => !prev);
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 16, minWidth: 0 }}>
|
}}
|
||||||
<OrchestratorSessions jobs={data?.activeJobs} />
|
style={{
|
||||||
<QuickActions />
|
padding: "6px 14px",
|
||||||
</div>
|
borderRadius: "var(--r)",
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
border: isEditing ? "1px solid var(--primary)" : "1px solid var(--border)",
|
||||||
<ActivityFeed items={data?.recentActivity} />
|
background: isEditing ? "var(--primary)" : "transparent",
|
||||||
<TokenBudget budgets={data?.tokenBudget} />
|
color: isEditing ? "#fff" : "var(--text-2)",
|
||||||
</div>
|
fontSize: "0.83rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.15s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isEditing ? "Done" : "Edit Layout"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Widget grid */}
|
||||||
|
<WidgetGrid
|
||||||
|
layout={layout}
|
||||||
|
onLayoutChange={handleLayoutChange}
|
||||||
|
{...(isEditing && { onRemoveWidget: handleRemoveWidget })}
|
||||||
|
isEditing={isEditing}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,16 +37,31 @@ export function BaseWidget({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-widget-id={id}
|
data-widget-id={id}
|
||||||
className={cn(
|
className={cn("flex flex-col h-full overflow-hidden", className)}
|
||||||
"flex flex-col h-full bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden",
|
style={{
|
||||||
className
|
background: "var(--surface)",
|
||||||
)}
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
boxShadow: "var(--shadow-sm)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Widget Header */}
|
{/* Widget Header */}
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 bg-gray-50">
|
<div
|
||||||
|
className="flex items-center justify-between px-4 py-3"
|
||||||
|
style={{
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
background: "var(--surface-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-sm font-semibold text-gray-900 truncate">{title}</h3>
|
<h3 className="text-sm font-semibold truncate" style={{ color: "var(--text)" }}>
|
||||||
{description && <p className="text-xs text-gray-500 truncate mt-0.5">{description}</p>}
|
{title}
|
||||||
|
</h3>
|
||||||
|
{description && (
|
||||||
|
<p className="text-xs truncate mt-0.5" style={{ color: "var(--muted)" }}>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Control buttons - only show if handlers provided */}
|
{/* Control buttons - only show if handlers provided */}
|
||||||
@@ -56,7 +71,8 @@ export function BaseWidget({
|
|||||||
<button
|
<button
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
aria-label="Edit widget"
|
aria-label="Edit widget"
|
||||||
className="p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded transition-colors"
|
className="p-1 rounded transition-colors"
|
||||||
|
style={{ color: "var(--muted)" }}
|
||||||
title="Edit widget"
|
title="Edit widget"
|
||||||
>
|
>
|
||||||
<Settings className="w-4 h-4" />
|
<Settings className="w-4 h-4" />
|
||||||
@@ -66,7 +82,8 @@ export function BaseWidget({
|
|||||||
<button
|
<button
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
aria-label="Remove widget"
|
aria-label="Remove widget"
|
||||||
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
className="p-1 rounded transition-colors"
|
||||||
|
style={{ color: "var(--muted)" }}
|
||||||
title="Remove widget"
|
title="Remove widget"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
@@ -81,15 +98,24 @@ export function BaseWidget({
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
<div
|
||||||
<span className="text-sm text-gray-500">Loading...</span>
|
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...
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-red-500 text-sm font-medium mb-1">Error</div>
|
<div className="text-sm font-medium mb-1" style={{ color: "var(--danger)" }}>
|
||||||
<div className="text-xs text-gray-600">{error}</div>
|
Error
|
||||||
|
</div>
|
||||||
|
<div className="text-xs" style={{ color: "var(--muted)" }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
|
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo, useRef, useState, useEffect } from "react";
|
||||||
import GridLayout from "react-grid-layout";
|
import GridLayout from "react-grid-layout";
|
||||||
import type { Layout, LayoutItem } from "react-grid-layout";
|
import type { Layout, LayoutItem } from "react-grid-layout";
|
||||||
import type { WidgetPlacement } from "@mosaic/shared";
|
import type { WidgetPlacement } from "@mosaic/shared";
|
||||||
@@ -33,6 +33,30 @@ export function WidgetGrid({
|
|||||||
isEditing = false,
|
isEditing = false,
|
||||||
className,
|
className,
|
||||||
}: WidgetGridProps): React.JSX.Element {
|
}: WidgetGridProps): React.JSX.Element {
|
||||||
|
// Measure container width for responsive grid
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [containerWidth, setContainerWidth] = useState(1200);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const observer = new ResizeObserver((entries): void => {
|
||||||
|
const entry = entries[0];
|
||||||
|
if (entry) {
|
||||||
|
setContainerWidth(entry.contentRect.width);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(el);
|
||||||
|
|
||||||
|
// Set initial width
|
||||||
|
setContainerWidth(el.clientWidth);
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Convert WidgetPlacement to react-grid-layout Layout format
|
// Convert WidgetPlacement to react-grid-layout Layout format
|
||||||
const gridLayout: Layout = useMemo(
|
const gridLayout: Layout = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -96,22 +120,34 @@ export function WidgetGrid({
|
|||||||
// Empty state
|
// Empty state
|
||||||
if (layout.length === 0) {
|
if (layout.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full min-h-[400px] bg-gray-50 rounded-lg border-2 border-dashed border-gray-300">
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="flex items-center justify-center h-full min-h-[400px]"
|
||||||
|
style={{
|
||||||
|
background: "var(--surface-2)",
|
||||||
|
borderRadius: "var(--r-lg)",
|
||||||
|
border: "2px dashed var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-gray-500 text-lg font-medium">No widgets yet</p>
|
<p className="text-lg font-medium" style={{ color: "var(--muted)" }}>
|
||||||
<p className="text-gray-400 text-sm mt-1">Add widgets to customize your dashboard</p>
|
No widgets yet
|
||||||
|
</p>
|
||||||
|
<p className="text-sm mt-1" style={{ color: "var(--muted)", opacity: 0.7 }}>
|
||||||
|
Add widgets to customize your dashboard
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("widget-grid-container", className)}>
|
<div ref={containerRef} className={cn("widget-grid-container", className)}>
|
||||||
<GridLayout
|
<GridLayout
|
||||||
className="layout"
|
className="layout"
|
||||||
layout={gridLayout}
|
layout={gridLayout}
|
||||||
onLayoutChange={handleLayoutChange}
|
onLayoutChange={handleLayoutChange}
|
||||||
width={1200}
|
width={containerWidth}
|
||||||
gridConfig={{
|
gridConfig={{
|
||||||
cols: 12,
|
cols: 12,
|
||||||
rowHeight: 100,
|
rowHeight: 100,
|
||||||
|
|||||||
@@ -3,11 +3,20 @@
|
|||||||
* Following TDD - write tests first!
|
* Following TDD - write tests first!
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi, beforeAll } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { WidgetGrid } from "../WidgetGrid";
|
import { WidgetGrid } from "../WidgetGrid";
|
||||||
import type { WidgetPlacement } from "@mosaic/shared";
|
import type { WidgetPlacement } from "@mosaic/shared";
|
||||||
|
|
||||||
|
// ResizeObserver is not available in jsdom
|
||||||
|
beforeAll((): void => {
|
||||||
|
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||||
|
observe: vi.fn(),
|
||||||
|
unobserve: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
// Mock react-grid-layout
|
// Mock react-grid-layout
|
||||||
vi.mock("react-grid-layout", () => ({
|
vi.mock("react-grid-layout", () => ({
|
||||||
default: ({ children }: { children: React.ReactNode }): React.JSX.Element => (
|
default: ({ children }: { children: React.ReactNode }): React.JSX.Element => (
|
||||||
|
|||||||
25
apps/web/src/components/widgets/defaultLayout.ts
Normal file
25
apps/web/src/components/widgets/defaultLayout.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Default dashboard layout — used when a user has no saved layout.
|
||||||
|
*
|
||||||
|
* Widget ID format: "WidgetType-default" where the prefix before the
|
||||||
|
* first "-" must match a key in WidgetRegistry.
|
||||||
|
*
|
||||||
|
* Grid: 12 columns, 100px row height.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { WidgetPlacement } from "@mosaic/shared";
|
||||||
|
|
||||||
|
export const DEFAULT_LAYOUT: WidgetPlacement[] = [
|
||||||
|
// Row 0 — top row (3 widgets, 4 cols each)
|
||||||
|
{ i: "TasksWidget-default", x: 0, y: 0, w: 4, h: 2, minW: 1, minH: 2, maxW: 4 },
|
||||||
|
{ i: "CalendarWidget-default", x: 4, y: 0, w: 4, h: 2, minW: 2, minH: 2, maxW: 4 },
|
||||||
|
{ i: "AgentStatusWidget-default", x: 8, y: 0, w: 4, h: 2, minW: 1, minH: 2, maxW: 3 },
|
||||||
|
|
||||||
|
// Row 2 — middle row
|
||||||
|
{ i: "ActiveProjectsWidget-default", x: 0, y: 2, w: 4, h: 3, minW: 2, minH: 2, maxW: 4 },
|
||||||
|
{ i: "TaskProgressWidget-default", x: 4, y: 2, w: 4, h: 2, minW: 1, minH: 2, maxW: 3 },
|
||||||
|
{ i: "OrchestratorEventsWidget-default", x: 8, y: 2, w: 4, h: 2, minW: 1, minH: 2, maxW: 4 },
|
||||||
|
|
||||||
|
// Row 4 — bottom
|
||||||
|
{ i: "QuickCaptureWidget-default", x: 4, y: 4, w: 4, h: 1, minW: 2, minH: 1, maxW: 4, maxH: 2 },
|
||||||
|
];
|
||||||
54
apps/web/src/lib/api/layouts.ts
Normal file
54
apps/web/src/lib/api/layouts.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Layout API client — CRUD for user dashboard layouts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { UserLayout, WidgetPlacement } from "@mosaic/shared";
|
||||||
|
import { apiGet, apiPost, apiPatch } from "./client";
|
||||||
|
|
||||||
|
export interface CreateLayoutPayload {
|
||||||
|
name: string;
|
||||||
|
isDefault?: boolean;
|
||||||
|
layout: WidgetPlacement[];
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLayoutPayload {
|
||||||
|
name?: string;
|
||||||
|
isDefault?: boolean;
|
||||||
|
layout?: WidgetPlacement[];
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the user's default layout for the active workspace.
|
||||||
|
* Returns null if no layout exists (404).
|
||||||
|
*/
|
||||||
|
export async function fetchDefaultLayout(workspaceId: string): Promise<UserLayout | null> {
|
||||||
|
try {
|
||||||
|
return await apiGet<UserLayout>("/api/layouts/default", workspaceId);
|
||||||
|
} catch {
|
||||||
|
// 404 = no layout yet — not an error
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new layout.
|
||||||
|
*/
|
||||||
|
export async function createLayout(
|
||||||
|
workspaceId: string,
|
||||||
|
payload: CreateLayoutPayload
|
||||||
|
): Promise<UserLayout> {
|
||||||
|
return apiPost<UserLayout>("/api/layouts", payload, workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing layout (partial patch).
|
||||||
|
*/
|
||||||
|
export async function updateLayout(
|
||||||
|
workspaceId: string,
|
||||||
|
layoutId: string,
|
||||||
|
payload: UpdateLayoutPayload
|
||||||
|
): Promise<UserLayout> {
|
||||||
|
return apiPatch<UserLayout>(`/api/layouts/${layoutId}`, payload, workspaceId);
|
||||||
|
}
|
||||||
@@ -8,8 +8,8 @@
|
|||||||
| TW-THM-001 | done | Theme architecture — Create theme definition interface, theme registry, and 5 built-in themes (Dark, Light, Nord, Dracula, Solarized) as TS files | #487 | web | feat/ms18-theme-architecture | TW-PLAN-001 | TW-THM-002,TW-THM-003 | worker | 2026-02-23 | 2026-02-23 | 30K | ~15K | PR #493 merged |
|
| TW-THM-001 | done | Theme architecture — Create theme definition interface, theme registry, and 5 built-in themes (Dark, Light, Nord, Dracula, Solarized) as TS files | #487 | web | feat/ms18-theme-architecture | TW-PLAN-001 | TW-THM-002,TW-THM-003 | worker | 2026-02-23 | 2026-02-23 | 30K | ~15K | PR #493 merged |
|
||||||
| TW-THM-002 | done | ThemeProvider upgrade — Load themes dynamically from registry, apply CSS variables, support instant theme switching without page reload | #487 | web | feat/ms18-theme-provider-upgrade | TW-THM-001 | TW-THM-003,TW-VER-002 | worker | 2026-02-23 | 2026-02-23 | 25K | ~12K | PR #494 merged |
|
| TW-THM-002 | done | ThemeProvider upgrade — Load themes dynamically from registry, apply CSS variables, support instant theme switching without page reload | #487 | web | feat/ms18-theme-provider-upgrade | TW-THM-001 | TW-THM-003,TW-VER-002 | worker | 2026-02-23 | 2026-02-23 | 25K | ~12K | PR #494 merged |
|
||||||
| TW-THM-003 | done | Theme selection UI — Settings page section with theme browser, live preview swatches, persist selection to UserPreference.theme via API | #487 | web | feat/ms18-theme-selection-ui | TW-THM-001,TW-THM-002 | TW-VER-002 | worker | 2026-02-23 | 2026-02-23 | 25K | ~10K | PR #495 merged |
|
| TW-THM-003 | done | Theme selection UI — Settings page section with theme browser, live preview swatches, persist selection to UserPreference.theme via API | #487 | web | feat/ms18-theme-selection-ui | TW-THM-001,TW-THM-002 | TW-VER-002 | worker | 2026-02-23 | 2026-02-23 | 25K | ~10K | PR #495 merged |
|
||||||
| TW-WDG-001 | not-started | Widget definition seeding — Seed 7 existing widgets into widget_definitions table with correct sizing constraints and configSchema | #488 | api | TBD | TW-PLAN-001 | TW-WDG-002 | worker | — | — | 15K | — | |
|
| TW-WDG-001 | done | Widget definition seeding — Seed 7 existing widgets into widget_definitions table with correct sizing constraints and configSchema | #488 | api | feat/ms18-widget-seed | TW-PLAN-001 | TW-WDG-002 | worker | 2026-02-23 | 2026-02-23 | 15K | ~8K | PR #496 merged |
|
||||||
| TW-WDG-002 | not-started | Dashboard → WidgetGrid migration — Replace hardcoded dashboard layout with WidgetGrid, load/save layout via UserLayout API, default layout on first visit | #488 | web | TBD | TW-WDG-001 | TW-WDG-003,TW-WDG-004,TW-WDG-005 | worker | — | — | 40K | — | |
|
| TW-WDG-002 | in-progress | Dashboard → WidgetGrid migration — Replace hardcoded dashboard layout with WidgetGrid, load/save layout via UserLayout API, default layout on first visit | #488 | web | feat/ms18-widget-grid-migration | TW-WDG-001 | TW-WDG-003,TW-WDG-004,TW-WDG-005 | worker | 2026-02-23 | — | 40K | — | |
|
||||||
| TW-WDG-003 | not-started | Widget picker UI — Drawer/dialog to browse available widgets from registry, preview size/description, add to dashboard | #488 | web | TBD | TW-WDG-002 | TW-VER-001 | worker | — | — | 25K | — | |
|
| TW-WDG-003 | not-started | Widget picker UI — Drawer/dialog to browse available widgets from registry, preview size/description, add to dashboard | #488 | web | TBD | TW-WDG-002 | TW-VER-001 | worker | — | — | 25K | — | |
|
||||||
| TW-WDG-004 | not-started | Widget configuration UI — Per-widget settings dialog using configSchema, configure data source/filters/colors/title | #488 | web | TBD | TW-WDG-002 | TW-VER-001 | worker | — | — | 30K | — | |
|
| TW-WDG-004 | not-started | Widget configuration UI — Per-widget settings dialog using configSchema, configure data source/filters/colors/title | #488 | web | TBD | TW-WDG-002 | TW-VER-001 | worker | — | — | 30K | — | |
|
||||||
| TW-WDG-005 | not-started | Layout management UI — Save/rename/switch/delete layouts, reset to default. UI controls in dashboard header area | #488 | web | TBD | TW-WDG-002 | TW-VER-001 | worker | — | — | 20K | — | |
|
| TW-WDG-005 | not-started | Layout management UI — Save/rename/switch/delete layouts, reset to default. UI controls in dashboard header area | #488 | web | TBD | TW-WDG-002 | TW-VER-001 | worker | — | — | 20K | — | |
|
||||||
@@ -26,9 +26,9 @@
|
|||||||
| Metric | Value |
|
| Metric | Value |
|
||||||
| ------------- | ------------------------- |
|
| ------------- | ------------------------- |
|
||||||
| Total tasks | 16 |
|
| Total tasks | 16 |
|
||||||
| Completed | 4 (PLAN-001, THM-001–003) |
|
| Completed | 5 (PLAN-001, THM-001–003, WDG-001) |
|
||||||
| In Progress | 0 |
|
| In Progress | 0 |
|
||||||
| Remaining | 12 |
|
| Remaining | 11 |
|
||||||
| PRs merged | #493, #494, #495 |
|
| PRs merged | #493, #494, #495, #496 |
|
||||||
| Issues closed | — |
|
| Issues closed | — |
|
||||||
| Milestone | MS18-ThemeWidgets |
|
| Milestone | MS18-ThemeWidgets |
|
||||||
|
|||||||
Reference in New Issue
Block a user