From 66bfde575d973fea9fb26139af421ab5b18c5bea Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 3 Jan 2026 00:03:54 +0900 Subject: feat(import): display expected CSV format dynamically per note type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show actual note types and their fields in the expected format section instead of a hardcoded example. Fields are sorted by order. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/client/components/ImportNotesModal.test.tsx | 244 ++++++++++++++++++++++++ src/client/components/ImportNotesModal.tsx | 24 ++- 2 files changed, 263 insertions(+), 5 deletions(-) create mode 100644 src/client/components/ImportNotesModal.test.tsx (limited to 'src/client') 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(); + + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("renders modal when open", async () => { + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockBasicNoteType }) + .mockResolvedValueOnce({ noteType: mockVocabularyNoteType }); + + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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({

Expected format:

- - note_type,Front,Back -
- Basic,hello,world -
+ {noteTypes.length === 0 ? ( +

Loading note types...

+ ) : ( +
+ {noteTypes.map((nt) => { + const sortedFields = [...nt.fields].sort( + (a, b) => a.order - b.order, + ); + const fieldNames = sortedFields.map((f) => f.name); + return ( + + note_type,{fieldNames.join(",")} +
+ {nt.name},{fieldNames.map(() => "...").join(",")} +
+ ); + })} +
+ )}
)} -- cgit v1.2.3-70-g09d2