aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/components
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-02 11:58:13 +0900
committernsfisis <nsfisis@gmail.com>2026-05-02 11:58:13 +0900
commitd47d1a014a71ae65cbbf1b384eed87c6fe078b07 (patch)
tree76c3af22e10f963104f84f3d680c5dddfc8b2be6 /src/client/components
parent023d0fcfce575030ee503c5f60df8c28dba7ab07 (diff)
downloadkioku-d47d1a014a71ae65cbbf1b384eed87c6fe078b07.tar.gz
kioku-d47d1a014a71ae65cbbf1b384eed87c6fe078b07.tar.zst
kioku-d47d1a014a71ae65cbbf1b384eed87c6fe078b07.zip
feat(note-types): make note type CRUD work fully offline-firstHEADmain
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>
Diffstat (limited to 'src/client/components')
-rw-r--r--src/client/components/CreateNoteTypeModal.test.tsx126
-rw-r--r--src/client/components/CreateNoteTypeModal.tsx39
-rw-r--r--src/client/components/DeleteNoteTypeModal.test.tsx131
-rw-r--r--src/client/components/DeleteNoteTypeModal.tsx34
-rw-r--r--src/client/components/EditNoteTypeModal.test.tsx348
-rw-r--r--src/client/components/EditNoteTypeModal.tsx226
-rw-r--r--src/client/components/NoteTypeEditor.test.tsx419
-rw-r--r--src/client/components/NoteTypeEditor.tsx188
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.");
}
};