aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/components')
-rw-r--r--src/client/components/CreateDeckModal.test.tsx221
-rw-r--r--src/client/components/CreateDeckModal.tsx53
-rw-r--r--src/client/components/DeleteDeckModal.test.tsx115
-rw-r--r--src/client/components/DeleteDeckModal.tsx29
-rw-r--r--src/client/components/EditDeckModal.test.tsx222
-rw-r--r--src/client/components/EditDeckModal.tsx111
6 files changed, 216 insertions, 535 deletions
diff --git a/src/client/components/CreateDeckModal.test.tsx b/src/client/components/CreateDeckModal.test.tsx
index fcaa572..e4a2bbc 100644
--- a/src/client/components/CreateDeckModal.test.tsx
+++ b/src/client/components/CreateDeckModal.test.tsx
@@ -3,46 +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";
-import { apiClient } from "../api/client";
-vi.mock("../api/client", () => ({
- apiClient: {
- getAuthHeader: vi.fn(),
- rpc: {
- api: {
- decks: {
- $post: vi.fn(),
- },
- },
- },
- },
- ApiClientError: class ApiClientError extends Error {
- constructor(
- message: string,
- public status: number,
- public code?: string,
- ) {
- super(message);
- this.name = "ApiClientError";
- }
+const mockCreate = vi.fn();
+const mockTriggerSync = vi.fn(() => Promise.resolve(null));
+
+vi.mock("../db/repositories", () => ({
+ localDeckRepository: {
+ create: (...args: unknown[]) => mockCreate(...args),
},
}));
-// Import after mock is set up
-import { CreateDeckModal } from "./CreateDeckModal";
+vi.mock("../atoms", () => ({
+ syncActionAtom: atom(null, () => mockTriggerSync()),
+ userAtom: atom({ id: "user-1", username: "alice" }),
+}));
-// Helper to create mock responses
-function mockResponse(data: {
- ok: boolean;
- status?: number;
- // biome-ignore lint/suspicious/noExplicitAny: Test helper needs flexible typing
- json: () => Promise<any>;
-}) {
- return data as unknown as Awaited<
- ReturnType<typeof apiClient.rpc.api.decks.$post>
- >;
-}
+import { CreateDeckModal } from "./CreateDeckModal";
describe("CreateDeckModal", () => {
const defaultProps = {
@@ -53,8 +31,12 @@ describe("CreateDeckModal", () => {
beforeEach(() => {
vi.clearAllMocks();
- vi.mocked(apiClient.getAuthHeader).mockReturnValue({
- Authorization: "Bearer access-token",
+ mockCreate.mockResolvedValue({
+ id: "deck-1",
+ userId: "user-1",
+ name: "Test Deck",
+ description: null,
+ defaultNoteTypeId: null,
});
});
@@ -93,8 +75,7 @@ describe("CreateDeckModal", () => {
const user = userEvent.setup();
render(<CreateDeckModal {...defaultProps} />);
- const nameInput = screen.getByLabelText("Name");
- await user.type(nameInput, "My Deck");
+ await user.type(screen.getByLabelText("Name"), "My Deck");
const createButton = screen.getByRole("button", { name: "Create Deck" });
expect(createButton).toHaveProperty("disabled", false);
@@ -110,47 +91,11 @@ describe("CreateDeckModal", () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
- it("calls onClose when clicking outside the modal", async () => {
- const user = userEvent.setup();
- const onClose = vi.fn();
- render(<CreateDeckModal {...defaultProps} onClose={onClose} />);
-
- // Click on the backdrop (the dialog element itself)
- 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(<CreateDeckModal {...defaultProps} onClose={onClose} />);
-
- // Click on an element inside the modal
- await user.click(screen.getByLabelText("Name"));
-
- expect(onClose).not.toHaveBeenCalled();
- });
-
- it("creates deck with name only", async () => {
+ it("creates deck via local repository with name only", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
const onDeckCreated = vi.fn();
- vi.mocked(apiClient.rpc.api.decks.$post).mockResolvedValue(
- mockResponse({
- ok: true,
- json: async () => ({
- deck: {
- id: "deck-1",
- name: "Test Deck",
- description: null,
- },
- }),
- }),
- );
-
render(
<CreateDeckModal
isOpen={true}
@@ -163,41 +108,21 @@ describe("CreateDeckModal", () => {
await user.click(screen.getByRole("button", { name: "Create Deck" }));
await waitFor(() => {
- expect(apiClient.rpc.api.decks.$post).toHaveBeenCalledWith(
- { json: { name: "Test Deck", description: null } },
- { headers: { Authorization: "Bearer access-token" } },
- );
+ expect(mockCreate).toHaveBeenCalledWith({
+ userId: "user-1",
+ name: "Test Deck",
+ description: null,
+ defaultNoteTypeId: null,
+ });
});
-
expect(onDeckCreated).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledTimes(1);
});
- it("creates deck with name and description", async () => {
+ it("includes description when provided", async () => {
const user = userEvent.setup();
- const onClose = vi.fn();
- const onDeckCreated = vi.fn();
-
- vi.mocked(apiClient.rpc.api.decks.$post).mockResolvedValue(
- mockResponse({
- ok: true,
- json: async () => ({
- deck: {
- id: "deck-1",
- name: "Test Deck",
- description: "A test description",
- },
- }),
- }),
- );
- render(
- <CreateDeckModal
- isOpen={true}
- onClose={onClose}
- onDeckCreated={onDeckCreated}
- />,
- );
+ render(<CreateDeckModal {...defaultProps} />);
await user.type(screen.getByLabelText("Name"), "Test Deck");
await user.type(
@@ -207,26 +132,18 @@ describe("CreateDeckModal", () => {
await user.click(screen.getByRole("button", { name: "Create Deck" }));
await waitFor(() => {
- expect(apiClient.rpc.api.decks.$post).toHaveBeenCalledWith(
- { json: { name: "Test Deck", description: "A test description" } },
- { headers: { Authorization: "Bearer access-token" } },
- );
+ expect(mockCreate).toHaveBeenCalledWith({
+ userId: "user-1",
+ name: "Test Deck",
+ description: "A test description",
+ defaultNoteTypeId: null,
+ });
});
-
- expect(onDeckCreated).toHaveBeenCalledTimes(1);
- expect(onClose).toHaveBeenCalledTimes(1);
});
it("trims whitespace from name and description", async () => {
const user = userEvent.setup();
- vi.mocked(apiClient.rpc.api.decks.$post).mockResolvedValue(
- mockResponse({
- ok: true,
- json: async () => ({ deck: { id: "deck-1" } }),
- }),
- );
-
render(<CreateDeckModal {...defaultProps} />);
await user.type(screen.getByLabelText("Name"), " Test Deck ");
@@ -237,19 +154,32 @@ describe("CreateDeckModal", () => {
await user.click(screen.getByRole("button", { name: "Create Deck" }));
await waitFor(() => {
- expect(apiClient.rpc.api.decks.$post).toHaveBeenCalledWith(
- { json: { name: "Test Deck", description: "Description" } },
- { headers: { Authorization: "Bearer access-token" } },
- );
+ expect(mockCreate).toHaveBeenCalledWith({
+ userId: "user-1",
+ name: "Test Deck",
+ description: "Description",
+ defaultNoteTypeId: null,
+ });
+ });
+ });
+
+ it("triggers a background sync after a successful create", async () => {
+ const user = userEvent.setup();
+
+ render(<CreateDeckModal {...defaultProps} />);
+
+ await user.type(screen.getByLabelText("Name"), "Test Deck");
+ await user.click(screen.getByRole("button", { name: "Create Deck" }));
+
+ await waitFor(() => {
+ expect(mockTriggerSync).toHaveBeenCalled();
});
});
it("shows loading state during submission", async () => {
const user = userEvent.setup();
- vi.mocked(apiClient.rpc.api.decks.$post).mockImplementation(
- () => new Promise(() => {}), // Never resolves
- );
+ mockCreate.mockImplementation(() => new Promise(() => {}));
render(<CreateDeckModal {...defaultProps} />);
@@ -272,35 +202,10 @@ describe("CreateDeckModal", () => {
);
});
- it("displays API error message", async () => {
+ it("displays a generic error when the local write fails", async () => {
const user = userEvent.setup();
- vi.mocked(apiClient.rpc.api.decks.$post).mockResolvedValue(
- mockResponse({
- ok: false,
- status: 400,
- json: async () => ({ error: "Deck name already exists" }),
- }),
- );
-
- render(<CreateDeckModal {...defaultProps} />);
-
- await user.type(screen.getByLabelText("Name"), "Test Deck");
- await user.click(screen.getByRole("button", { name: "Create Deck" }));
-
- await waitFor(() => {
- expect(screen.getByRole("alert").textContent).toContain(
- "Deck name already exists",
- );
- });
- });
-
- it("displays generic error on unexpected failure", async () => {
- const user = userEvent.setup();
-
- vi.mocked(apiClient.rpc.api.decks.$post).mockRejectedValue(
- new Error("Network error"),
- );
+ mockCreate.mockRejectedValue(new Error("disk full"));
render(<CreateDeckModal {...defaultProps} />);
@@ -326,17 +231,14 @@ describe("CreateDeckModal", () => {
/>,
);
- // Type something in the form
await user.type(screen.getByLabelText("Name"), "Test Deck");
await user.type(
screen.getByLabelText("Description (optional)"),
"Test Description",
);
- // Click cancel to close
await user.click(screen.getByRole("button", { name: "Cancel" }));
- // Reopen the modal
rerender(
<CreateDeckModal
isOpen={true}
@@ -345,7 +247,6 @@ describe("CreateDeckModal", () => {
/>,
);
- // Form should be reset
expect(screen.getByLabelText("Name")).toHaveProperty("value", "");
expect(screen.getByLabelText("Description (optional)")).toHaveProperty(
"value",
@@ -357,13 +258,6 @@ describe("CreateDeckModal", () => {
const user = userEvent.setup();
const onClose = vi.fn();
- vi.mocked(apiClient.rpc.api.decks.$post).mockResolvedValue(
- mockResponse({
- ok: true,
- json: async () => ({ deck: { id: "deck-1" } }),
- }),
- );
-
const { rerender } = render(
<CreateDeckModal
isOpen={true}
@@ -372,7 +266,6 @@ describe("CreateDeckModal", () => {
/>,
);
- // Create a deck
await user.type(screen.getByLabelText("Name"), "Test Deck");
await user.click(screen.getByRole("button", { name: "Create Deck" }));
@@ -380,7 +273,6 @@ describe("CreateDeckModal", () => {
expect(onClose).toHaveBeenCalled();
});
- // Reopen the modal
rerender(
<CreateDeckModal
isOpen={true}
@@ -389,7 +281,6 @@ describe("CreateDeckModal", () => {
/>,
);
- // Form should be reset
expect(screen.getByLabelText("Name")).toHaveProperty("value", "");
expect(screen.getByLabelText("Description (optional)")).toHaveProperty(
"value",
diff --git a/src/client/components/CreateDeckModal.tsx b/src/client/components/CreateDeckModal.tsx
index 34d46e7..11dc712 100644
--- a/src/client/components/CreateDeckModal.tsx
+++ b/src/client/components/CreateDeckModal.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 { localDeckRepository } from "../db/repositories";
interface CreateDeckModalProps {
isOpen: boolean;
@@ -18,7 +18,8 @@ export function CreateDeckModal({
const [description, setDescription] = useState("");
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("");
@@ -33,40 +34,29 @@ export function CreateDeckModal({
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
+ if (!user) {
+ setError("You must be signed in to create a deck.");
+ return;
+ }
setError(null);
setIsSubmitting(true);
try {
- const res = await apiClient.rpc.api.decks.$post(
- {
- json: {
- name: name.trim(),
- description: description.trim() || null,
- },
- },
- {
- headers: apiClient.getAuthHeader(),
- },
- );
-
- if (!res.ok) {
- const errorBody = await res.json().catch(() => ({}));
- throw new ApiClientError(
- (errorBody as { error?: string }).error ||
- `Request failed with status ${res.status}`,
- res.status,
- );
- }
+ await localDeckRepository.create({
+ userId: user.id,
+ name: name.trim(),
+ description: description.trim() || null,
+ defaultNoteTypeId: null,
+ });
resetForm();
onDeckCreated();
onClose();
- } catch (err) {
- if (err instanceof ApiClientError) {
- setError(err.message);
- } else {
- setError("Failed to create deck. Please try again.");
- }
+ // Fire-and-forget: server push will be retried by the sync engine if it
+ // fails (e.g. offline), so we deliberately do not await or surface errors.
+ void triggerSync().catch(() => {});
+ } catch {
+ setError("Failed to create deck. Please try again.");
} finally {
setIsSubmitting(false);
}
@@ -163,8 +153,7 @@ export function CreateDeckModal({
</button>
<button
type="submit"
- disabled={isSubmitting || !name.trim() || !isOnline}
- title={!isOnline ? "Reconnect to create a deck" : 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 Deck"}
diff --git a/src/client/components/DeleteDeckModal.test.tsx b/src/client/components/DeleteDeckModal.test.tsx
index 4441064..c091ad1 100644
--- a/src/client/components/DeleteDeckModal.test.tsx
+++ b/src/client/components/DeleteDeckModal.test.tsx
@@ -3,38 +3,22 @@
*/
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 mockTriggerSync = vi.fn(() => Promise.resolve(null));
-vi.mock("../api/client", () => ({
- apiClient: {
- rpc: {
- api: {
- decks: {
- ":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", () => ({
+ localDeckRepository: {
+ delete: (...args: unknown[]) => mockDelete(...args),
},
}));
-import { ApiClientError } from "../api/client";
-// Import after mock is set up
+vi.mock("../atoms", () => ({
+ syncActionAtom: atom(null, () => mockTriggerSync()),
+}));
+
import { DeleteDeckModal } from "./DeleteDeckModal";
describe("DeleteDeckModal", () => {
@@ -52,8 +36,7 @@ describe("DeleteDeckModal", () => {
beforeEach(() => {
vi.clearAllMocks();
- mockDelete.mockResolvedValue({ ok: true });
- mockHandleResponse.mockResolvedValue({});
+ mockDelete.mockResolvedValue(true);
});
afterEach(() => {
@@ -105,30 +88,7 @@ describe("DeleteDeckModal", () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
- it("calls onClose when clicking outside the modal", async () => {
- const user = userEvent.setup();
- const onClose = vi.fn();
- render(<DeleteDeckModal {...defaultProps} onClose={onClose} />);
-
- // Click on the backdrop (the dialog element itself)
- 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(<DeleteDeckModal {...defaultProps} onClose={onClose} />);
-
- // Click on an element inside the modal
- await user.click(screen.getByText("Test Deck"));
-
- expect(onClose).not.toHaveBeenCalled();
- });
-
- it("deletes deck when Delete is clicked", async () => {
+ it("deletes deck via local repository when Delete is clicked", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
const onDeckDeleted = vi.fn();
@@ -145,19 +105,29 @@ describe("DeleteDeckModal", () => {
await user.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => {
- expect(mockDelete).toHaveBeenCalledWith({
- param: { id: "deck-123" },
- });
+ expect(mockDelete).toHaveBeenCalledWith("deck-123");
});
expect(onDeckDeleted).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledTimes(1);
});
+ it("triggers a background sync after a successful delete", async () => {
+ const user = userEvent.setup();
+
+ render(<DeleteDeckModal {...defaultProps} />);
+
+ await user.click(screen.getByRole("button", { name: "Delete" }));
+
+ await waitFor(() => {
+ expect(mockTriggerSync).toHaveBeenCalled();
+ });
+ });
+
it("shows loading state during deletion", async () => {
const user = userEvent.setup();
- mockDelete.mockImplementation(() => new Promise(() => {})); // Never resolves
+ mockDelete.mockImplementation(() => new Promise(() => {}));
render(<DeleteDeckModal {...defaultProps} />);
@@ -174,26 +144,10 @@ describe("DeleteDeckModal", () => {
);
});
- it("displays API error message", async () => {
+ it("shows an error when the deck no longer exists locally", async () => {
const user = userEvent.setup();
- mockHandleResponse.mockRejectedValue(
- new ApiClientError("Deck not found", 404),
- );
-
- render(<DeleteDeckModal {...defaultProps} />);
-
- await user.click(screen.getByRole("button", { name: "Delete" }));
-
- await waitFor(() => {
- expect(screen.getByRole("alert").textContent).toContain("Deck not found");
- });
- });
-
- it("displays generic error on unexpected failure", async () => {
- const user = userEvent.setup();
-
- mockDelete.mockRejectedValue(new Error("Network error"));
+ mockDelete.mockResolvedValue(false);
render(<DeleteDeckModal {...defaultProps} />);
@@ -201,17 +155,15 @@ describe("DeleteDeckModal", () => {
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain(
- "Failed to delete deck. Please try again.",
+ "Deck 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(<DeleteDeckModal {...defaultProps} />);
@@ -219,7 +171,7 @@ describe("DeleteDeckModal", () => {
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain(
- "Not authenticated",
+ "Failed to delete deck. Please try again.",
);
});
});
@@ -228,23 +180,20 @@ describe("DeleteDeckModal", () => {
const user = userEvent.setup();
const onClose = vi.fn();
- mockHandleResponse.mockRejectedValue(new ApiClientError("Some error", 404));
+ mockDelete.mockRejectedValueOnce(new Error("Some error"));
const { rerender } = render(
<DeleteDeckModal {...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(<DeleteDeckModal {...defaultProps} onClose={onClose} />);
- // Error should be cleared
expect(screen.queryByRole("alert")).toBeNull();
});
diff --git a/src/client/components/DeleteDeckModal.tsx b/src/client/components/DeleteDeckModal.tsx
index 954431e..5e166fc 100644
--- a/src/client/components/DeleteDeckModal.tsx
+++ b/src/client/components/DeleteDeckModal.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 { localDeckRepository } from "../db/repositories";
interface Deck {
id: string;
@@ -23,7 +23,7 @@ export function DeleteDeckModal({
}: DeleteDeckModalProps) {
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,17 @@ export function DeleteDeckModal({
setIsDeleting(true);
try {
- const res = await apiClient.rpc.api.decks[":id"].$delete({
- param: { id: deck.id },
- });
- await apiClient.handleResponse(res);
+ const deleted = await localDeckRepository.delete(deck.id);
+ if (!deleted) {
+ setError("Deck not found.");
+ return;
+ }
onDeckDeleted();
onClose();
- } catch (err) {
- if (err instanceof ApiClientError) {
- setError(err.message);
- } else {
- setError("Failed to delete deck. Please try again.");
- }
+ void triggerSync().catch(() => {});
+ } catch {
+ setError("Failed to delete deck. Please try again.");
} finally {
setIsDeleting(false);
}
@@ -132,8 +130,7 @@ export function DeleteDeckModal({
<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/EditDeckModal.test.tsx b/src/client/components/EditDeckModal.test.tsx
index 248c74f..15a30a1 100644
--- a/src/client/components/EditDeckModal.test.tsx
+++ b/src/client/components/EditDeckModal.test.tsx
@@ -3,43 +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 mockPut = vi.fn();
-const mockHandleResponse = vi.fn();
+const mockUpdate = vi.fn();
+const mockTriggerSync = vi.fn(() => Promise.resolve(null));
-const mockGetNoteTypes = vi.fn();
-
-vi.mock("../api/client", () => ({
- apiClient: {
- rpc: {
- api: {
- decks: {
- ":id": {
- $put: (args: unknown) => mockPut(args),
- },
- },
- "note-types": {
- $get: () => mockGetNoteTypes(),
- },
- },
- },
- 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", () => ({
+ localDeckRepository: {
+ update: (...args: unknown[]) => mockUpdate(...args),
},
}));
-import { ApiClientError } from "../api/client";
-// Import after mock is set up
+vi.mock("../atoms", () => ({
+ syncActionAtom: atom(null, () => mockTriggerSync()),
+ noteTypesAtom: atom({ data: [] as { id: string; name: string }[] }),
+}));
+
import { EditDeckModal } from "./EditDeckModal";
describe("EditDeckModal", () => {
@@ -57,25 +37,14 @@ describe("EditDeckModal", () => {
onDeckUpdated: vi.fn(),
};
- const noteTypesResponse = { ok: true, _type: "noteTypes" };
- const putResponse = { ok: true, _type: "put" };
-
beforeEach(() => {
vi.clearAllMocks();
- mockPut.mockResolvedValue(putResponse);
- mockGetNoteTypes.mockResolvedValue(noteTypesResponse);
- mockHandleResponse.mockImplementation((res: unknown) => {
- if (res === noteTypesResponse) {
- return Promise.resolve({ noteTypes: [] });
- }
- return Promise.resolve({
- deck: {
- id: "deck-123",
- name: "Test Deck",
- description: "Test description",
- defaultNoteTypeId: null,
- },
- });
+ mockUpdate.mockResolvedValue({
+ id: "deck-123",
+ userId: "user-1",
+ name: "Test Deck",
+ description: "Test description",
+ defaultNoteTypeId: null,
});
});
@@ -155,30 +124,7 @@ describe("EditDeckModal", () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
- it("calls onClose when clicking outside the modal", async () => {
- const user = userEvent.setup();
- const onClose = vi.fn();
- render(<EditDeckModal {...defaultProps} onClose={onClose} />);
-
- // Click on the backdrop (the dialog element itself)
- 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(<EditDeckModal {...defaultProps} onClose={onClose} />);
-
- // Click on an element inside the modal
- await user.click(screen.getByLabelText("Name"));
-
- expect(onClose).not.toHaveBeenCalled();
- });
-
- it("updates deck with new name", async () => {
+ it("updates deck via local repository with new name", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
const onDeckUpdated = vi.fn();
@@ -198,47 +144,10 @@ describe("EditDeckModal", () => {
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
- expect(mockPut).toHaveBeenCalledWith({
- param: { id: "deck-123" },
- json: {
- name: "Updated Deck",
- description: "Test description",
- defaultNoteTypeId: null,
- },
- });
- });
-
- expect(onDeckUpdated).toHaveBeenCalledTimes(1);
- expect(onClose).toHaveBeenCalledTimes(1);
- });
-
- it("updates deck with new description", async () => {
- const user = userEvent.setup();
- const onClose = vi.fn();
- const onDeckUpdated = vi.fn();
-
- render(
- <EditDeckModal
- isOpen={true}
- deck={mockDeck}
- onClose={onClose}
- onDeckUpdated={onDeckUpdated}
- />,
- );
-
- const descInput = screen.getByLabelText("Description (optional)");
- await user.clear(descInput);
- await user.type(descInput, "New description");
- await user.click(screen.getByRole("button", { name: "Save Changes" }));
-
- await waitFor(() => {
- expect(mockPut).toHaveBeenCalledWith({
- param: { id: "deck-123" },
- json: {
- name: "Test Deck",
- description: "New description",
- defaultNoteTypeId: null,
- },
+ expect(mockUpdate).toHaveBeenCalledWith("deck-123", {
+ name: "Updated Deck",
+ description: "Test description",
+ defaultNoteTypeId: null,
});
});
@@ -256,13 +165,10 @@ describe("EditDeckModal", () => {
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
- expect(mockPut).toHaveBeenCalledWith({
- param: { id: "deck-123" },
- json: {
- name: "Test Deck",
- description: null,
- defaultNoteTypeId: null,
- },
+ expect(mockUpdate).toHaveBeenCalledWith("deck-123", {
+ name: "Test Deck",
+ description: null,
+ defaultNoteTypeId: null,
});
});
});
@@ -280,21 +186,30 @@ describe("EditDeckModal", () => {
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
- expect(mockPut).toHaveBeenCalledWith({
- param: { id: "deck-123" },
- json: {
- name: "Deck",
- description: "Description",
- defaultNoteTypeId: null,
- },
+ expect(mockUpdate).toHaveBeenCalledWith("deck-123", {
+ name: "Deck",
+ description: "Description",
+ defaultNoteTypeId: null,
});
});
});
+ it("triggers a background sync after a successful update", async () => {
+ const user = userEvent.setup();
+
+ render(<EditDeckModal {...defaultProps} />);
+
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
+
+ await waitFor(() => {
+ expect(mockTriggerSync).toHaveBeenCalled();
+ });
+ });
+
it("shows loading state during submission", async () => {
const user = userEvent.setup();
- mockPut.mockImplementation(() => new Promise(() => {})); // Never resolves
+ mockUpdate.mockImplementation(() => new Promise(() => {}));
render(<EditDeckModal {...defaultProps} />);
@@ -316,32 +231,26 @@ describe("EditDeckModal", () => {
);
});
- it("displays API error message", async () => {
+ it("shows an error when the deck no longer exists locally", async () => {
const user = userEvent.setup();
- render(<EditDeckModal {...defaultProps} />);
+ mockUpdate.mockResolvedValue(undefined);
- // Wait for note types to load, then override handleResponse for the PUT
- await waitFor(() => {
- expect(mockGetNoteTypes).toHaveBeenCalled();
- });
- mockHandleResponse.mockRejectedValue(
- new ApiClientError("Deck name already exists", 400),
- );
+ render(<EditDeckModal {...defaultProps} />);
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain(
- "Deck name already exists",
+ "Deck not found.",
);
});
});
- it("displays generic error on unexpected failure", async () => {
+ it("displays a generic error when the local write fails", async () => {
const user = userEvent.setup();
- mockPut.mockRejectedValue(new Error("Network error"));
+ mockUpdate.mockRejectedValue(new Error("disk full"));
render(<EditDeckModal {...defaultProps} />);
@@ -354,28 +263,6 @@ describe("EditDeckModal", () => {
});
});
- it("displays error when handleResponse throws", async () => {
- const user = userEvent.setup();
-
- render(<EditDeckModal {...defaultProps} />);
-
- // Wait for note types to load, then override handleResponse for the PUT
- await waitFor(() => {
- expect(mockGetNoteTypes).toHaveBeenCalled();
- });
- mockHandleResponse.mockRejectedValue(
- new ApiClientError("Not authenticated", 401),
- );
-
- await user.click(screen.getByRole("button", { name: "Save Changes" }));
-
- await waitFor(() => {
- expect(screen.getByRole("alert").textContent).toContain(
- "Not authenticated",
- );
- });
- });
-
it("updates form when deck prop changes", () => {
const { rerender } = render(<EditDeckModal {...defaultProps} />);
@@ -402,27 +289,20 @@ describe("EditDeckModal", () => {
const user = userEvent.setup();
const onClose = vi.fn();
+ mockUpdate.mockRejectedValueOnce(new Error("Some error"));
+
const { rerender } = render(
<EditDeckModal {...defaultProps} onClose={onClose} />,
);
- // Wait for note types to load, then override handleResponse for the PUT
- await waitFor(() => {
- expect(mockGetNoteTypes).toHaveBeenCalled();
- });
- mockHandleResponse.mockRejectedValue(new ApiClientError("Some error", 400));
-
- // 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(<EditDeckModal {...defaultProps} onClose={onClose} />);
- // Error should be cleared
expect(screen.queryByRole("alert")).toBeNull();
});
});
diff --git a/src/client/components/EditDeckModal.tsx b/src/client/components/EditDeckModal.tsx
index e9c2b7b..dc7ec11 100644
--- a/src/client/components/EditDeckModal.tsx
+++ b/src/client/components/EditDeckModal.tsx
@@ -1,7 +1,7 @@
-import { useAtomValue } from "jotai";
-import { type FormEvent, useCallback, useEffect, useState } from "react";
-import { ApiClientError, apiClient } from "../api";
-import { isOnlineAtom } from "../atoms";
+import { useAtomValue, useSetAtom } from "jotai";
+import { type FormEvent, useEffect, useState } from "react";
+import { noteTypesAtom, syncActionAtom } from "../atoms";
+import { localDeckRepository } from "../db/repositories";
interface Deck {
id: string;
@@ -10,11 +10,6 @@ interface Deck {
defaultNoteTypeId: string | null;
}
-interface NoteTypeSummary {
- id: string;
- name: string;
-}
-
interface EditDeckModalProps {
isOpen: boolean;
deck: Deck | null;
@@ -22,54 +17,43 @@ interface EditDeckModalProps {
onDeckUpdated: () => void;
}
-export function EditDeckModal({
- isOpen,
+export function EditDeckModal(props: EditDeckModalProps) {
+ if (!props.isOpen || !props.deck) {
+ return null;
+ }
+ // Render the body only when actually open so the suspense-driven note types
+ // query does not fire on every host render (e.g. HomePage keeps the modal
+ // mounted at all times).
+ return <EditDeckModalContent {...props} deck={props.deck} />;
+}
+
+interface EditDeckModalContentProps extends EditDeckModalProps {
+ deck: Deck;
+}
+
+function EditDeckModalContent({
deck,
onClose,
onDeckUpdated,
-}: EditDeckModalProps) {
- const [name, setName] = useState("");
- const [description, setDescription] = useState("");
+}: EditDeckModalContentProps) {
+ const [name, setName] = useState(deck.name);
+ const [description, setDescription] = useState(deck.description ?? "");
const [defaultNoteTypeId, setDefaultNoteTypeId] = useState<string | null>(
- null,
+ deck.defaultNoteTypeId,
);
- const [noteTypes, setNoteTypes] = useState<NoteTypeSummary[]>([]);
- const [isLoadingNoteTypes, setIsLoadingNoteTypes] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
- const isOnline = useAtomValue(isOnlineAtom);
+ const noteTypesQuery = useAtomValue(noteTypesAtom);
+ const noteTypes = noteTypesQuery.data ?? [];
+ const triggerSync = useSetAtom(syncActionAtom);
- const fetchNoteTypes = useCallback(async () => {
- setIsLoadingNoteTypes(true);
- try {
- const res = await apiClient.rpc.api["note-types"].$get();
- const data = await apiClient.handleResponse<{
- noteTypes: NoteTypeSummary[];
- }>(res);
- setNoteTypes(data.noteTypes);
- } catch {
- // Non-critical: note type list is optional
- } finally {
- setIsLoadingNoteTypes(false);
- }
- }, []);
-
- // Sync form state when deck changes
useEffect(() => {
- if (deck) {
- setName(deck.name);
- setDescription(deck.description ?? "");
- setDefaultNoteTypeId(deck.defaultNoteTypeId);
- setError(null);
- }
+ setName(deck.name);
+ setDescription(deck.description ?? "");
+ setDefaultNoteTypeId(deck.defaultNoteTypeId);
+ setError(null);
}, [deck]);
- useEffect(() => {
- if (isOpen) {
- fetchNoteTypes();
- }
- }, [isOpen, fetchNoteTypes]);
-
const handleClose = () => {
setError(null);
onClose();
@@ -77,39 +61,31 @@ export function EditDeckModal({
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
- if (!deck) return;
setError(null);
setIsSubmitting(true);
try {
- const res = await apiClient.rpc.api.decks[":id"].$put({
- param: { id: deck.id },
- json: {
- name: name.trim(),
- description: description.trim() || null,
- defaultNoteTypeId: defaultNoteTypeId || null,
- },
+ const updated = await localDeckRepository.update(deck.id, {
+ name: name.trim(),
+ description: description.trim() || null,
+ defaultNoteTypeId: defaultNoteTypeId || null,
});
- await apiClient.handleResponse(res);
+ if (!updated) {
+ setError("Deck not found.");
+ return;
+ }
onDeckUpdated();
onClose();
- } catch (err) {
- if (err instanceof ApiClientError) {
- setError(err.message);
- } else {
- setError("Failed to update deck. Please try again.");
- }
+ void triggerSync().catch(() => {});
+ } catch {
+ setError("Failed to update deck. Please try again.");
} finally {
setIsSubmitting(false);
}
};
- if (!isOpen || !deck) {
- return null;
- }
-
return (
<div
role="dialog"
@@ -196,7 +172,7 @@ export function EditDeckModal({
id="edit-deck-default-note-type"
value={defaultNoteTypeId ?? ""}
onChange={(e) => setDefaultNoteTypeId(e.target.value || null)}
- disabled={isSubmitting || isLoadingNoteTypes}
+ disabled={isSubmitting}
className="w-full px-4 py-2.5 bg-ivory border border-border rounded-lg text-slate transition-all duration-200 hover:border-muted focus:border-primary focus:ring-2 focus:ring-primary/10 disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="">None</option>
@@ -219,8 +195,7 @@ export function EditDeckModal({
</button>
<button
type="submit"
- disabled={isSubmitting || !name.trim() || !isOnline}
- title={!isOnline ? "Reconnect to save changes" : 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 ? "Saving..." : "Save Changes"}