Add toast notification system with sonner library

- Create Toaster component wrapping sonner at bottom-right position
- Add showToast utility with success/error/info methods
- Error toasts persist until dismissed, others auto-dismiss after 5s
- Migrate error handling to toasts across all pages:
  - Dashboard (override toggle errors)
  - Settings (save/load success/error)
  - Garmin settings (connection success/error)
  - Calendar (load errors)
  - Period History (load/delete errors)
- Add dark mode support for toast styling
- Add Toaster provider to root layout
- 27 new tests (23 toaster component + 4 integration)
- Total: 977 unit tests passing

P5.2 COMPLETE - All P0-P5 items now complete.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-12 23:04:27 +00:00
parent 38bea1ffd7
commit e971fe683f
17 changed files with 697 additions and 187 deletions

View File

@@ -11,6 +11,16 @@ vi.mock("next/navigation", () => ({
}),
}));
// Mock showToast utility with vi.hoisted to avoid hoisting issues
const mockShowToast = vi.hoisted(() => ({
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
}));
vi.mock("@/components/ui/toaster", () => ({
showToast: mockShowToast,
}));
// Mock fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
@@ -30,6 +40,9 @@ describe("CalendarPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockShowToast.success.mockClear();
mockShowToast.error.mockClear();
mockShowToast.info.mockClear();
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockUser),
@@ -134,6 +147,21 @@ describe("CalendarPage", () => {
expect(screen.getByRole("alert")).toBeInTheDocument();
});
});
it("shows error toast when fetching fails", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ error: "Network error" }),
});
render(<CalendarPage />);
await waitFor(() => {
expect(mockShowToast.error).toHaveBeenCalledWith(
"Unable to fetch data. Retry?",
);
});
});
});
describe("month navigation", () => {

View File

@@ -5,6 +5,7 @@
import Link from "next/link";
import { useEffect, useState } from "react";
import { MonthView } from "@/components/calendar/month-view";
import { showToast } from "@/components/ui/toaster";
interface User {
id: string;
@@ -30,12 +31,15 @@ export default function CalendarPage() {
const res = await fetch("/api/user");
const data = await res.json();
if (!res.ok) {
setError(data.error || "Failed to fetch user");
const message = data.error || "Failed to fetch user";
setError(message);
showToast.error("Unable to fetch data. Retry?");
return;
}
setUser(data);
} catch {
setError("Failed to fetch user data");
showToast.error("Unable to fetch data. Retry?");
} finally {
setLoading(false);
}

View File

@@ -15,6 +15,11 @@ vi.mock("next/font/google", () => ({
}),
}));
// Mock the Toaster component to avoid sonner dependencies in tests
vi.mock("@/components/ui/toaster", () => ({
Toaster: () => <div data-testid="toaster">Toast Provider</div>,
}));
import RootLayout from "./layout";
describe("RootLayout", () => {
@@ -56,5 +61,15 @@ describe("RootLayout", () => {
expect(screen.getByTestId("child-content")).toBeInTheDocument();
});
it("renders the Toaster component for toast notifications", () => {
render(
<RootLayout>
<main id="main-content">Test content</main>
</RootLayout>,
);
expect(screen.getByTestId("toaster")).toBeInTheDocument();
});
});
});

View File

