diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-07 18:29:26 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-07 18:29:26 +0900 |
| commit | 858178d6878229c0ac413d3ea5a4f799d6114ecb (patch) | |
| tree | c0ae42155437011bd6518411d84a7267a03efc07 | |
| parent | deef992b8cc7e57b880c1c38f994d38825240ca1 (diff) | |
| download | kioku-858178d6878229c0ac413d3ea5a4f799d6114ecb.tar.gz kioku-858178d6878229c0ac413d3ea5a4f799d6114ecb.tar.zst kioku-858178d6878229c0ac413d3ea5a4f799d6114ecb.zip | |
feat(client): add edit card modal with form validation
Add EditCardModal component allowing users to edit existing cards.
Includes Edit button on each card in the deck detail page and
comprehensive unit tests.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| -rw-r--r-- | docs/dev/roadmap.md | 2 | ||||
| -rw-r--r-- | src/client/components/EditCardModal.test.tsx | 411 | ||||
| -rw-r--r-- | src/client/components/EditCardModal.tsx | 210 | ||||
| -rw-r--r-- | src/client/pages/DeckDetailPage.tsx | 19 |
4 files changed, 641 insertions, 1 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index 9988839..7552761 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -105,7 +105,7 @@ Smaller features first to enable early MVP validation. ### Frontend - [x] Card list view (in deck detail page) - [x] Create card form (front/back) -- [ ] Edit card +- [x] Edit card - [ ] Delete card - [ ] Add tests diff --git a/src/client/components/EditCardModal.test.tsx b/src/client/components/EditCardModal.test.tsx new file mode 100644 index 0000000..f37698f --- /dev/null +++ b/src/client/components/EditCardModal.test.tsx @@ -0,0 +1,411 @@ +/** + * @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 { EditCardModal } from "./EditCardModal"; + +// Mock fetch globally +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +describe("EditCardModal", () => { + const mockCard = { + id: "card-123", + front: "Test front", + back: "Test back", + }; + + const defaultProps = { + isOpen: true, + deckId: "deck-456", + card: mockCard, + onClose: vi.fn(), + onCardUpdated: 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(<EditCardModal {...defaultProps} isOpen={false} />); + + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("does not render when card is null", () => { + render(<EditCardModal {...defaultProps} card={null} />); + + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("renders modal when open with card", () => { + render(<EditCardModal {...defaultProps} />); + + expect(screen.getByRole("dialog")).toBeDefined(); + expect(screen.getByRole("heading", { name: "Edit Card" })).toBeDefined(); + expect(screen.getByLabelText("Front")).toBeDefined(); + expect(screen.getByLabelText("Back")).toBeDefined(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Save" })).toBeDefined(); + }); + + it("populates form with card values", () => { + render(<EditCardModal {...defaultProps} />); + + expect(screen.getByLabelText("Front")).toHaveProperty( + "value", + "Test front", + ); + expect(screen.getByLabelText("Back")).toHaveProperty("value", "Test back"); + }); + + it("disables save button when front is empty", async () => { + const user = userEvent.setup(); + render(<EditCardModal {...defaultProps} />); + + const frontInput = screen.getByLabelText("Front"); + await user.clear(frontInput); + + const saveButton = screen.getByRole("button", { name: "Save" }); + expect(saveButton).toHaveProperty("disabled", true); + }); + + it("disables save button when back is empty", async () => { + const user = userEvent.setup(); + render(<EditCardModal {...defaultProps} />); + + const backInput = screen.getByLabelText("Back"); + await user.clear(backInput); + + const saveButton = screen.getByRole("button", { name: "Save" }); + expect(saveButton).toHaveProperty("disabled", true); + }); + + it("enables save button when both front and back have content", () => { + render(<EditCardModal {...defaultProps} />); + + const saveButton = screen.getByRole("button", { name: "Save" }); + expect(saveButton).toHaveProperty("disabled", false); + }); + + it("calls onClose when Cancel is clicked", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + render(<EditCardModal {...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(<EditCardModal {...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(<EditCardModal {...defaultProps} onClose={onClose} />); + + // Click on an element inside the modal + await user.click(screen.getByLabelText("Front")); + + expect(onClose).not.toHaveBeenCalled(); + }); + + it("updates card with new front", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + const onCardUpdated = vi.fn(); + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + card: { + id: "card-123", + front: "Updated front", + back: "Test back", + }, + }), + }); + + render( + <EditCardModal + isOpen={true} + deckId="deck-456" + card={mockCard} + onClose={onClose} + onCardUpdated={onCardUpdated} + />, + ); + + const frontInput = screen.getByLabelText("Front"); + await user.clear(frontInput); + await user.type(frontInput, "Updated front"); + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + "/api/decks/deck-456/cards/card-123", + { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer access-token", + }, + body: JSON.stringify({ + front: "Updated front", + back: "Test back", + }), + }, + ); + }); + + expect(onCardUpdated).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("updates card with new back", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + const onCardUpdated = vi.fn(); + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + card: { + id: "card-123", + front: "Test front", + back: "Updated back", + }, + }), + }); + + render( + <EditCardModal + isOpen={true} + deckId="deck-456" + card={mockCard} + onClose={onClose} + onCardUpdated={onCardUpdated} + />, + ); + + const backInput = screen.getByLabelText("Back"); + await user.clear(backInput); + await user.type(backInput, "Updated back"); + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + "/api/decks/deck-456/cards/card-123", + { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer access-token", + }, + body: JSON.stringify({ + front: "Test front", + back: "Updated back", + }), + }, + ); + }); + + expect(onCardUpdated).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("trims whitespace from front and back", async () => { + const user = userEvent.setup(); + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ card: { id: "card-123" } }), + }); + + const cardWithWhitespace = { + ...mockCard, + front: " Front ", + back: " Back ", + }; + render(<EditCardModal {...defaultProps} card={cardWithWhitespace} />); + + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + "/api/decks/deck-456/cards/card-123", + { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer access-token", + }, + body: JSON.stringify({ + front: "Front", + back: "Back", + }), + }, + ); + }); + }); + + it("shows loading state during submission", async () => { + const user = userEvent.setup(); + + mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves + + render(<EditCardModal {...defaultProps} />); + + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(screen.getByRole("button", { name: "Saving..." })).toBeDefined(); + expect(screen.getByRole("button", { name: "Saving..." })).toHaveProperty( + "disabled", + true, + ); + expect(screen.getByRole("button", { name: "Cancel" })).toHaveProperty( + "disabled", + true, + ); + expect(screen.getByLabelText("Front")).toHaveProperty("disabled", true); + expect(screen.getByLabelText("Back")).toHaveProperty("disabled", true); + }); + + it("displays API error message", async () => { + const user = userEvent.setup(); + + mockFetch.mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ error: "Card not found" }), + }); + + render(<EditCardModal {...defaultProps} />); + + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toContain("Card not found"); + }); + }); + + it("displays generic error on unexpected failure", async () => { + const user = userEvent.setup(); + + mockFetch.mockRejectedValue(new Error("Network error")); + + render(<EditCardModal {...defaultProps} />); + + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toContain( + "Failed to update card. Please try again.", + ); + }); + }); + + it("displays error when not authenticated", async () => { + const user = userEvent.setup(); + + vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined); + + render(<EditCardModal {...defaultProps} />); + + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toContain( + "Not authenticated", + ); + }); + }); + + it("updates form when card prop changes", () => { + const { rerender } = render(<EditCardModal {...defaultProps} />); + + expect(screen.getByLabelText("Front")).toHaveProperty( + "value", + "Test front", + ); + + const newCard = { + ...mockCard, + front: "New front", + back: "New back", + }; + rerender(<EditCardModal {...defaultProps} card={newCard} />); + + expect(screen.getByLabelText("Front")).toHaveProperty("value", "New front"); + expect(screen.getByLabelText("Back")).toHaveProperty("value", "New back"); + }); + + it("clears error when modal is closed", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + + mockFetch.mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ error: "Some error" }), + }); + + const { rerender } = render( + <EditCardModal {...defaultProps} onClose={onClose} />, + ); + + // Trigger error + await user.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => { + expect(screen.getByRole("alert")).toBeDefined(); + }); + + // Close and reopen the modal + await user.click(screen.getByRole("button", { name: "Cancel" })); + rerender(<EditCardModal {...defaultProps} onClose={onClose} />); + + // Error should be cleared + expect(screen.queryByRole("alert")).toBeNull(); + }); +}); diff --git a/src/client/components/EditCardModal.tsx b/src/client/components/EditCardModal.tsx new file mode 100644 index 0000000..2d04581 --- /dev/null +++ b/src/client/components/EditCardModal.tsx @@ -0,0 +1,210 @@ +import { type FormEvent, useEffect, useState } from "react"; +import { ApiClientError, apiClient } from "../api"; + +interface Card { + id: string; + front: string; + back: string; +} + +interface EditCardModalProps { + isOpen: boolean; + deckId: string; + card: Card | null; + onClose: () => void; + onCardUpdated: () => void; +} + +export function EditCardModal({ + isOpen, + deckId, + card, + onClose, + onCardUpdated, +}: EditCardModalProps) { + const [front, setFront] = useState(""); + const [back, setBack] = useState(""); + const [error, setError] = useState<string | null>(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Sync form state when card changes + useEffect(() => { + if (card) { + setFront(card.front); + setBack(card.back); + setError(null); + } + }, [card]); + + const handleClose = () => { + setError(null); + onClose(); + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + if (!card) return; + + setError(null); + setIsSubmitting(true); + + try { + const authHeader = apiClient.getAuthHeader(); + if (!authHeader) { + throw new ApiClientError("Not authenticated", 401); + } + + const res = await fetch(`/api/decks/${deckId}/cards/${card.id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + ...authHeader, + }, + body: JSON.stringify({ + front: front.trim(), + back: back.trim(), + }), + }); + + if (!res.ok) { + const errorBody = await res.json().catch(() => ({})); + throw new ApiClientError( + (errorBody as { error?: string }).error || + `Request failed with status ${res.status}`, + res.status, + ); + } + + onCardUpdated(); + onClose(); + } catch (err) { + if (err instanceof ApiClientError) { + setError(err.message); + } else { + setError("Failed to update card. Please try again."); + } + } finally { + setIsSubmitting(false); + } + }; + + if (!isOpen || !card) { + return null; + } + + const isFormValid = front.trim() && back.trim(); + + return ( + <div + role="dialog" + aria-modal="true" + aria-labelledby="edit-card-title" + style={{ + position: "fixed", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(0, 0, 0, 0.5)", + display: "flex", + alignItems: "center", + justifyContent: "center", + zIndex: 1000, + }} + onClick={(e) => { + if (e.target === e.currentTarget) { + handleClose(); + } + }} + onKeyDown={(e) => { + if (e.key === "Escape") { + handleClose(); + } + }} + > + <div + style={{ + backgroundColor: "white", + padding: "1.5rem", + borderRadius: "8px", + width: "100%", + maxWidth: "500px", + margin: "1rem", + }} + > + <h2 id="edit-card-title" style={{ marginTop: 0 }}> + Edit Card + </h2> + + <form onSubmit={handleSubmit}> + {error && ( + <div role="alert" style={{ color: "red", marginBottom: "1rem" }}> + {error} + </div> + )} + + <div style={{ marginBottom: "1rem" }}> + <label + htmlFor="edit-card-front" + style={{ display: "block", marginBottom: "0.25rem" }} + > + Front + </label> + <textarea + id="edit-card-front" + value={front} + onChange={(e) => setFront(e.target.value)} + required + disabled={isSubmitting} + rows={3} + placeholder="Question or prompt" + style={{ + width: "100%", + boxSizing: "border-box", + resize: "vertical", + }} + /> + </div> + + <div style={{ marginBottom: "1rem" }}> + <label + htmlFor="edit-card-back" + style={{ display: "block", marginBottom: "0.25rem" }} + > + Back + </label> + <textarea + id="edit-card-back" + value={back} + onChange={(e) => setBack(e.target.value)} + required + disabled={isSubmitting} + rows={3} + placeholder="Answer or explanation" + style={{ + width: "100%", + boxSizing: "border-box", + resize: "vertical", + }} + /> + </div> + + <div + style={{ + display: "flex", + gap: "0.5rem", + justifyContent: "flex-end", + }} + > + <button type="button" onClick={handleClose} disabled={isSubmitting}> + Cancel + </button> + <button type="submit" disabled={isSubmitting || !isFormValid}> + {isSubmitting ? "Saving..." : "Save"} + </button> + </div> + </form> + </div> + </div> + ); +} diff --git a/src/client/pages/DeckDetailPage.tsx b/src/client/pages/DeckDetailPage.tsx index 9fce7b7..57e4af9 100644 --- a/src/client/pages/DeckDetailPage.tsx +++ b/src/client/pages/DeckDetailPage.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { Link, useParams } from "wouter"; import { ApiClientError, apiClient } from "../api"; import { CreateCardModal } from "../components/CreateCardModal"; +import { EditCardModal } from "../components/EditCardModal"; interface Card { id: string; @@ -36,6 +37,7 @@ export function DeckDetailPage() { const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState<string | null>(null); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [editingCard, setEditingCard] = useState<Card | null>(null); const fetchDeck = useCallback(async () => { if (!deckId) return; @@ -239,6 +241,13 @@ export function DeckDetailPage() { <span>Lapses: {card.lapses}</span> </div> </div> + <button + type="button" + onClick={() => setEditingCard(card)} + style={{ marginLeft: "1rem" }} + > + Edit + </button> </div> </li> ))} @@ -255,6 +264,16 @@ export function DeckDetailPage() { onCardCreated={fetchCards} /> )} + + {deckId && ( + <EditCardModal + isOpen={editingCard !== null} + deckId={deckId} + card={editingCard} + onClose={() => setEditingCard(null)} + onCardUpdated={fetchCards} + /> + )} </div> ); } |
