fix(CQ-WEB-10): Add loading/error states to pages with mock data
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Convert tasks, calendar, and dashboard pages from synchronous mock data to async loading pattern with useState/useEffect. Each page now shows a loading state via child components while data loads, and displays a PDA-friendly amber-styled message with a retry button if loading fails. This prepares these pages for real API integration by establishing the async data flow pattern. Child components (TaskList, Calendar, dashboard widgets) already handled isLoading props — now the pages actually use them. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
46
apps/web/src/app/(authenticated)/calendar/page.test.tsx
Normal file
46
apps/web/src/app/(authenticated)/calendar/page.test.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import CalendarPage from "./page";
|
||||||
|
|
||||||
|
// Mock the Calendar component
|
||||||
|
vi.mock("@/components/calendar/Calendar", () => ({
|
||||||
|
Calendar: ({
|
||||||
|
events,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
events: unknown[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}): React.JSX.Element => (
|
||||||
|
<div data-testid="calendar">{isLoading ? "Loading" : `${String(events.length)} events`}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("CalendarPage", (): void => {
|
||||||
|
it("should render the page title", (): void => {
|
||||||
|
render(<CalendarPage />);
|
||||||
|
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Calendar");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show loading state initially", (): void => {
|
||||||
|
render(<CalendarPage />);
|
||||||
|
expect(screen.getByTestId("calendar")).toHaveTextContent("Loading");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render the Calendar with events after loading", async (): Promise<void> => {
|
||||||
|
render(<CalendarPage />);
|
||||||
|
await waitFor((): void => {
|
||||||
|
expect(screen.getByTestId("calendar")).toHaveTextContent("3 events");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have proper layout structure", (): void => {
|
||||||
|
const { container } = render(<CalendarPage />);
|
||||||
|
const main = container.querySelector("main");
|
||||||
|
expect(main).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render the subtitle text", (): void => {
|
||||||
|
render(<CalendarPage />);
|
||||||
|
expect(screen.getByText("View your schedule at a glance")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,18 +1,39 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { Calendar } from "@/components/calendar/Calendar";
|
import { Calendar } from "@/components/calendar/Calendar";
|
||||||
import { mockEvents } from "@/lib/api/events";
|
import { mockEvents } from "@/lib/api/events";
|
||||||
|
import type { Event } from "@mosaic/shared";
|
||||||
|
|
||||||
export default function CalendarPage(): ReactElement {
|
export default function CalendarPage(): ReactElement {
|
||||||
// TODO: Replace with real API call when backend is ready
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
// const { data: events, isLoading } = useQuery({
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
// queryKey: ["events"],
|
const [error, setError] = useState<string | null>(null);
|
||||||
// queryFn: fetchEvents,
|
|
||||||
// });
|
|
||||||
|
|
||||||
const events = mockEvents;
|
useEffect(() => {
|
||||||
const isLoading = false;
|
void loadEvents();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function loadEvents(): Promise<void> {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: Replace with real API call when backend is ready
|
||||||
|
// const data = await fetchEvents();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
setEvents(mockEvents);
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "We had trouble loading your calendar. 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">
|
||||||
@@ -20,7 +41,20 @@ export default function CalendarPage(): ReactElement {
|
|||||||
<h1 className="text-3xl font-bold text-gray-900">Calendar</h1>
|
<h1 className="text-3xl font-bold text-gray-900">Calendar</h1>
|
||||||
<p className="text-gray-600 mt-2">View your schedule at a glance</p>
|
<p className="text-gray-600 mt-2">View your schedule at a glance</p>
|
||||||
</div>
|
</div>
|
||||||
<Calendar events={events} isLoading={isLoading} />
|
|
||||||
|
{error !== null ? (
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 p-6 text-center">
|
||||||
|
<p className="text-amber-800">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => void loadEvents()}
|
||||||
|
className="mt-4 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 transition-colors"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Calendar events={events} isLoading={isLoading} />
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
85
apps/web/src/app/(authenticated)/page.test.tsx
Normal file
85
apps/web/src/app/(authenticated)/page.test.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import DashboardPage from "./page";
|
||||||
|
|
||||||
|
// Mock dashboard widgets
|
||||||
|
vi.mock("@/components/dashboard/RecentTasksWidget", () => ({
|
||||||
|
RecentTasksWidget: ({
|
||||||
|
tasks,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
tasks: unknown[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}): React.JSX.Element => (
|
||||||
|
<div data-testid="recent-tasks">
|
||||||
|
{isLoading ? "Loading tasks" : `${String(tasks.length)} tasks`}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/dashboard/UpcomingEventsWidget", () => ({
|
||||||
|
UpcomingEventsWidget: ({
|
||||||
|
events,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
events: unknown[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}): React.JSX.Element => (
|
||||||
|
<div data-testid="upcoming-events">
|
||||||
|
{isLoading ? "Loading events" : `${String(events.length)} events`}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/dashboard/QuickCaptureWidget", () => ({
|
||||||
|
QuickCaptureWidget: (): React.JSX.Element => <div data-testid="quick-capture">Quick Capture</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/dashboard/DomainOverviewWidget", () => ({
|
||||||
|
DomainOverviewWidget: ({
|
||||||
|
tasks,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
tasks: unknown[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}): React.JSX.Element => (
|
||||||
|
<div data-testid="domain-overview">
|
||||||
|
{isLoading ? "Loading overview" : `${String(tasks.length)} tasks overview`}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("DashboardPage", (): void => {
|
||||||
|
it("should render the page title", (): void => {
|
||||||
|
render(<DashboardPage />);
|
||||||
|
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Dashboard");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show loading state initially", (): void => {
|
||||||
|
render(<DashboardPage />);
|
||||||
|
expect(screen.getByTestId("recent-tasks")).toHaveTextContent("Loading tasks");
|
||||||
|
expect(screen.getByTestId("upcoming-events")).toHaveTextContent("Loading events");
|
||||||
|
expect(screen.getByTestId("domain-overview")).toHaveTextContent("Loading overview");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render all widgets with data after loading", async (): Promise<void> => {
|
||||||
|
render(<DashboardPage />);
|
||||||
|
await waitFor((): void => {
|
||||||
|
expect(screen.getByTestId("recent-tasks")).toHaveTextContent("4 tasks");
|
||||||
|
expect(screen.getByTestId("upcoming-events")).toHaveTextContent("3 events");
|
||||||
|
expect(screen.getByTestId("domain-overview")).toHaveTextContent("4 tasks overview");
|
||||||
|
expect(screen.getByTestId("quick-capture")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have proper layout structure", (): void => {
|
||||||
|
const { container } = render(<DashboardPage />);
|
||||||
|
const main = container.querySelector("main");
|
||||||
|
expect(main).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render the welcome subtitle", (): void => {
|
||||||
|
render(<DashboardPage />);
|
||||||
|
expect(screen.getByText(/Welcome back/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { RecentTasksWidget } from "@/components/dashboard/RecentTasksWidget";
|
import { RecentTasksWidget } from "@/components/dashboard/RecentTasksWidget";
|
||||||
import { UpcomingEventsWidget } from "@/components/dashboard/UpcomingEventsWidget";
|
import { UpcomingEventsWidget } from "@/components/dashboard/UpcomingEventsWidget";
|
||||||
@@ -5,43 +8,71 @@ import { QuickCaptureWidget } from "@/components/dashboard/QuickCaptureWidget";
|
|||||||
import { DomainOverviewWidget } from "@/components/dashboard/DomainOverviewWidget";
|
import { DomainOverviewWidget } from "@/components/dashboard/DomainOverviewWidget";
|
||||||
import { mockTasks } from "@/lib/api/tasks";
|
import { mockTasks } from "@/lib/api/tasks";
|
||||||
import { mockEvents } from "@/lib/api/events";
|
import { mockEvents } from "@/lib/api/events";
|
||||||
|
import type { Task, Event } from "@mosaic/shared";
|
||||||
|
|
||||||
export default function DashboardPage(): ReactElement {
|
export default function DashboardPage(): ReactElement {
|
||||||
// TODO: Replace with real API call when backend is ready
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
// const { data: tasks, isLoading: tasksLoading } = useQuery({
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
// queryKey: ["tasks"],
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
// queryFn: fetchTasks,
|
const [error, setError] = useState<string | null>(null);
|
||||||
// });
|
|
||||||
// const { data: events, isLoading: eventsLoading } = useQuery({
|
|
||||||
// queryKey: ["events"],
|
|
||||||
// queryFn: fetchEvents,
|
|
||||||
// });
|
|
||||||
|
|
||||||
const tasks = mockTasks;
|
useEffect(() => {
|
||||||
const events = mockEvents;
|
void loadDashboardData();
|
||||||
const tasksLoading = false;
|
}, []);
|
||||||
const eventsLoading = false;
|
|
||||||
|
async function loadDashboardData(): Promise<void> {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: Replace with real API calls when backend is ready
|
||||||
|
// const [tasksData, eventsData] = await Promise.all([fetchTasks(), fetchEvents()]);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
setTasks(mockTasks);
|
||||||
|
setEvents(mockEvents);
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "We had trouble loading your dashboard. 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">Dashboard</h1>
|
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
|
||||||
<p className="text-gray-600 mt-2">Welcome back! Here's your overview</p>
|
<p className="text-gray-600 mt-2">Welcome back! Here's your overview</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
{error !== null ? (
|
||||||
{/* Top row: Domain Overview and Quick Capture */}
|
<div className="rounded-lg border border-amber-200 bg-amber-50 p-6 text-center">
|
||||||
<div className="lg:col-span-2">
|
<p className="text-amber-800">{error}</p>
|
||||||
<DomainOverviewWidget tasks={tasks} isLoading={tasksLoading} />
|
<button
|
||||||
|
onClick={() => void loadDashboardData()}
|
||||||
|
className="mt-4 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 transition-colors"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Top row: Domain Overview and Quick Capture */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<DomainOverviewWidget tasks={tasks} isLoading={isLoading} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<RecentTasksWidget tasks={tasks} isLoading={tasksLoading} />
|
<RecentTasksWidget tasks={tasks} isLoading={isLoading} />
|
||||||
<UpcomingEventsWidget events={events} isLoading={eventsLoading} />
|
<UpcomingEventsWidget events={events} isLoading={isLoading} />
|
||||||
|
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<QuickCaptureWidget />
|
<QuickCaptureWidget />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import TasksPage from "./page";
|
import TasksPage from "./page";
|
||||||
|
|
||||||
// Mock the TaskList component
|
// Mock the TaskList component
|
||||||
@@ -15,9 +15,16 @@ describe("TasksPage", (): void => {
|
|||||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Tasks");
|
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Tasks");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render the TaskList component", (): void => {
|
it("should show loading state initially", (): void => {
|
||||||
render(<TasksPage />);
|
render(<TasksPage />);
|
||||||
expect(screen.getByTestId("task-list")).toBeInTheDocument();
|
expect(screen.getByTestId("task-list")).toHaveTextContent("Loading");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render the TaskList with tasks after loading", async (): Promise<void> => {
|
||||||
|
render(<TasksPage />);
|
||||||
|
await waitFor((): void => {
|
||||||
|
expect(screen.getByTestId("task-list")).toHaveTextContent("4 tasks");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should have proper layout structure", (): void => {
|
it("should have proper layout structure", (): void => {
|
||||||
@@ -25,4 +32,9 @@ describe("TasksPage", (): void => {
|
|||||||
const main = container.querySelector("main");
|
const main = container.querySelector("main");
|
||||||
expect(main).toBeInTheDocument();
|
expect(main).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should render the subtitle text", (): void => {
|
||||||
|
render(<TasksPage />);
|
||||||
|
expect(screen.getByText("Organize your work at your own pace")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,19 +1,40 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
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 { mockTasks } from "@/lib/api/tasks";
|
||||||
|
import type { Task } from "@mosaic/shared";
|
||||||
|
|
||||||
export default function TasksPage(): ReactElement {
|
export default function TasksPage(): ReactElement {
|
||||||
// TODO: Replace with real API call when backend is ready
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
// const { data: tasks, isLoading } = useQuery({
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
// queryKey: ["tasks"],
|
const [error, setError] = useState<string | null>(null);
|
||||||
// queryFn: fetchTasks,
|
|
||||||
// });
|
|
||||||
|
|
||||||
const tasks = mockTasks;
|
useEffect(() => {
|
||||||
const isLoading = false;
|
void loadTasks();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="container mx-auto px-4 py-8">
|
||||||
@@ -21,7 +42,20 @@ export default function TasksPage(): ReactElement {
|
|||||||
<h1 className="text-3xl font-bold text-gray-900">Tasks</h1>
|
<h1 className="text-3xl font-bold text-gray-900">Tasks</h1>
|
||||||
<p className="text-gray-600 mt-2">Organize your work at your own pace</p>
|
<p className="text-gray-600 mt-2">Organize your work at your own pace</p>
|
||||||
</div>
|
</div>
|
||||||
<TaskList tasks={tasks} isLoading={isLoading} />
|
|
||||||
|
{error !== null ? (
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 p-6 text-center">
|
||||||
|
<p className="text-amber-800">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => void loadTasks()}
|
||||||
|
className="mt-4 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 transition-colors"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TaskList tasks={tasks} isLoading={isLoading} />
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user