diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/client/pages/StudyPage.test.tsx | 221 | ||||
| -rw-r--r-- | src/client/pages/StudyPage.tsx | 59 |
2 files changed, 278 insertions, 2 deletions
diff --git a/src/client/pages/StudyPage.test.tsx b/src/client/pages/StudyPage.test.tsx index 9c48083..860777a 100644 --- a/src/client/pages/StudyPage.test.tsx +++ b/src/client/pages/StudyPage.test.tsx @@ -27,6 +27,49 @@ vi.mock("../utils/shuffle", () => ({ shuffle: <T,>(array: T[]): T[] => [...array], })); +const mockEditNoteModalOnClose = vi.fn(); +const mockEditNoteModalOnNoteUpdated = vi.fn(); + +vi.mock("../components/EditNoteModal", () => ({ + EditNoteModal: ({ + isOpen, + deckId, + noteId, + onClose, + onNoteUpdated, + }: { + isOpen: boolean; + deckId: string; + noteId: string | null; + onClose: () => void; + onNoteUpdated: () => void; + }) => { + // Store callbacks so tests can call them + mockEditNoteModalOnClose.mockImplementation(onClose); + mockEditNoteModalOnNoteUpdated.mockImplementation(onNoteUpdated); + + if (!isOpen) return null; + return ( + <div + data-testid="edit-note-modal" + data-deck-id={deckId} + data-note-id={noteId} + > + <button type="button" data-testid="edit-modal-close" onClick={onClose}> + Cancel + </button> + <button + type="button" + data-testid="edit-modal-save" + onClick={onNoteUpdated} + > + Save Changes + </button> + </div> + ); + }, +})); + vi.mock("../api/client", () => ({ apiClient: { login: vi.fn(), @@ -935,4 +978,182 @@ describe("StudyPage", () => { ); }); }); + + describe("Edit Card", () => { + it("shows edit button on card", () => { + renderWithProviders({ + initialStudyData: { deck: mockDeck, cards: mockDueCards }, + }); + + expect(screen.getByTestId("edit-card-button")).toBeDefined(); + }); + + it("opens edit modal when edit button is clicked", async () => { + const user = userEvent.setup(); + + renderWithProviders({ + initialStudyData: { deck: mockDeck, cards: mockDueCards }, + }); + + expect(screen.queryByTestId("edit-note-modal")).toBeNull(); + + await user.click(screen.getByTestId("edit-card-button")); + + expect(screen.getByTestId("edit-note-modal")).toBeDefined(); + expect( + screen.getByTestId("edit-note-modal").getAttribute("data-note-id"), + ).toBe("note-1"); + expect( + screen.getByTestId("edit-note-modal").getAttribute("data-deck-id"), + ).toBe("deck-1"); + }); + + it("does not flip card when edit button is clicked", async () => { + const user = userEvent.setup(); + + renderWithProviders({ + initialStudyData: { deck: mockDeck, cards: mockDueCards }, + }); + + await user.click(screen.getByTestId("edit-card-button")); + + // Card should still show front, not back + expect(screen.getByTestId("card-front")).toBeDefined(); + expect(screen.queryByTestId("card-back")).toBeNull(); + }); + + it("closes edit modal when close button is clicked", async () => { + const user = userEvent.setup(); + + renderWithProviders({ + initialStudyData: { deck: mockDeck, cards: mockDueCards }, + }); + + await user.click(screen.getByTestId("edit-card-button")); + expect(screen.getByTestId("edit-note-modal")).toBeDefined(); + + await user.click(screen.getByTestId("edit-modal-close")); + + expect(screen.queryByTestId("edit-note-modal")).toBeNull(); + }); + + it("closes edit modal when save button is clicked", async () => { + const user = userEvent.setup(); + + renderWithProviders({ + initialStudyData: { deck: mockDeck, cards: mockDueCards }, + }); + + await user.click(screen.getByTestId("edit-card-button")); + expect(screen.getByTestId("edit-note-modal")).toBeDefined(); + + await user.click(screen.getByTestId("edit-modal-save")); + + expect(screen.queryByTestId("edit-note-modal")).toBeNull(); + }); + + it("opens edit modal with E key", async () => { + const user = userEvent.setup(); + + renderWithProviders({ + initialStudyData: { deck: mockDeck, cards: mockDueCards }, + }); + + await user.keyboard("e"); + + expect(screen.getByTestId("edit-note-modal")).toBeDefined(); + expect( + screen.getByTestId("edit-note-modal").getAttribute("data-note-id"), + ).toBe("note-1"); + }); + + it("opens edit modal with E key when card is flipped", async () => { + const user = userEvent.setup(); + + renderWithProviders({ + initialStudyData: { deck: mockDeck, cards: mockDueCards }, + }); + + // Flip card first + await user.keyboard(" "); + expect(screen.getByTestId("card-back")).toBeDefined(); + + await user.keyboard("e"); + + expect(screen.getByTestId("edit-note-modal")).toBeDefined(); + }); + + it("disables keyboard shortcuts while edit modal is open", async () => { + const user = userEvent.setup(); + + renderWithProviders({ + initialStudyData: { deck: mockDeck, cards: mockDueCards }, + }); + + // Open edit modal + await user.keyboard("e"); + expect(screen.getByTestId("edit-note-modal")).toBeDefined(); + + // Space should not flip the card + await user.keyboard(" "); + expect(screen.getByTestId("card-front")).toBeDefined(); + expect(screen.queryByTestId("card-back")).toBeNull(); + }); + + it("disables rating shortcuts while edit modal is open", async () => { + const user = userEvent.setup(); + + renderWithProviders({ + initialStudyData: { deck: mockDeck, cards: mockDueCards }, + }); + + // Flip card, then open edit modal via E key + await user.keyboard(" "); + expect(screen.getByTestId("card-back")).toBeDefined(); + + await user.keyboard("e"); + expect(screen.getByTestId("edit-note-modal")).toBeDefined(); + + // Number keys should not rate the card + await user.keyboard("3"); + + // Card should still be showing (not moved to next) + expect(screen.getByTestId("card-back")).toBeDefined(); + expect(screen.getByTestId("remaining-count").textContent).toBe( + "2 remaining", + ); + }); + + it("re-enables keyboard shortcuts after edit modal is closed", async () => { + const user = userEvent.setup(); + + renderWithProviders({ + initialStudyData: { deck: mockDeck, cards: mockDueCards }, + }); + + // Open and close edit modal + await user.keyboard("e"); + expect(screen.getByTestId("edit-note-modal")).toBeDefined(); + + await user.click(screen.getByTestId("edit-modal-close")); + expect(screen.queryByTestId("edit-note-modal")).toBeNull(); + + // Space should now flip the card + await user.keyboard(" "); + expect(screen.getByTestId("card-back")).toBeDefined(); + }); + + it("shows edit button when card is flipped", async () => { + const user = userEvent.setup(); + + renderWithProviders({ + initialStudyData: { deck: mockDeck, cards: mockDueCards }, + }); + + await user.click(screen.getByTestId("card-container")); + + expect(screen.getByTestId("card-back")).toBeDefined(); + expect(screen.getByTestId("edit-card-button")).toBeDefined(); + }); + }); }); diff --git a/src/client/pages/StudyPage.tsx b/src/client/pages/StudyPage.tsx index 33fd290..c8eb603 100644 --- a/src/client/pages/StudyPage.tsx +++ b/src/client/pages/StudyPage.tsx @@ -2,6 +2,7 @@ import { faCheck, faChevronLeft, faCircleCheck, + faPen, faRotateLeft, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -17,6 +18,7 @@ import { import { Link, useLocation, useParams } from "wouter"; import { ApiClientError, apiClient } from "../api"; import { studyDataAtomFamily } from "../atoms"; +import { EditNoteModal } from "../components/EditNoteModal"; import { ErrorBoundary } from "../components/ErrorBoundary"; import { queryClient } from "../queryClient"; import { renderCard } from "../utils/templateRenderer"; @@ -60,6 +62,7 @@ function StudySession({ null, ); const pendingReviewRef = useRef<PendingReview | null>(null); + const [editingNoteId, setEditingNoteId] = useState<string | null>(null); // Keep ref in sync with state for cleanup effect useEffect(() => { @@ -174,6 +177,14 @@ function StudySession({ const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (isSubmitting) return; + if (editingNoteId) return; + + // Edit: E key to open edit modal + if (e.key === "e" && cards[currentIndex]) { + e.preventDefault(); + setEditingNoteId(cards[currentIndex].noteId); + return; + } // Undo: Ctrl+Z / Cmd+Z anytime, or z when card is not flipped if ( @@ -206,7 +217,16 @@ function StudySession({ } } }, - [isFlipped, isSubmitting, handleFlip, handleRating, handleUndo], + [ + isFlipped, + isSubmitting, + editingNoteId, + cards, + currentIndex, + handleFlip, + handleRating, + handleUndo, + ], ); useEffect(() => { @@ -395,11 +415,37 @@ function StudySession({ : "bg-ivory/50" }`} > + {/* Edit button */} + {/* biome-ignore lint/a11y/useSemanticElements: Cannot nest <button> inside parent <button>, using span with role="button" instead */} + <span + role="button" + tabIndex={0} + data-testid="edit-card-button" + onClick={(e) => { + e.stopPropagation(); + setEditingNoteId(currentCard.noteId); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.stopPropagation(); + e.preventDefault(); + setEditingNoteId(currentCard.noteId); + } + }} + className="absolute top-3 right-3 w-8 h-8 flex items-center justify-center text-muted hover:text-slate transition-colors rounded-lg hover:bg-ivory" + aria-label="Edit card" + > + <FontAwesomeIcon + icon={faPen} + className="w-3.5 h-3.5" + aria-hidden="true" + /> + </span> {/* New card badge */} {currentCard.state === 0 && ( <span data-testid="new-card-badge" - className="absolute top-3 right-3 bg-primary/10 text-primary text-xs font-medium px-2 py-0.5 rounded-full" + className="absolute top-3 right-10 bg-primary/10 text-primary text-xs font-medium px-2 py-0.5 rounded-full" > New </span> @@ -456,6 +502,15 @@ function StudySession({ )} </div> )} + + {/* Edit Note Modal */} + <EditNoteModal + isOpen={!!editingNoteId} + deckId={deckId} + noteId={editingNoteId} + onClose={() => setEditingNoteId(null)} + onNoteUpdated={() => setEditingNoteId(null)} + /> </div> ); } |
