From 020803f7dfd094dcf5157943644a28d601629b35 Mon Sep 17 00:00:00 2001
From: nsfisis <nsfisis@gmail.com>
Date: Sun, 7 Dec 2025 17:51:34 +0900
Subject: feat(client): add create deck modal with form validation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Add CreateDeckModal component that allows users to create new decks
with name and optional description fields. Integrates with HomePage
via a "Create Deck" button that opens the modal, and refreshes the
deck list after successful creation.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---
 src/client/pages/HomePage.test.tsx | 155 ++++++++++++++++++++++++++++++++++++-
 1 file changed, 152 insertions(+), 3 deletions(-)

(limited to 'src/client/pages/HomePage.test.tsx')

diff --git a/src/client/pages/HomePage.test.tsx b/src/client/pages/HomePage.test.tsx
index f471924..93351d5 100644
--- a/src/client/pages/HomePage.test.tsx
+++ b/src/client/pages/HomePage.test.tsx
@@ -21,6 +21,7 @@ vi.mock("../api/client", () => ({
 			api: {
 				decks: {
 					$get: vi.fn(),
+					$post: vi.fn(),
 				},
 			},
 		},
@@ -38,9 +39,26 @@ vi.mock("../api/client", () => ({
 }));
 
 // Helper to create mock responses compatible with Hono's ClientResponse
-// biome-ignore lint/suspicious/noExplicitAny: Test helper needs flexible typing
-function mockResponse(data: { ok: boolean; status?: number; json: () => Promise<any> }) {
-	return data as unknown as Awaited<ReturnType<typeof apiClient.rpc.api.decks.$get>>;
+function mockResponse(data: {
+	ok: boolean;
+	status?: number;
+	// biome-ignore lint/suspicious/noExplicitAny: Test helper needs flexible typing
+	json: () => Promise<any>;
+}) {
+	return data as unknown as Awaited<
+		ReturnType<typeof apiClient.rpc.api.decks.$get>
+	>;
+}
+
+function mockPostResponse(data: {
+	ok: boolean;
+	status?: number;
+	// biome-ignore lint/suspicious/noExplicitAny: Test helper needs flexible typing
+	json: () => Promise<any>;
+}) {
+	return data as unknown as Awaited<
+		ReturnType<typeof apiClient.rpc.api.decks.$post>
+	>;
 }
 
 const mockDecks = [
@@ -288,4 +306,135 @@ describe("HomePage", () => {
 			});
 		});
 	});
+
+	describe("Create Deck", () => {
+		it("shows Create Deck button", async () => {
+			vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
+				mockResponse({
+					ok: true,
+					json: async () => ({ decks: [] }),
+				}),
+			);
+
+			renderWithProviders();
+
+			await waitFor(() => {
+				expect(screen.queryByText("Loading decks...")).toBeNull();
+			});
+
+			expect(screen.getByRole("button", { name: "Create Deck" })).toBeDefined();
+		});
+
+		it("opens modal when Create Deck button is clicked", async () => {
+			const user = userEvent.setup();
+			vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
+				mockResponse({
+					ok: true,
+					json: async () => ({ decks: [] }),
+				}),
+			);
+
+			renderWithProviders();
+
+			await waitFor(() => {
+				expect(screen.queryByText("Loading decks...")).toBeNull();
+			});
+
+			await user.click(screen.getByRole("button", { name: "Create Deck" }));
+
+			expect(screen.getByRole("dialog")).toBeDefined();
+			expect(
+				screen.getByRole("heading", { name: "Create New Deck" }),
+			).toBeDefined();
+		});
+
+		it("closes modal when Cancel is clicked", async () => {
+			const user = userEvent.setup();
+			vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
+				mockResponse({
+					ok: true,
+					json: async () => ({ decks: [] }),
+				}),
+			);
+
+			renderWithProviders();
+
+			await waitFor(() => {
+				expect(screen.queryByText("Loading decks...")).toBeNull();
+			});
+
+			await user.click(screen.getByRole("button", { name: "Create Deck" }));
+			expect(screen.getByRole("dialog")).toBeDefined();
+
+			await user.click(screen.getByRole("button", { name: "Cancel" }));
+
+			expect(screen.queryByRole("dialog")).toBeNull();
+		});
+
+		it("creates deck and refreshes list", async () => {
+			const user = userEvent.setup();
+			const newDeck = {
+				id: "deck-new",
+				name: "New Deck",
+				description: "A new deck",
+				newCardsPerDay: 20,
+				createdAt: "2024-01-03T00:00:00Z",
+				updatedAt: "2024-01-03T00:00:00Z",
+			};
+
+			vi.mocked(apiClient.rpc.api.decks.$get)
+				.mockResolvedValueOnce(
+					mockResponse({
+						ok: true,
+						json: async () => ({ decks: [] }),
+					}),
+				)
+				.mockResolvedValueOnce(
+					mockResponse({
+						ok: true,
+						json: async () => ({ decks: [newDeck] }),
+					}),
+				);
+
+			vi.mocked(apiClient.rpc.api.decks.$post).mockResolvedValue(
+				mockPostResponse({
+					ok: true,
+					json: async () => ({ deck: newDeck }),
+				}),
+			);
+
+			renderWithProviders();
+
+			await waitFor(() => {
+				expect(screen.queryByText("Loading decks...")).toBeNull();
+			});
+
+			// Open modal
+			await user.click(screen.getByRole("button", { name: "Create Deck" }));
+
+			// Fill in form
+			await user.type(screen.getByLabelText("Name"), "New Deck");
+			await user.type(
+				screen.getByLabelText("Description (optional)"),
+				"A new deck",
+			);
+
+			// Submit
+			await user.click(screen.getByRole("button", { name: "Create" }));
+
+			// Modal should close
+			await waitFor(() => {
+				expect(screen.queryByRole("dialog")).toBeNull();
+			});
+
+			// Deck list should be refreshed with new deck
+			await waitFor(() => {
+				expect(screen.getByRole("heading", { name: "New Deck" })).toBeDefined();
+			});
+			expect(screen.getByText("A new deck")).toBeDefined();
+
+			// API should have been called twice (initial + refresh)
+			expect(apiClient.rpc.api.decks.$get).toHaveBeenCalledTimes(2);
+		});
+	});
 });
-- 
cgit v1.2.3-70-g09d2

