diff options
| -rw-r--r-- | src/client/components/ImportNotesModal.test.tsx | 244 | ||||
| -rw-r--r-- | src/client/components/ImportNotesModal.tsx | 24 |
2 files changed, 263 insertions, 5 deletions
diff --git a/src/client/components/ImportNotesModal.test.tsx b/src/client/components/ImportNotesModal.test.tsx new file mode 100644 index 0000000..c4ce537 --- /dev/null +++ b/src/client/components/ImportNotesModal.test.tsx @@ -0,0 +1,244 @@ +/** + * @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"; + +const mockNoteTypesGet = vi.fn(); +const mockNoteTypeGet = vi.fn(); +const mockImportPost = vi.fn(); +const mockHandleResponse = vi.fn(); + +vi.mock("../api/client", () => ({ + apiClient: { + rpc: { + api: { + "note-types": { + $get: () => mockNoteTypesGet(), + ":id": { + $get: (args: unknown) => mockNoteTypeGet(args), + }, + }, + decks: { + ":deckId": { + notes: { + import: { + $post: (args: unknown) => mockImportPost(args), + }, + }, + }, + }, + }, + }, + handleResponse: (res: unknown) => mockHandleResponse(res), + }, + ApiClientError: class ApiClientError extends Error { + constructor( + message: string, + public status: number, + public code?: string, + ) { + super(message); + this.name = "ApiClientError"; + } + }, +})); + +import { ApiClientError } from "../api/client"; +import { ImportNotesModal } from "./ImportNotesModal"; + +describe("ImportNotesModal", () => { + const defaultProps = { + isOpen: true, + deckId: "deck-123", + onClose: vi.fn(), + onImportComplete: vi.fn(), + }; + + const mockNoteTypes = [ + { id: "note-type-1", name: "Basic" }, + { id: "note-type-2", name: "Vocabulary" }, + ]; + + const mockBasicNoteType = { + id: "note-type-1", + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + fields: [ + { id: "field-1", name: "Front", order: 0 }, + { id: "field-2", name: "Back", order: 1 }, + ], + }; + + const mockVocabularyNoteType = { + id: "note-type-2", + name: "Vocabulary", + frontTemplate: "{{Word or Phrase}}", + backTemplate: "{{Meaning}}", + isReversible: false, + fields: [ + { id: "field-3", name: "Word or Phrase", order: 0 }, + { id: "field-4", name: "Example", order: 1 }, + { id: "field-5", name: "Meaning", order: 2 }, + ], + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockNoteTypesGet.mockResolvedValue({ ok: true }); + mockNoteTypeGet.mockResolvedValue({ ok: true }); + mockImportPost.mockResolvedValue({ ok: true }); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it("does not render when closed", () => { + render(<ImportNotesModal {...defaultProps} isOpen={false} />); + + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("renders modal when open", async () => { + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockBasicNoteType }) + .mockResolvedValueOnce({ noteType: mockVocabularyNoteType }); + + render(<ImportNotesModal {...defaultProps} />); + + expect(screen.getByRole("dialog")).toBeDefined(); + expect( + screen.getByRole("heading", { name: "Import Notes from CSV" }), + ).toBeDefined(); + }); + + it("shows loading state while note types are being fetched", () => { + mockNoteTypesGet.mockImplementation(() => new Promise(() => {})); // Never resolves + + render(<ImportNotesModal {...defaultProps} />); + + expect(screen.getByText("Loading note types...")).toBeDefined(); + }); + + it("displays expected format for each note type", async () => { + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockBasicNoteType }) + .mockResolvedValueOnce({ noteType: mockVocabularyNoteType }); + + render(<ImportNotesModal {...defaultProps} />); + + await waitFor(() => { + expect(screen.getByText(/note_type,Front,Back/)).toBeDefined(); + expect(screen.getByText(/Basic,\.\.\.,\.\.\./)).toBeDefined(); + }); + + await waitFor(() => { + expect( + screen.getByText(/note_type,Word or Phrase,Example,Meaning/), + ).toBeDefined(); + expect(screen.getByText(/Vocabulary,\.\.\.,\.\.\.,\.\.\./)).toBeDefined(); + }); + }); + + it("sorts fields by order when displaying expected format", async () => { + const noteTypeWithUnorderedFields = { + id: "note-type-3", + name: "Test", + frontTemplate: "{{A}}", + backTemplate: "{{B}}", + isReversible: false, + fields: [ + { id: "field-c", name: "C", order: 2 }, + { id: "field-a", name: "A", order: 0 }, + { id: "field-b", name: "B", order: 1 }, + ], + }; + + mockHandleResponse + .mockResolvedValueOnce({ + noteTypes: [{ id: "note-type-3", name: "Test" }], + }) + .mockResolvedValueOnce({ noteType: noteTypeWithUnorderedFields }); + + render(<ImportNotesModal {...defaultProps} />); + + await waitFor(() => { + // Should be A,B,C (sorted by order), not C,A,B + expect(screen.getByText(/note_type,A,B,C/)).toBeDefined(); + }); + }); + + it("calls onClose when Cancel is clicked", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockBasicNoteType }) + .mockResolvedValueOnce({ noteType: mockVocabularyNoteType }); + + render(<ImportNotesModal {...defaultProps} onClose={onClose} />); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Cancel" })).toBeDefined(); + }); + + 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(); + + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockBasicNoteType }) + .mockResolvedValueOnce({ noteType: mockVocabularyNoteType }); + + render(<ImportNotesModal {...defaultProps} onClose={onClose} />); + + await waitFor(() => { + expect(screen.getByRole("dialog")).toBeDefined(); + }); + + const dialog = screen.getByRole("dialog"); + await user.click(dialog); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("displays error when note types fail to load", async () => { + mockHandleResponse.mockRejectedValueOnce( + new ApiClientError("Server error", 500), + ); + + render(<ImportNotesModal {...defaultProps} />); + + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toContain("Server error"); + }); + }); + + it("shows file input in upload phase", async () => { + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockBasicNoteType }) + .mockResolvedValueOnce({ noteType: mockVocabularyNoteType }); + + render(<ImportNotesModal {...defaultProps} />); + + await waitFor(() => { + expect(screen.getByText("Choose File")).toBeDefined(); + expect(screen.getByText("Select a CSV file to import")).toBeDefined(); + }); + }); +}); diff --git a/src/client/components/ImportNotesModal.tsx b/src/client/components/ImportNotesModal.tsx index 2eb0922..d3a2c0c 100644 --- a/src/client/components/ImportNotesModal.tsx +++ b/src/client/components/ImportNotesModal.tsx @@ -313,11 +313,25 @@ export function ImportNotesModal({ </div> <div className="bg-ivory rounded-lg px-4 py-3 text-sm text-muted"> <p className="font-medium text-slate mb-1">Expected format:</p> - <code className="text-xs"> - note_type,Front,Back - <br /> - Basic,hello,world - </code> + {noteTypes.length === 0 ? ( + <p className="text-xs text-muted">Loading note types...</p> + ) : ( + <div className="text-xs space-y-2"> + {noteTypes.map((nt) => { + const sortedFields = [...nt.fields].sort( + (a, b) => a.order - b.order, + ); + const fieldNames = sortedFields.map((f) => f.name); + return ( + <code key={nt.id} className="block"> + note_type,{fieldNames.join(",")} + <br /> + {nt.name},{fieldNames.map(() => "...").join(",")} + </code> + ); + })} + </div> + )} </div> </div> )} |
