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(); + }); + }); }); 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"), - }, -];