From 83789dd12efe82b645445fbb46d98bcb2a003b57 Mon Sep 17 00:00:00 2001
From: nsfisis <nsfisis@gmail.com>
Date: Sun, 7 Dec 2025 18:03:04 +0900
Subject: feat(client): add delete deck modal with confirmation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Add DeleteDeckModal component that prompts users for confirmation
before deleting a deck. Includes warning about permanent deletion
and all associated cards.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---
 src/client/components/DeleteDeckModal.test.tsx | 266 +++++++++++++++++++++++++
 1 file changed, 266 insertions(+)
 create mode 100644 src/client/components/DeleteDeckModal.test.tsx

(limited to 'src/client/components/DeleteDeckModal.test.tsx')

diff --git a/src/client/components/DeleteDeckModal.test.tsx b/src/client/components/DeleteDeckModal.test.tsx
new file mode 100644
index 0000000..ad1463d
--- /dev/null
+++ b/src/client/components/DeleteDeckModal.test.tsx
@@ -0,0 +1,266 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { apiClient } from "../api/client";
+
+vi.mock("../api/client", () => ({
+	apiClient: {
+		getAuthHeader: vi.fn(),
+	},
+	ApiClientError: class ApiClientError extends Error {
+		constructor(
+			message: string,
+			public status: number,
+			public code?: string,
+		) {
+			super(message);
+			this.name = "ApiClientError";
+		}
+	},
+}));
+
+// Import after mock is set up
+import { DeleteDeckModal } from "./DeleteDeckModal";
+
+// Mock fetch globally
+const mockFetch = vi.fn();
+global.fetch = mockFetch;
+
+describe("DeleteDeckModal", () => {
+	const mockDeck = {
+		id: "deck-123",
+		name: "Test Deck",
+	};
+
+	const defaultProps = {
+		isOpen: true,
+		deck: mockDeck,
+		onClose: vi.fn(),
+		onDeckDeleted: vi.fn(),
+	};
+
+	beforeEach(() => {
+		vi.clearAllMocks();
+		vi.mocked(apiClient.getAuthHeader).mockReturnValue({
+			Authorization: "Bearer access-token",
+		});
+	});
+
+	afterEach(() => {
+		cleanup();
+		vi.restoreAllMocks();
+	});
+
+	it("does not render when closed", () => {
+		render(<DeleteDeckModal {...defaultProps} isOpen={false} />);
+
+		expect(screen.queryByRole("dialog")).toBeNull();
+	});
+
+	it("does not render when deck is null", () => {
+		render(<DeleteDeckModal {...defaultProps} deck={null} />);
+
+		expect(screen.queryByRole("dialog")).toBeNull();
+	});
+
+	it("renders modal when open with deck", () => {
+		render(<DeleteDeckModal {...defaultProps} />);
+
+		expect(screen.getByRole("dialog")).toBeDefined();
+		expect(screen.getByRole("heading", { name: "Delete Deck" })).toBeDefined();
+		expect(screen.getByRole("button", { name: "Cancel" })).toBeDefined();
+		expect(screen.getByRole("button", { name: "Delete" })).toBeDefined();
+	});
+
+	it("displays confirmation message with deck name", () => {
+		render(<DeleteDeckModal {...defaultProps} />);
+
+		expect(screen.getByText(/Are you sure you want to delete/)).toBeDefined();
+		expect(screen.getByText("Test Deck")).toBeDefined();
+	});
+
+	it("displays warning about permanent deletion", () => {
+		render(<DeleteDeckModal {...defaultProps} />);
+
+		expect(screen.getByText(/This action cannot be undone/)).toBeDefined();
+	});
+
+	it("calls onClose when Cancel is clicked", async () => {
+		const user = userEvent.setup();
+		const onClose = vi.fn();
+		render(<DeleteDeckModal {...defaultProps} onClose={onClose} />);
+
+		await user.click(screen.getByRole("button", { name: "Cancel" }));
+
+		expect(onClose).toHaveBeenCalledTimes(1);
+	});
+
+	it("calls onClose when clicking outside the modal", async () => {
+		const user = userEvent.setup();
+		const onClose = vi.fn();
+		render(<DeleteDeckModal {...defaultProps} onClose={onClose} />);
+
+		// Click on the backdrop (the dialog element itself)
+		const dialog = screen.getByRole("dialog");
+		await user.click(dialog);
+
+		expect(onClose).toHaveBeenCalledTimes(1);
+	});
+
+	it("does not call onClose when clicking inside the modal content", async () => {
+		const user = userEvent.setup();
+		const onClose = vi.fn();
+		render(<DeleteDeckModal {...defaultProps} onClose={onClose} />);
+
+		// Click on an element inside the modal
+		await user.click(screen.getByText("Test Deck"));
+
+		expect(onClose).not.toHaveBeenCalled();
+	});
+
+	it("deletes deck when Delete is clicked", async () => {
+		const user = userEvent.setup();
+		const onClose = vi.fn();
+		const onDeckDeleted = vi.fn();
+
+		mockFetch.mockResolvedValue({
+			ok: true,
+			json: async () => ({}),
+		});
+
+		render(
+			<DeleteDeckModal
+				isOpen={true}
+				deck={mockDeck}
+				onClose={onClose}
+				onDeckDeleted={onDeckDeleted}
+			/>,
+		);
+
+		await user.click(screen.getByRole("button", { name: "Delete" }));
+
+		await waitFor(() => {
+			expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123", {
+				method: "DELETE",
+				headers: {
+					Authorization: "Bearer access-token",
+				},
+			});
+		});
+
+		expect(onDeckDeleted).toHaveBeenCalledTimes(1);
+		expect(onClose).toHaveBeenCalledTimes(1);
+	});
+
+	it("shows loading state during deletion", async () => {
+		const user = userEvent.setup();
+
+		mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves
+
+		render(<DeleteDeckModal {...defaultProps} />);
+
+		await user.click(screen.getByRole("button", { name: "Delete" }));
+
+		expect(screen.getByRole("button", { name: "Deleting..." })).toBeDefined();
+		expect(screen.getByRole("button", { name: "Deleting..." })).toHaveProperty(
+			"disabled",
+			true,
+		);
+		expect(screen.getByRole("button", { name: "Cancel" })).toHaveProperty(
+			"disabled",
+			true,
+		);
+	});
+
+	it("displays API error message", async () => {
+		const user = userEvent.setup();
+
+		mockFetch.mockResolvedValue({
+			ok: false,
+			status: 404,
+			json: async () => ({ error: "Deck not found" }),
+		});
+
+		render(<DeleteDeckModal {...defaultProps} />);
+
+		await user.click(screen.getByRole("button", { name: "Delete" }));
+
+		await waitFor(() => {
+			expect(screen.getByRole("alert").textContent).toContain("Deck not found");
+		});
+	});
+
+	it("displays generic error on unexpected failure", async () => {
+		const user = userEvent.setup();
+
+		mockFetch.mockRejectedValue(new Error("Network error"));
+
+		render(<DeleteDeckModal {...defaultProps} />);
+
+		await user.click(screen.getByRole("button", { name: "Delete" }));
+
+		await waitFor(() => {
+			expect(screen.getByRole("alert").textContent).toContain(
+				"Failed to delete deck. Please try again.",
+			);
+		});
+	});
+
+	it("displays error when not authenticated", async () => {
+		const user = userEvent.setup();
+
+		vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined);
+
+		render(<DeleteDeckModal {...defaultProps} />);
+
+		await user.click(screen.getByRole("button", { name: "Delete" }));
+
+		await waitFor(() => {
+			expect(screen.getByRole("alert").textContent).toContain(
+				"Not authenticated",
+			);
+		});
+	});
+
+	it("clears error when modal is closed", async () => {
+		const user = userEvent.setup();
+		const onClose = vi.fn();
+
+		mockFetch.mockResolvedValue({
+			ok: false,
+			status: 404,
+			json: async () => ({ error: "Some error" }),
+		});
+
+		const { rerender } = render(
+			<DeleteDeckModal {...defaultProps} onClose={onClose} />,
+		);
+
+		// Trigger error
+		await user.click(screen.getByRole("button", { name: "Delete" }));
+		await waitFor(() => {
+			expect(screen.getByRole("alert")).toBeDefined();
+		});
+
+		// Close and reopen the modal
+		await user.click(screen.getByRole("button", { name: "Cancel" }));
+		rerender(<DeleteDeckModal {...defaultProps} onClose={onClose} />);
+
+		// Error should be cleared
+		expect(screen.queryByRole("alert")).toBeNull();
+	});
+
+	it("displays deck name correctly when changed", () => {
+		const { rerender } = render(<DeleteDeckModal {...defaultProps} />);
+
+		expect(screen.getByText("Test Deck")).toBeDefined();
+
+		const newDeck = { id: "deck-456", name: "Another Deck" };
+		rerender(<DeleteDeckModal {...defaultProps} deck={newDeck} />);
+
+		expect(screen.getByText("Another Deck")).toBeDefined();
+	});
+});
-- 
cgit v1.2.3-70-g09d2