@@ -1,8 +1,9 @@
// ABOUTME: Root layout for PhaseFlow application.
// ABOUTME: Configures fonts, metadata, and global styles.
// ABOUTME: Configures fonts, metadata, Toaster provider, and global styles.
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Toaster } from "@/components/ui/toaster";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -37,6 +38,7 @@ export default function RootLayout({
Skip to main content
</a>
{children}
<Toaster />
</body>
</html>
);

View File

@@ -3,6 +3,16 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock showToast utility with vi.hoisted to avoid hoisting issues
const mockShowToast = vi.hoisted(() => ({
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
}));
vi.mock("@/components/ui/toaster", () => ({
showToast: mockShowToast,
}));
// Mock fetch globally
const mockFetch = vi.fn();
global.fetch = mockFetch;
@@ -55,6 +65,9 @@ const mockUserResponse = {
describe("Dashboard", () => {
beforeEach(() => {
vi.clearAllMocks();
mockShowToast.success.mockClear();
mockShowToast.error.mockClear();
mockShowToast.info.mockClear();
});
describe("rendering", () => {
@@ -496,6 +509,42 @@ describe("Dashboard", () => {
expect(screen.getByText(/flare mode active/i)).toBeInTheDocument();
});
});
it("shows error toast when toggle fails", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockTodayResponse),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUserResponse),
});
render(<Dashboard />);
await waitFor(() => {
expect(screen.getByText("Flare Mode")).toBeInTheDocument();
});
// Clear mock and set up for failed toggle
mockFetch.mockClear();
mockFetch.mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ error: "Failed to update override" }),
});
const flareCheckbox = screen.getByRole("checkbox", {
name: /flare mode/i,
});
fireEvent.click(flareCheckbox);
await waitFor(() => {
expect(mockShowToast.error).toHaveBeenCalledWith(
"Failed to update override",
);
});
});
});
describe("error handling", () => {

View File

@@ -12,6 +12,7 @@ import { OnboardingBanner } from "@/components/dashboard/onboarding-banner";
import { OverrideToggles } from "@/components/dashboard/override-toggles";
import { PeriodDateModal } from "@/components/dashboard/period-date-modal";
import { DashboardSkeleton } from "@/components/dashboard/skeletons";
import { showToast } from "@/components/ui/toaster";
import type {
CyclePhase,
Decision,
@@ -173,9 +174,9 @@ export default function Dashboard() {
const newTodayData = await fetchTodayData();
setTodayData(newTodayData);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to toggle override",
);
const message =
err instanceof Error ? err.message : "Failed to toggle override";
showToast.error(message);
}
};

View File

@@ -11,6 +11,16 @@ vi.mock("next/navigation", () => ({
}),
}));
// Mock showToast utility with vi.hoisted to avoid hoisting issues
const mockShowToast = vi.hoisted(() => ({
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
}));
vi.mock("@/components/ui/toaster", () => ({
showToast: mockShowToast,
}));
// Mock fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
@@ -41,6 +51,9 @@ describe("PeriodHistoryPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockShowToast.success.mockClear();
mockShowToast.error.mockClear();
mockShowToast.info.mockClear();
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockHistoryResponse),
@@ -255,6 +268,57 @@ describe("PeriodHistoryPage", () => {
expect(screen.getByText(/network error/i)).toBeInTheDocument();
});
});
it("shows error toast on fetch failure", async () => {
mockFetch.mockResolvedValue({
ok: false,
json: () =>
Promise.resolve({ error: "Failed to fetch period history" }),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(mockShowToast.error).toHaveBeenCalledWith(
"Unable to fetch data. Retry?",
);
});
});
it("shows error toast when delete fails", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockHistoryResponse),
})
.mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ error: "Failed to delete period" }),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /delete/i }),
).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
await waitFor(() => {
expect(screen.getByText(/are you sure/i)).toBeInTheDocument();
});
const confirmButton = screen.getByRole("button", { name: /confirm/i });
fireEvent.click(confirmButton);
await waitFor(() => {
expect(mockShowToast.error).toHaveBeenCalledWith(
"Failed to delete period",
);
});
});
});
describe("pagination", () => {

View File

@@ -4,6 +4,7 @@
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { showToast } from "@/components/ui/toaster";
interface PeriodLogWithCycleLength {
id: string;
@@ -93,6 +94,7 @@ export default function PeriodHistoryPage() {
} catch (err) {
const message = err instanceof Error ? err.message : "An error occurred";
setError(message);
showToast.error("Unable to fetch data. Retry?");
} finally {
setLoading(false);
}
@@ -180,6 +182,7 @@ export default function PeriodHistoryPage() {
} catch (err) {
const message = err instanceof Error ? err.message : "An error occurred";
setError(message);
showToast.error(message || "Failed to delete. Try again.");
setDeletingPeriod(null);
}
};

View File

@@ -14,6 +14,16 @@ vi.mock("next/link", () => ({
}) => <a href={href}>{children}</a>,
}));
// Mock showToast utility with vi.hoisted to avoid hoisting issues
const mockShowToast = vi.hoisted(() => ({
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
}));
vi.mock("@/components/ui/toaster", () => ({
showToast: mockShowToast,
}));
// Mock fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
@@ -23,6 +33,9 @@ import GarminSettingsPage from "./page";
describe("GarminSettingsPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockShowToast.success.mockClear();
mockShowToast.error.mockClear();
mockShowToast.info.mockClear();
// Default mock for disconnected state
mockFetch.mockResolvedValue({
ok: true,
@@ -266,8 +279,7 @@ describe("GarminSettingsPage", () => {
fireEvent.click(saveButton);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText(/invalid json format/i)).toBeInTheDocument();
expect(mockShowToast.error).toHaveBeenCalledWith("Invalid JSON format");
});
});
@@ -285,8 +297,7 @@ describe("GarminSettingsPage", () => {
fireEvent.click(saveButton);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText(/oauth2.*required/i)).toBeInTheDocument();
expect(mockShowToast.error).toHaveBeenCalledWith("oauth2 is required");
});
});
@@ -349,7 +360,7 @@ describe("GarminSettingsPage", () => {
});
});
it("shows success message after saving tokens", async () => {
it("shows success toast after saving tokens", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
@@ -400,7 +411,9 @@ describe("GarminSettingsPage", () => {
fireEvent.click(saveButton);
await waitFor(() => {
expect(screen.getByText(/tokens saved/i)).toBeInTheDocument();
expect(mockShowToast.success).toHaveBeenCalledWith(
"Tokens saved successfully",
);
});
});
@@ -457,7 +470,7 @@ describe("GarminSettingsPage", () => {
});
});
it("shows error when save fails", async () => {
it("shows error toast when save fails", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
@@ -493,8 +506,9 @@ describe("GarminSettingsPage", () => {
fireEvent.click(saveButton);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText(/failed to save tokens/i)).toBeInTheDocument();
expect(mockShowToast.error).toHaveBeenCalledWith(
"Failed to save tokens",
);
});
});
});
@@ -561,7 +575,7 @@ describe("GarminSettingsPage", () => {
});
});
it("shows disconnected message after successful disconnect", async () => {
it("shows success toast after successful disconnect", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
@@ -603,7 +617,9 @@ describe("GarminSettingsPage", () => {
fireEvent.click(disconnectButton);
await waitFor(() => {
expect(screen.getByText(/garmin disconnected/i)).toBeInTheDocument();
expect(mockShowToast.success).toHaveBeenCalledWith(
"Garmin disconnected successfully",
);
});
});
@@ -651,7 +667,7 @@ describe("GarminSettingsPage", () => {
resolveDisconnect({ success: true, garminConnected: false });
});
it("shows error when disconnect fails", async () => {
it("shows error toast when disconnect fails", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
@@ -682,8 +698,9 @@ describe("GarminSettingsPage", () => {
fireEvent.click(disconnectButton);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText(/failed to disconnect/i)).toBeInTheDocument();
expect(mockShowToast.error).toHaveBeenCalledWith(
"Failed to disconnect",
);
});
});
});
@@ -703,26 +720,19 @@ describe("GarminSettingsPage", () => {
});
});
it("clears error when user modifies input", async () => {
it("shows error toast on load failure", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ error: "Network error" }),
});
render(<GarminSettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/paste tokens/i)).toBeInTheDocument();
expect(mockShowToast.error).toHaveBeenCalledWith(
"Unable to fetch data. Retry?",
);
});
const textarea = screen.getByLabelText(/paste tokens/i);
fireEvent.change(textarea, { target: { value: "invalid json" } });
const saveButton = screen.getByRole("button", { name: /save tokens/i });
fireEvent.click(saveButton);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
});
fireEvent.change(textarea, { target: { value: '{"oauth1": {}}' } });
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});
});

