From c510d6fbc66b952e9e98e3a48c392775a7ff0f7a Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 22 Feb 2026 21:49:38 -0600 Subject: [PATCH 1/2] feat(web): wire tasks page to real API data Replace mock data with real fetchTasks() API call. Add workspace-aware data fetching, MosaicSpinner loading state, empty state card, and error state with retry using design tokens throughout. Refs #467 --- .../src/app/(authenticated)/tasks/page.tsx | 122 ++++++++++++++---- apps/web/src/lib/api/tasks.ts | 80 +----------- 2 files changed, 95 insertions(+), 107 deletions(-) diff --git a/apps/web/src/app/(authenticated)/tasks/page.tsx b/apps/web/src/app/(authenticated)/tasks/page.tsx index 6873ce1..a22a2c6 100644 --- a/apps/web/src/app/(authenticated)/tasks/page.tsx +++ b/apps/web/src/app/(authenticated)/tasks/page.tsx @@ -4,57 +4,123 @@ import { useState, useEffect } from "react"; import type { ReactElement } from "react"; import { TaskList } from "@/components/tasks/TaskList"; -import { mockTasks } from "@/lib/api/tasks"; +import { MosaicSpinner } from "@/components/ui/MosaicSpinner"; +import { fetchTasks } from "@/lib/api/tasks"; +import { useWorkspaceId } from "@/lib/hooks"; import type { Task } from "@mosaic/shared"; export default function TasksPage(): ReactElement { + const workspaceId = useWorkspaceId(); const [tasks, setTasks] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { - void loadTasks(); - }, []); - - async function loadTasks(): Promise { - setIsLoading(true); - setError(null); - - try { - // TODO: Replace with real API call when backend is ready - // const data = await fetchTasks(); - await new Promise((resolve) => setTimeout(resolve, 300)); - setTasks(mockTasks); - } catch (err) { - setError( - err instanceof Error - ? err.message - : "We had trouble loading your tasks. Please try again when you're ready." - ); - } finally { + if (!workspaceId) { setIsLoading(false); + return; } + + let cancelled = false; + setError(null); + setIsLoading(true); + + async function loadTasks(): Promise { + try { + const filters = workspaceId !== null ? { workspaceId } : {}; + const data = await fetchTasks(filters); + if (!cancelled) { + setTasks(data); + } + } catch (err: unknown) { + console.error("[Tasks] Failed to fetch tasks:", err); + if (!cancelled) { + setError( + err instanceof Error + ? err.message + : "We had trouble loading your tasks. Please try again when you're ready." + ); + } + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + } + + void loadTasks(); + + return (): void => { + cancelled = true; + }; + }, [workspaceId]); + + function handleRetry(): void { + if (!workspaceId) return; + setError(null); + setIsLoading(true); + + fetchTasks({ workspaceId }) + .then((data) => { + setTasks(data); + }) + .catch((err: unknown) => { + console.error("[Tasks] Retry failed:", err); + setError( + err instanceof Error + ? err.message + : "We had trouble loading your tasks. Please try again when you're ready." + ); + }) + .finally(() => { + setIsLoading(false); + }); } return (
-

Tasks

-

Organize your work at your own pace

+

+ Tasks +

+

+ Organize your work at your own pace +

