diff options
Diffstat (limited to 'src/client/components')
| -rw-r--r-- | src/client/components/CreateNoteTypeModal.test.tsx | 126 | ||||
| -rw-r--r-- | src/client/components/CreateNoteTypeModal.tsx | 39 | ||||
| -rw-r--r-- | src/client/components/DeleteNoteTypeModal.test.tsx | 131 | ||||
| -rw-r--r-- | src/client/components/DeleteNoteTypeModal.tsx | 34 | ||||
| -rw-r--r-- | src/client/components/EditNoteTypeModal.test.tsx | 348 | ||||
| -rw-r--r-- | src/client/components/EditNoteTypeModal.tsx | 226 | ||||
| -rw-r--r-- | src/client/components/NoteTypeEditor.test.tsx | 419 | ||||
| -rw-r--r-- | src/client/components/NoteTypeEditor.tsx | 188 |
8 files changed, 347 insertions, 1164 deletions
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(); +const mockCreate = vi.fn(); +const mockTriggerSync = vi.fn(() => Promise.resolve(null)); -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"; - } +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(<CreateNoteTypeModal {...defaultProps} onClose={onClose} />); - - 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(<CreateNoteTypeModal {...defaultProps} />); + + 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(<CreateNoteTypeModal {...defaultProps} />); @@ -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(<CreateNoteTypeModal {...defaultProps} />); - - 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(<CreateNoteTypeModal {...defaultProps} />); @@ -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( <CreateNoteTypeModal isOpen={true} @@ -270,7 +233,6 @@ describe("CreateNoteTypeModal", () => { />, ); - // 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<string | null>(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({ </button> <button type="submit" - disabled={isSubmitting || !name.trim() || !isOnline} - title={!isOnline ? "Reconnect to create" : undefined} + disabled={isSubmitting || !name.trim()} className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" > {isSubmitting ? "Creating..." : "Create"} 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(); +const mockHasNotes = vi.fn(); +const mockTriggerSync = vi.fn(() => Promise.resolve(null)); -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"; - } +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(); }); diff --git a/src/client/components/DeleteNoteTypeModal.tsx b/src/client/components/DeleteNoteTypeModal.tsx index 2fbf808..121761b 100644 --- a/src/client/components/DeleteNoteTypeModal.tsx +++ b/src/client/components/DeleteNoteTypeModal.tsx @@ -1,7 +1,7 @@ -import { useAtomValue } from "jotai"; +import { useSetAtom } from "jotai"; import { useState } from "react"; -import { ApiClientError, apiClient } from "../api"; -import { isOnlineAtom } from "../atoms"; +import { syncActionAtom } from "../atoms"; +import { localNoteTypeRepository } from "../db/repositories"; interface NoteType { id: string; @@ -23,7 +23,7 @@ export function DeleteNoteTypeModal({ }: DeleteNoteTypeModalProps) { const [error, setError] = useState<string | null>(null); const [isDeleting, setIsDeleting] = useState(false); - const isOnline = useAtomValue(isOnlineAtom); + const triggerSync = useSetAtom(syncActionAtom); const handleClose = () => { setError(null); @@ -37,19 +37,22 @@ export function DeleteNoteTypeModal({ setIsDeleting(true); try { - const res = await apiClient.rpc.api["note-types"][":id"].$delete({ - param: { id: noteType.id }, - }); - await apiClient.handleResponse(res); + if (await localNoteTypeRepository.hasNotes(noteType.id)) { + setError("Cannot delete note type with existing notes."); + return; + } + + const deleted = await localNoteTypeRepository.delete(noteType.id); + if (!deleted) { + setError("Note type not found."); + return; + } onNoteTypeDeleted(); onClose(); - } catch (err) { - if (err instanceof ApiClientError) { - setError(err.message); - } else { - setError("Failed to delete note type. Please try again."); - } + void triggerSync().catch(() => {}); + } catch { + setError("Failed to delete note type. Please try again."); } finally { setIsDeleting(false); } @@ -132,8 +135,7 @@ export function DeleteNoteTypeModal({ <button type="button" onClick={handleDelete} - disabled={isDeleting || !isOnline} - title={!isOnline ? "Reconnect to delete" : undefined} + disabled={isDeleting} className="px-4 py-2 bg-error hover:bg-error/90 text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed min-w-[100px]" > {isDeleting ? "Deleting..." : "Delete"} diff --git a/src/client/components/EditNoteTypeModal.test.tsx b/src/client/components/EditNoteTypeModal.test.tsx deleted file mode 100644 index cc23d8f..0000000 --- a/src/client/components/EditNoteTypeModal.test.tsx +++ /dev/null @@ -1,348 +0,0 @@ -/** - * @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 mockPut = vi.fn(); -const mockHandleResponse = vi.fn(); - -vi.mock("../api/client", () => ({ - apiClient: { - rpc: { - api: { - "note-types": { - ":id": { - $put: (args: unknown) => mockPut(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 after mock is set up -import { EditNoteTypeModal } from "./EditNoteTypeModal"; - -describe("EditNoteTypeModal", () => { - const mockNoteType = { - id: "note-type-123", - name: "Basic", - frontTemplate: "{{Front}}", - backTemplate: "{{Back}}", - isReversible: false, - }; - - const defaultProps = { - isOpen: true, - noteType: mockNoteType, - onClose: vi.fn(), - onNoteTypeUpdated: vi.fn(), - }; - - beforeEach(() => { - vi.clearAllMocks(); - mockPut.mockResolvedValue({ ok: true }); - mockHandleResponse.mockResolvedValue({ noteType: mockNoteType }); - }); - - afterEach(() => { - cleanup(); - vi.restoreAllMocks(); - }); - - it("does not render when closed", () => { - render(<EditNoteTypeModal {...defaultProps} isOpen={false} />); - - expect(screen.queryByRole("dialog")).toBeNull(); - }); - - it("does not render when noteType is null", () => { - render(<EditNoteTypeModal {...defaultProps} noteType={null} />); - - expect(screen.queryByRole("dialog")).toBeNull(); - }); - - it("renders modal when open with noteType", () => { - render(<EditNoteTypeModal {...defaultProps} />); - - expect(screen.getByRole("dialog")).toBeDefined(); - expect( - screen.getByRole("heading", { name: "Edit Note Type" }), - ).toBeDefined(); - expect(screen.getByLabelText("Name")).toBeDefined(); - expect(screen.getByLabelText("Front Template")).toBeDefined(); - expect(screen.getByLabelText("Back Template")).toBeDefined(); - expect(screen.getByLabelText("Create reversed cards")).toBeDefined(); - expect(screen.getByRole("button", { name: "Cancel" })).toBeDefined(); - expect(screen.getByRole("button", { name: "Save Changes" })).toBeDefined(); - }); - - it("populates form with noteType data", () => { - render(<EditNoteTypeModal {...defaultProps} />); - - expect(screen.getByLabelText("Name")).toHaveProperty("value", "Basic"); - expect(screen.getByLabelText("Front Template")).toHaveProperty( - "value", - "{{Front}}", - ); - expect(screen.getByLabelText("Back Template")).toHaveProperty( - "value", - "{{Back}}", - ); - expect(screen.getByLabelText("Create reversed cards")).toHaveProperty( - "checked", - false, - ); - }); - - it("populates form with reversible noteType", () => { - const reversibleNoteType = { - ...mockNoteType, - isReversible: true, - }; - - render( - <EditNoteTypeModal {...defaultProps} noteType={reversibleNoteType} />, - ); - - expect(screen.getByLabelText("Create reversed cards")).toHaveProperty( - "checked", - true, - ); - }); - - it("disables save button when name is empty", async () => { - const user = userEvent.setup(); - render(<EditNoteTypeModal {...defaultProps} />); - - const nameInput = screen.getByLabelText("Name"); - await user.clear(nameInput); - - const saveButton = screen.getByRole("button", { name: "Save Changes" }); - expect(saveButton).toHaveProperty("disabled", true); - }); - - it("calls onClose when Cancel is clicked", async () => { - const user = userEvent.setup(); - const onClose = vi.fn(); - render(<EditNoteTypeModal {...defaultProps} onClose={onClose} />); - - 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(); - render(<EditNoteTypeModal {...defaultProps} onClose={onClose} />); - - const dialog = screen.getByRole("dialog"); - await user.click(dialog); - - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("updates noteType when Save Changes is clicked", async () => { - const user = userEvent.setup(); - const onClose = vi.fn(); - const onNoteTypeUpdated = vi.fn(); - - render( - <EditNoteTypeModal - isOpen={true} - noteType={mockNoteType} - onClose={onClose} - onNoteTypeUpdated={onNoteTypeUpdated} - />, - ); - - // Update fields - const nameInput = screen.getByLabelText("Name"); - await user.clear(nameInput); - await user.type(nameInput, "Updated Basic"); - await user.click(screen.getByLabelText("Create reversed cards")); - - // Save - await user.click(screen.getByRole("button", { name: "Save Changes" })); - - await waitFor(() => { - expect(mockPut).toHaveBeenCalledWith({ - param: { id: "note-type-123" }, - json: { - name: "Updated Basic", - frontTemplate: "{{Front}}", - backTemplate: "{{Back}}", - isReversible: true, - }, - }); - }); - - expect(onNoteTypeUpdated).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("trims whitespace from text fields", async () => { - const user = userEvent.setup(); - - render(<EditNoteTypeModal {...defaultProps} />); - - const nameInput = screen.getByLabelText("Name"); - await user.clear(nameInput); - await user.type(nameInput, " Updated Basic "); - await user.click(screen.getByRole("button", { name: "Save Changes" })); - - await waitFor(() => { - expect(mockPut).toHaveBeenCalledWith({ - param: { id: "note-type-123" }, - json: expect.objectContaining({ - name: "Updated Basic", - }), - }); - }); - }); - - it("shows loading state during submission", async () => { - const user = userEvent.setup(); - - mockPut.mockImplementation(() => new Promise(() => {})); // Never resolves - - render(<EditNoteTypeModal {...defaultProps} />); - - await user.click(screen.getByRole("button", { name: "Save Changes" })); - - expect(screen.getByRole("button", { name: "Saving..." })).toBeDefined(); - expect(screen.getByRole("button", { name: "Saving..." })).toHaveProperty( - "disabled", - true, - ); - expect(screen.getByRole("button", { name: "Cancel" })).toHaveProperty( - "disabled", - true, - ); - expect(screen.getByLabelText("Name")).toHaveProperty("disabled", true); - }); - - it("displays API error message", async () => { - const user = userEvent.setup(); - - mockHandleResponse.mockRejectedValue( - new ApiClientError("Note type not found", 404), - ); - - render(<EditNoteTypeModal {...defaultProps} />); - - await user.click(screen.getByRole("button", { name: "Save Changes" })); - - await waitFor(() => { - expect(screen.getByRole("alert").textContent).toContain( - "Note type not found", - ); - }); - }); - - it("displays generic error on unexpected failure", async () => { - const user = userEvent.setup(); - - mockPut.mockRejectedValue(new Error("Network error")); - - render(<EditNoteTypeModal {...defaultProps} />); - - await user.click(screen.getByRole("button", { name: "Save Changes" })); - - await waitFor(() => { - expect(screen.getByRole("alert").textContent).toContain( - "Failed to update note type. Please try again.", - ); - }); - }); - - it("displays error when handleResponse throws", async () => { - const user = userEvent.setup(); - - mockHandleResponse.mockRejectedValue( - new ApiClientError("Not authenticated", 401), - ); - - render(<EditNoteTypeModal {...defaultProps} />); - - await user.click(screen.getByRole("button", { name: "Save Changes" })); - - await waitFor(() => { - expect(screen.getByRole("alert").textContent).toContain( - "Not authenticated", - ); - }); - }); - - it("updates form when noteType prop changes", () => { - const { rerender } = render(<EditNoteTypeModal {...defaultProps} />); - - expect(screen.getByLabelText("Name")).toHaveProperty("value", "Basic"); - - const newNoteType = { - id: "note-type-456", - name: "Another Note Type", - frontTemplate: "Q: {{Front}}", - backTemplate: "A: {{Back}}", - isReversible: true, - }; - - rerender(<EditNoteTypeModal {...defaultProps} noteType={newNoteType} />); - - expect(screen.getByLabelText("Name")).toHaveProperty( - "value", - "Another Note Type", - ); - expect(screen.getByLabelText("Front Template")).toHaveProperty( - "value", - "Q: {{Front}}", - ); - expect(screen.getByLabelText("Back Template")).toHaveProperty( - "value", - "A: {{Back}}", - ); - expect(screen.getByLabelText("Create reversed cards")).toHaveProperty( - "checked", - true, - ); - }); - - it("clears error when modal is closed", async () => { - const user = userEvent.setup(); - const onClose = vi.fn(); - - mockHandleResponse.mockRejectedValue(new ApiClientError("Some error", 404)); - - const { rerender } = render( - <EditNoteTypeModal {...defaultProps} onClose={onClose} />, - ); - - // Trigger error - await user.click(screen.getByRole("button", { name: "Save Changes" })); - await waitFor(() => { - expect(screen.getByRole("alert")).toBeDefined(); - }); - - // Close and reopen the modal - await user.click(screen.getByRole("button", { name: "Cancel" })); - rerender(<EditNoteTypeModal {...defaultProps} onClose={onClose} />); - - // Error should be cleared - expect(screen.queryByRole("alert")).toBeNull(); - }); -}); diff --git a/src/client/components/EditNoteTypeModal.tsx b/src/client/components/EditNoteTypeModal.tsx deleted file mode 100644 index 5916ff0..0000000 --- a/src/client/components/EditNoteTypeModal.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import { useAtomValue } from "jotai"; -import { type FormEvent, useEffect, useState } from "react"; -import { ApiClientError, apiClient } from "../api"; -import { isOnlineAtom } from "../atoms"; - -interface NoteType { - id: string; - name: string; - frontTemplate: string; - backTemplate: string; - isReversible: boolean; -} - -interface EditNoteTypeModalProps { - isOpen: boolean; - noteType: NoteType | null; - onClose: () => void; - onNoteTypeUpdated: () => void; -} - -export function EditNoteTypeModal({ - isOpen, - noteType, - onClose, - onNoteTypeUpdated, -}: EditNoteTypeModalProps) { - const [name, setName] = useState(""); - const [frontTemplate, setFrontTemplate] = useState(""); - const [backTemplate, setBackTemplate] = useState(""); - const [isReversible, setIsReversible] = useState(false); - const [error, setError] = useState<string | null>(null); - const [isSubmitting, setIsSubmitting] = useState(false); - const isOnline = useAtomValue(isOnlineAtom); - - // Sync form state when noteType changes - useEffect(() => { - if (noteType) { - setName(noteType.name); - setFrontTemplate(noteType.frontTemplate); - setBackTemplate(noteType.backTemplate); - setIsReversible(noteType.isReversible); - setError(null); - } - }, [noteType]); - - const handleClose = () => { - setError(null); - onClose(); - }; - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - if (!noteType) return; - - setError(null); - setIsSubmitting(true); - - try { - const res = await apiClient.rpc.api["note-types"][":id"].$put({ - param: { id: noteType.id }, - json: { - name: name.trim(), - frontTemplate: frontTemplate.trim(), - backTemplate: backTemplate.trim(), - isReversible, - }, - }); - await apiClient.handleResponse(res); - - onNoteTypeUpdated(); - onClose(); - } catch (err) { - if (err instanceof ApiClientError) { - setError(err.message); - } else { - setError("Failed to update note type. Please try again."); - } - } finally { - setIsSubmitting(false); - } - }; - - if (!isOpen || !noteType) { - return null; - } - - return ( - <div - role="dialog" - aria-modal="true" - aria-labelledby="edit-note-type-title" - className="fixed inset-0 bg-ink/40 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in" - onClick={(e) => { - if (e.target === e.currentTarget) { - handleClose(); - } - }} - onKeyDown={(e) => { - if (e.key === "Escape") { - handleClose(); - } - }} - > - <div className="bg-white rounded-2xl shadow-xl w-full max-w-md animate-scale-in"> - <div className="p-6"> - <h2 - id="edit-note-type-title" - className="font-display text-xl font-medium text-ink mb-6" - > - Edit Note Type - </h2> - - <form onSubmit={handleSubmit} className="space-y-4"> - {error && ( - <div - role="alert" - className="bg-error/5 text-error text-sm px-4 py-3 rounded-lg border border-error/20" - > - {error} - </div> - )} - - <div> - <label - htmlFor="edit-note-type-name" - className="block text-sm font-medium text-slate mb-1.5" - > - Name - </label> - <input - id="edit-note-type-name" - type="text" - value={name} - onChange={(e) => setName(e.target.value)} - required - maxLength={255} - disabled={isSubmitting} - className="w-full px-4 py-2.5 bg-ivory border border-border rounded-lg text-slate placeholder-muted transition-all duration-200 hover:border-muted focus:border-primary focus:ring-2 focus:ring-primary/10 disabled:opacity-50 disabled:cursor-not-allowed" - /> - </div> - - <div> - <label - htmlFor="edit-front-template" - className="block text-sm font-medium text-slate mb-1.5" - > - Front Template - </label> - <textarea - id="edit-front-template" - value={frontTemplate} - onChange={(e) => setFrontTemplate(e.target.value)} - required - maxLength={1000} - disabled={isSubmitting} - rows={3} - className="w-full px-4 py-2.5 bg-ivory border border-border rounded-lg text-slate placeholder-muted font-mono text-sm transition-all duration-200 hover:border-muted focus:border-primary focus:ring-2 focus:ring-primary/10 disabled:opacity-50 disabled:cursor-not-allowed resize-y" - /> - <p className="text-muted text-xs mt-1"> - Use {"{{FieldName}}"} to insert field values - </p> - </div> - - <div> - <label - htmlFor="edit-back-template" - className="block text-sm font-medium text-slate mb-1.5" - > - Back Template - </label> - <textarea - id="edit-back-template" - value={backTemplate} - onChange={(e) => setBackTemplate(e.target.value)} - required - maxLength={1000} - disabled={isSubmitting} - rows={3} - className="w-full px-4 py-2.5 bg-ivory border border-border rounded-lg text-slate placeholder-muted font-mono text-sm transition-all duration-200 hover:border-muted focus:border-primary focus:ring-2 focus:ring-primary/10 disabled:opacity-50 disabled:cursor-not-allowed resize-y" - /> - </div> - - <div className="flex items-center gap-3"> - <input - id="edit-is-reversible" - type="checkbox" - checked={isReversible} - onChange={(e) => setIsReversible(e.target.checked)} - disabled={isSubmitting} - className="w-4 h-4 text-primary bg-ivory border-border rounded focus:ring-primary/20 focus:ring-2 disabled:opacity-50" - /> - <label - htmlFor="edit-is-reversible" - className="text-sm font-medium text-slate" - > - Create reversed cards - </label> - </div> - <p className="text-muted text-xs -mt-2 ml-7"> - Only affects new notes; existing cards are not modified - </p> - - <div className="flex gap-3 justify-end pt-2"> - <button - type="button" - onClick={handleClose} - disabled={isSubmitting} - className="px-4 py-2 text-slate hover:bg-ivory rounded-lg transition-colors disabled:opacity-50" - > - Cancel - </button> - <button - type="submit" - disabled={isSubmitting || !name.trim() || !isOnline} - title={!isOnline ? "Reconnect to save changes" : undefined} - className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" - > - {isSubmitting ? "Saving..." : "Save Changes"} - </button> - </div> - </form> - </div> - </div> - </div> - ); -} diff --git a/src/client/components/NoteTypeEditor.test.tsx b/src/client/components/NoteTypeEditor.test.tsx index a628859..0ea184e 100644 --- a/src/client/components/NoteTypeEditor.test.tsx +++ b/src/client/components/NoteTypeEditor.test.tsx @@ -3,81 +3,83 @@ */ 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 mockNoteTypeGet = vi.fn(); -const mockNoteTypePut = vi.fn(); -const mockFieldPost = vi.fn(); -const mockFieldPut = vi.fn(); +const mockNoteTypeFindById = vi.fn(); +const mockNoteTypeUpdate = vi.fn(); +const mockFieldFindByNoteTypeId = vi.fn(); +const mockFieldCreate = vi.fn(); +const mockFieldUpdate = vi.fn(); const mockFieldDelete = vi.fn(); -const mockFieldsReorder = vi.fn(); -const mockHandleResponse = vi.fn(); +const mockFieldHasValues = vi.fn(); +const mockFieldReorder = vi.fn(); +const mockTriggerSync = vi.fn(() => Promise.resolve(null)); -vi.mock("../api/client", () => ({ - apiClient: { - rpc: { - api: { - "note-types": { - ":id": { - $get: (args: unknown) => mockNoteTypeGet(args), - $put: (args: unknown) => mockNoteTypePut(args), - fields: { - $post: (args: unknown) => mockFieldPost(args), - ":fieldId": { - $put: (args: unknown) => mockFieldPut(args), - $delete: (args: unknown) => mockFieldDelete(args), - }, - reorder: { - $put: (args: unknown) => mockFieldsReorder(args), - }, - }, - }, - }, - }, - }, - handleResponse: (res: unknown) => mockHandleResponse(res), +vi.mock("../db/repositories", () => ({ + localNoteTypeRepository: { + findById: (...args: unknown[]) => mockNoteTypeFindById(...args), + update: (...args: unknown[]) => mockNoteTypeUpdate(...args), }, - ApiClientError: class ApiClientError extends Error { - constructor( - message: string, - public status: number, - public code?: string, - ) { - super(message); - this.name = "ApiClientError"; - } + localNoteFieldTypeRepository: { + findByNoteTypeId: (...args: unknown[]) => + mockFieldFindByNoteTypeId(...args), + create: (...args: unknown[]) => mockFieldCreate(...args), + update: (...args: unknown[]) => mockFieldUpdate(...args), + delete: (...args: unknown[]) => mockFieldDelete(...args), + hasNoteFieldValues: (...args: unknown[]) => mockFieldHasValues(...args), + reorder: (...args: unknown[]) => mockFieldReorder(...args), }, })); -import { ApiClientError } from "../api/client"; -// Import after mock is set up +vi.mock("../atoms", () => ({ + syncActionAtom: atom(null, () => mockTriggerSync()), +})); + import { NoteTypeEditor } from "./NoteTypeEditor"; describe("NoteTypeEditor", () => { - const mockNoteTypeWithFields = { + const noteTypeRow = { id: "note-type-123", + userId: "user-1", name: "Basic", frontTemplate: "{{Front}}", backTemplate: "{{Back}}", isReversible: false, - fields: [ - { - id: "field-1", - noteTypeId: "note-type-123", - name: "Front", - order: 0, - fieldType: "text", - }, - { - id: "field-2", - noteTypeId: "note-type-123", - name: "Back", - order: 1, - fieldType: "text", - }, - ], + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + deletedAt: null, + syncVersion: 0, + _synced: true, }; + const fieldRows = [ + { + id: "field-1", + noteTypeId: "note-type-123", + name: "Front", + order: 0, + fieldType: "text", + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + deletedAt: null, + syncVersion: 0, + _synced: true, + }, + { + id: "field-2", + noteTypeId: "note-type-123", + name: "Back", + order: 1, + fieldType: "text", + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + deletedAt: null, + syncVersion: 0, + _synced: true, + }, + ]; + const defaultProps = { isOpen: true, noteTypeId: "note-type-123", @@ -87,12 +89,38 @@ describe("NoteTypeEditor", () => { beforeEach(() => { vi.clearAllMocks(); - mockNoteTypeGet.mockResolvedValue({ ok: true }); - mockNoteTypePut.mockResolvedValue({ ok: true }); - mockFieldPost.mockResolvedValue({ ok: true }); - mockFieldPut.mockResolvedValue({ ok: true }); - mockFieldDelete.mockResolvedValue({ ok: true }); - mockFieldsReorder.mockResolvedValue({ ok: true }); + mockNoteTypeFindById.mockResolvedValue(noteTypeRow); + mockFieldFindByNoteTypeId.mockResolvedValue(fieldRows); + mockNoteTypeUpdate.mockResolvedValue(noteTypeRow); + mockFieldCreate.mockImplementation(async (data: { name: string }) => ({ + id: "field-3", + noteTypeId: "note-type-123", + name: data.name, + order: 2, + fieldType: "text", + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + syncVersion: 0, + _synced: false, + })); + mockFieldUpdate.mockImplementation( + async (id: string, data: { name?: string }) => ({ + ...fieldRows.find((f) => f.id === id), + ...data, + updatedAt: new Date(), + _synced: false, + }), + ); + mockFieldDelete.mockResolvedValue(true); + mockFieldHasValues.mockResolvedValue(false); + mockFieldReorder.mockImplementation( + async (_noteTypeId: string, ids: string[]) => + ids.map((id, i) => ({ + ...fieldRows.find((f) => f.id === id), + order: i, + })), + ); }); afterEach(() => { @@ -106,31 +134,22 @@ describe("NoteTypeEditor", () => { expect(screen.queryByRole("dialog")).toBeNull(); }); - it("renders modal and fetches note type when open", async () => { - mockHandleResponse.mockResolvedValueOnce({ - noteType: mockNoteTypeWithFields, - }); - + it("loads the note type and fields from the local database when opened", async () => { render(<NoteTypeEditor {...defaultProps} />); expect(screen.getByRole("dialog")).toBeDefined(); await waitFor(() => { - expect(mockNoteTypeGet).toHaveBeenCalledWith({ - param: { id: "note-type-123" }, - }); + expect(mockNoteTypeFindById).toHaveBeenCalledWith("note-type-123"); }); await waitFor(() => { expect(screen.getByLabelText("Name")).toHaveProperty("value", "Basic"); }); + expect(mockFieldFindByNoteTypeId).toHaveBeenCalledWith("note-type-123"); }); it("displays note type data after loading", async () => { - mockHandleResponse.mockResolvedValueOnce({ - noteType: mockNoteTypeWithFields, - }); - render(<NoteTypeEditor {...defaultProps} />); await waitFor(() => { @@ -153,79 +172,53 @@ describe("NoteTypeEditor", () => { expect(screen.getByText("Back")).toBeDefined(); }); - it("displays loading state while fetching", async () => { - mockNoteTypeGet.mockImplementation(() => new Promise(() => {})); // Never resolves - - render(<NoteTypeEditor {...defaultProps} />); - - // Should show dialog - expect(screen.getByRole("dialog")).toBeDefined(); - }); - - it("displays error when fetch fails", async () => { - mockHandleResponse.mockRejectedValueOnce( - new ApiClientError("Note type not found", 404), - ); + it("displays an error when the note type is missing locally", async () => { + mockNoteTypeFindById.mockResolvedValueOnce(undefined); render(<NoteTypeEditor {...defaultProps} />); await waitFor(() => { expect(screen.getByRole("alert").textContent).toContain( - "Note type not found", + "Note type not found.", ); }); }); - it("calls onClose when Cancel is clicked", async () => { - const user = userEvent.setup(); - const onClose = vi.fn(); - - mockHandleResponse.mockResolvedValueOnce({ - noteType: mockNoteTypeWithFields, + it("displays an error when the note type is soft-deleted", async () => { + mockNoteTypeFindById.mockResolvedValueOnce({ + ...noteTypeRow, + deletedAt: new Date(), }); - render(<NoteTypeEditor {...defaultProps} onClose={onClose} />); + render(<NoteTypeEditor {...defaultProps} />); await waitFor(() => { - expect(screen.getByLabelText("Name")).toBeDefined(); + expect(screen.getByRole("alert").textContent).toContain( + "Note type not found.", + ); }); - - await user.click(screen.getByRole("button", { name: "Cancel" })); - - expect(onClose).toHaveBeenCalledTimes(1); }); - it("calls onClose when clicking outside the modal", async () => { + it("calls onClose when Cancel is clicked", async () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockHandleResponse.mockResolvedValueOnce({ - noteType: mockNoteTypeWithFields, - }); - render(<NoteTypeEditor {...defaultProps} onClose={onClose} />); await waitFor(() => { expect(screen.getByLabelText("Name")).toBeDefined(); }); - const dialog = screen.getByRole("dialog"); - await user.click(dialog); + await user.click(screen.getByRole("button", { name: "Cancel" })); expect(onClose).toHaveBeenCalledTimes(1); }); - it("updates note type when Save Changes is clicked", async () => { + it("updates the note type when Save Changes is clicked", async () => { const user = userEvent.setup(); const onClose = vi.fn(); const onNoteTypeUpdated = vi.fn(); - mockHandleResponse - .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) - .mockResolvedValueOnce({ - noteType: { ...mockNoteTypeWithFields, name: "Updated Basic" }, - }); - render( <NoteTypeEditor isOpen={true} @@ -246,36 +239,24 @@ describe("NoteTypeEditor", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockNoteTypePut).toHaveBeenCalledWith({ - param: { id: "note-type-123" }, - json: { - name: "Updated Basic", - frontTemplate: "{{Front}}", - backTemplate: "{{Back}}", - isReversible: false, - }, + expect(mockNoteTypeUpdate).toHaveBeenCalledWith("note-type-123", { + name: "Updated Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, }); }); expect(onNoteTypeUpdated).toHaveBeenCalledTimes(1); expect(onClose).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(mockTriggerSync).toHaveBeenCalled(); + }); }); - it("adds a new field", async () => { + it("adds a new field via the local repository", async () => { const user = userEvent.setup(); - mockHandleResponse - .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) - .mockResolvedValueOnce({ - field: { - id: "field-3", - noteTypeId: "note-type-123", - name: "Hint", - order: 2, - fieldType: "text", - }, - }); - render(<NoteTypeEditor {...defaultProps} />); await waitFor(() => { @@ -287,13 +268,10 @@ describe("NoteTypeEditor", () => { await user.click(screen.getByRole("button", { name: "Add" })); await waitFor(() => { - expect(mockFieldPost).toHaveBeenCalledWith({ - param: { id: "note-type-123" }, - json: { - name: "Hint", - order: 2, - fieldType: "text", - }, + expect(mockFieldCreate).toHaveBeenCalledWith({ + noteTypeId: "note-type-123", + name: "Hint", + order: 2, }); }); @@ -302,42 +280,30 @@ describe("NoteTypeEditor", () => { }); }); - it("deletes a field", async () => { + it("deletes a field via the local repository", async () => { const user = userEvent.setup(); - mockHandleResponse - .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) - .mockResolvedValueOnce({ success: true }); - render(<NoteTypeEditor {...defaultProps} />); await waitFor(() => { expect(screen.getByText("Front")).toBeDefined(); }); - // Find the delete button for the "Front" field (first delete button) const deleteButtons = screen.getAllByTitle("Delete field"); - expect(deleteButtons.length).toBeGreaterThan(0); const deleteButton = deleteButtons.at(0); if (!deleteButton) throw new Error("Delete button not found"); await user.click(deleteButton); await waitFor(() => { - expect(mockFieldDelete).toHaveBeenCalledWith({ - param: { id: "note-type-123", fieldId: "field-1" }, - }); + expect(mockFieldDelete).toHaveBeenCalledWith("field-1"); }); }); - it("displays error when field deletion fails", async () => { + it("blocks deletion when the field still has stored values", async () => { const user = userEvent.setup(); - mockHandleResponse - .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) - .mockRejectedValueOnce( - new ApiClientError("Cannot delete field with existing values", 409), - ); + mockFieldHasValues.mockResolvedValue(true); render(<NoteTypeEditor {...defaultProps} />); @@ -345,9 +311,7 @@ describe("NoteTypeEditor", () => { expect(screen.getByText("Front")).toBeDefined(); }); - // Find the delete button for the "Front" field (first delete button) const deleteButtons = screen.getAllByTitle("Delete field"); - expect(deleteButtons.length).toBeGreaterThan(0); const deleteButton = deleteButtons.at(0); if (!deleteButton) throw new Error("Delete button not found"); @@ -363,150 +327,78 @@ describe("NoteTypeEditor", () => { ), ).toBe(true); }); + expect(mockFieldDelete).not.toHaveBeenCalled(); }); - it("moves a field up", async () => { + it("reorders fields when Move up is clicked", async () => { const user = userEvent.setup(); - mockHandleResponse - .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) - .mockResolvedValueOnce({ - fields: [ - { - id: "field-2", - noteTypeId: "note-type-123", - name: "Back", - order: 0, - fieldType: "text", - }, - { - id: "field-1", - noteTypeId: "note-type-123", - name: "Front", - order: 1, - fieldType: "text", - }, - ], - }); - render(<NoteTypeEditor {...defaultProps} />); await waitFor(() => { expect(screen.getByText("Back")).toBeDefined(); }); - // Find the "move up" button for the "Back" field (second field) const moveUpButtons = screen.getAllByTitle("Move up"); - expect(moveUpButtons.length).toBeGreaterThan(1); - // The first field's move up button is disabled, so click the second one (Back field) const secondMoveUpButton = moveUpButtons.at(1); if (!secondMoveUpButton) throw new Error("Move up button not found"); await user.click(secondMoveUpButton); await waitFor(() => { - expect(mockFieldsReorder).toHaveBeenCalledWith({ - param: { id: "note-type-123" }, - json: { - fieldIds: ["field-2", "field-1"], - }, - }); + expect(mockFieldReorder).toHaveBeenCalledWith("note-type-123", [ + "field-2", + "field-1", + ]); }); }); - it("moves a field down", async () => { + it("reorders fields when Move down is clicked", async () => { const user = userEvent.setup(); - mockHandleResponse - .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) - .mockResolvedValueOnce({ - fields: [ - { - id: "field-2", - noteTypeId: "note-type-123", - name: "Back", - order: 0, - fieldType: "text", - }, - { - id: "field-1", - noteTypeId: "note-type-123", - name: "Front", - order: 1, - fieldType: "text", - }, - ], - }); - render(<NoteTypeEditor {...defaultProps} />); await waitFor(() => { expect(screen.getByText("Front")).toBeDefined(); }); - // Find the "move down" button for the "Front" field (first field) const moveDownButtons = screen.getAllByTitle("Move down"); - expect(moveDownButtons.length).toBeGreaterThan(0); const firstMoveDownButton = moveDownButtons.at(0); if (!firstMoveDownButton) throw new Error("Move down button not found"); await user.click(firstMoveDownButton); await waitFor(() => { - expect(mockFieldsReorder).toHaveBeenCalledWith({ - param: { id: "note-type-123" }, - json: { - fieldIds: ["field-2", "field-1"], - }, - }); + expect(mockFieldReorder).toHaveBeenCalledWith("note-type-123", [ + "field-2", + "field-1", + ]); }); }); - it("edits a field name", async () => { + it("edits a field name via the local repository", async () => { const user = userEvent.setup(); - mockHandleResponse - .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) - .mockResolvedValueOnce({ - field: { - id: "field-1", - noteTypeId: "note-type-123", - name: "Question", - order: 0, - fieldType: "text", - }, - }); - render(<NoteTypeEditor {...defaultProps} />); await waitFor(() => { expect(screen.getByText("Front")).toBeDefined(); }); - // Click on the field name to start editing await user.click(screen.getByText("Front")); - // Now there should be an input field const editInput = screen.getByDisplayValue("Front"); await user.clear(editInput); await user.type(editInput, "Question"); - // Blur to save await user.tab(); await waitFor(() => { - expect(mockFieldPut).toHaveBeenCalledWith({ - param: { id: "note-type-123", fieldId: "field-1" }, - json: { - name: "Question", - }, + expect(mockFieldUpdate).toHaveBeenCalledWith("field-1", { + name: "Question", }); }); }); it("shows available fields in template help text", async () => { - mockHandleResponse.mockResolvedValueOnce({ - noteType: mockNoteTypeWithFields, - }); - render(<NoteTypeEditor {...defaultProps} />); await waitFor(() => { @@ -515,10 +407,6 @@ describe("NoteTypeEditor", () => { }); it("disables move up button for first field", async () => { - mockHandleResponse.mockResolvedValueOnce({ - noteType: mockNoteTypeWithFields, - }); - render(<NoteTypeEditor {...defaultProps} />); await waitFor(() => { @@ -526,15 +414,10 @@ describe("NoteTypeEditor", () => { }); const moveUpButtons = screen.getAllByTitle("Move up"); - // First field's move up button should be disabled expect(moveUpButtons[0]).toHaveProperty("disabled", true); }); it("disables move down button for last field", async () => { - mockHandleResponse.mockResolvedValueOnce({ - noteType: mockNoteTypeWithFields, - }); - render(<NoteTypeEditor {...defaultProps} />); await waitFor(() => { @@ -542,7 +425,6 @@ describe("NoteTypeEditor", () => { }); const moveDownButtons = screen.getAllByTitle("Move down"); - // Last field's move down button should be disabled expect(moveDownButtons[moveDownButtons.length - 1]).toHaveProperty( "disabled", true, @@ -550,10 +432,6 @@ describe("NoteTypeEditor", () => { }); it("disables Add button when new field name is empty", async () => { - mockHandleResponse.mockResolvedValueOnce({ - noteType: mockNoteTypeWithFields, - }); - render(<NoteTypeEditor {...defaultProps} />); await waitFor(() => { @@ -564,15 +442,9 @@ describe("NoteTypeEditor", () => { expect(addButton).toHaveProperty("disabled", true); }); - it("toggles reversible option", async () => { + it("toggles reversible option and persists it on save", async () => { const user = userEvent.setup(); - mockHandleResponse - .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) - .mockResolvedValueOnce({ - noteType: { ...mockNoteTypeWithFields, isReversible: true }, - }); - render(<NoteTypeEditor {...defaultProps} />); await waitFor(() => { @@ -583,18 +455,15 @@ describe("NoteTypeEditor", () => { expect(checkbox).toHaveProperty("checked", false); await user.click(checkbox); - expect(checkbox).toHaveProperty("checked", true); await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockNoteTypePut).toHaveBeenCalledWith({ - param: { id: "note-type-123" }, - json: expect.objectContaining({ - isReversible: true, - }), - }); + expect(mockNoteTypeUpdate).toHaveBeenCalledWith( + "note-type-123", + expect.objectContaining({ isReversible: true }), + ); }); }); }); diff --git a/src/client/components/NoteTypeEditor.tsx b/src/client/components/NoteTypeEditor.tsx index 2487c62..a6b7c5d 100644 --- a/src/client/components/NoteTypeEditor.tsx +++ b/src/client/components/NoteTypeEditor.tsx @@ -6,6 +6,7 @@ import { faTrash, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useSetAtom } from "jotai"; import { type FormEvent, useCallback, @@ -13,7 +14,11 @@ import { useRef, useState, } from "react"; -import { ApiClientError, apiClient } from "../api"; +import { syncActionAtom } from "../atoms"; +import { + localNoteFieldTypeRepository, + localNoteTypeRepository, +} from "../db/repositories"; interface NoteFieldType { id: string; @@ -31,10 +36,6 @@ interface NoteType { isReversible: boolean; } -interface NoteTypeWithFields extends NoteType { - fields: NoteFieldType[]; -} - interface NoteTypeEditorProps { isOpen: boolean; noteTypeId: string | null; @@ -48,7 +49,7 @@ export function NoteTypeEditor({ onClose, onNoteTypeUpdated, }: NoteTypeEditorProps) { - const [noteType, setNoteType] = useState<NoteTypeWithFields | null>(null); + const [noteType, setNoteType] = useState<NoteType | null>(null); const [name, setName] = useState(""); const [frontTemplate, setFrontTemplate] = useState(""); const [backTemplate, setBackTemplate] = useState(""); @@ -63,33 +64,37 @@ export function NoteTypeEditor({ const [editingFieldName, setEditingFieldName] = useState(""); const [fieldError, setFieldError] = useState<string | null>(null); const editInputRef = useRef<HTMLInputElement>(null); + const triggerSync = useSetAtom(syncActionAtom); - const fetchNoteType = useCallback(async () => { + const loadNoteType = useCallback(async () => { if (!noteTypeId) return; setIsLoading(true); setError(null); try { - const res = await apiClient.rpc.api["note-types"][":id"].$get({ - param: { id: noteTypeId }, - }); - const data = await apiClient.handleResponse<{ - noteType: NoteTypeWithFields; - }>(res); - const fetchedNoteType = data.noteType; - setNoteType(fetchedNoteType); - setName(fetchedNoteType.name); - setFrontTemplate(fetchedNoteType.frontTemplate); - setBackTemplate(fetchedNoteType.backTemplate); - setIsReversible(fetchedNoteType.isReversible); - setFields(fetchedNoteType.fields.sort((a, b) => a.order - b.order) || []); - } catch (err) { - if (err instanceof ApiClientError) { - setError(err.message); - } else { - setError("Failed to load note type. Please try again."); + const localNoteType = await localNoteTypeRepository.findById(noteTypeId); + if (!localNoteType || localNoteType.deletedAt !== null) { + setError("Note type not found."); + return; } + const localFields = + await localNoteFieldTypeRepository.findByNoteTypeId(noteTypeId); + + setNoteType({ + id: localNoteType.id, + name: localNoteType.name, + frontTemplate: localNoteType.frontTemplate, + backTemplate: localNoteType.backTemplate, + isReversible: localNoteType.isReversible, + }); + setName(localNoteType.name); + setFrontTemplate(localNoteType.frontTemplate); + setBackTemplate(localNoteType.backTemplate); + setIsReversible(localNoteType.isReversible); + setFields(localFields); + } catch { + setError("Failed to load note type. Please try again."); } finally { setIsLoading(false); } @@ -97,9 +102,9 @@ export function NoteTypeEditor({ useEffect(() => { if (isOpen && noteTypeId) { - fetchNoteType(); + loadNoteType(); } - }, [isOpen, noteTypeId, fetchNoteType]); + }, [isOpen, noteTypeId, loadNoteType]); useEffect(() => { if (editingFieldId && editInputRef.current) { @@ -125,25 +130,22 @@ export function NoteTypeEditor({ setIsSubmitting(true); try { - const res = await apiClient.rpc.api["note-types"][":id"].$put({ - param: { id: noteType.id }, - json: { - name: name.trim(), - frontTemplate: frontTemplate.trim(), - backTemplate: backTemplate.trim(), - isReversible, - }, + const updated = await localNoteTypeRepository.update(noteType.id, { + name: name.trim(), + frontTemplate: frontTemplate.trim(), + backTemplate: backTemplate.trim(), + isReversible, }); - await apiClient.handleResponse(res); + if (!updated) { + setError("Note type not found."); + return; + } onNoteTypeUpdated(); onClose(); - } catch (err) { - if (err instanceof ApiClientError) { - setError(err.message); - } else { - setError("Failed to update note type. Please try again."); - } + void triggerSync().catch(() => {}); + } catch { + setError("Failed to update note type. Please try again."); } finally { setIsSubmitting(false); } @@ -159,25 +161,16 @@ export function NoteTypeEditor({ const newOrder = fields.length > 0 ? Math.max(...fields.map((f) => f.order)) + 1 : 0; - const res = await apiClient.rpc.api["note-types"][":id"].fields.$post({ - param: { id: noteType.id }, - json: { - name: newFieldName.trim(), - order: newOrder, - fieldType: "text", - }, + const created = await localNoteFieldTypeRepository.create({ + noteTypeId: noteType.id, + name: newFieldName.trim(), + order: newOrder, }); - const data = await apiClient.handleResponse<{ field: NoteFieldType }>( - res, - ); - setFields([...fields, data.field]); + setFields([...fields, created]); setNewFieldName(""); - } catch (err) { - if (err instanceof ApiClientError) { - setFieldError(err.message); - } else { - setFieldError("Failed to add field. Please try again."); - } + void triggerSync().catch(() => {}); + } catch { + setFieldError("Failed to add field. Please try again."); } finally { setIsAddingField(false); } @@ -189,26 +182,19 @@ export function NoteTypeEditor({ setFieldError(null); try { - const res = await apiClient.rpc.api["note-types"][":id"].fields[ - ":fieldId" - ].$put({ - param: { id: noteType.id, fieldId }, - json: { - name: editingFieldName.trim(), - }, + const updated = await localNoteFieldTypeRepository.update(fieldId, { + name: editingFieldName.trim(), }); - const data = await apiClient.handleResponse<{ field: NoteFieldType }>( - res, - ); - setFields(fields.map((f) => (f.id === fieldId ? data.field : f))); + if (!updated) { + setFieldError("Field not found."); + return; + } + setFields(fields.map((f) => (f.id === fieldId ? updated : f))); setEditingFieldId(null); setEditingFieldName(""); - } catch (err) { - if (err instanceof ApiClientError) { - setFieldError(err.message); - } else { - setFieldError("Failed to update field. Please try again."); - } + void triggerSync().catch(() => {}); + } catch { + setFieldError("Failed to update field. Please try again."); } }; @@ -218,19 +204,19 @@ export function NoteTypeEditor({ setFieldError(null); try { - const res = await apiClient.rpc.api["note-types"][":id"].fields[ - ":fieldId" - ].$delete({ - param: { id: noteType.id, fieldId }, - }); - await apiClient.handleResponse(res); - setFields(fields.filter((f) => f.id !== fieldId)); - } catch (err) { - if (err instanceof ApiClientError) { - setFieldError(err.message); - } else { - setFieldError("Failed to delete field. Please try again."); + if (await localNoteFieldTypeRepository.hasNoteFieldValues(fieldId)) { + setFieldError("Cannot delete field with existing values."); + return; } + const deleted = await localNoteFieldTypeRepository.delete(fieldId); + if (!deleted) { + setFieldError("Field not found."); + return; + } + setFields(fields.filter((f) => f.id !== fieldId)); + void triggerSync().catch(() => {}); + } catch { + setFieldError("Failed to delete field. Please try again."); } }; @@ -253,26 +239,14 @@ export function NoteTypeEditor({ setFieldError(null); try { - const res = await apiClient.rpc.api["note-types"][ - ":id" - ].fields.reorder.$put({ - param: { id: noteType.id }, - json: { fieldIds }, - }); - const data = await apiClient.handleResponse<{ fields: NoteFieldType[] }>( - res, + const reordered = await localNoteFieldTypeRepository.reorder( + noteType.id, + fieldIds, ); - setFields( - data.fields.sort( - (a: NoteFieldType, b: NoteFieldType) => a.order - b.order, - ), - ); - } catch (err) { - if (err instanceof ApiClientError) { - setFieldError(err.message); - } else { - setFieldError("Failed to reorder fields. Please try again."); - } + setFields(reordered); + void triggerSync().catch(() => {}); + } catch { + setFieldError("Failed to reorder fields. Please try again."); } }; |
