From ad950be46447a74e523eeb2bd278641600dff2fb Mon Sep 17 00:00:00 2001 From: nsfisis Date: Wed, 31 Dec 2025 13:40:59 +0900 Subject: feat(client): group cards by note in DeckDetailPage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display note-based cards as grouped items showing all cards generated from the same note, with note-level edit/delete actions. Legacy cards without note association are shown separately with a "Legacy" badge. - Add NoteGroupCard component for displaying note groups with card stats - Add LegacyCardItem component for backward-compatible card display - Add DeleteNoteModal for deleting notes and their cards - Show Normal/Reversed badges for cards within note groups ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/client/pages/DeckDetailPage.test.tsx | 307 +++++++++++++++++++++++- src/client/pages/DeckDetailPage.tsx | 394 ++++++++++++++++++++++++------- 2 files changed, 610 insertions(+), 91 deletions(-) (limited to 'src/client/pages') diff --git a/src/client/pages/DeckDetailPage.test.tsx b/src/client/pages/DeckDetailPage.test.tsx index a6b8531..35303d9 100644 --- a/src/client/pages/DeckDetailPage.test.tsx +++ b/src/client/pages/DeckDetailPage.test.tsx @@ -51,10 +51,13 @@ const mockDeck = { updatedAt: "2024-01-01T00:00:00Z", }; -const mockCards = [ +// Legacy cards (no noteId) for backward compatibility testing +const mockLegacyCards = [ { id: "card-1", deckId: "deck-1", + noteId: null, + isReversed: null, front: "Hello", back: "ใ“ใ‚“ใซใกใฏ", state: 0, @@ -74,6 +77,8 @@ const mockCards = [ { id: "card-2", deckId: "deck-1", + noteId: null, + isReversed: null, front: "Goodbye", back: "ใ•ใ‚ˆใ†ใชใ‚‰", state: 2, @@ -92,6 +97,58 @@ const mockCards = [ }, ]; +// Note-based cards (with noteId) +const mockNoteBasedCards = [ + { + id: "card-3", + deckId: "deck-1", + noteId: "note-1", + isReversed: false, + front: "Apple", + back: "ใ‚Šใ‚“ใ”", + state: 0, + due: "2024-01-01T00:00:00Z", + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + lastReview: null, + createdAt: "2024-01-02T00:00:00Z", + updatedAt: "2024-01-02T00:00:00Z", + deletedAt: null, + syncVersion: 0, + }, + { + id: "card-4", + deckId: "deck-1", + noteId: "note-1", + isReversed: true, + front: "ใ‚Šใ‚“ใ”", + back: "Apple", + state: 0, + due: "2024-01-01T00:00:00Z", + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 2, + lapses: 0, + lastReview: null, + createdAt: "2024-01-02T00:00:00Z", + updatedAt: "2024-01-02T00:00:00Z", + deletedAt: null, + syncVersion: 0, + }, +]; + +// Mixed cards (both legacy and note-based) +const mockMixedCards = [...mockLegacyCards, ...mockNoteBasedCards]; + +// Alias for backward compatibility in existing tests +const mockCards = mockLegacyCards; + function renderWithProviders(path = "/decks/deck-1") { const { hook } = memoryLocation({ path, static: true }); return render( @@ -583,4 +640,252 @@ describe("DeckDetailPage", () => { }); }); }); + + describe("Card Grouping by Note", () => { + it("groups cards by noteId and displays as note groups", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockNoteBasedCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + // Should show note group container + expect(screen.getByTestId("note-group")).toBeDefined(); + }); + + // Should display both cards within the note group + const noteCards = screen.getAllByTestId("note-card"); + expect(noteCards.length).toBe(2); + }); + + it("displays legacy cards separately from note groups", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockMixedCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + // Should show both note groups and legacy cards + expect(screen.getByTestId("note-group")).toBeDefined(); + }); + + const legacyCards = screen.getAllByTestId("legacy-card"); + expect(legacyCards.length).toBe(2); // 2 legacy cards + + // Should show "Legacy" badge for legacy cards + const legacyBadges = screen.getAllByText("Legacy"); + expect(legacyBadges.length).toBe(2); + }); + + it("shows Normal and Reversed badges for note-based cards", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockNoteBasedCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Normal")).toBeDefined(); + }); + + expect(screen.getByText("Reversed")).toBeDefined(); + }); + + it("shows note card count in note group header", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockNoteBasedCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + // Should show "Note (2 cards)" since there are 2 cards from the same note + expect(screen.getByText("Note (2 cards)")).toBeDefined(); + }); + }); + + it("shows edit note button for note groups", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockNoteBasedCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("note-group")).toBeDefined(); + }); + + const editNoteButton = screen.getByRole("button", { name: "Edit note" }); + expect(editNoteButton).toBeDefined(); + }); + + it("shows delete note button for note groups", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockNoteBasedCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("note-group")).toBeDefined(); + }); + + const deleteNoteButton = screen.getByRole("button", { + name: "Delete note", + }); + expect(deleteNoteButton).toBeDefined(); + }); + + it("opens delete note 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: mockNoteBasedCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("note-group")).toBeDefined(); + }); + + const deleteNoteButton = screen.getByRole("button", { + name: "Delete note", + }); + await user.click(deleteNoteButton); + + expect(screen.getByRole("dialog")).toBeDefined(); + expect( + screen.getByRole("heading", { name: "Delete Note" }), + ).toBeDefined(); + }); + + it("deletes note and refreshes list when confirmed", async () => { + const user = userEvent.setup(); + + mockFetch + // Initial load + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockNoteBasedCards }), + }) + // Delete request + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true }), + }) + // Refresh cards after deletion + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: [] }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("note-group")).toBeDefined(); + }); + + const deleteNoteButton = screen.getByRole("button", { + name: "Delete note", + }); + await user.click(deleteNoteButton); + + // Confirm deletion in modal + const dialog = screen.getByRole("dialog"); + const modalButtons = dialog.querySelectorAll("button"); + const confirmDeleteButton = Array.from(modalButtons).find((btn) => + btn.textContent?.includes("Delete"), + ); + if (confirmDeleteButton) { + await user.click(confirmDeleteButton); + } + + // Wait for modal to close + await waitFor(() => { + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + // Verify DELETE request was made to notes endpoint + expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1/notes/note-1", { + method: "DELETE", + headers: { Authorization: "Bearer access-token" }, + }); + + // Should show empty state after deletion + await waitFor(() => { + expect(screen.getByText("No cards yet")).toBeDefined(); + }); + }); + + it("displays note preview from normal card content", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockNoteBasedCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("note-group")).toBeDefined(); + }); + + // The normal card's front/back should be displayed as preview + expect(screen.getByText("Apple")).toBeDefined(); + expect(screen.getByText("ใ‚Šใ‚“ใ”")).toBeDefined(); + }); + }); }); diff --git a/src/client/pages/DeckDetailPage.tsx b/src/client/pages/DeckDetailPage.tsx index a06fcc7..87f9dc3 100644 --- a/src/client/pages/DeckDetailPage.tsx +++ b/src/client/pages/DeckDetailPage.tsx @@ -2,17 +2,19 @@ import { faChevronLeft, faCirclePlay, faFile, + faLayerGroup, faPen, faPlus, faSpinner, faTrash, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Link, useParams } from "wouter"; import { ApiClientError, apiClient } from "../api"; import { CreateNoteModal } from "../components/CreateNoteModal"; import { DeleteCardModal } from "../components/DeleteCardModal"; +import { DeleteNoteModal } from "../components/DeleteNoteModal"; import { EditCardModal } from "../components/EditCardModal"; import { EditNoteModal } from "../components/EditNoteModal"; @@ -31,6 +33,11 @@ interface Card { updatedAt: string; } +/** Combined type for display: either a note group or a legacy card */ +type CardDisplayItem = + | { type: "note"; noteId: string; cards: Card[] } + | { type: "legacy"; card: Card }; + interface Deck { id: string; name: string; @@ -51,6 +58,215 @@ const CardStateColors: Record = { 3: "bg-error/10 text-error", }; +/** Component for displaying a group of cards from the same note */ +function NoteGroupCard({ + noteId, + cards, + index, + onEditNote, + onDeleteNote, +}: { + noteId: string; + cards: Card[]; + index: number; + onEditNote: () => void; + onDeleteNote: () => void; +}) { + // Use the first card's front/back as preview (normal card takes precedence) + const previewCard = cards.find((c) => !c.isReversed) ?? cards[0]; + if (!previewCard) return null; + + return ( +
+ {/* Note Header */} +
+
+
+
+ + +
+
+ + {/* Note Content Preview */} +
+
+
+ + Front + +

+ {previewCard.front} +

+
+
+ + Back + +

+ {previewCard.back} +

+
+
+ + {/* Cards within this note */} +
+ {cards.map((card) => ( +
+ + {CardStateLabels[card.state] || "Unknown"} + + {card.isReversed ? ( + + Reversed + + ) : ( + + Normal + + )} + {card.reps} reviews + {card.lapses > 0 && ( + {card.lapses} lapses + )} +
+ ))} +
+
+
+ ); +} + +/** Component for displaying a legacy card (without note association) */ +function LegacyCardItem({ + card, + index, + onEdit, + onDelete, +}: { + card: Card; + index: number; + onEdit: () => void; + onDelete: () => void; +}) { + return ( +
+
+
+ {/* Front/Back Preview */} +
+
+ + Front + +

+ {card.front} +

+
+
+ + Back + +

+ {card.back} +

+
+
+ + {/* Card Stats */} +
+ + {CardStateLabels[card.state] || "Unknown"} + + + Legacy + + {card.reps} reviews + {card.lapses > 0 && ( + {card.lapses} lapses + )} +
+
+ + {/* Actions */} +
+ + +
+
+
+ ); +} + export function DeckDetailPage() { const { deckId } = useParams<{ deckId: string }>(); const [deck, setDeck] = useState(null); @@ -61,6 +277,61 @@ export function DeckDetailPage() { const [editingCard, setEditingCard] = useState(null); const [editingNoteId, setEditingNoteId] = useState(null); const [deletingCard, setDeletingCard] = useState(null); + const [deletingNoteId, setDeletingNoteId] = useState(null); + + // Group cards by note for display + const displayItems = useMemo((): CardDisplayItem[] => { + const noteGroups = new Map(); + const legacyCards: Card[] = []; + + for (const card of cards) { + if (card.noteId) { + const existing = noteGroups.get(card.noteId); + if (existing) { + existing.push(card); + } else { + noteGroups.set(card.noteId, [card]); + } + } else { + legacyCards.push(card); + } + } + + const items: CardDisplayItem[] = []; + + // Add note groups first, sorted by earliest card creation + const sortedNoteGroups = Array.from(noteGroups.entries()).sort( + ([, cardsA], [, cardsB]) => { + const minA = Math.min( + ...cardsA.map((c) => new Date(c.createdAt).getTime()), + ); + const minB = Math.min( + ...cardsB.map((c) => new Date(c.createdAt).getTime()), + ); + return minB - minA; // Newest first + }, + ); + + for (const [noteId, noteCards] of sortedNoteGroups) { + // Sort cards within group: normal first, then reversed + noteCards.sort((a, b) => { + if (a.isReversed === b.isReversed) return 0; + return a.isReversed ? 1 : -1; + }); + items.push({ type: "note", noteId, cards: noteCards }); + } + + // Add legacy cards, newest first + legacyCards.sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + for (const card of legacyCards) { + items.push({ type: "legacy", card }); + } + + return items; + }, [cards]); const fetchDeck = useCallback(async () => { if (!deckId) return; @@ -277,96 +548,29 @@ export function DeckDetailPage() { )} - {/* Card List */} + {/* Card List - Grouped by Note */} {cards.length > 0 && ( -
- {cards.map((card, index) => ( -
-
-
- {/* Front/Back Preview */} -
-
- - Front - -

- {card.front} -

-
-
- - Back - -

- {card.back} -

-
-
- - {/* Card Stats */} -
- - {CardStateLabels[card.state] || "Unknown"} - - {card.isReversed && ( - - Reversed - - )} - - {card.reps} reviews - - {card.lapses > 0 && ( - - {card.lapses} lapses - - )} -
-
- - {/* Actions */} -
- - -
-
-
- ))} +
+ {displayItems.map((item, index) => + item.type === "note" ? ( + setEditingNoteId(item.noteId)} + onDeleteNote={() => setDeletingNoteId(item.noteId)} + /> + ) : ( + setEditingCard(item.card)} + onDelete={() => setDeletingCard(item.card)} + /> + ), + )}
)}
@@ -412,6 +616,16 @@ export function DeckDetailPage() { onCardDeleted={fetchCards} /> )} + + {deckId && ( + setDeletingNoteId(null)} + onNoteDeleted={fetchCards} + /> + )} ); } -- cgit v1.2.3-70-g09d2