test(web): update tasks page tests for real API integration #475
@@ -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 { 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";
|
import TasksPage from "./page";
|
||||||
|
|
||||||
// Mock the TaskList component
|
// 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 => (
|
||||||
|
<div data-testid="mosaic-spinner">{label ?? "Loading..."}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock useWorkspaceId
|
||||||
|
const mockUseWorkspaceId = vi.fn<() => string | null>();
|
||||||
|
vi.mock("@/lib/hooks", () => ({
|
||||||
|
useWorkspaceId: (): string | null => mockUseWorkspaceId(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock fetchTasks
|
||||||
|
const mockFetchTasks = vi.fn<() => Promise<Task[]>>();
|
||||||
|
vi.mock("@/lib/api/tasks", () => ({
|
||||||
|
fetchTasks: (...args: unknown[]): Promise<Task[]> => 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 => {
|
describe("TasksPage", (): void => {
|
||||||
|
beforeEach((): void => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockUseWorkspaceId.mockReturnValue("ws-1");
|
||||||
|
mockFetchTasks.mockResolvedValue(fakeTasks);
|
||||||
|
});
|
||||||
|
|
||||||
it("should render the page title", (): void => {
|
it("should render the page title", (): void => {
|
||||||
render(<TasksPage />);
|
render(<TasksPage />);
|
||||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Tasks");
|
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<Task[]>(() => {}));
|
||||||
render(<TasksPage />);
|
render(<TasksPage />);
|
||||||
expect(screen.getByTestId("task-list")).toHaveTextContent("Loading");
|
expect(screen.getByTestId("mosaic-spinner")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render the TaskList with tasks after loading", async (): Promise<void> => {
|
it("should render the TaskList with tasks after loading", async (): Promise<void> => {
|
||||||
render(<TasksPage />);
|
render(<TasksPage />);
|
||||||
await waitFor((): void => {
|
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<void> => {
|
||||||
|
mockFetchTasks.mockResolvedValue([]);
|
||||||
|
render(<TasksPage />);
|
||||||
|
await waitFor((): void => {
|
||||||
|
expect(screen.getByText("No tasks found")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show error state on API failure", async (): Promise<void> => {
|
||||||
|
mockFetchTasks.mockRejectedValue(new Error("Network error"));
|
||||||
|
render(<TasksPage />);
|
||||||
|
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<void> => {
|
||||||
|
mockFetchTasks.mockRejectedValueOnce(new Error("Network error"));
|
||||||
|
render(<TasksPage />);
|
||||||
|
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(<TasksPage />);
|
render(<TasksPage />);
|
||||||
expect(screen.getByText("Organize your work at your own pace")).toBeInTheDocument();
|
expect(screen.getByText("Organize your work at your own pace")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should not fetch when workspace ID is not available", async (): Promise<void> => {
|
||||||
|
mockUseWorkspaceId.mockReturnValue(null);
|
||||||
|
render(<TasksPage />);
|
||||||
|
|
||||||
|
// Wait a tick to ensure useEffect ran
|
||||||
|
await waitFor((): void => {
|
||||||
|
expect(mockFetchTasks).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,57 +4,123 @@ import { useState, useEffect } from "react";
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
import { TaskList } from "@/components/tasks/TaskList";
|
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";
|
import type { Task } from "@mosaic/shared";
|
||||||
|
|
||||||
export default function TasksPage(): ReactElement {
|
export default function TasksPage(): ReactElement {
|
||||||
|
const workspaceId = useWorkspaceId();
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadTasks();
|
if (!workspaceId) {
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function loadTasks(): Promise<void> {
|
|
||||||
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 {
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
async function loadTasks(): Promise<void> {
|
||||||
|
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 (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="container mx-auto px-4 py-8">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Tasks</h1>
|
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
|
||||||
<p className="text-gray-600 mt-2">Organize your work at your own pace</p>
|
Tasks
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Organize your work at your own pace
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error !== null ? (
|
{isLoading ? (
|
||||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-6 text-center">
|
<div className="flex justify-center py-16">
|
||||||
<p className="text-amber-800">{error}</p>
|
<MosaicSpinner label="Loading tasks..." />
|
||||||
|
</div>
|
||||||
|
) : error !== null ? (
|
||||||
|
<div
|
||||||
|
className="rounded-lg p-6 text-center"
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: "var(--danger)" }}>{error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => void loadTasks()}
|
onClick={handleRetry}
|
||||||
className="mt-4 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 transition-colors"
|
className="mt-4 rounded-md px-4 py-2 text-sm font-medium text-white transition-colors"
|
||||||
|
style={{ background: "var(--danger)" }}
|
||||||
>
|
>
|
||||||
Try again
|
Try again
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : tasks.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="rounded-lg p-8 text-center"
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: "var(--text-muted)" }}>No tasks found</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<TaskList tasks={tasks} isLoading={isLoading} />
|
<TaskList tasks={tasks} isLoading={false} />
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Task } from "@mosaic/shared";
|
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";
|
import { apiGet, type ApiResponse } from "./client";
|
||||||
|
|
||||||
export interface TaskFilters {
|
export interface TaskFilters {
|
||||||
@@ -34,81 +34,3 @@ export async function fetchTasks(filters?: TaskFilters): Promise<Task[]> {
|
|||||||
const response = await apiGet<ApiResponse<Task[]>>(endpoint, filters?.workspaceId);
|
const response = await apiGet<ApiResponse<Task[]>>(endpoint, filters?.workspaceId);
|
||||||
return response.data;
|
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"),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|||||||
Reference in New Issue
Block a user