aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-07 18:29:26 +0900
committernsfisis <nsfisis@gmail.com>2025-12-07 18:29:26 +0900
commit858178d6878229c0ac413d3ea5a4f799d6114ecb (patch)
treec0ae42155437011bd6518411d84a7267a03efc07
parentdeef992b8cc7e57b880c1c38f994d38825240ca1 (diff)
downloadkioku-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.md2
-rw-r--r--src/client/components/EditCardModal.test.tsx411
-rw-r--r--src/client/components/EditCardModal.tsx210
-rw-r--r--src/client/pages/DeckDetailPage.tsx19
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>
);
}