aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-07 18:25:40 +0900
committernsfisis <nsfisis@gmail.com>2025-12-07 18:25:40 +0900
commitdeef992b8cc7e57b880c1c38f994d38825240ca1 (patch)
tree7309c798df469fb249eb6a30da350712ce38f66f
parentaf9a4912f914bb198fe13bd3421ea33ff3bf9d45 (diff)
downloadkioku-deef992b8cc7e57b880c1c38f994d38825240ca1.tar.gz
kioku-deef992b8cc7e57b880c1c38f994d38825240ca1.tar.zst
kioku-deef992b8cc7e57b880c1c38f994d38825240ca1.zip
feat(client): add create card modal with form validation
Add CreateCardModal component to allow users to create new flashcards with front and back text fields. Integrate the modal into DeckDetailPage with an "Add Card" button. Include 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/CreateCardModal.test.tsx381
-rw-r--r--src/client/components/CreateCardModal.tsx194
-rw-r--r--src/client/pages/DeckDetailPage.tsx14
4 files changed, 590 insertions, 1 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md
index 86e0a5e..9988839 100644
--- a/docs/dev/roadmap.md
+++ b/docs/dev/roadmap.md
@@ -104,7 +104,7 @@ Smaller features first to enable early MVP validation.
### Frontend
- [x] Card list view (in deck detail page)
-- [ ] Create card form (front/back)
+- [x] Create card form (front/back)
- [ ] Edit card
- [ ] Delete card
- [ ] Add tests
diff --git a/src/client/components/CreateCardModal.test.tsx b/src/client/components/CreateCardModal.test.tsx
new file mode 100644
index 0000000..6b429c8
--- /dev/null
+++ b/src/client/components/CreateCardModal.test.tsx
@@ -0,0 +1,381 @@
+/**
+ * @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(),
+ rpc: {
+ api: {
+ decks: {
+ ":deckId": {
+ cards: {
+ $post: 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 { CreateCardModal } from "./CreateCardModal";
+
+type CardPostFn = (typeof apiClient.rpc.api.decks)[":deckId"]["cards"]["$post"];
+
+// Helper to create mock responses
+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<CardPostFn>>;
+}
+
+describe("CreateCardModal", () => {
+ const defaultProps = {
+ isOpen: true,
+ deckId: "deck-123",
+ onClose: vi.fn(),
+ onCardCreated: 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(<CreateCardModal {...defaultProps} isOpen={false} />);
+
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+
+ it("renders modal when open", () => {
+ render(<CreateCardModal {...defaultProps} />);
+
+ expect(screen.getByRole("dialog")).toBeDefined();
+ expect(
+ screen.getByRole("heading", { name: "Create New Card" }),
+ ).toBeDefined();
+ expect(screen.getByLabelText("Front")).toBeDefined();
+ expect(screen.getByLabelText("Back")).toBeDefined();
+ expect(screen.getByRole("button", { name: "Cancel" })).toBeDefined();
+ expect(screen.getByRole("button", { name: "Create" })).toBeDefined();
+ });
+
+ it("disables create button when front is empty", async () => {
+ const user = userEvent.setup();
+ render(<CreateCardModal {...defaultProps} />);
+
+ await user.type(screen.getByLabelText("Back"), "Answer");
+
+ const createButton = screen.getByRole("button", { name: "Create" });
+ expect(createButton).toHaveProperty("disabled", true);
+ });
+
+ it("disables create button when back is empty", async () => {
+ const user = userEvent.setup();
+ render(<CreateCardModal {...defaultProps} />);
+
+ await user.type(screen.getByLabelText("Front"), "Question");
+
+ const createButton = screen.getByRole("button", { name: "Create" });
+ expect(createButton).toHaveProperty("disabled", true);
+ });
+
+ it("enables create button when both front and back have content", async () => {
+ const user = userEvent.setup();
+ render(<CreateCardModal {...defaultProps} />);
+
+ await user.type(screen.getByLabelText("Front"), "Question");
+ await user.type(screen.getByLabelText("Back"), "Answer");
+
+ const createButton = screen.getByRole("button", { name: "Create" });
+ expect(createButton).toHaveProperty("disabled", false);
+ });
+
+ it("calls onClose when Cancel is clicked", async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+ render(<CreateCardModal {...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(<CreateCardModal {...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(<CreateCardModal {...defaultProps} onClose={onClose} />);
+
+ // Click on an element inside the modal
+ await user.click(screen.getByLabelText("Front"));
+
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it("creates card with front and back", async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+ const onCardCreated = vi.fn();
+
+ vi.mocked(apiClient.rpc.api.decks[":deckId"].cards.$post).mockResolvedValue(
+ mockResponse({
+ ok: true,
+ json: async () => ({
+ card: {
+ id: "card-1",
+ deckId: "deck-123",
+ front: "What is 2+2?",
+ back: "4",
+ },
+ }),
+ }),
+ );
+
+ render(
+ <CreateCardModal
+ isOpen={true}
+ deckId="deck-123"
+ onClose={onClose}
+ onCardCreated={onCardCreated}
+ />,
+ );
+
+ await user.type(screen.getByLabelText("Front"), "What is 2+2?");
+ await user.type(screen.getByLabelText("Back"), "4");
+ await user.click(screen.getByRole("button", { name: "Create" }));
+
+ await waitFor(() => {
+ expect(
+ apiClient.rpc.api.decks[":deckId"].cards.$post,
+ ).toHaveBeenCalledWith(
+ {
+ param: { deckId: "deck-123" },
+ json: { front: "What is 2+2?", back: "4" },
+ },
+ { headers: { Authorization: "Bearer access-token" } },
+ );
+ });
+
+ expect(onCardCreated).toHaveBeenCalledTimes(1);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("trims whitespace from front and back", async () => {
+ const user = userEvent.setup();
+
+ vi.mocked(apiClient.rpc.api.decks[":deckId"].cards.$post).mockResolvedValue(
+ mockResponse({
+ ok: true,
+ json: async () => ({ card: { id: "card-1" } }),
+ }),
+ );
+
+ render(<CreateCardModal {...defaultProps} />);
+
+ await user.type(screen.getByLabelText("Front"), " Question ");
+ await user.type(screen.getByLabelText("Back"), " Answer ");
+ await user.click(screen.getByRole("button", { name: "Create" }));
+
+ await waitFor(() => {
+ expect(
+ apiClient.rpc.api.decks[":deckId"].cards.$post,
+ ).toHaveBeenCalledWith(
+ {
+ param: { deckId: "deck-123" },
+ json: { front: "Question", back: "Answer" },
+ },
+ { headers: { Authorization: "Bearer access-token" } },
+ );
+ });
+ });
+
+ it("shows loading state during submission", async () => {
+ const user = userEvent.setup();
+
+ vi.mocked(
+ apiClient.rpc.api.decks[":deckId"].cards.$post,
+ ).mockImplementation(
+ () => new Promise(() => {}), // Never resolves
+ );
+
+ render(<CreateCardModal {...defaultProps} />);
+
+ await user.type(screen.getByLabelText("Front"), "Question");
+ await user.type(screen.getByLabelText("Back"), "Answer");
+ await user.click(screen.getByRole("button", { name: "Create" }));
+
+ expect(screen.getByRole("button", { name: "Creating..." })).toBeDefined();
+ expect(screen.getByRole("button", { name: "Creating..." })).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();
+
+ vi.mocked(apiClient.rpc.api.decks[":deckId"].cards.$post).mockResolvedValue(
+ mockResponse({
+ ok: false,
+ status: 400,
+ json: async () => ({ error: "Card front cannot be empty" }),
+ }),
+ );
+
+ render(<CreateCardModal {...defaultProps} />);
+
+ await user.type(screen.getByLabelText("Front"), "Question");
+ await user.type(screen.getByLabelText("Back"), "Answer");
+ await user.click(screen.getByRole("button", { name: "Create" }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert").textContent).toContain(
+ "Card front cannot be empty",
+ );
+ });
+ });
+
+ it("displays generic error on unexpected failure", async () => {
+ const user = userEvent.setup();
+
+ vi.mocked(apiClient.rpc.api.decks[":deckId"].cards.$post).mockRejectedValue(
+ new Error("Network error"),
+ );
+
+ render(<CreateCardModal {...defaultProps} />);
+
+ await user.type(screen.getByLabelText("Front"), "Question");
+ await user.type(screen.getByLabelText("Back"), "Answer");
+ await user.click(screen.getByRole("button", { name: "Create" }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert").textContent).toContain(
+ "Failed to create card. Please try again.",
+ );
+ });
+ });
+
+ it("resets form when closed and reopened", async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+
+ const { rerender } = render(
+ <CreateCardModal
+ isOpen={true}
+ deckId="deck-123"
+ onClose={onClose}
+ onCardCreated={vi.fn()}
+ />,
+ );
+
+ // Type something in the form
+ await user.type(screen.getByLabelText("Front"), "Question");
+ await user.type(screen.getByLabelText("Back"), "Answer");
+
+ // Click cancel to close
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+
+ // Reopen the modal
+ rerender(
+ <CreateCardModal
+ isOpen={true}
+ deckId="deck-123"
+ onClose={onClose}
+ onCardCreated={vi.fn()}
+ />,
+ );
+
+ // Form should be reset
+ expect(screen.getByLabelText("Front")).toHaveProperty("value", "");
+ expect(screen.getByLabelText("Back")).toHaveProperty("value", "");
+ });
+
+ it("resets form after successful creation", async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+
+ vi.mocked(apiClient.rpc.api.decks[":deckId"].cards.$post).mockResolvedValue(
+ mockResponse({
+ ok: true,
+ json: async () => ({ card: { id: "card-1" } }),
+ }),
+ );
+
+ const { rerender } = render(
+ <CreateCardModal
+ isOpen={true}
+ deckId="deck-123"
+ onClose={onClose}
+ onCardCreated={vi.fn()}
+ />,
+ );
+
+ // Create a card
+ await user.type(screen.getByLabelText("Front"), "Question");
+ await user.type(screen.getByLabelText("Back"), "Answer");
+ await user.click(screen.getByRole("button", { name: "Create" }));
+
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ // Reopen the modal
+ rerender(
+ <CreateCardModal
+ isOpen={true}
+ deckId="deck-123"
+ onClose={onClose}
+ onCardCreated={vi.fn()}
+ />,
+ );
+
+ // Form should be reset
+ expect(screen.getByLabelText("Front")).toHaveProperty("value", "");
+ expect(screen.getByLabelText("Back")).toHaveProperty("value", "");
+ });
+});
diff --git a/src/client/components/CreateCardModal.tsx b/src/client/components/CreateCardModal.tsx
new file mode 100644
index 0000000..c28cf0f
--- /dev/null
+++ b/src/client/components/CreateCardModal.tsx
@@ -0,0 +1,194 @@
+import { type FormEvent, useState } from "react";
+import { ApiClientError, apiClient } from "../api";
+
+interface CreateCardModalProps {
+ isOpen: boolean;
+ deckId: string;
+ onClose: () => void;
+ onCardCreated: () => void;
+}
+
+export function CreateCardModal({
+ isOpen,
+ deckId,
+ onClose,
+ onCardCreated,
+}: CreateCardModalProps) {
+ const [front, setFront] = useState("");
+ const [back, setBack] = useState("");
+ const [error, setError] = useState<string | null>(null);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const resetForm = () => {
+ setFront("");
+ setBack("");
+ setError(null);
+ };
+
+ const handleClose = () => {
+ resetForm();
+ onClose();
+ };
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setError(null);
+ setIsSubmitting(true);
+
+ try {
+ const res = await apiClient.rpc.api.decks[":deckId"].cards.$post(
+ {
+ param: { deckId },
+ json: {
+ front: front.trim(),
+ back: back.trim(),
+ },
+ },
+ {
+ headers: apiClient.getAuthHeader(),
+ },
+ );
+
+ 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,
+ );
+ }
+
+ resetForm();
+ onCardCreated();
+ onClose();
+ } catch (err) {
+ if (err instanceof ApiClientError) {
+ setError(err.message);
+ } else {
+ setError("Failed to create card. Please try again.");
+ }
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ if (!isOpen) {
+ return null;
+ }
+
+ const isFormValid = front.trim() && back.trim();
+
+ return (
+ <div
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="create-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="create-card-title" style={{ marginTop: 0 }}>
+ Create New Card
+ </h2>
+
+ <form onSubmit={handleSubmit}>
+ {error && (
+ <div role="alert" style={{ color: "red", marginBottom: "1rem" }}>
+ {error}
+ </div>
+ )}
+
+ <div style={{ marginBottom: "1rem" }}>
+ <label
+ htmlFor="card-front"
+ style={{ display: "block", marginBottom: "0.25rem" }}
+ >
+ Front
+ </label>
+ <textarea
+ id="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="card-back"
+ style={{ display: "block", marginBottom: "0.25rem" }}
+ >
+ Back
+ </label>
+ <textarea
+ id="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 ? "Creating..." : "Create"}
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+ );
+}
diff --git a/src/client/pages/DeckDetailPage.tsx b/src/client/pages/DeckDetailPage.tsx
index c713ab0..9fce7b7 100644
--- a/src/client/pages/DeckDetailPage.tsx
+++ b/src/client/pages/DeckDetailPage.tsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { Link, useParams } from "wouter";
import { ApiClientError, apiClient } from "../api";
+import { CreateCardModal } from "../components/CreateCardModal";
interface Card {
id: string;
@@ -34,6 +35,7 @@ export function DeckDetailPage() {
const [cards, setCards] = useState<Card[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
+ const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const fetchDeck = useCallback(async () => {
if (!deckId) return;
@@ -158,6 +160,9 @@ export function DeckDetailPage() {
}}
>
<h2 style={{ margin: 0 }}>Cards ({cards.length})</h2>
+ <button type="button" onClick={() => setIsCreateModalOpen(true)}>
+ Add Card
+ </button>
</div>
{cards.length === 0 && (
@@ -241,6 +246,15 @@ export function DeckDetailPage() {
)}
</main>
)}
+
+ {deckId && (
+ <CreateCardModal
+ isOpen={isCreateModalOpen}
+ deckId={deckId}
+ onClose={() => setIsCreateModalOpen(false)}
+ onCardCreated={fetchCards}
+ />
+ )}
</div>
);
}