- {error !== null ? ( -
-

{error}

+ {isLoading ? ( +
+ +
+ ) : error !== null ? ( +
+

{error}

+ ) : tasks.length === 0 ? ( +
+

No tasks found

+
) : ( - + )}
); diff --git a/apps/web/src/lib/api/tasks.ts b/apps/web/src/lib/api/tasks.ts index db01403..9074b59 100644 --- a/apps/web/src/lib/api/tasks.ts +++ b/apps/web/src/lib/api/tasks.ts @@ -4,7 +4,7 @@ */ import type { Task } from "@mosaic/shared"; -import { TaskStatus, TaskPriority } from "@mosaic/shared"; +import type { TaskStatus, TaskPriority } from "@mosaic/shared"; import { apiGet, type ApiResponse } from "./client"; export interface TaskFilters { @@ -34,81 +34,3 @@ export async function fetchTasks(filters?: TaskFilters): Promise { const response = await apiGet>(endpoint, filters?.workspaceId); return response.data; } - -/** - * Mock tasks for development (until backend endpoints are ready) - */ -export const mockTasks: Task[] = [ - { - id: "task-1", - title: "Review pull request", - description: "Review and provide feedback on frontend PR", - status: TaskStatus.IN_PROGRESS, - priority: TaskPriority.HIGH, - dueDate: new Date("2026-01-29"), - creatorId: "user-1", - assigneeId: "user-1", - workspaceId: "workspace-1", - projectId: null, - parentId: null, - sortOrder: 0, - metadata: {}, - completedAt: null, - createdAt: new Date("2026-01-28"), - updatedAt: new Date("2026-01-28"), - }, - { - id: "task-2", - title: "Update documentation", - description: "Add setup instructions for new developers", - status: TaskStatus.IN_PROGRESS, - priority: TaskPriority.MEDIUM, - dueDate: new Date("2026-01-30"), - creatorId: "user-1", - assigneeId: "user-1", - workspaceId: "workspace-1", - projectId: null, - parentId: null, - sortOrder: 1, - metadata: {}, - completedAt: null, - createdAt: new Date("2026-01-28"), - updatedAt: new Date("2026-01-28"), - }, - { - id: "task-3", - title: "Plan Q1 roadmap", - description: "Define priorities for Q1 2026", - status: TaskStatus.NOT_STARTED, - priority: TaskPriority.HIGH, - dueDate: new Date("2026-02-03"), - creatorId: "user-1", - assigneeId: "user-1", - workspaceId: "workspace-1", - projectId: null, - parentId: null, - sortOrder: 2, - metadata: {}, - completedAt: null, - createdAt: new Date("2026-01-28"), - updatedAt: new Date("2026-01-28"), - }, - { - id: "task-4", - title: "Research new libraries", - description: "Evaluate options for state management", - status: TaskStatus.PAUSED, - priority: TaskPriority.LOW, - dueDate: new Date("2026-02-10"), - creatorId: "user-1", - assigneeId: "user-1", - workspaceId: "workspace-1", - projectId: null, - parentId: null, - sortOrder: 3, - metadata: {}, - completedAt: null, - createdAt: new Date("2026-01-28"), - updatedAt: new Date("2026-01-28"), - }, -]; -- 2.49.1 From 5b62353c0678d042e0bacc29b031e4bef4281944 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 22 Feb 2026 21:59:02 -0600 Subject: [PATCH 2/2] test(web): update tasks page tests for real API integration Update page tests to mock fetchTasks and useWorkspaceId instead of relying on removed mock data. Add coverage for loading, empty, error, and retry states. Refs #467 --- .../app/(authenticated)/tasks/page.test.tsx | 121 +++++++++++++++++- 1 file changed, 117 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/(authenticated)/tasks/page.test.tsx b/apps/web/src/app/(authenticated)/tasks/page.test.tsx index a0c9966..bcc7b75 100644 --- a/apps/web/src/app/(authenticated)/tasks/page.test.tsx +++ b/apps/web/src/app/(authenticated)/tasks/page.test.tsx @@ -1,5 +1,8 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { Task } from "@mosaic/shared"; +import { TaskStatus, TaskPriority } from "@mosaic/shared"; import TasksPage from "./page"; // Mock the TaskList component @@ -9,21 +12,121 @@ vi.mock("@/components/tasks/TaskList", () => ({ ), })); +// Mock MosaicSpinner +vi.mock("@/components/ui/MosaicSpinner", () => ({ + MosaicSpinner: ({ label }: { label?: string }): React.JSX.Element => ( +
{label ?? "Loading..."}
+ ), +})); + +// Mock useWorkspaceId +const mockUseWorkspaceId = vi.fn<() => string | null>(); +vi.mock("@/lib/hooks", () => ({ + useWorkspaceId: (): string | null => mockUseWorkspaceId(), +})); + +// Mock fetchTasks +const mockFetchTasks = vi.fn<() => Promise>(); +vi.mock("@/lib/api/tasks", () => ({ + fetchTasks: (...args: unknown[]): Promise => mockFetchTasks(...(args as [])), +})); + +const fakeTasks: Task[] = [ + { + id: "task-1", + title: "Test task 1", + description: "Description 1", + status: TaskStatus.IN_PROGRESS, + priority: TaskPriority.HIGH, + dueDate: new Date("2026-02-01"), + creatorId: "user-1", + assigneeId: "user-1", + workspaceId: "ws-1", + projectId: null, + parentId: null, + sortOrder: 0, + metadata: {}, + completedAt: null, + createdAt: new Date("2026-01-28"), + updatedAt: new Date("2026-01-28"), + }, + { + id: "task-2", + title: "Test task 2", + description: "Description 2", + status: TaskStatus.NOT_STARTED, + priority: TaskPriority.MEDIUM, + dueDate: new Date("2026-02-02"), + creatorId: "user-1", + assigneeId: "user-1", + workspaceId: "ws-1", + projectId: null, + parentId: null, + sortOrder: 1, + metadata: {}, + completedAt: null, + createdAt: new Date("2026-01-28"), + updatedAt: new Date("2026-01-28"), + }, +]; + describe("TasksPage", (): void => { + beforeEach((): void => { + vi.clearAllMocks(); + mockUseWorkspaceId.mockReturnValue("ws-1"); + mockFetchTasks.mockResolvedValue(fakeTasks); + }); + it("should render the page title", (): void => { render(); expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Tasks"); }); - it("should show loading state initially", (): void => { + it("should show loading spinner initially", (): void => { + // Never resolve so we stay in loading state + // eslint-disable-next-line @typescript-eslint/no-empty-function + mockFetchTasks.mockReturnValue(new Promise(() => {})); render(); - expect(screen.getByTestId("task-list")).toHaveTextContent("Loading"); + expect(screen.getByTestId("mosaic-spinner")).toBeInTheDocument(); }); it("should render the TaskList with tasks after loading", async (): Promise => { render(); await waitFor((): void => { - expect(screen.getByTestId("task-list")).toHaveTextContent("4 tasks"); + expect(screen.getByTestId("task-list")).toHaveTextContent("2 tasks"); + }); + }); + + it("should show empty state when no tasks exist", async (): Promise => { + mockFetchTasks.mockResolvedValue([]); + render(); + await waitFor((): void => { + expect(screen.getByText("No tasks found")).toBeInTheDocument(); + }); + }); + + it("should show error state on API failure", async (): Promise => { + mockFetchTasks.mockRejectedValue(new Error("Network error")); + render(); + await waitFor((): void => { + expect(screen.getByText("Network error")).toBeInTheDocument(); + }); + expect(screen.getByRole("button", { name: /try again/i })).toBeInTheDocument(); + }); + + it("should retry fetching on retry button click", async (): Promise => { + mockFetchTasks.mockRejectedValueOnce(new Error("Network error")); + render(); + await waitFor((): void => { + expect(screen.getByText("Network error")).toBeInTheDocument(); + }); + + mockFetchTasks.mockResolvedValueOnce(fakeTasks); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: /try again/i })); + + await waitFor((): void => { + expect(screen.getByTestId("task-list")).toHaveTextContent("2 tasks"); }); }); @@ -37,4 +140,14 @@ describe("TasksPage", (): void => { render(); expect(screen.getByText("Organize your work at your own pace")).toBeInTheDocument(); }); + + it("should not fetch when workspace ID is not available", async (): Promise => { + mockUseWorkspaceId.mockReturnValue(null); + render(); + + // Wait a tick to ensure useEffect ran + await waitFor((): void => { + expect(mockFetchTasks).not.toHaveBeenCalled(); + }); + }); }); -- 2.49.1