diff options
Diffstat (limited to 'src/client/pages')
| -rw-r--r-- | src/client/pages/HomePage.test.tsx | 155 | ||||
| -rw-r--r-- | src/client/pages/HomePage.tsx | 22 |
2 files changed, 173 insertions, 4 deletions
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); + }); + }); }); diff --git a/src/client/pages/HomePage.tsx b/src/client/pages/HomePage.tsx index c9d0843..d753aa1 100644 --- a/src/client/pages/HomePage.tsx +++ b/src/client/pages/HomePage.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { ApiClientError, apiClient } from "../api"; +import { CreateDeckModal } from "../components/CreateDeckModal"; import { useAuth } from "../stores"; interface Deck { @@ -16,6 +17,7 @@ export function HomePage() { const [decks, setDecks] = useState<Deck[]>([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState<string | null>(null); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const fetchDecks = useCallback(async () => { setIsLoading(true); @@ -69,7 +71,19 @@ export function HomePage() { </header> <main> - <h2>Your Decks</h2> + <div + style={{ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: "1rem", + }} + > + <h2 style={{ margin: 0 }}>Your Decks</h2> + <button type="button" onClick={() => setIsCreateModalOpen(true)}> + Create Deck + </button> + </div> {isLoading && <p>Loading decks...</p>} @@ -116,6 +130,12 @@ export function HomePage() { </ul> )} </main> + + <CreateDeckModal + isOpen={isCreateModalOpen} + onClose={() => setIsCreateModalOpen(false)} + onDeckCreated={fetchDecks} + /> </div> ); } |