View File

@@ -4,6 +4,7 @@
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { showToast } from "@/components/ui/toaster";
interface GarminStatus {
connected: boolean;
@@ -17,13 +18,12 @@ export default function GarminSettingsPage() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [disconnecting, setDisconnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [tokenInput, setTokenInput] = useState("");
const fetchStatus = useCallback(async () => {
setLoading(true);
setError(null);
setLoadError(null);
try {
const response = await fetch("/api/garmin/status");
@@ -36,7 +36,8 @@ export default function GarminSettingsPage() {
setStatus(data);
} catch (err) {
const message = err instanceof Error ? err.message : "An error occurred";
setError(message);
setLoadError(message);
showToast.error("Unable to fetch data. Retry?");
} finally {
setLoading(false);
}
@@ -48,12 +49,6 @@ export default function GarminSettingsPage() {
const handleTokenChange = (value: string) => {
setTokenInput(value);
if (error) {
setError(null);
}
if (success) {
setSuccess(null);
}
};
const validateTokens = (
@@ -90,13 +85,11 @@ export default function GarminSettingsPage() {
const handleSaveTokens = async () => {
const validation = validateTokens(tokenInput);
if (!validation.valid) {
setError(validation.error);
showToast.error(validation.error);
return;
}
setSaving(true);
setError(null);
setSuccess(null);
try {
const response = await fetch("/api/garmin/tokens", {
@@ -111,12 +104,12 @@ export default function GarminSettingsPage() {
throw new Error(data.error || "Failed to save tokens");
}
setSuccess("Tokens saved successfully");
showToast.success("Tokens saved successfully");
setTokenInput("");
await fetchStatus();
} catch (err) {
const message = err instanceof Error ? err.message : "An error occurred";
setError(message);
showToast.error(message || "Failed to save. Try again.");
} finally {
setSaving(false);
}
@@ -124,8 +117,6 @@ export default function GarminSettingsPage() {
const handleDisconnect = async () => {
setDisconnecting(true);
setError(null);
setSuccess(null);
try {
const response = await fetch("/api/garmin/tokens", {
@@ -138,11 +129,11 @@ export default function GarminSettingsPage() {
throw new Error(data.error || "Failed to disconnect");
}
setSuccess("Garmin disconnected successfully");
showToast.success("Garmin disconnected successfully");
await fetchStatus();
} catch (err) {
const message = err instanceof Error ? err.message : "An error occurred";
setError(message);
showToast.error(message || "Failed to disconnect. Try again.");
} finally {
setDisconnecting(false);
}
@@ -173,18 +164,12 @@ export default function GarminSettingsPage() {
</Link>
</div>
{error && (
{loadError && (
<div
role="alert"
className="bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-4 py-3 rounded mb-6"
>
{error}
</div>
)}
{success && (
<div className="bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-400 px-4 py-3 rounded mb-6">
{success}
{loadError}
</div>
)}

View File

@@ -11,6 +11,16 @@ vi.mock("next/navigation", () => ({
}),
}));
// Mock showToast utility with vi.hoisted to avoid hoisting issues
const mockShowToast = vi.hoisted(() => ({
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
}));
vi.mock("@/components/ui/toaster", () => ({
showToast: mockShowToast,
}));
// Mock fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
@@ -31,6 +41,9 @@ describe("SettingsPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockShowToast.success.mockClear();
mockShowToast.error.mockClear();
mockShowToast.info.mockClear();
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockUser),
@@ -302,7 +315,7 @@ describe("SettingsPage", () => {
resolveSave(mockUser);
});
it("shows success message on save", async () => {
it("shows success toast on save", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
@@ -323,11 +336,13 @@ describe("SettingsPage", () => {
fireEvent.click(saveButton);
await waitFor(() => {
expect(screen.getByText(/settings saved/i)).toBeInTheDocument();
expect(mockShowToast.success).toHaveBeenCalledWith(
"Settings saved successfully",
);
});
});
it("shows error on save failure", async () => {
it("shows error toast on save failure", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
@@ -349,10 +364,9 @@ describe("SettingsPage", () => {
fireEvent.click(saveButton);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(
screen.getByText(/cycleLength must be between 21 and 45/i),
).toBeInTheDocument();
expect(mockShowToast.error).toHaveBeenCalledWith(
"cycleLength must be between 21 and 45",
);
});
});
@@ -377,7 +391,7 @@ describe("SettingsPage", () => {
fireEvent.click(saveButton);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(mockShowToast.error).toHaveBeenCalled();
});
expect(screen.getByLabelText(/cycle length/i)).not.toBeDisabled();
@@ -444,65 +458,20 @@ describe("SettingsPage", () => {
});
});
describe("error handling", () => {
it("clears error when user starts typing", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUser),
})
.mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ error: "Failed to save" }),
});
describe("toast notifications", () => {
it("shows toast with fetch error on load failure", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ error: "Failed to fetch user" }),
});
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/cycle length/i)).toBeInTheDocument();
expect(mockShowToast.error).toHaveBeenCalledWith(
"Unable to fetch data. Retry?",
);
});
const saveButton = screen.getByRole("button", { name: /save/i });
fireEvent.click(saveButton);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
});
const cycleLengthInput = screen.getByLabelText(/cycle length/i);
fireEvent.change(cycleLengthInput, { target: { value: "30" } });
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("clears success message when user modifies form", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUser),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUser),
});
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/cycle length/i)).toBeInTheDocument();
});
const saveButton = screen.getByRole("button", { name: /save/i });
fireEvent.click(saveButton);
await waitFor(() => {
expect(screen.getByText(/settings saved/i)).toBeInTheDocument();
});
const cycleLengthInput = screen.getByLabelText(/cycle length/i);
fireEvent.change(cycleLengthInput, { target: { value: "30" } });
expect(screen.queryByText(/settings saved/i)).not.toBeInTheDocument();
});
});
@@ -643,7 +612,7 @@ describe("SettingsPage", () => {
});
});
it("shows error if logout fails", async () => {
it("shows error toast if logout fails", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
@@ -666,8 +635,7 @@ describe("SettingsPage", () => {
fireEvent.click(logoutButton);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText(/logout failed/i)).toBeInTheDocument();
expect(mockShowToast.error).toHaveBeenCalledWith("Logout failed");
});
});
});

