From d47d1a014a71ae65cbbf1b384eed87c6fe078b07 Mon Sep 17 00:00:00 2001
From: nsfisis <nsfisis@gmail.com>
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) <noreply@anthropic.com>
---
 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(<DeleteNoteTypeModal {...defaultProps} onClose={onClose} />);
-
-		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(<DeleteNoteTypeModal {...defaultProps} onClose={onClose} />);
-
-		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(<DeleteNoteTypeModal {...defaultProps} />);
 
 		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(<DeleteNoteTypeModal {...defaultProps} />);
 
@@ -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(<DeleteNoteTypeModal {...defaultProps} />);
 
 		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(<DeleteNoteTypeModal {...defaultProps} />);
 
@@ -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(<DeleteNoteTypeModal {...defaultProps} />);
 
@@ -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(
 			<DeleteNoteTypeModal {...defaultProps} onClose={onClose} />,
 		);
 
-		// 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(<DeleteNoteTypeModal {...defaultProps} onClose={onClose} />);
 
-		// Error should be cleared
 		expect(screen.queryByRole("alert")).toBeNull();
 	});
 
-- 
cgit v1.3.1

