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;
@@ -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");
});
});
});