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/CreateNoteTypeModal.test.tsx | 128 +++---- src/client/components/CreateNoteTypeModal.tsx | 39 +- src/client/components/DeleteNoteTypeModal.test.tsx | 133 ++----- src/client/components/DeleteNoteTypeModal.tsx | 34 +- src/client/components/EditNoteTypeModal.test.tsx | 348 ----------------- src/client/components/EditNoteTypeModal.tsx | 226 ----------- src/client/components/NoteTypeEditor.test.tsx | 421 +++++++-------------- src/client/components/NoteTypeEditor.tsx | 188 ++++----- 8 files changed, 350 insertions(+), 1167 deletions(-) delete mode 100644 src/client/components/EditNoteTypeModal.test.tsx delete mode 100644 src/client/components/EditNoteTypeModal.tsx (limited to 'src/client/components') diff --git a/src/client/components/CreateNoteTypeModal.test.tsx b/src/client/components/CreateNoteTypeModal.test.tsx index 59d8312..81ee45a 100644 --- a/src/client/components/CreateNoteTypeModal.test.tsx +++ b/src/client/components/CreateNoteTypeModal.test.tsx @@ -3,36 +3,23 @@ */ 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 mockPost = vi.fn(); -const mockHandleResponse = vi.fn(); - -vi.mock("../api/client", () => ({ - apiClient: { - rpc: { - api: { - "note-types": { - $post: (args: unknown) => mockPost(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 mockCreate = vi.fn(); +const mockTriggerSync = vi.fn(() => Promise.resolve(null)); + +vi.mock("../db/repositories", () => ({ + localNoteTypeRepository: { + create: (...args: unknown[]) => mockCreate(...args), }, })); -import { ApiClientError } from "../api/client"; -// Import after mock is set up +vi.mock("../atoms", () => ({ + syncActionAtom: atom(null, () => mockTriggerSync()), + userAtom: atom({ id: "user-1", username: "alice" }), +})); + import { CreateNoteTypeModal } from "./CreateNoteTypeModal"; describe("CreateNoteTypeModal", () => { @@ -44,15 +31,13 @@ describe("CreateNoteTypeModal", () => { beforeEach(() => { vi.clearAllMocks(); - mockPost.mockResolvedValue({ ok: true }); - mockHandleResponse.mockResolvedValue({ - noteType: { - id: "note-type-1", - name: "Test Note Type", - frontTemplate: "{{Front}}", - backTemplate: "{{Back}}", - isReversible: false, - }, + mockCreate.mockResolvedValue({ + id: "note-type-1", + userId: "user-1", + name: "Test Note Type", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, }); }); @@ -123,18 +108,7 @@ describe("CreateNoteTypeModal", () => { 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("creates note type with all fields", async () => { + it("creates note type via local repository with all fields", async () => { const user = userEvent.setup(); const onClose = vi.fn(); const onNoteTypeCreated = vi.fn(); @@ -148,18 +122,16 @@ describe("CreateNoteTypeModal", () => { ); await user.type(screen.getByLabelText("Name"), "Test Note Type"); - // Keep default templates and just toggle reversible await user.click(screen.getByLabelText("Create reversed cards")); await user.click(screen.getByRole("button", { name: "Create" })); await waitFor(() => { - expect(mockPost).toHaveBeenCalledWith({ - json: { - name: "Test Note Type", - frontTemplate: "{{Front}}", - backTemplate: "{{Back}}", - isReversible: true, - }, + expect(mockCreate).toHaveBeenCalledWith({ + userId: "user-1", + name: "Test Note Type", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: true, }); }); @@ -176,18 +148,31 @@ describe("CreateNoteTypeModal", () => { await user.click(screen.getByRole("button", { name: "Create" })); await waitFor(() => { - expect(mockPost).toHaveBeenCalledWith({ - json: expect.objectContaining({ + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ name: "Test Note Type", }), - }); + ); + }); + }); + + it("triggers a background sync after a successful create", async () => { + const user = userEvent.setup(); + + render(); + + await user.type(screen.getByLabelText("Name"), "Test Note Type"); + await user.click(screen.getByRole("button", { name: "Create" })); + + await waitFor(() => { + expect(mockTriggerSync).toHaveBeenCalled(); }); }); it("shows loading state during submission", async () => { const user = userEvent.setup(); - mockPost.mockImplementation(() => new Promise(() => {})); // Never resolves + mockCreate.mockImplementation(() => new Promise(() => {})); render(); @@ -206,29 +191,10 @@ describe("CreateNoteTypeModal", () => { expect(screen.getByLabelText("Name")).toHaveProperty("disabled", true); }); - it("displays API error message", async () => { - const user = userEvent.setup(); - - mockHandleResponse.mockRejectedValue( - new ApiClientError("Note type name already exists", 400), - ); - - render(); - - await user.type(screen.getByLabelText("Name"), "Test Note Type"); - await user.click(screen.getByRole("button", { name: "Create" })); - - await waitFor(() => { - expect(screen.getByRole("alert").textContent).toContain( - "Note type name already exists", - ); - }); - }); - - it("displays generic error on unexpected failure", async () => { + it("displays a generic error when the local write fails", async () => { const user = userEvent.setup(); - mockPost.mockRejectedValue(new Error("Network error")); + mockCreate.mockRejectedValue(new Error("disk full")); render(); @@ -254,14 +220,11 @@ describe("CreateNoteTypeModal", () => { />, ); - // Type something in the form await user.type(screen.getByLabelText("Name"), "Test Note Type"); await user.click(screen.getByLabelText("Create reversed cards")); - // Click cancel to close await user.click(screen.getByRole("button", { name: "Cancel" })); - // Reopen the modal rerender( { />, ); - // Form should be reset expect(screen.getByLabelText("Name")).toHaveProperty("value", ""); expect(screen.getByLabelText("Front Template")).toHaveProperty( "value", diff --git a/src/client/components/CreateNoteTypeModal.tsx b/src/client/components/CreateNoteTypeModal.tsx index bbd43a1..382935f 100644 --- a/src/client/components/CreateNoteTypeModal.tsx +++ b/src/client/components/CreateNoteTypeModal.tsx @@ -1,7 +1,7 @@ -import { useAtomValue } from "jotai"; +import { useAtomValue, useSetAtom } from "jotai"; import { type FormEvent, useState } from "react"; -import { ApiClientError, apiClient } from "../api"; -import { isOnlineAtom } from "../atoms"; +import { syncActionAtom, userAtom } from "../atoms"; +import { localNoteTypeRepository } from "../db/repositories"; interface CreateNoteTypeModalProps { isOpen: boolean; @@ -20,7 +20,8 @@ export function CreateNoteTypeModal({ const [isReversible, setIsReversible] = useState(false); const [error, setError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); - const isOnline = useAtomValue(isOnlineAtom); + const user = useAtomValue(userAtom); + const triggerSync = useSetAtom(syncActionAtom); const resetForm = () => { setName(""); @@ -37,29 +38,28 @@ export function CreateNoteTypeModal({ const handleSubmit = async (e: FormEvent) => { e.preventDefault(); + if (!user) { + setError("You must be signed in to create a note type."); + return; + } setError(null); setIsSubmitting(true); try { - const res = await apiClient.rpc.api["note-types"].$post({ - json: { - name: name.trim(), - frontTemplate: frontTemplate.trim(), - backTemplate: backTemplate.trim(), - isReversible, - }, + await localNoteTypeRepository.create({ + userId: user.id, + name: name.trim(), + frontTemplate: frontTemplate.trim(), + backTemplate: backTemplate.trim(), + isReversible, }); - await apiClient.handleResponse(res); resetForm(); onNoteTypeCreated(); onClose(); - } catch (err) { - if (err instanceof ApiClientError) { - setError(err.message); - } else { - setError("Failed to create note type. Please try again."); - } + void triggerSync().catch(() => {}); + } catch { + setError("Failed to create note type. Please try again."); } finally { setIsSubmitting(false); } @@ -200,8 +200,7 @@ export function CreateNoteTypeModal({