aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/pages
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-07 18:33:16 +0900
committernsfisis <nsfisis@gmail.com>2025-12-07 18:33:16 +0900
commitc2609af9d8bac65d3e70b3860160ac8bfe097241 (patch)
treeb6c2968188fd7e4a54451c2c5ba330a04dc3e8c0 /src/client/pages
parent858178d6878229c0ac413d3ea5a4f799d6114ecb (diff)
downloadkioku-c2609af9d8bac65d3e70b3860160ac8bfe097241.tar.gz
kioku-c2609af9d8bac65d3e70b3860160ac8bfe097241.tar.zst
kioku-c2609af9d8bac65d3e70b3860160ac8bfe097241.zip
feat(client): add delete card modal with confirmation
Completes Phase 5 card management by adding the ability to delete cards with a confirmation dialog. Includes unit tests for the modal component and integration tests for the delete flow in DeckDetailPage. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'src/client/pages')
-rw-r--r--src/client/pages/DeckDetailPage.test.tsx203
-rw-r--r--src/client/pages/DeckDetailPage.tsx45
2 files changed, 242 insertions, 6 deletions
diff --git a/src/client/pages/DeckDetailPage.test.tsx b/src/client/pages/DeckDetailPage.test.tsx
index de22b08..0589073 100644
--- a/src/client/pages/DeckDetailPage.test.tsx
+++ b/src/client/pages/DeckDetailPage.test.tsx
@@ -372,4 +372,207 @@ describe("DeckDetailPage", () => {
// No description should be shown
expect(screen.queryByText("Common Japanese words")).toBeNull();
});
+
+ describe("Delete Card", () => {
+ it("shows Delete button for each card", async () => {
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ deck: mockDeck }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ cards: mockCards }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByText("Hello")).toBeDefined();
+ });
+
+ const deleteButtons = screen.getAllByRole("button", { name: "Delete" });
+ expect(deleteButtons.length).toBe(2);
+ });
+
+ it("opens delete confirmation modal when Delete button is clicked", async () => {
+ const user = userEvent.setup();
+
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ deck: mockDeck }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ cards: mockCards }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByText("Hello")).toBeDefined();
+ });
+
+ const deleteButtons = screen.getAllByRole("button", { name: "Delete" });
+ const firstDeleteButton = deleteButtons[0];
+ if (firstDeleteButton) {
+ await user.click(firstDeleteButton);
+ }
+
+ expect(screen.getByRole("dialog")).toBeDefined();
+ expect(
+ screen.getByRole("heading", { name: "Delete Card" }),
+ ).toBeDefined();
+ });
+
+ it("closes delete modal when Cancel is clicked", async () => {
+ const user = userEvent.setup();
+
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ deck: mockDeck }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ cards: mockCards }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByText("Hello")).toBeDefined();
+ });
+
+ const deleteButtons = screen.getAllByRole("button", { name: "Delete" });
+ const firstDeleteButton = deleteButtons[0];
+ if (firstDeleteButton) {
+ await user.click(firstDeleteButton);
+ }
+
+ expect(screen.getByRole("dialog")).toBeDefined();
+
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+
+ it("deletes card and refreshes list on confirmation", async () => {
+ const user = userEvent.setup();
+
+ mockFetch
+ // Initial load
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ deck: mockDeck }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ cards: mockCards }),
+ })
+ // Delete request
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({}),
+ })
+ // Refresh cards after deletion
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ cards: [mockCards[1]] }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByText("Hello")).toBeDefined();
+ });
+
+ const deleteButtons = screen.getAllByRole("button", { name: "Delete" });
+ const firstDeleteButton = deleteButtons[0];
+ if (firstDeleteButton) {
+ await user.click(firstDeleteButton);
+ }
+
+ // Find the Delete button in the modal (not the card list)
+ const modalDeleteButtons = screen.getAllByRole("button", {
+ name: "Delete",
+ });
+ const confirmDeleteButton = modalDeleteButtons.find((btn) =>
+ btn.closest('[role="dialog"]'),
+ );
+ if (confirmDeleteButton) {
+ await user.click(confirmDeleteButton);
+ }
+
+ // Wait for modal to close and list to refresh
+ await waitFor(() => {
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+
+ // Verify DELETE request was made
+ expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1/cards/card-1", {
+ method: "DELETE",
+ headers: { Authorization: "Bearer access-token" },
+ });
+
+ // Verify card count updated
+ await waitFor(() => {
+ expect(
+ screen.getByRole("heading", { name: "Cards (1)" }),
+ ).toBeDefined();
+ });
+ });
+
+ it("displays error when delete fails", async () => {
+ const user = userEvent.setup();
+
+ mockFetch
+ // Initial load
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ deck: mockDeck }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ cards: mockCards }),
+ })
+ // Delete request fails
+ .mockResolvedValueOnce({
+ ok: false,
+ status: 500,
+ json: async () => ({ error: "Failed to delete card" }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByText("Hello")).toBeDefined();
+ });
+
+ const deleteButtons = screen.getAllByRole("button", { name: "Delete" });
+ const firstDeleteButton = deleteButtons[0];
+ if (firstDeleteButton) {
+ await user.click(firstDeleteButton);
+ }
+
+ // Find the Delete button in the modal
+ const modalDeleteButtons = screen.getAllByRole("button", {
+ name: "Delete",
+ });
+ const confirmDeleteButton = modalDeleteButtons.find((btn) =>
+ btn.closest('[role="dialog"]'),
+ );
+ if (confirmDeleteButton) {
+ await user.click(confirmDeleteButton);
+ }
+
+ // Error should be displayed in the modal
+ await waitFor(() => {
+ expect(screen.getByRole("alert").textContent).toContain(
+ "Failed to delete card",
+ );
+ });
+ });
+ });
});
diff --git a/src/client/pages/DeckDetailPage.tsx b/src/client/pages/DeckDetailPage.tsx
index 57e4af9..cdc216a 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 { DeleteCardModal } from "../components/DeleteCardModal";
import { EditCardModal } from "../components/EditCardModal";
interface Card {
@@ -38,6 +39,7 @@ export function DeckDetailPage() {
const [error, setError] = useState<string | null>(null);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingCard, setEditingCard] = useState<Card | null>(null);
+ const [deletingCard, setDeletingCard] = useState<Card | null>(null);
const fetchDeck = useCallback(async () => {
if (!deckId) return;
@@ -241,13 +243,34 @@ export function DeckDetailPage() {
<span>Lapses: {card.lapses}</span>
</div>
</div>
- <button
- type="button"
- onClick={() => setEditingCard(card)}
- style={{ marginLeft: "1rem" }}
+ <div
+ style={{
+ display: "flex",
+ gap: "0.5rem",
+ marginLeft: "1rem",
+ }}
>
- Edit
- </button>
+ <button
+ type="button"
+ onClick={() => setEditingCard(card)}
+ >
+ Edit
+ </button>
+ <button
+ type="button"
+ onClick={() => setDeletingCard(card)}
+ style={{
+ backgroundColor: "#dc3545",
+ color: "white",
+ border: "none",
+ padding: "0.5rem 1rem",
+ borderRadius: "4px",
+ cursor: "pointer",
+ }}
+ >
+ Delete
+ </button>
+ </div>
</div>
</li>
))}
@@ -274,6 +297,16 @@ export function DeckDetailPage() {
onCardUpdated={fetchCards}
/>
)}
+
+ {deckId && (
+ <DeleteCardModal
+ isOpen={deletingCard !== null}
+ deckId={deckId}
+ card={deletingCard}
+ onClose={() => setDeletingCard(null)}
+ onCardDeleted={fetchCards}
+ />
+ )}
</div>
);
}