From d47d1a014a71ae65cbbf1b384eed87c6fe078b07 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 2 May 2026 11:58:13 +0900 Subject: feat(note-types): make note type CRUD work fully offline-first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CreateNoteTypeModal, DeleteNoteTypeModal, and the NoteTypeEditor (which covers field add/edit/delete/reorder) now write through the local IndexedDB repositories and fire-and-forget syncActionAtom, mirroring the deck-CRUD pattern. The dead EditNoteTypeModal — never imported — is removed. The local hasNotes / hasNoteFieldValues guards mirror the server's delete-time checks so a note type with attached notes, or a field with saved values, can't be silently soft-deleted offline. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/client/components/DeleteNoteTypeModal.test.tsx | 133 +++++++-------------- 1 file changed, 42 insertions(+), 91 deletions(-) (limited to 'src/client/components/DeleteNoteTypeModal.test.tsx') diff --git a/src/client/components/DeleteNoteTypeModal.test.tsx b/src/client/components/DeleteNoteTypeModal.test.tsx index c73fbe0..ec67a82 100644 --- a/src/client/components/DeleteNoteTypeModal.test.tsx +++ b/src/client/components/DeleteNoteTypeModal.test.tsx @@ -3,38 +3,24 @@ */ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { atom } from "jotai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mockDelete = vi.fn(); -const mockHandleResponse = vi.fn(); - -vi.mock("../api/client", () => ({ - apiClient: { - rpc: { - api: { - "note-types": { - ":id": { - $delete: (args: unknown) => mockDelete(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"; - } +const mockHasNotes = vi.fn(); +const mockTriggerSync = vi.fn(() => Promise.resolve(null)); + +vi.mock("../db/repositories", () => ({ + localNoteTypeRepository: { + delete: (...args: unknown[]) => mockDelete(...args), + hasNotes: (...args: unknown[]) => mockHasNotes(...args), }, })); -import { ApiClientError } from "../api/client"; -// Import after mock is set up +vi.mock("../atoms", () => ({ + syncActionAtom: atom(null, () => mockTriggerSync()), +})); + import { DeleteNoteTypeModal } from "./DeleteNoteTypeModal"; describe("DeleteNoteTypeModal", () => { @@ -52,8 +38,8 @@ describe("DeleteNoteTypeModal", () => { beforeEach(() => { vi.clearAllMocks(); - mockDelete.mockResolvedValue({ ok: true }); - mockHandleResponse.mockResolvedValue({}); + mockDelete.mockResolvedValue(true); + mockHasNotes.mockResolvedValue(false); }); afterEach(() => { @@ -109,28 +95,7 @@ describe("DeleteNoteTypeModal", () => { expect(onClose).toHaveBeenCalledTimes(1); }); - it("calls onClose when clicking outside the modal", async () => { - const user = userEvent.setup(); - const onClose = vi.fn(); - render(); - - const dialog = screen.getByRole("dialog"); - await user.click(dialog); - - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("does not call onClose when clicking inside the modal content", async () => { - const user = userEvent.setup(); - const onClose = vi.fn(); - render(); - - await user.click(screen.getByText("Basic")); - - expect(onClose).not.toHaveBeenCalled(); - }); - - it("deletes noteType when Delete is clicked", async () => { + it("deletes noteType via local repository when Delete is clicked", async () => { const user = userEvent.setup(); const onClose = vi.fn(); const onNoteTypeDeleted = vi.fn(); @@ -147,41 +112,29 @@ describe("DeleteNoteTypeModal", () => { await user.click(screen.getByRole("button", { name: "Delete" })); await waitFor(() => { - expect(mockDelete).toHaveBeenCalledWith({ - param: { id: "note-type-123" }, - }); + expect(mockDelete).toHaveBeenCalledWith("note-type-123"); }); expect(onNoteTypeDeleted).toHaveBeenCalledTimes(1); expect(onClose).toHaveBeenCalledTimes(1); }); - it("shows loading state during deletion", async () => { + it("triggers a background sync after a successful delete", async () => { const user = userEvent.setup(); - mockDelete.mockImplementation(() => new Promise(() => {})); // Never resolves - render(); await user.click(screen.getByRole("button", { name: "Delete" })); - expect(screen.getByRole("button", { name: "Deleting..." })).toBeDefined(); - expect(screen.getByRole("button", { name: "Deleting..." })).toHaveProperty( - "disabled", - true, - ); - expect(screen.getByRole("button", { name: "Cancel" })).toHaveProperty( - "disabled", - true, - ); + await waitFor(() => { + expect(mockTriggerSync).toHaveBeenCalled(); + }); }); - it("displays API error message", async () => { + it("blocks deletion when notes still reference the type", async () => { const user = userEvent.setup(); - mockHandleResponse.mockRejectedValue( - new ApiClientError("Note type not found", 404), - ); + mockHasNotes.mockResolvedValue(true); render(); @@ -189,33 +142,36 @@ describe("DeleteNoteTypeModal", () => { await waitFor(() => { expect(screen.getByRole("alert").textContent).toContain( - "Note type not found", + "Cannot delete note type with existing notes", ); }); + expect(mockDelete).not.toHaveBeenCalled(); }); - it("displays conflict error when notes exist", async () => { + it("shows loading state during deletion", async () => { const user = userEvent.setup(); - mockHandleResponse.mockRejectedValue( - new ApiClientError("Cannot delete note type with existing notes", 409), - ); + mockDelete.mockImplementation(() => new Promise(() => {})); render(); await user.click(screen.getByRole("button", { name: "Delete" })); - await waitFor(() => { - expect(screen.getByRole("alert").textContent).toContain( - "Cannot delete note type with existing notes", - ); - }); + expect(screen.getByRole("button", { name: "Deleting..." })).toBeDefined(); + expect(screen.getByRole("button", { name: "Deleting..." })).toHaveProperty( + "disabled", + true, + ); + expect(screen.getByRole("button", { name: "Cancel" })).toHaveProperty( + "disabled", + true, + ); }); - it("displays generic error on unexpected failure", async () => { + it("shows an error when the note type no longer exists locally", async () => { const user = userEvent.setup(); - mockDelete.mockRejectedValue(new Error("Network error")); + mockDelete.mockResolvedValue(false); render(); @@ -223,17 +179,15 @@ describe("DeleteNoteTypeModal", () => { await waitFor(() => { expect(screen.getByRole("alert").textContent).toContain( - "Failed to delete note type. Please try again.", + "Note type not found.", ); }); }); - it("displays error when handleResponse throws", async () => { + it("displays a generic error when the local write fails", async () => { const user = userEvent.setup(); - mockHandleResponse.mockRejectedValue( - new ApiClientError("Not authenticated", 401), - ); + mockDelete.mockRejectedValue(new Error("disk full")); render(); @@ -241,7 +195,7 @@ describe("DeleteNoteTypeModal", () => { await waitFor(() => { expect(screen.getByRole("alert").textContent).toContain( - "Not authenticated", + "Failed to delete note type. Please try again.", ); }); }); @@ -250,23 +204,20 @@ describe("DeleteNoteTypeModal", () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockHandleResponse.mockRejectedValue(new ApiClientError("Some error", 404)); + mockDelete.mockRejectedValueOnce(new Error("Some error")); const { rerender } = render( , ); - // Trigger error await user.click(screen.getByRole("button", { name: "Delete" })); await waitFor(() => { expect(screen.getByRole("alert")).toBeDefined(); }); - // Close and reopen the modal await user.click(screen.getByRole("button", { name: "Cancel" })); rerender(); - // Error should be cleared expect(screen.queryByRole("alert")).toBeNull(); }); -- cgit v1.3.1