View File

@@ -5,6 +5,7 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { showToast } from "@/components/ui/toaster";
interface UserData {
id: string;
@@ -23,8 +24,7 @@ export default function SettingsPage() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [loggingOut, setLoggingOut] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [cycleLength, setCycleLength] = useState(28);
const [notificationTime, setNotificationTime] = useState("08:00");
@@ -32,7 +32,7 @@ export default function SettingsPage() {
const fetchUserData = useCallback(async () => {
setLoading(true);
setError(null);
setLoadError(null);
try {
const response = await fetch("/api/user");
@@ -48,7 +48,8 @@ export default function SettingsPage() {
setTimezone(data.timezone);
} catch (err) {
const message = err instanceof Error ? err.message : "An error occurred";
setError(message);
setLoadError(message);
showToast.error("Unable to fetch data. Retry?");
} finally {
setLoading(false);
}
@@ -63,20 +64,12 @@ export default function SettingsPage() {
value: T,
) => {
setter(value);
if (error) {
setError(null);
}
if (success) {
setSuccess(null);
}
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setSaving(true);
setError(null);
setSuccess(null);
try {
const response = await fetch("/api/user", {
@@ -96,10 +89,10 @@ export default function SettingsPage() {
}
setUserData(data);
setSuccess("Settings saved successfully");
showToast.success("Settings saved successfully");
} catch (err) {
const message = err instanceof Error ? err.message : "An error occurred";
setError(message);
showToast.error(message || "Failed to save. Try again.");
} finally {
setSaving(false);
}
@@ -107,7 +100,6 @@ export default function SettingsPage() {
const handleLogout = async () => {
setLoggingOut(true);
setError(null);
try {
const response = await fetch("/api/auth/logout", {
@@ -123,7 +115,7 @@ export default function SettingsPage() {
router.push(data.redirectTo || "/login");
} catch (err) {
const message = err instanceof Error ? err.message : "Logout failed";
setError(message);
showToast.error(message);
setLoggingOut(false);
}
};
@@ -149,18 +141,12 @@ export default function SettingsPage() {
</Link>
</div>
{error && (
{loadError && (
<div
role="alert"
className="bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-4 py-3 rounded mb-6"
>
{error}
</div>
)}
{success && (
<div className="bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-400 px-4 py-3 rounded mb-6">
{success}
{loadError}
</div>
)}

View File

@@ -0,0 +1,291 @@
// ABOUTME: Unit tests for the Toaster component and toast utility functions.
// ABOUTME: Tests cover rendering, toast types, auto-dismiss behavior, and error persistence.
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AUTO_DISMISS_DURATION, showToast, Toaster } from "./toaster";
describe("Toaster", () => {
// Clear any existing toasts between tests
beforeEach(() => {
// Render a fresh toaster for each test
document.body.innerHTML = "";
// Mock setPointerCapture/releasePointerCapture for jsdom
// Sonner uses these for swipe gestures which jsdom doesn't support
if (!Element.prototype.setPointerCapture) {
Element.prototype.setPointerCapture = vi.fn();
}
if (!Element.prototype.releasePointerCapture) {
Element.prototype.releasePointerCapture = vi.fn();
}
});
describe("rendering", () => {
it("renders without crashing", () => {
render(<Toaster />);
// Toaster renders a portal, so it won't have visible content initially
expect(document.body).toBeDefined();
});
it("renders in bottom-right position by default", () => {
render(<Toaster />);
// Sonner creates an ol element for toasts with data-sonner-toaster attribute
const toaster = document.querySelector("[data-sonner-toaster]");
expect(toaster).toBeDefined();
});
});
describe("showToast utility", () => {
it("shows success toast", async () => {
render(<Toaster />);
showToast.success("Operation completed");
await waitFor(() => {
expect(screen.getByText("Operation completed")).toBeInTheDocument();
});
});
it("shows error toast", async () => {
render(<Toaster />);
showToast.error("Something went wrong");
await waitFor(() => {
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
});
});
it("shows info toast", async () => {
render(<Toaster />);
showToast.info("Here is some information");
await waitFor(() => {
expect(
screen.getByText("Here is some information"),
).toBeInTheDocument();
});
});
it("shows toast with custom message from spec examples", async () => {
render(<Toaster />);
showToast.error("Unable to fetch data. Retry?");
await waitFor(() => {
expect(
screen.getByText("Unable to fetch data. Retry?"),
).toBeInTheDocument();
});
});
it("shows toast with Garmin sync message from spec", async () => {
render(<Toaster />);
showToast.error("Garmin data unavailable. Using last known values.");
await waitFor(() => {
expect(
screen.getByText("Garmin data unavailable. Using last known values."),
).toBeInTheDocument();
});
});
it("shows toast with save error message from spec", async () => {
render(<Toaster />);
showToast.error("Failed to save. Try again.");
await waitFor(() => {
expect(
screen.getByText("Failed to save. Try again."),
).toBeInTheDocument();
});
});
});
describe("toast duration configuration", () => {
it("exports AUTO_DISMISS_DURATION as 5000ms", () => {
expect(AUTO_DISMISS_DURATION).toBe(5000);
});
it("success toasts are configured with auto-dismiss duration", () => {
// We verify the implementation by checking the exported constant
// and trusting the sonner library to honor the duration
expect(AUTO_DISMISS_DURATION).toBe(5000);
});
it("error toasts are configured to persist", async () => {
// Error toasts should use Infinity duration
// We verify by checking that the error toast API exists
expect(showToast.error).toBeDefined();
expect(typeof showToast.error).toBe("function");
});
});
describe("multiple toasts", () => {
it("can show multiple toasts at once", async () => {
render(<Toaster />);
showToast.success("First toast");
showToast.error("Second toast");
showToast.info("Third toast");
await waitFor(() => {
expect(screen.getByText("First toast")).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText("Second toast")).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText("Third toast")).toBeInTheDocument();
});
});
});
describe("toast styling", () => {
it("applies correct data-type attribute for success toasts", async () => {
render(<Toaster />);
showToast.success("Styled success");
await waitFor(() => {
const toast = screen
.getByText("Styled success")
.closest("[data-sonner-toast]");
expect(toast).toHaveAttribute("data-type", "success");
});
});
it("applies correct data-type attribute for error toasts", async () => {
render(<Toaster />);
showToast.error("Styled error");
await waitFor(() => {
const toast = screen
.getByText("Styled error")
.closest("[data-sonner-toast]");
expect(toast).toHaveAttribute("data-type", "error");
});
});
it("applies correct data-type attribute for info toasts", async () => {
render(<Toaster />);
showToast.info("Styled info");
await waitFor(() => {
const toast = screen
.getByText("Styled info")
.closest("[data-sonner-toast]");
expect(toast).toHaveAttribute("data-type", "info");
});
});
});
describe("accessibility", () => {
it("toast container has aria-live for screen readers", async () => {
render(<Toaster />);
showToast.success("Accessible toast");
await waitFor(() => {
expect(screen.getByText("Accessible toast")).toBeInTheDocument();
});
// Sonner uses aria-live on the section container for announcements
const section = document.querySelector("section[aria-live]");
expect(section).toHaveAttribute("aria-live", "polite");
expect(section).toHaveAttribute("aria-label");
});
it("toast container has aria-atomic for complete announcements", async () => {
render(<Toaster />);
showToast.error("Error for screen reader");
await waitFor(() => {
expect(screen.getByText("Error for screen reader")).toBeInTheDocument();
});
// The section should have aria-atomic for screen reader announcements
const section = document.querySelector("section[aria-live]");
expect(section).toHaveAttribute("aria-atomic");
});
});
describe("toast dismissal", () => {
it("toasts have close button", async () => {
render(<Toaster />);
showToast.error("Dismissible toast");
await waitFor(() => {
const toast = screen
.getByText("Dismissible toast")
.closest("[data-sonner-toast]");
expect(toast).toBeInTheDocument();
});
// Close button should be rendered for all toasts
const closeButton = document.querySelector(
"[data-sonner-toast] button[data-close-button]",
);
expect(closeButton).toBeInTheDocument();
});
it("clicking close button dismisses toast", async () => {
const user = userEvent.setup();
render(<Toaster />);
showToast.error("Toast to dismiss");
await waitFor(() => {
expect(screen.getByText("Toast to dismiss")).toBeInTheDocument();
});
// Find and click the close button
const closeButton = document.querySelector(
"[data-sonner-toast] button[data-close-button]",
) as HTMLElement;
expect(closeButton).toBeInTheDocument();
await user.click(closeButton);
// Wait for toast to be dismissed
await waitFor(
() => {
expect(
screen.queryByText("Toast to dismiss"),
).not.toBeInTheDocument();
},
{ timeout: 1000 },
);
});
});
describe("API surface", () => {
it("exports Toaster component", () => {
expect(Toaster).toBeDefined();
expect(typeof Toaster).toBe("function");
});
it("exports showToast with success method", () => {
expect(showToast.success).toBeDefined();
expect(typeof showToast.success).toBe("function");
});
it("exports showToast with error method", () => {
expect(showToast.error).toBeDefined();
expect(typeof showToast.error).toBe("function");
});
it("exports showToast with info method", () => {
expect(showToast.info).toBeDefined();
expect(typeof showToast.info).toBe("function");
});
});
});

View File

@@ -0,0 +1,76 @@
// ABOUTME: Toast notification component wrapping sonner for consistent user feedback.
// ABOUTME: Exports Toaster component and showToast utility for success/error/info messages.
"use client";
import { Toaster as SonnerToaster, toast } from "sonner";
// Auto-dismiss duration in ms (5 seconds per spec)
export const AUTO_DISMISS_DURATION = 5000;
// Error duration - Infinity means persist until dismissed
const ERROR_PERSIST_DURATION = Number.POSITIVE_INFINITY;
/**
* Toaster component - renders in bottom-right position per spec.
* Should be placed in the root layout to be available throughout the app.
*/
export function Toaster() {
return (
<SonnerToaster
position="bottom-right"
toastOptions={{
// Default duration for non-error toasts
duration: AUTO_DISMISS_DURATION,
// Add close button for all toasts
closeButton: true,
// Styling that works with dark mode
classNames: {
toast:
"group toast group-[.toaster]:bg-white group-[.toaster]:text-zinc-950 group-[.toaster]:border-zinc-200 group-[.toaster]:shadow-lg dark:group-[.toaster]:bg-zinc-950 dark:group-[.toaster]:text-zinc-50 dark:group-[.toaster]:border-zinc-800",
success:
"group-[.toaster]:border-green-500 group-[.toaster]:text-green-700 dark:group-[.toaster]:text-green-400",
error:
"group-[.toaster]:border-red-500 group-[.toaster]:text-red-700 dark:group-[.toaster]:text-red-400",
info: "group-[.toaster]:border-blue-500 group-[.toaster]:text-blue-700 dark:group-[.toaster]:text-blue-400",
closeButton:
"group-[.toast]:bg-zinc-100 group-[.toast]:text-zinc-500 group-[.toast]:border-zinc-200 dark:group-[.toast]:bg-zinc-800 dark:group-[.toast]:text-zinc-400 dark:group-[.toast]:border-zinc-700",
},
}}
/>
);
}
/**
* Toast utility functions for showing notifications.
* Use these instead of calling toast() directly for consistent behavior.
*/
export const showToast = {
/**
* Show a success toast that auto-dismisses after 5 seconds.
*/
success: (message: string) => {
toast.success(message, {
duration: AUTO_DISMISS_DURATION,
});
},
/**
* Show an error toast that persists until manually dismissed.
* Per spec: "Errors persist until dismissed"
*/
error: (message: string) => {
toast.error(message, {
duration: ERROR_PERSIST_DURATION,
});
},
/**
* Show an info toast that auto-dismisses after 5 seconds.
*/
info: (message: string) => {
toast.info(message, {
duration: AUTO_DISMISS_DURATION,
});
},
};