aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/components
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-01-01 23:44:50 +0900
committernsfisis <nsfisis@gmail.com>2026-01-01 23:47:21 +0900
commit2fb6471a685bec1433be3335f377a1a2313e4820 (patch)
tree328ddaeec0c591b06bf005d48b0242345c1336be /src/client/components
parentf30566e1c7126db4c6242ab38d07a9478f79d3db (diff)
downloadkioku-2fb6471a685bec1433be3335f377a1a2313e4820.tar.gz
kioku-2fb6471a685bec1433be3335f377a1a2313e4820.tar.zst
kioku-2fb6471a685bec1433be3335f377a1a2313e4820.zip
refactor(client): migrate API calls to typed RPC client
Replace raw fetch() calls with apiClient.rpc typed client across all modal and page components. This provides better type safety and eliminates manual auth header handling. - Make handleResponse public for component usage - Update all component tests to mock RPC methods instead of fetch - Change POSTGRES_HOST default to kioku-db for Docker compatibility 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'src/client/components')
-rw-r--r--src/client/components/CreateNoteModal.test.tsx348
-rw-r--r--src/client/components/CreateNoteModal.tsx76
-rw-r--r--src/client/components/CreateNoteTypeModal.test.tsx96
-rw-r--r--src/client/components/CreateNoteTypeModal.tsx26
-rw-r--r--src/client/components/DeleteCardModal.test.tsx70
-rw-r--r--src/client/components/DeleteCardModal.tsx22
-rw-r--r--src/client/components/DeleteDeckModal.test.tsx61
-rw-r--r--src/client/components/DeleteDeckModal.tsx20
-rw-r--r--src/client/components/DeleteNoteModal.test.tsx73
-rw-r--r--src/client/components/DeleteNoteModal.tsx22
-rw-r--r--src/client/components/DeleteNoteTypeModal.test.tsx71
-rw-r--r--src/client/components/DeleteNoteTypeModal.tsx20
-rw-r--r--src/client/components/EditCardModal.test.tsx141
-rw-r--r--src/client/components/EditCardModal.tsx29
-rw-r--r--src/client/components/EditDeckModal.test.tsx143
-rw-r--r--src/client/components/EditDeckModal.tsx27
-rw-r--r--src/client/components/EditNoteModal.test.tsx329
-rw-r--r--src/client/components/EditNoteModal.tsx75
-rw-r--r--src/client/components/EditNoteTypeModal.test.tsx87
-rw-r--r--src/client/components/EditNoteTypeModal.tsx27
-rw-r--r--src/client/components/ImportNotesModal.tsx60
-rw-r--r--src/client/components/NoteTypeEditor.test.tsx417
-rw-r--r--src/client/components/NoteTypeEditor.tsx178
23 files changed, 793 insertions, 1625 deletions
diff --git a/src/client/components/CreateNoteModal.test.tsx b/src/client/components/CreateNoteModal.test.tsx
index 5e6932b..dcb3360 100644
--- a/src/client/components/CreateNoteModal.test.tsx
+++ b/src/client/components/CreateNoteModal.test.tsx
@@ -4,11 +4,32 @@
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";
-import { apiClient } from "../api/client";
+
+const mockNoteTypesGet = vi.fn();
+const mockNoteTypeGet = vi.fn();
+const mockNotesPost = vi.fn();
+const mockHandleResponse = vi.fn();
vi.mock("../api/client", () => ({
apiClient: {
- getAuthHeader: vi.fn(),
+ rpc: {
+ api: {
+ "note-types": {
+ $get: () => mockNoteTypesGet(),
+ ":id": {
+ $get: (args: unknown) => mockNoteTypeGet(args),
+ },
+ },
+ decks: {
+ ":deckId": {
+ notes: {
+ $post: (args: unknown) => mockNotesPost(args),
+ },
+ },
+ },
+ },
+ },
+ handleResponse: (res: unknown) => mockHandleResponse(res),
},
ApiClientError: class ApiClientError extends Error {
constructor(
@@ -22,13 +43,10 @@ vi.mock("../api/client", () => ({
},
}));
+import { ApiClientError } from "../api/client";
// Import after mock is set up
import { CreateNoteModal } from "./CreateNoteModal";
-// Mock fetch globally
-const mockFetch = vi.fn();
-global.fetch = mockFetch;
-
describe("CreateNoteModal", () => {
const defaultProps = {
isOpen: true,
@@ -56,9 +74,9 @@ describe("CreateNoteModal", () => {
beforeEach(() => {
vi.clearAllMocks();
- vi.mocked(apiClient.getAuthHeader).mockReturnValue({
- Authorization: "Bearer access-token",
- });
+ mockNoteTypesGet.mockResolvedValue({ ok: true });
+ mockNoteTypeGet.mockResolvedValue({ ok: true });
+ mockNotesPost.mockResolvedValue({ ok: true });
});
afterEach(() => {
@@ -73,15 +91,9 @@ describe("CreateNoteModal", () => {
});
it("renders modal when open", async () => {
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteTypes: mockNoteTypes }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ noteTypes: mockNoteTypes })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields });
render(<CreateNoteModal {...defaultProps} />);
@@ -97,35 +109,21 @@ describe("CreateNoteModal", () => {
});
it("loads note types on open", async () => {
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteTypes: mockNoteTypes }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ noteTypes: mockNoteTypes })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields });
render(<CreateNoteModal {...defaultProps} />);
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith("/api/note-types", {
- headers: { Authorization: "Bearer access-token" },
- });
+ expect(mockNoteTypesGet).toHaveBeenCalled();
});
});
it("auto-selects first note type and loads its fields", async () => {
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteTypes: mockNoteTypes }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ noteTypes: mockNoteTypes })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields });
render(<CreateNoteModal {...defaultProps} />);
@@ -136,21 +134,15 @@ describe("CreateNoteModal", () => {
});
// Verify the note type details were fetched
- expect(mockFetch).toHaveBeenCalledWith("/api/note-types/note-type-1", {
- headers: { Authorization: "Bearer access-token" },
+ expect(mockNoteTypeGet).toHaveBeenCalledWith({
+ param: { id: "note-type-1" },
});
});
it("displays note type options in select", async () => {
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteTypes: mockNoteTypes }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ noteTypes: mockNoteTypes })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields });
render(<CreateNoteModal {...defaultProps} />);
@@ -166,10 +158,7 @@ describe("CreateNoteModal", () => {
});
it("shows message when no note types available", async () => {
- mockFetch.mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteTypes: [] }),
- });
+ mockHandleResponse.mockResolvedValueOnce({ noteTypes: [] });
render(<CreateNoteModal {...defaultProps} />);
@@ -188,15 +177,9 @@ describe("CreateNoteModal", () => {
fields: [],
};
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteTypes: mockNoteTypes }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: noteTypeWithNoFields }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ noteTypes: mockNoteTypes })
+ .mockResolvedValueOnce({ noteType: noteTypeWithNoFields });
render(<CreateNoteModal {...defaultProps} />);
@@ -210,15 +193,9 @@ describe("CreateNoteModal", () => {
});
it("disables create button when fields are empty", async () => {
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteTypes: mockNoteTypes }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ noteTypes: mockNoteTypes })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields });
render(<CreateNoteModal {...defaultProps} />);
@@ -233,15 +210,9 @@ describe("CreateNoteModal", () => {
it("enables create button when all fields have values", async () => {
const user = userEvent.setup();
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteTypes: mockNoteTypes }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ noteTypes: mockNoteTypes })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields });
render(<CreateNoteModal {...defaultProps} />);
@@ -260,15 +231,9 @@ describe("CreateNoteModal", () => {
const user = userEvent.setup();
const onClose = vi.fn();
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteTypes: mockNoteTypes }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ noteTypes: mockNoteTypes })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields });
render(<CreateNoteModal {...defaultProps} onClose={onClose} />);
@@ -285,15 +250,9 @@ describe("CreateNoteModal", () => {
const user = userEvent.setup();
const onClose = vi.fn();
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteTypes: mockNoteTypes }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ noteTypes: mockNoteTypes })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields });
render(<CreateNoteModal {...defaultProps} onClose={onClose} />);
@@ -313,22 +272,13 @@ describe("CreateNoteModal", () => {
const onClose = vi.fn();
const onNoteCreated = vi.fn();
- mockFetch
+ mockHandleResponse
+ .mockResolvedValueOnce({ noteTypes: mockNoteTypes })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields })
.mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteTypes: mockNoteTypes }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({
- note: { id: "note-1" },
- fieldValues: [],
- cards: [{ id: "card-1", isReversed: false }],
- }),
+ note: { id: "note-1" },
+ fieldValues: [],
+ cards: [{ id: "card-1", isReversed: false }],
});
render(
@@ -349,19 +299,15 @@ describe("CreateNoteModal", () => {
await user.click(screen.getByRole("button", { name: "Create Note" }));
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123/notes", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Authorization: "Bearer access-token",
- },
- body: JSON.stringify({
+ expect(mockNotesPost).toHaveBeenCalledWith({
+ param: { deckId: "deck-123" },
+ json: {
noteTypeId: "note-type-1",
fields: {
"field-1": "What is 2+2?",
"field-2": "4",
},
- }),
+ },
});
});
@@ -372,22 +318,13 @@ describe("CreateNoteModal", () => {
it("trims whitespace from field values", async () => {
const user = userEvent.setup();
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteTypes: mockNoteTypes }),
- })
+ mockHandleResponse
+ .mockResolvedValueOnce({ noteTypes: mockNoteTypes })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields })
.mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({
- note: { id: "note-1" },
- fieldValues: [],
- cards: [],
- }),
+ note: { id: "note-1" },
+ fieldValues: [],
+ cards: [],
});
render(<CreateNoteModal {...defaultProps} />);
@@ -401,19 +338,15 @@ describe("CreateNoteModal", () => {
await user.click(screen.getByRole("button", { name: "Create Note" }));
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123/notes", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Authorization: "Bearer access-token",
- },
- body: JSON.stringify({
+ expect(mockNotesPost).toHaveBeenCalledWith({
+ param: { deckId: "deck-123" },
+ json: {
noteTypeId: "note-type-1",
fields: {
"field-1": "Question",
"field-2": "Answer",
},
- }),
+ },
});
});
});
@@ -421,16 +354,11 @@ describe("CreateNoteModal", () => {
it("shows loading state during submission", async () => {
const user = userEvent.setup();
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteTypes: mockNoteTypes }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- })
- .mockImplementationOnce(() => new Promise(() => {})); // Never resolves
+ mockHandleResponse
+ .mockResolvedValueOnce({ noteTypes: mockNoteTypes })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields });
+
+ mockNotesPost.mockImplementationOnce(() => new Promise(() => {})); // Never resolves
render(<CreateNoteModal {...defaultProps} />);
@@ -452,20 +380,10 @@ describe("CreateNoteModal", () => {
it("displays API error message", async () => {
const user = userEvent.setup();
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteTypes: mockNoteTypes }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- })
- .mockResolvedValueOnce({
- ok: false,
- status: 400,
- json: async () => ({ error: "Note type not found" }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ noteTypes: mockNoteTypes })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields })
+ .mockRejectedValueOnce(new ApiClientError("Note type not found", 400));
render(<CreateNoteModal {...defaultProps} />);
@@ -487,16 +405,11 @@ describe("CreateNoteModal", () => {
it("displays generic error on unexpected failure", async () => {
const user = userEvent.setup();
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteTypes: mockNoteTypes }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- })
- .mockRejectedValueOnce(new Error("Network error"));
+ mockHandleResponse
+ .mockResolvedValueOnce({ noteTypes: mockNoteTypes })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields });
+
+ mockNotesPost.mockRejectedValueOnce(new Error("Network error"));
render(<CreateNoteModal {...defaultProps} />);
@@ -530,19 +443,10 @@ describe("CreateNoteModal", () => {
],
};
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteTypes: mockNoteTypes }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: reversedNoteType }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ noteTypes: mockNoteTypes })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields })
+ .mockResolvedValueOnce({ noteType: reversedNoteType });
render(<CreateNoteModal {...defaultProps} />);
@@ -561,8 +465,8 @@ describe("CreateNoteModal", () => {
});
// Verify the note type details were fetched for the new type
- expect(mockFetch).toHaveBeenCalledWith("/api/note-types/note-type-2", {
- headers: { Authorization: "Bearer access-token" },
+ expect(mockNoteTypeGet).toHaveBeenCalledWith({
+ param: { id: "note-type-2" },
});
});
@@ -572,15 +476,9 @@ describe("CreateNoteModal", () => {
isReversible: true,
};
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteTypes: mockNoteTypes }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: reversedNoteType }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ noteTypes: mockNoteTypes })
+ .mockResolvedValueOnce({ noteType: reversedNoteType });
render(<CreateNoteModal {...defaultProps} />);
@@ -591,15 +489,9 @@ describe("CreateNoteModal", () => {
});
it("shows card count preview for non-reversible note type", async () => {
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteTypes: mockNoteTypes }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ noteTypes: mockNoteTypes })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields });
render(<CreateNoteModal {...defaultProps} />);
@@ -609,11 +501,9 @@ describe("CreateNoteModal", () => {
});
it("displays error when note types fail to load", async () => {
- mockFetch.mockResolvedValueOnce({
- ok: false,
- status: 500,
- json: async () => ({ error: "Server error" }),
- });
+ mockHandleResponse.mockRejectedValueOnce(
+ new ApiClientError("Server error", 500),
+ );
render(<CreateNoteModal {...defaultProps} />);
@@ -626,15 +516,9 @@ describe("CreateNoteModal", () => {
const user = userEvent.setup();
const onClose = vi.fn();
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteTypes: mockNoteTypes }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ noteTypes: mockNoteTypes })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields });
const { rerender } = render(
<CreateNoteModal
@@ -656,16 +540,8 @@ describe("CreateNoteModal", () => {
// Click cancel to close
await user.click(screen.getByRole("button", { name: "Cancel" }));
- // Setup mocks for reopening
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteTypes: mockNoteTypes }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- });
+ // Note: The component already has note types loaded (hasLoadedNoteTypes = true)
+ // so it won't fetch again
// Reopen the modal
rerender(
diff --git a/src/client/components/CreateNoteModal.tsx b/src/client/components/CreateNoteModal.tsx
index 86a02a5..912aea8 100644
--- a/src/client/components/CreateNoteModal.tsx
+++ b/src/client/components/CreateNoteModal.tsx
@@ -53,25 +53,10 @@ export function CreateNoteModal({
setError(null);
try {
- const authHeader = apiClient.getAuthHeader();
- if (!authHeader) {
- throw new ApiClientError("Not authenticated", 401);
- }
-
- const res = await fetch(`/api/note-types/${noteTypeId}`, {
- headers: authHeader,
+ const res = await apiClient.rpc.api["note-types"][":id"].$get({
+ param: { id: noteTypeId },
});
-
- 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,
- );
- }
-
- const data = await res.json();
+ const data = await apiClient.handleResponse<{ noteType: NoteType }>(res);
setSelectedNoteType(data.noteType);
// Initialize field values for the new note type
@@ -96,31 +81,17 @@ export function CreateNoteModal({
setError(null);
try {
- const authHeader = apiClient.getAuthHeader();
- if (!authHeader) {
- throw new ApiClientError("Not authenticated", 401);
- }
-
- const res = await fetch("/api/note-types", {
- headers: authHeader,
- });
-
- 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,
- );
- }
-
- const data = await res.json();
+ const res = await apiClient.rpc.api["note-types"].$get();
+ const data = await apiClient.handleResponse<{
+ noteTypes: NoteTypeSummary[];
+ }>(res);
setNoteTypes(data.noteTypes);
setHasLoadedNoteTypes(true);
// Auto-select first note type if available
- if (data.noteTypes.length > 0) {
- await fetchNoteTypeDetails(data.noteTypes[0].id);
+ const firstNoteType = data.noteTypes[0];
+ if (firstNoteType) {
+ await fetchNoteTypeDetails(firstNoteType.id);
}
} catch (err) {
if (err instanceof ApiClientError) {
@@ -183,37 +154,20 @@ export function CreateNoteModal({
setIsSubmitting(true);
try {
- const authHeader = apiClient.getAuthHeader();
- if (!authHeader) {
- throw new ApiClientError("Not authenticated", 401);
- }
-
// Trim all field values
const trimmedFields: Record<string, string> = {};
for (const [fieldId, value] of Object.entries(fieldValues)) {
trimmedFields[fieldId] = value.trim();
}
- const res = await fetch(`/api/decks/${deckId}/notes`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- ...authHeader,
- },
- body: JSON.stringify({
+ const res = await apiClient.rpc.api.decks[":deckId"].notes.$post({
+ param: { deckId },
+ json: {
noteTypeId: selectedNoteType.id,
fields: trimmedFields,
- }),
+ },
});
-
- 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 apiClient.handleResponse(res);
resetForm();
onNoteCreated();
diff --git a/src/client/components/CreateNoteTypeModal.test.tsx b/src/client/components/CreateNoteTypeModal.test.tsx
index 9536f53..59d8312 100644
--- a/src/client/components/CreateNoteTypeModal.test.tsx
+++ b/src/client/components/CreateNoteTypeModal.test.tsx
@@ -4,11 +4,20 @@
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";
-import { apiClient } from "../api/client";
+
+const mockPost = vi.fn();
+const mockHandleResponse = vi.fn();
vi.mock("../api/client", () => ({
apiClient: {
- getAuthHeader: vi.fn(),
+ rpc: {
+ api: {
+ "note-types": {
+ $post: (args: unknown) => mockPost(args),
+ },
+ },
+ },
+ handleResponse: (res: unknown) => mockHandleResponse(res),
},
ApiClientError: class ApiClientError extends Error {
constructor(
@@ -22,13 +31,10 @@ vi.mock("../api/client", () => ({
},
}));
+import { ApiClientError } from "../api/client";
// Import after mock is set up
import { CreateNoteTypeModal } from "./CreateNoteTypeModal";
-// Mock fetch globally
-const mockFetch = vi.fn();
-global.fetch = mockFetch;
-
describe("CreateNoteTypeModal", () => {
const defaultProps = {
isOpen: true,
@@ -38,8 +44,15 @@ describe("CreateNoteTypeModal", () => {
beforeEach(() => {
vi.clearAllMocks();
- vi.mocked(apiClient.getAuthHeader).mockReturnValue({
- Authorization: "Bearer access-token",
+ mockPost.mockResolvedValue({ ok: true });
+ mockHandleResponse.mockResolvedValue({
+ noteType: {
+ id: "note-type-1",
+ name: "Test Note Type",
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ isReversible: false,
+ },
});
});
@@ -126,19 +139,6 @@ describe("CreateNoteTypeModal", () => {
const onClose = vi.fn();
const onNoteTypeCreated = vi.fn();
- mockFetch.mockResolvedValue({
- ok: true,
- json: async () => ({
- noteType: {
- id: "note-type-1",
- name: "Test Note Type",
- frontTemplate: "{{Front}}",
- backTemplate: "{{Back}}",
- isReversible: true,
- },
- }),
- });
-
render(
<CreateNoteTypeModal
isOpen={true}
@@ -153,18 +153,13 @@ describe("CreateNoteTypeModal", () => {
await user.click(screen.getByRole("button", { name: "Create" }));
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith("/api/note-types", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Authorization: "Bearer access-token",
- },
- body: JSON.stringify({
+ expect(mockPost).toHaveBeenCalledWith({
+ json: {
name: "Test Note Type",
frontTemplate: "{{Front}}",
backTemplate: "{{Back}}",
isReversible: true,
- }),
+ },
});
});
@@ -175,30 +170,24 @@ describe("CreateNoteTypeModal", () => {
it("trims whitespace from text fields", async () => {
const user = userEvent.setup();
- mockFetch.mockResolvedValue({
- ok: true,
- json: async () => ({ noteType: { id: "note-type-1" } }),
- });
-
render(<CreateNoteTypeModal {...defaultProps} />);
await user.type(screen.getByLabelText("Name"), " Test Note Type ");
await user.click(screen.getByRole("button", { name: "Create" }));
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith(
- "/api/note-types",
- expect.objectContaining({
- body: expect.stringContaining('"name":"Test Note Type"'),
+ expect(mockPost).toHaveBeenCalledWith({
+ json: expect.objectContaining({
+ name: "Test Note Type",
}),
- );
+ });
});
});
it("shows loading state during submission", async () => {
const user = userEvent.setup();
- mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves
+ mockPost.mockImplementation(() => new Promise(() => {})); // Never resolves
render(<CreateNoteTypeModal {...defaultProps} />);
@@ -220,11 +209,9 @@ describe("CreateNoteTypeModal", () => {
it("displays API error message", async () => {
const user = userEvent.setup();
- mockFetch.mockResolvedValue({
- ok: false,
- status: 400,
- json: async () => ({ error: "Note type name already exists" }),
- });
+ mockHandleResponse.mockRejectedValue(
+ new ApiClientError("Note type name already exists", 400),
+ );
render(<CreateNoteTypeModal {...defaultProps} />);
@@ -241,7 +228,7 @@ describe("CreateNoteTypeModal", () => {
it("displays generic error on unexpected failure", async () => {
const user = userEvent.setup();
- mockFetch.mockRejectedValue(new Error("Network error"));
+ mockPost.mockRejectedValue(new Error("Network error"));
render(<CreateNoteTypeModal {...defaultProps} />);
@@ -255,23 +242,6 @@ describe("CreateNoteTypeModal", () => {
});
});
- it("displays error when not authenticated", async () => {
- const user = userEvent.setup();
-
- vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined);
-
- 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(
- "Not authenticated",
- );
- });
- });
-
it("resets form when closed and reopened", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
diff --git a/src/client/components/CreateNoteTypeModal.tsx b/src/client/components/CreateNoteTypeModal.tsx
index 6e43c96..4c3b232 100644
--- a/src/client/components/CreateNoteTypeModal.tsx
+++ b/src/client/components/CreateNoteTypeModal.tsx
@@ -38,33 +38,15 @@ export function CreateNoteTypeModal({
setIsSubmitting(true);
try {
- const authHeader = apiClient.getAuthHeader();
- if (!authHeader) {
- throw new ApiClientError("Not authenticated", 401);
- }
-
- const res = await fetch("/api/note-types", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- ...authHeader,
- },
- body: JSON.stringify({
+ const res = await apiClient.rpc.api["note-types"].$post({
+ json: {
name: name.trim(),
frontTemplate: frontTemplate.trim(),
backTemplate: backTemplate.trim(),
isReversible,
- }),
+ },
});
-
- 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 apiClient.handleResponse(res);
resetForm();
onNoteTypeCreated();
diff --git a/src/client/components/DeleteCardModal.test.tsx b/src/client/components/DeleteCardModal.test.tsx
index 4178ee8..6536c6f 100644
--- a/src/client/components/DeleteCardModal.test.tsx
+++ b/src/client/components/DeleteCardModal.test.tsx
@@ -4,11 +4,26 @@
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";
-import { apiClient } from "../api/client";
+
+const mockDelete = vi.fn();
+const mockHandleResponse = vi.fn();
vi.mock("../api/client", () => ({
apiClient: {
- getAuthHeader: vi.fn(),
+ rpc: {
+ api: {
+ decks: {
+ ":deckId": {
+ cards: {
+ ":cardId": {
+ $delete: (args: unknown) => mockDelete(args),
+ },
+ },
+ },
+ },
+ },
+ },
+ handleResponse: (res: unknown) => mockHandleResponse(res),
},
ApiClientError: class ApiClientError extends Error {
constructor(
@@ -22,13 +37,10 @@ vi.mock("../api/client", () => ({
},
}));
+import { ApiClientError } from "../api/client";
// Import after mock is set up
import { DeleteCardModal } from "./DeleteCardModal";
-// Mock fetch globally
-const mockFetch = vi.fn();
-global.fetch = mockFetch;
-
describe("DeleteCardModal", () => {
const mockCard = {
id: "card-123",
@@ -45,9 +57,8 @@ describe("DeleteCardModal", () => {
beforeEach(() => {
vi.clearAllMocks();
- vi.mocked(apiClient.getAuthHeader).mockReturnValue({
- Authorization: "Bearer access-token",
- });
+ mockDelete.mockResolvedValue({ ok: true });
+ mockHandleResponse.mockResolvedValue({});
});
afterEach(() => {
@@ -142,11 +153,6 @@ describe("DeleteCardModal", () => {
const onClose = vi.fn();
const onCardDeleted = vi.fn();
- mockFetch.mockResolvedValue({
- ok: true,
- json: async () => ({}),
- });
-
render(
<DeleteCardModal
isOpen={true}
@@ -160,15 +166,9 @@ describe("DeleteCardModal", () => {
await user.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith(
- "/api/decks/deck-456/cards/card-123",
- {
- method: "DELETE",
- headers: {
- Authorization: "Bearer access-token",
- },
- },
- );
+ expect(mockDelete).toHaveBeenCalledWith({
+ param: { deckId: "deck-456", cardId: "card-123" },
+ });
});
expect(onCardDeleted).toHaveBeenCalledTimes(1);
@@ -178,7 +178,7 @@ describe("DeleteCardModal", () => {
it("shows loading state during deletion", async () => {
const user = userEvent.setup();
- mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves
+ mockDelete.mockImplementation(() => new Promise(() => {})); // Never resolves
render(<DeleteCardModal {...defaultProps} />);
@@ -198,11 +198,9 @@ describe("DeleteCardModal", () => {
it("displays API error message", async () => {
const user = userEvent.setup();
- mockFetch.mockResolvedValue({
- ok: false,
- status: 404,
- json: async () => ({ error: "Card not found" }),
- });
+ mockHandleResponse.mockRejectedValue(
+ new ApiClientError("Card not found", 404),
+ );
render(<DeleteCardModal {...defaultProps} />);
@@ -216,7 +214,7 @@ describe("DeleteCardModal", () => {
it("displays generic error on unexpected failure", async () => {
const user = userEvent.setup();
- mockFetch.mockRejectedValue(new Error("Network error"));
+ mockDelete.mockRejectedValue(new Error("Network error"));
render(<DeleteCardModal {...defaultProps} />);
@@ -229,10 +227,12 @@ describe("DeleteCardModal", () => {
});
});
- it("displays error when not authenticated", async () => {
+ it("displays error when handleResponse throws", async () => {
const user = userEvent.setup();
- vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined);
+ mockHandleResponse.mockRejectedValue(
+ new ApiClientError("Not authenticated", 401),
+ );
render(<DeleteCardModal {...defaultProps} />);
@@ -249,11 +249,7 @@ describe("DeleteCardModal", () => {
const user = userEvent.setup();
const onClose = vi.fn();
- mockFetch.mockResolvedValue({
- ok: false,
- status: 404,
- json: async () => ({ error: "Some error" }),
- });
+ mockHandleResponse.mockRejectedValue(new ApiClientError("Some error", 404));
const { rerender } = render(
<DeleteCardModal {...defaultProps} onClose={onClose} />,
diff --git a/src/client/components/DeleteCardModal.tsx b/src/client/components/DeleteCardModal.tsx
index 44a745d..d9cf098 100644
--- a/src/client/components/DeleteCardModal.tsx
+++ b/src/client/components/DeleteCardModal.tsx
@@ -36,24 +36,12 @@ export function DeleteCardModal({
setIsDeleting(true);
try {
- const authHeader = apiClient.getAuthHeader();
- if (!authHeader) {
- throw new ApiClientError("Not authenticated", 401);
- }
-
- const res = await fetch(`/api/decks/${deckId}/cards/${card.id}`, {
- method: "DELETE",
- headers: authHeader,
+ const res = await apiClient.rpc.api.decks[":deckId"].cards[
+ ":cardId"
+ ].$delete({
+ param: { deckId, cardId: card.id },
});
-
- 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 apiClient.handleResponse(res);
onCardDeleted();
onClose();
diff --git a/src/client/components/DeleteDeckModal.test.tsx b/src/client/components/DeleteDeckModal.test.tsx
index ad1463d..4441064 100644
--- a/src/client/components/DeleteDeckModal.test.tsx
+++ b/src/client/components/DeleteDeckModal.test.tsx
@@ -4,11 +4,22 @@
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";
-import { apiClient } from "../api/client";
+
+const mockDelete = vi.fn();
+const mockHandleResponse = vi.fn();
vi.mock("../api/client", () => ({
apiClient: {
- getAuthHeader: vi.fn(),
+ rpc: {
+ api: {
+ decks: {
+ ":id": {
+ $delete: (args: unknown) => mockDelete(args),
+ },
+ },
+ },
+ },
+ handleResponse: (res: unknown) => mockHandleResponse(res),
},
ApiClientError: class ApiClientError extends Error {
constructor(
@@ -22,13 +33,10 @@ vi.mock("../api/client", () => ({
},
}));
+import { ApiClientError } from "../api/client";
// Import after mock is set up
import { DeleteDeckModal } from "./DeleteDeckModal";
-// Mock fetch globally
-const mockFetch = vi.fn();
-global.fetch = mockFetch;
-
describe("DeleteDeckModal", () => {
const mockDeck = {
id: "deck-123",
@@ -44,9 +52,8 @@ describe("DeleteDeckModal", () => {
beforeEach(() => {
vi.clearAllMocks();
- vi.mocked(apiClient.getAuthHeader).mockReturnValue({
- Authorization: "Bearer access-token",
- });
+ mockDelete.mockResolvedValue({ ok: true });
+ mockHandleResponse.mockResolvedValue({});
});
afterEach(() => {
@@ -126,11 +133,6 @@ describe("DeleteDeckModal", () => {
const onClose = vi.fn();
const onDeckDeleted = vi.fn();
- mockFetch.mockResolvedValue({
- ok: true,
- json: async () => ({}),
- });
-
render(
<DeleteDeckModal
isOpen={true}
@@ -143,11 +145,8 @@ describe("DeleteDeckModal", () => {
await user.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123", {
- method: "DELETE",
- headers: {
- Authorization: "Bearer access-token",
- },
+ expect(mockDelete).toHaveBeenCalledWith({
+ param: { id: "deck-123" },
});
});
@@ -158,7 +157,7 @@ describe("DeleteDeckModal", () => {
it("shows loading state during deletion", async () => {
const user = userEvent.setup();
- mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves
+ mockDelete.mockImplementation(() => new Promise(() => {})); // Never resolves
render(<DeleteDeckModal {...defaultProps} />);
@@ -178,11 +177,9 @@ describe("DeleteDeckModal", () => {
it("displays API error message", async () => {
const user = userEvent.setup();
- mockFetch.mockResolvedValue({
- ok: false,
- status: 404,
- json: async () => ({ error: "Deck not found" }),
- });
+ mockHandleResponse.mockRejectedValue(
+ new ApiClientError("Deck not found", 404),
+ );
render(<DeleteDeckModal {...defaultProps} />);
@@ -196,7 +193,7 @@ describe("DeleteDeckModal", () => {
it("displays generic error on unexpected failure", async () => {
const user = userEvent.setup();
- mockFetch.mockRejectedValue(new Error("Network error"));
+ mockDelete.mockRejectedValue(new Error("Network error"));
render(<DeleteDeckModal {...defaultProps} />);
@@ -209,10 +206,12 @@ describe("DeleteDeckModal", () => {
});
});
- it("displays error when not authenticated", async () => {
+ it("displays error when handleResponse throws", async () => {
const user = userEvent.setup();
- vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined);
+ mockHandleResponse.mockRejectedValue(
+ new ApiClientError("Not authenticated", 401),
+ );
render(<DeleteDeckModal {...defaultProps} />);
@@ -229,11 +228,7 @@ describe("DeleteDeckModal", () => {
const user = userEvent.setup();
const onClose = vi.fn();
- mockFetch.mockResolvedValue({
- ok: false,
- status: 404,
- json: async () => ({ error: "Some error" }),
- });
+ mockHandleResponse.mockRejectedValue(new ApiClientError("Some error", 404));
const { rerender } = render(
<DeleteDeckModal {...defaultProps} onClose={onClose} />,
diff --git a/src/client/components/DeleteDeckModal.tsx b/src/client/components/DeleteDeckModal.tsx
index 5a252e6..edc6093 100644
--- a/src/client/components/DeleteDeckModal.tsx
+++ b/src/client/components/DeleteDeckModal.tsx
@@ -34,24 +34,10 @@ export function DeleteDeckModal({
setIsDeleting(true);
try {
- const authHeader = apiClient.getAuthHeader();
- if (!authHeader) {
- throw new ApiClientError("Not authenticated", 401);
- }
-
- const res = await fetch(`/api/decks/${deck.id}`, {
- method: "DELETE",
- headers: authHeader,
+ const res = await apiClient.rpc.api.decks[":id"].$delete({
+ param: { id: deck.id },
});
-
- 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 apiClient.handleResponse(res);
onDeckDeleted();
onClose();
diff --git a/src/client/components/DeleteNoteModal.test.tsx b/src/client/components/DeleteNoteModal.test.tsx
index a17323a..7130176 100644
--- a/src/client/components/DeleteNoteModal.test.tsx
+++ b/src/client/components/DeleteNoteModal.test.tsx
@@ -4,12 +4,26 @@
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";
-import { apiClient } from "../api/client";
-import { DeleteNoteModal } from "./DeleteNoteModal";
+
+const mockDelete = vi.fn();
+const mockHandleResponse = vi.fn();
vi.mock("../api/client", () => ({
apiClient: {
- getAuthHeader: vi.fn(),
+ rpc: {
+ api: {
+ decks: {
+ ":deckId": {
+ notes: {
+ ":noteId": {
+ $delete: (args: unknown) => mockDelete(args),
+ },
+ },
+ },
+ },
+ },
+ },
+ handleResponse: (res: unknown) => mockHandleResponse(res),
},
ApiClientError: class ApiClientError extends Error {
constructor(
@@ -23,8 +37,8 @@ vi.mock("../api/client", () => ({
},
}));
-const mockFetch = vi.fn();
-global.fetch = mockFetch;
+import { ApiClientError } from "../api/client";
+import { DeleteNoteModal } from "./DeleteNoteModal";
describe("DeleteNoteModal", () => {
const defaultProps = {
@@ -37,9 +51,8 @@ describe("DeleteNoteModal", () => {
beforeEach(() => {
vi.clearAllMocks();
- vi.mocked(apiClient.getAuthHeader).mockReturnValue({
- Authorization: "Bearer access-token",
- });
+ mockDelete.mockResolvedValue({ ok: true });
+ mockHandleResponse.mockResolvedValue({});
});
afterEach(() => {
@@ -125,11 +138,6 @@ describe("DeleteNoteModal", () => {
const onClose = vi.fn();
const onNoteDeleted = vi.fn();
- mockFetch.mockResolvedValueOnce({
- ok: true,
- json: async () => ({ success: true }),
- });
-
render(
<DeleteNoteModal
{...defaultProps}
@@ -141,9 +149,8 @@ describe("DeleteNoteModal", () => {
await user.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1/notes/note-1", {
- method: "DELETE",
- headers: { Authorization: "Bearer access-token" },
+ expect(mockDelete).toHaveBeenCalledWith({
+ param: { deckId: "deck-1", noteId: "note-1" },
});
});
@@ -154,11 +161,9 @@ describe("DeleteNoteModal", () => {
it("displays error message when delete fails", async () => {
const user = userEvent.setup();
- mockFetch.mockResolvedValueOnce({
- ok: false,
- status: 500,
- json: async () => ({ error: "Failed to delete note" }),
- });
+ mockHandleResponse.mockRejectedValue(
+ new ApiClientError("Failed to delete note", 500),
+ );
render(<DeleteNoteModal {...defaultProps} />);
@@ -175,12 +180,7 @@ describe("DeleteNoteModal", () => {
const user = userEvent.setup();
// Create a promise that we can control
- let resolveDelete: ((value: unknown) => void) | undefined;
- const deletePromise = new Promise((resolve) => {
- resolveDelete = resolve;
- });
-
- mockFetch.mockReturnValueOnce(deletePromise);
+ mockDelete.mockImplementation(() => new Promise(() => {})); // Never resolves
render(<DeleteNoteModal {...defaultProps} />);
@@ -188,24 +188,13 @@ describe("DeleteNoteModal", () => {
// Should show "Deleting..." while request is in progress
expect(screen.getByText("Deleting...")).toBeDefined();
-
- // Resolve the delete request to cleanup
- resolveDelete?.({
- ok: true,
- json: async () => ({ success: true }),
- });
});
it("disables buttons while deleting", async () => {
const user = userEvent.setup();
// Create a promise that we can control
- let resolveDelete: ((value: unknown) => void) | undefined;
- const deletePromise = new Promise((resolve) => {
- resolveDelete = resolve;
- });
-
- mockFetch.mockReturnValueOnce(deletePromise);
+ mockDelete.mockImplementation(() => new Promise(() => {})); // Never resolves
render(<DeleteNoteModal {...defaultProps} />);
@@ -220,11 +209,5 @@ describe("DeleteNoteModal", () => {
"disabled",
true,
);
-
- // Resolve the delete request to cleanup
- resolveDelete?.({
- ok: true,
- json: async () => ({ success: true }),
- });
});
});
diff --git a/src/client/components/DeleteNoteModal.tsx b/src/client/components/DeleteNoteModal.tsx
index 8eec124..5d81fdc 100644
--- a/src/client/components/DeleteNoteModal.tsx
+++ b/src/client/components/DeleteNoteModal.tsx
@@ -31,24 +31,12 @@ export function DeleteNoteModal({
setIsDeleting(true);
try {
- const authHeader = apiClient.getAuthHeader();
- if (!authHeader) {
- throw new ApiClientError("Not authenticated", 401);
- }
-
- const res = await fetch(`/api/decks/${deckId}/notes/${noteId}`, {
- method: "DELETE",
- headers: authHeader,
+ const res = await apiClient.rpc.api.decks[":deckId"].notes[
+ ":noteId"
+ ].$delete({
+ param: { deckId, noteId },
});
-
- 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 apiClient.handleResponse(res);
onNoteDeleted();
onClose();
diff --git a/src/client/components/DeleteNoteTypeModal.test.tsx b/src/client/components/DeleteNoteTypeModal.test.tsx
index b7159ab..c73fbe0 100644
--- a/src/client/components/DeleteNoteTypeModal.test.tsx
+++ b/src/client/components/DeleteNoteTypeModal.test.tsx
@@ -4,11 +4,22 @@
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";
-import { apiClient } from "../api/client";
+
+const mockDelete = vi.fn();
+const mockHandleResponse = vi.fn();
vi.mock("../api/client", () => ({
apiClient: {
- getAuthHeader: vi.fn(),
+ rpc: {
+ api: {
+ "note-types": {
+ ":id": {
+ $delete: (args: unknown) => mockDelete(args),
+ },
+ },
+ },
+ },
+ handleResponse: (res: unknown) => mockHandleResponse(res),
},
ApiClientError: class ApiClientError extends Error {
constructor(
@@ -22,13 +33,10 @@ vi.mock("../api/client", () => ({
},
}));
+import { ApiClientError } from "../api/client";
// Import after mock is set up
import { DeleteNoteTypeModal } from "./DeleteNoteTypeModal";
-// Mock fetch globally
-const mockFetch = vi.fn();
-global.fetch = mockFetch;
-
describe("DeleteNoteTypeModal", () => {
const mockNoteType = {
id: "note-type-123",
@@ -44,9 +52,8 @@ describe("DeleteNoteTypeModal", () => {
beforeEach(() => {
vi.clearAllMocks();
- vi.mocked(apiClient.getAuthHeader).mockReturnValue({
- Authorization: "Bearer access-token",
- });
+ mockDelete.mockResolvedValue({ ok: true });
+ mockHandleResponse.mockResolvedValue({});
});
afterEach(() => {
@@ -128,11 +135,6 @@ describe("DeleteNoteTypeModal", () => {
const onClose = vi.fn();
const onNoteTypeDeleted = vi.fn();
- mockFetch.mockResolvedValue({
- ok: true,
- json: async () => ({ success: true }),
- });
-
render(
<DeleteNoteTypeModal
isOpen={true}
@@ -145,11 +147,8 @@ describe("DeleteNoteTypeModal", () => {
await user.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith("/api/note-types/note-type-123", {
- method: "DELETE",
- headers: {
- Authorization: "Bearer access-token",
- },
+ expect(mockDelete).toHaveBeenCalledWith({
+ param: { id: "note-type-123" },
});
});
@@ -160,7 +159,7 @@ describe("DeleteNoteTypeModal", () => {
it("shows loading state during deletion", async () => {
const user = userEvent.setup();
- mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves
+ mockDelete.mockImplementation(() => new Promise(() => {})); // Never resolves
render(<DeleteNoteTypeModal {...defaultProps} />);
@@ -180,11 +179,9 @@ describe("DeleteNoteTypeModal", () => {
it("displays API error message", async () => {
const user = userEvent.setup();
- mockFetch.mockResolvedValue({
- ok: false,
- status: 404,
- json: async () => ({ error: "Note type not found" }),
- });
+ mockHandleResponse.mockRejectedValue(
+ new ApiClientError("Note type not found", 404),
+ );
render(<DeleteNoteTypeModal {...defaultProps} />);
@@ -200,13 +197,9 @@ describe("DeleteNoteTypeModal", () => {
it("displays conflict error when notes exist", async () => {
const user = userEvent.setup();
- mockFetch.mockResolvedValue({
- ok: false,
- status: 409,
- json: async () => ({
- error: "Cannot delete note type with existing notes",
- }),
- });
+ mockHandleResponse.mockRejectedValue(
+ new ApiClientError("Cannot delete note type with existing notes", 409),
+ );
render(<DeleteNoteTypeModal {...defaultProps} />);
@@ -222,7 +215,7 @@ describe("DeleteNoteTypeModal", () => {
it("displays generic error on unexpected failure", async () => {
const user = userEvent.setup();
- mockFetch.mockRejectedValue(new Error("Network error"));
+ mockDelete.mockRejectedValue(new Error("Network error"));
render(<DeleteNoteTypeModal {...defaultProps} />);
@@ -235,10 +228,12 @@ describe("DeleteNoteTypeModal", () => {
});
});
- it("displays error when not authenticated", async () => {
+ it("displays error when handleResponse throws", async () => {
const user = userEvent.setup();
- vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined);
+ mockHandleResponse.mockRejectedValue(
+ new ApiClientError("Not authenticated", 401),
+ );
render(<DeleteNoteTypeModal {...defaultProps} />);
@@ -255,11 +250,7 @@ describe("DeleteNoteTypeModal", () => {
const user = userEvent.setup();
const onClose = vi.fn();
- mockFetch.mockResolvedValue({
- ok: false,
- status: 404,
- json: async () => ({ error: "Some error" }),
- });
+ mockHandleResponse.mockRejectedValue(new ApiClientError("Some error", 404));
const { rerender } = render(
<DeleteNoteTypeModal {...defaultProps} onClose={onClose} />,
diff --git a/src/client/components/DeleteNoteTypeModal.tsx b/src/client/components/DeleteNoteTypeModal.tsx
index bd6b4a5..db93482 100644
--- a/src/client/components/DeleteNoteTypeModal.tsx
+++ b/src/client/components/DeleteNoteTypeModal.tsx
@@ -34,24 +34,10 @@ export function DeleteNoteTypeModal({
setIsDeleting(true);
try {
- const authHeader = apiClient.getAuthHeader();
- if (!authHeader) {
- throw new ApiClientError("Not authenticated", 401);
- }
-
- const res = await fetch(`/api/note-types/${noteType.id}`, {
- method: "DELETE",
- headers: authHeader,
+ const res = await apiClient.rpc.api["note-types"][":id"].$delete({
+ param: { id: noteType.id },
});
-
- 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 apiClient.handleResponse(res);
onNoteTypeDeleted();
onClose();
diff --git a/src/client/components/EditCardModal.test.tsx b/src/client/components/EditCardModal.test.tsx
index b07dd4b..3486441 100644
--- a/src/client/components/EditCardModal.test.tsx
+++ b/src/client/components/EditCardModal.test.tsx
@@ -4,11 +4,26 @@
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";
-import { apiClient } from "../api/client";
+
+const mockPut = vi.fn();
+const mockHandleResponse = vi.fn();
vi.mock("../api/client", () => ({
apiClient: {
- getAuthHeader: vi.fn(),
+ rpc: {
+ api: {
+ decks: {
+ ":deckId": {
+ cards: {
+ ":cardId": {
+ $put: (args: unknown) => mockPut(args),
+ },
+ },
+ },
+ },
+ },
+ },
+ handleResponse: (res: unknown) => mockHandleResponse(res),
},
ApiClientError: class ApiClientError extends Error {
constructor(
@@ -22,13 +37,10 @@ vi.mock("../api/client", () => ({
},
}));
+import { ApiClientError } from "../api/client";
// Import after mock is set up
import { EditCardModal } from "./EditCardModal";
-// Mock fetch globally
-const mockFetch = vi.fn();
-global.fetch = mockFetch;
-
describe("EditCardModal", () => {
const mockCard = {
id: "card-123",
@@ -46,8 +58,13 @@ describe("EditCardModal", () => {
beforeEach(() => {
vi.clearAllMocks();
- vi.mocked(apiClient.getAuthHeader).mockReturnValue({
- Authorization: "Bearer access-token",
+ mockPut.mockResolvedValue({ ok: true });
+ mockHandleResponse.mockResolvedValue({
+ card: {
+ id: "card-123",
+ front: "Test front",
+ back: "Test back",
+ },
});
});
@@ -156,17 +173,6 @@ describe("EditCardModal", () => {
const onClose = vi.fn();
const onCardUpdated = vi.fn();
- mockFetch.mockResolvedValue({
- ok: true,
- json: async () => ({
- card: {
- id: "card-123",
- front: "Updated front",
- back: "Test back",
- },
- }),
- });
-
render(
<EditCardModal
isOpen={true}
@@ -183,20 +189,13 @@ describe("EditCardModal", () => {
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith(
- "/api/decks/deck-456/cards/card-123",
- {
- method: "PUT",
- headers: {
- "Content-Type": "application/json",
- Authorization: "Bearer access-token",
- },
- body: JSON.stringify({
- front: "Updated front",
- back: "Test back",
- }),
+ expect(mockPut).toHaveBeenCalledWith({
+ param: { deckId: "deck-456", cardId: "card-123" },
+ json: {
+ front: "Updated front",
+ back: "Test back",
},
- );
+ });
});
expect(onCardUpdated).toHaveBeenCalledTimes(1);
@@ -208,17 +207,6 @@ describe("EditCardModal", () => {
const onClose = vi.fn();
const onCardUpdated = vi.fn();
- mockFetch.mockResolvedValue({
- ok: true,
- json: async () => ({
- card: {
- id: "card-123",
- front: "Test front",
- back: "Updated back",
- },
- }),
- });
-
render(
<EditCardModal
isOpen={true}
@@ -235,20 +223,13 @@ describe("EditCardModal", () => {
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith(
- "/api/decks/deck-456/cards/card-123",
- {
- method: "PUT",
- headers: {
- "Content-Type": "application/json",
- Authorization: "Bearer access-token",
- },
- body: JSON.stringify({
- front: "Test front",
- back: "Updated back",
- }),
+ expect(mockPut).toHaveBeenCalledWith({
+ param: { deckId: "deck-456", cardId: "card-123" },
+ json: {
+ front: "Test front",
+ back: "Updated back",
},
- );
+ });
});
expect(onCardUpdated).toHaveBeenCalledTimes(1);
@@ -258,11 +239,6 @@ describe("EditCardModal", () => {
it("trims whitespace from front and back", async () => {
const user = userEvent.setup();
- mockFetch.mockResolvedValue({
- ok: true,
- json: async () => ({ card: { id: "card-123" } }),
- });
-
const cardWithWhitespace = {
...mockCard,
front: " Front ",
@@ -273,27 +249,20 @@ describe("EditCardModal", () => {
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith(
- "/api/decks/deck-456/cards/card-123",
- {
- method: "PUT",
- headers: {
- "Content-Type": "application/json",
- Authorization: "Bearer access-token",
- },
- body: JSON.stringify({
- front: "Front",
- back: "Back",
- }),
+ expect(mockPut).toHaveBeenCalledWith({
+ param: { deckId: "deck-456", cardId: "card-123" },
+ json: {
+ front: "Front",
+ back: "Back",
},
- );
+ });
});
});
it("shows loading state during submission", async () => {
const user = userEvent.setup();
- mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves
+ mockPut.mockImplementation(() => new Promise(() => {})); // Never resolves
render(<EditCardModal {...defaultProps} />);
@@ -315,11 +284,9 @@ describe("EditCardModal", () => {
it("displays API error message", async () => {
const user = userEvent.setup();
- mockFetch.mockResolvedValue({
- ok: false,
- status: 400,
- json: async () => ({ error: "Card not found" }),
- });
+ mockHandleResponse.mockRejectedValue(
+ new ApiClientError("Card not found", 404),
+ );
render(<EditCardModal {...defaultProps} />);
@@ -333,7 +300,7 @@ describe("EditCardModal", () => {
it("displays generic error on unexpected failure", async () => {
const user = userEvent.setup();
- mockFetch.mockRejectedValue(new Error("Network error"));
+ mockPut.mockRejectedValue(new Error("Network error"));
render(<EditCardModal {...defaultProps} />);
@@ -346,10 +313,12 @@ describe("EditCardModal", () => {
});
});
- it("displays error when not authenticated", async () => {
+ it("displays error when handleResponse throws", async () => {
const user = userEvent.setup();
- vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined);
+ mockHandleResponse.mockRejectedValue(
+ new ApiClientError("Not authenticated", 401),
+ );
render(<EditCardModal {...defaultProps} />);
@@ -385,11 +354,7 @@ describe("EditCardModal", () => {
const user = userEvent.setup();
const onClose = vi.fn();
- mockFetch.mockResolvedValue({
- ok: false,
- status: 400,
- json: async () => ({ error: "Some error" }),
- });
+ mockHandleResponse.mockRejectedValue(new ApiClientError("Some error", 400));
const { rerender } = render(
<EditCardModal {...defaultProps} onClose={onClose} />,
diff --git a/src/client/components/EditCardModal.tsx b/src/client/components/EditCardModal.tsx
index e38a2b1..726a003 100644
--- a/src/client/components/EditCardModal.tsx
+++ b/src/client/components/EditCardModal.tsx
@@ -49,31 +49,16 @@ export function EditCardModal({
setIsSubmitting(true);
try {
- const authHeader = apiClient.getAuthHeader();
- if (!authHeader) {
- throw new ApiClientError("Not authenticated", 401);
- }
-
- const res = await fetch(`/api/decks/${deckId}/cards/${card.id}`, {
- method: "PUT",
- headers: {
- "Content-Type": "application/json",
- ...authHeader,
- },
- body: JSON.stringify({
+ const res = await apiClient.rpc.api.decks[":deckId"].cards[
+ ":cardId"
+ ].$put({
+ param: { deckId, cardId: card.id },
+ json: {
front: front.trim(),
back: back.trim(),
- }),
+ },
});
-
- 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 apiClient.handleResponse(res);
onCardUpdated();
onClose();
diff --git a/src/client/components/EditDeckModal.test.tsx b/src/client/components/EditDeckModal.test.tsx
index c627dd5..fce17f6 100644
--- a/src/client/components/EditDeckModal.test.tsx
+++ b/src/client/components/EditDeckModal.test.tsx
@@ -4,11 +4,22 @@
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";
-import { apiClient } from "../api/client";
+
+const mockPut = vi.fn();
+const mockHandleResponse = vi.fn();
vi.mock("../api/client", () => ({
apiClient: {
- getAuthHeader: vi.fn(),
+ rpc: {
+ api: {
+ decks: {
+ ":id": {
+ $put: (args: unknown) => mockPut(args),
+ },
+ },
+ },
+ },
+ handleResponse: (res: unknown) => mockHandleResponse(res),
},
ApiClientError: class ApiClientError extends Error {
constructor(
@@ -22,13 +33,10 @@ vi.mock("../api/client", () => ({
},
}));
+import { ApiClientError } from "../api/client";
// Import after mock is set up
import { EditDeckModal } from "./EditDeckModal";
-// Mock fetch globally
-const mockFetch = vi.fn();
-global.fetch = mockFetch;
-
describe("EditDeckModal", () => {
const mockDeck = {
id: "deck-123",
@@ -46,8 +54,14 @@ describe("EditDeckModal", () => {
beforeEach(() => {
vi.clearAllMocks();
- vi.mocked(apiClient.getAuthHeader).mockReturnValue({
- Authorization: "Bearer access-token",
+ mockPut.mockResolvedValue({ ok: true });
+ mockHandleResponse.mockResolvedValue({
+ deck: {
+ id: "deck-123",
+ name: "Test Deck",
+ description: "Test description",
+ newCardsPerDay: 20,
+ },
});
});
@@ -155,18 +169,6 @@ describe("EditDeckModal", () => {
const onClose = vi.fn();
const onDeckUpdated = vi.fn();
- mockFetch.mockResolvedValue({
- ok: true,
- json: async () => ({
- deck: {
- id: "deck-123",
- name: "Updated Deck",
- description: "Test description",
- newCardsPerDay: 20,
- },
- }),
- });
-
render(
<EditDeckModal
isOpen={true}
@@ -182,16 +184,12 @@ describe("EditDeckModal", () => {
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123", {
- method: "PUT",
- headers: {
- "Content-Type": "application/json",
- Authorization: "Bearer access-token",
- },
- body: JSON.stringify({
+ expect(mockPut).toHaveBeenCalledWith({
+ param: { id: "deck-123" },
+ json: {
name: "Updated Deck",
description: "Test description",
- }),
+ },
});
});
@@ -204,18 +202,6 @@ describe("EditDeckModal", () => {
const onClose = vi.fn();
const onDeckUpdated = vi.fn();
- mockFetch.mockResolvedValue({
- ok: true,
- json: async () => ({
- deck: {
- id: "deck-123",
- name: "Test Deck",
- description: "New description",
- newCardsPerDay: 20,
- },
- }),
- });
-
render(
<EditDeckModal
isOpen={true}
@@ -231,16 +217,12 @@ describe("EditDeckModal", () => {
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123", {
- method: "PUT",
- headers: {
- "Content-Type": "application/json",
- Authorization: "Bearer access-token",
- },
- body: JSON.stringify({
+ expect(mockPut).toHaveBeenCalledWith({
+ param: { id: "deck-123" },
+ json: {
name: "Test Deck",
description: "New description",
- }),
+ },
});
});
@@ -251,18 +233,6 @@ describe("EditDeckModal", () => {
it("clears description when input is emptied", async () => {
const user = userEvent.setup();
- mockFetch.mockResolvedValue({
- ok: true,
- json: async () => ({
- deck: {
- id: "deck-123",
- name: "Test Deck",
- description: null,
- newCardsPerDay: 20,
- },
- }),
- });
-
render(<EditDeckModal {...defaultProps} />);
const descInput = screen.getByLabelText("Description (optional)");
@@ -270,16 +240,12 @@ describe("EditDeckModal", () => {
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123", {
- method: "PUT",
- headers: {
- "Content-Type": "application/json",
- Authorization: "Bearer access-token",
- },
- body: JSON.stringify({
+ expect(mockPut).toHaveBeenCalledWith({
+ param: { id: "deck-123" },
+ json: {
name: "Test Deck",
description: null,
- }),
+ },
});
});
});
@@ -287,11 +253,6 @@ describe("EditDeckModal", () => {
it("trims whitespace from name and description", async () => {
const user = userEvent.setup();
- mockFetch.mockResolvedValue({
- ok: true,
- json: async () => ({ deck: { id: "deck-123" } }),
- });
-
const deckWithWhitespace = {
...mockDeck,
name: " Deck ",
@@ -302,16 +263,12 @@ describe("EditDeckModal", () => {
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123", {
- method: "PUT",
- headers: {
- "Content-Type": "application/json",
- Authorization: "Bearer access-token",
- },
- body: JSON.stringify({
+ expect(mockPut).toHaveBeenCalledWith({
+ param: { id: "deck-123" },
+ json: {
name: "Deck",
description: "Description",
- }),
+ },
});
});
});
@@ -319,7 +276,7 @@ describe("EditDeckModal", () => {
it("shows loading state during submission", async () => {
const user = userEvent.setup();
- mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves
+ mockPut.mockImplementation(() => new Promise(() => {})); // Never resolves
render(<EditDeckModal {...defaultProps} />);
@@ -344,11 +301,9 @@ describe("EditDeckModal", () => {
it("displays API error message", async () => {
const user = userEvent.setup();
- mockFetch.mockResolvedValue({
- ok: false,
- status: 400,
- json: async () => ({ error: "Deck name already exists" }),
- });
+ mockHandleResponse.mockRejectedValue(
+ new ApiClientError("Deck name already exists", 400),
+ );
render(<EditDeckModal {...defaultProps} />);
@@ -364,7 +319,7 @@ describe("EditDeckModal", () => {
it("displays generic error on unexpected failure", async () => {
const user = userEvent.setup();
- mockFetch.mockRejectedValue(new Error("Network error"));
+ mockPut.mockRejectedValue(new Error("Network error"));
render(<EditDeckModal {...defaultProps} />);
@@ -377,10 +332,12 @@ describe("EditDeckModal", () => {
});
});
- it("displays error when not authenticated", async () => {
+ it("displays error when handleResponse throws", async () => {
const user = userEvent.setup();
- vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined);
+ mockHandleResponse.mockRejectedValue(
+ new ApiClientError("Not authenticated", 401),
+ );
render(<EditDeckModal {...defaultProps} />);
@@ -419,11 +376,7 @@ describe("EditDeckModal", () => {
const user = userEvent.setup();
const onClose = vi.fn();
- mockFetch.mockResolvedValue({
- ok: false,
- status: 400,
- json: async () => ({ error: "Some error" }),
- });
+ mockHandleResponse.mockRejectedValue(new ApiClientError("Some error", 400));
const { rerender } = render(
<EditDeckModal {...defaultProps} onClose={onClose} />,
diff --git a/src/client/components/EditDeckModal.tsx b/src/client/components/EditDeckModal.tsx
index e589900..3babeb5 100644
--- a/src/client/components/EditDeckModal.tsx
+++ b/src/client/components/EditDeckModal.tsx
@@ -48,31 +48,14 @@ export function EditDeckModal({
setIsSubmitting(true);
try {
- const authHeader = apiClient.getAuthHeader();
- if (!authHeader) {
- throw new ApiClientError("Not authenticated", 401);
- }
-
- const res = await fetch(`/api/decks/${deck.id}`, {
- method: "PUT",
- headers: {
- "Content-Type": "application/json",
- ...authHeader,
- },
- body: JSON.stringify({
+ const res = await apiClient.rpc.api.decks[":id"].$put({
+ param: { id: deck.id },
+ json: {
name: name.trim(),
description: description.trim() || null,
- }),
+ },
});
-
- 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 apiClient.handleResponse(res);
onDeckUpdated();
onClose();
diff --git a/src/client/components/EditNoteModal.test.tsx b/src/client/components/EditNoteModal.test.tsx
index 61f94bd..20c437f 100644
--- a/src/client/components/EditNoteModal.test.tsx
+++ b/src/client/components/EditNoteModal.test.tsx
@@ -4,11 +4,34 @@
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";
-import { apiClient } from "../api/client";
+
+const mockNoteGet = vi.fn();
+const mockNotePut = vi.fn();
+const mockNoteTypeGet = vi.fn();
+const mockHandleResponse = vi.fn();
vi.mock("../api/client", () => ({
apiClient: {
- getAuthHeader: vi.fn(),
+ rpc: {
+ api: {
+ decks: {
+ ":deckId": {
+ notes: {
+ ":noteId": {
+ $get: (args: unknown) => mockNoteGet(args),
+ $put: (args: unknown) => mockNotePut(args),
+ },
+ },
+ },
+ },
+ "note-types": {
+ ":id": {
+ $get: (args: unknown) => mockNoteTypeGet(args),
+ },
+ },
+ },
+ },
+ handleResponse: (res: unknown) => mockHandleResponse(res),
},
ApiClientError: class ApiClientError extends Error {
constructor(
@@ -22,13 +45,10 @@ vi.mock("../api/client", () => ({
},
}));
+import { ApiClientError } from "../api/client";
// Import after mock is set up
import { EditNoteModal } from "./EditNoteModal";
-// Mock fetch globally
-const mockFetch = vi.fn();
-global.fetch = mockFetch;
-
describe("EditNoteModal", () => {
const defaultProps = {
isOpen: true,
@@ -72,9 +92,9 @@ describe("EditNoteModal", () => {
beforeEach(() => {
vi.clearAllMocks();
- vi.mocked(apiClient.getAuthHeader).mockReturnValue({
- Authorization: "Bearer access-token",
- });
+ mockNoteGet.mockResolvedValue({ ok: true });
+ mockNotePut.mockResolvedValue({ ok: true });
+ mockNoteTypeGet.mockResolvedValue({ ok: true });
});
afterEach(() => {
@@ -95,15 +115,9 @@ describe("EditNoteModal", () => {
});
it("renders modal when open with noteId", async () => {
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ note: mockNoteWithFieldValues }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ note: mockNoteWithFieldValues })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields });
render(<EditNoteModal {...defaultProps} />);
@@ -117,44 +131,29 @@ describe("EditNoteModal", () => {
});
it("fetches note and note type on open", async () => {
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ note: mockNoteWithFieldValues }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ note: mockNoteWithFieldValues })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields });
render(<EditNoteModal {...defaultProps} />);
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith(
- "/api/decks/deck-123/notes/note-456",
- {
- headers: { Authorization: "Bearer access-token" },
- },
- );
+ expect(mockNoteGet).toHaveBeenCalledWith({
+ param: { deckId: "deck-123", noteId: "note-456" },
+ });
});
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith("/api/note-types/note-type-1", {
- headers: { Authorization: "Bearer access-token" },
+ expect(mockNoteTypeGet).toHaveBeenCalledWith({
+ param: { id: "note-type-1" },
});
});
});
it("populates form with note field values", async () => {
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ note: mockNoteWithFieldValues }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ note: mockNoteWithFieldValues })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields });
render(<EditNoteModal {...defaultProps} />);
@@ -171,15 +170,9 @@ describe("EditNoteModal", () => {
});
it("displays note type name (read-only)", async () => {
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ note: mockNoteWithFieldValues }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ note: mockNoteWithFieldValues })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields });
render(<EditNoteModal {...defaultProps} />);
@@ -191,15 +184,9 @@ describe("EditNoteModal", () => {
it("disables save button when fields are empty", async () => {
const user = userEvent.setup();
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ note: mockNoteWithFieldValues }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ note: mockNoteWithFieldValues })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields });
render(<EditNoteModal {...defaultProps} />);
@@ -216,15 +203,9 @@ describe("EditNoteModal", () => {
});
it("enables save button when all fields have values", async () => {
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ note: mockNoteWithFieldValues }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ note: mockNoteWithFieldValues })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields });
render(<EditNoteModal {...defaultProps} />);
@@ -240,15 +221,9 @@ describe("EditNoteModal", () => {
const user = userEvent.setup();
const onClose = vi.fn();
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ note: mockNoteWithFieldValues }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ note: mockNoteWithFieldValues })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields });
render(<EditNoteModal {...defaultProps} onClose={onClose} />);
@@ -265,15 +240,9 @@ describe("EditNoteModal", () => {
const user = userEvent.setup();
const onClose = vi.fn();
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ note: mockNoteWithFieldValues }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ note: mockNoteWithFieldValues })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields });
render(<EditNoteModal {...defaultProps} onClose={onClose} />);
@@ -293,19 +262,10 @@ describe("EditNoteModal", () => {
const onClose = vi.fn();
const onNoteUpdated = vi.fn();
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ note: mockNoteWithFieldValues }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ note: mockNoteWithFieldValues }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ note: mockNoteWithFieldValues })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields })
+ .mockResolvedValueOnce({ note: mockNoteWithFieldValues });
render(
<EditNoteModal
@@ -327,22 +287,15 @@ describe("EditNoteModal", () => {
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith(
- "/api/decks/deck-123/notes/note-456",
- {
- method: "PUT",
- headers: {
- "Content-Type": "application/json",
- Authorization: "Bearer access-token",
+ expect(mockNotePut).toHaveBeenCalledWith({
+ param: { deckId: "deck-123", noteId: "note-456" },
+ json: {
+ fields: {
+ "field-1": "Updated front",
+ "field-2": "Existing back",
},
- body: JSON.stringify({
- fields: {
- "field-1": "Updated front",
- "field-2": "Existing back",
- },
- }),
},
- );
+ });
});
expect(onNoteUpdated).toHaveBeenCalledTimes(1);
@@ -370,19 +323,10 @@ describe("EditNoteModal", () => {
],
};
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ note: noteWithWhitespace }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ note: noteWithWhitespace }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ note: noteWithWhitespace })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields })
+ .mockResolvedValueOnce({ note: noteWithWhitespace });
render(<EditNoteModal {...defaultProps} />);
@@ -393,27 +337,20 @@ describe("EditNoteModal", () => {
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith(
- "/api/decks/deck-123/notes/note-456",
- {
- method: "PUT",
- headers: {
- "Content-Type": "application/json",
- Authorization: "Bearer access-token",
+ expect(mockNotePut).toHaveBeenCalledWith({
+ param: { deckId: "deck-123", noteId: "note-456" },
+ json: {
+ fields: {
+ "field-1": "Trimmed",
+ "field-2": "Value",
},
- body: JSON.stringify({
- fields: {
- "field-1": "Trimmed",
- "field-2": "Value",
- },
- }),
},
- );
+ });
});
});
it("shows loading state during fetch", async () => {
- mockFetch.mockImplementationOnce(() => new Promise(() => {})); // Never resolves
+ mockNoteGet.mockImplementationOnce(() => new Promise(() => {})); // Never resolves
render(<EditNoteModal {...defaultProps} />);
@@ -423,16 +360,11 @@ describe("EditNoteModal", () => {
it("shows loading state during submission", async () => {
const user = userEvent.setup();
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ note: mockNoteWithFieldValues }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- })
- .mockImplementationOnce(() => new Promise(() => {})); // Never resolves
+ mockHandleResponse
+ .mockResolvedValueOnce({ note: mockNoteWithFieldValues })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields });
+
+ mockNotePut.mockImplementationOnce(() => new Promise(() => {})); // Never resolves
render(<EditNoteModal {...defaultProps} />);
@@ -450,11 +382,9 @@ describe("EditNoteModal", () => {
});
it("displays API error message when note fetch fails", async () => {
- mockFetch.mockResolvedValueOnce({
- ok: false,
- status: 404,
- json: async () => ({ error: "Note not found" }),
- });
+ mockHandleResponse.mockRejectedValueOnce(
+ new ApiClientError("Note not found", 404),
+ );
render(<EditNoteModal {...defaultProps} />);
@@ -464,16 +394,9 @@ describe("EditNoteModal", () => {
});
it("displays API error message when note type fetch fails", async () => {
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ note: mockNoteWithFieldValues }),
- })
- .mockResolvedValueOnce({
- ok: false,
- status: 404,
- json: async () => ({ error: "Note type not found" }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ note: mockNoteWithFieldValues })
+ .mockRejectedValueOnce(new ApiClientError("Note type not found", 404));
render(<EditNoteModal {...defaultProps} />);
@@ -487,20 +410,10 @@ describe("EditNoteModal", () => {
it("displays API error message when update fails", async () => {
const user = userEvent.setup();
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ note: mockNoteWithFieldValues }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- })
- .mockResolvedValueOnce({
- ok: false,
- status: 400,
- json: async () => ({ error: "Failed to update note" }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ note: mockNoteWithFieldValues })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields })
+ .mockRejectedValueOnce(new ApiClientError("Failed to update note", 400));
render(<EditNoteModal {...defaultProps} />);
@@ -518,7 +431,7 @@ describe("EditNoteModal", () => {
});
it("displays generic error on unexpected failure", async () => {
- mockFetch.mockRejectedValueOnce(new Error("Network error"));
+ mockNoteGet.mockRejectedValueOnce(new Error("Network error"));
render(<EditNoteModal {...defaultProps} />);
@@ -529,30 +442,12 @@ describe("EditNoteModal", () => {
});
});
- it("displays error when not authenticated", async () => {
- vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined);
-
- render(<EditNoteModal {...defaultProps} />);
-
- await waitFor(() => {
- expect(screen.getByRole("alert").textContent).toContain(
- "Not authenticated",
- );
- });
- });
-
it("resets form when modal is closed and reopened with different noteId", async () => {
const onClose = vi.fn();
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ note: mockNoteWithFieldValues }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ note: mockNoteWithFieldValues })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields });
const { rerender } = render(
<EditNoteModal
@@ -603,15 +498,9 @@ describe("EditNoteModal", () => {
],
};
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ note: newNoteWithFieldValues }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ note: newNoteWithFieldValues })
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields });
// Reopen with different noteId
rerender(
@@ -639,15 +528,9 @@ describe("EditNoteModal", () => {
isReversible: true,
};
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ note: mockNoteWithFieldValues }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: reversibleNoteType }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ note: mockNoteWithFieldValues })
+ .mockResolvedValueOnce({ noteType: reversibleNoteType });
render(<EditNoteModal {...defaultProps} />);
diff --git a/src/client/components/EditNoteModal.tsx b/src/client/components/EditNoteModal.tsx
index 5bd864d..ac22332 100644
--- a/src/client/components/EditNoteModal.tsx
+++ b/src/client/components/EditNoteModal.tsx
@@ -60,25 +60,10 @@ export function EditNoteModal({
setError(null);
try {
- const authHeader = apiClient.getAuthHeader();
- if (!authHeader) {
- throw new ApiClientError("Not authenticated", 401);
- }
-
- const res = await fetch(`/api/note-types/${noteTypeId}`, {
- headers: authHeader,
+ const res = await apiClient.rpc.api["note-types"][":id"].$get({
+ param: { id: noteTypeId },
});
-
- 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,
- );
- }
-
- const data = await res.json();
+ const data = await apiClient.handleResponse<{ noteType: NoteType }>(res);
setNoteType(data.noteType);
} catch (err) {
if (err instanceof ApiClientError) {
@@ -98,25 +83,14 @@ export function EditNoteModal({
setError(null);
try {
- const authHeader = apiClient.getAuthHeader();
- if (!authHeader) {
- throw new ApiClientError("Not authenticated", 401);
- }
-
- const res = await fetch(`/api/decks/${deckId}/notes/${noteId}`, {
- headers: authHeader,
+ const res = await apiClient.rpc.api.decks[":deckId"].notes[
+ ":noteId"
+ ].$get({
+ param: { deckId, noteId },
});
-
- 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,
- );
- }
-
- const data = await res.json();
+ const data = await apiClient.handleResponse<{
+ note: NoteWithFieldValues;
+ }>(res);
setNote(data.note);
// Initialize field values from note
@@ -176,36 +150,21 @@ export function EditNoteModal({
setIsSubmitting(true);
try {
- const authHeader = apiClient.getAuthHeader();
- if (!authHeader) {
- throw new ApiClientError("Not authenticated", 401);
- }
-
// Trim all field values
const trimmedFields: Record<string, string> = {};
for (const [fieldId, value] of Object.entries(fieldValues)) {
trimmedFields[fieldId] = value.trim();
}
- const res = await fetch(`/api/decks/${deckId}/notes/${note.id}`, {
- method: "PUT",
- headers: {
- "Content-Type": "application/json",
- ...authHeader,
- },
- body: JSON.stringify({
+ const res = await apiClient.rpc.api.decks[":deckId"].notes[
+ ":noteId"
+ ].$put({
+ param: { deckId, noteId: note.id },
+ json: {
fields: trimmedFields,
- }),
+ },
});
-
- 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 apiClient.handleResponse(res);
onNoteUpdated();
handleClose();
diff --git a/src/client/components/EditNoteTypeModal.test.tsx b/src/client/components/EditNoteTypeModal.test.tsx
index 61130e2..cc23d8f 100644
--- a/src/client/components/EditNoteTypeModal.test.tsx
+++ b/src/client/components/EditNoteTypeModal.test.tsx
@@ -4,11 +4,22 @@
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";
-import { apiClient } from "../api/client";
+
+const mockPut = vi.fn();
+const mockHandleResponse = vi.fn();
vi.mock("../api/client", () => ({
apiClient: {
- getAuthHeader: vi.fn(),
+ rpc: {
+ api: {
+ "note-types": {
+ ":id": {
+ $put: (args: unknown) => mockPut(args),
+ },
+ },
+ },
+ },
+ handleResponse: (res: unknown) => mockHandleResponse(res),
},
ApiClientError: class ApiClientError extends Error {
constructor(
@@ -22,13 +33,10 @@ vi.mock("../api/client", () => ({
},
}));
+import { ApiClientError } from "../api/client";
// Import after mock is set up
import { EditNoteTypeModal } from "./EditNoteTypeModal";
-// Mock fetch globally
-const mockFetch = vi.fn();
-global.fetch = mockFetch;
-
describe("EditNoteTypeModal", () => {
const mockNoteType = {
id: "note-type-123",
@@ -47,9 +55,8 @@ describe("EditNoteTypeModal", () => {
beforeEach(() => {
vi.clearAllMocks();
- vi.mocked(apiClient.getAuthHeader).mockReturnValue({
- Authorization: "Bearer access-token",
- });
+ mockPut.mockResolvedValue({ ok: true });
+ mockHandleResponse.mockResolvedValue({ noteType: mockNoteType });
});
afterEach(() => {
@@ -155,17 +162,6 @@ describe("EditNoteTypeModal", () => {
const onClose = vi.fn();
const onNoteTypeUpdated = vi.fn();
- mockFetch.mockResolvedValue({
- ok: true,
- json: async () => ({
- noteType: {
- ...mockNoteType,
- name: "Updated Basic",
- isReversible: true,
- },
- }),
- });
-
render(
<EditNoteTypeModal
isOpen={true}
@@ -185,18 +181,14 @@ describe("EditNoteTypeModal", () => {
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith("/api/note-types/note-type-123", {
- method: "PUT",
- headers: {
- "Content-Type": "application/json",
- Authorization: "Bearer access-token",
- },
- body: JSON.stringify({
+ expect(mockPut).toHaveBeenCalledWith({
+ param: { id: "note-type-123" },
+ json: {
name: "Updated Basic",
frontTemplate: "{{Front}}",
backTemplate: "{{Back}}",
isReversible: true,
- }),
+ },
});
});
@@ -207,11 +199,6 @@ describe("EditNoteTypeModal", () => {
it("trims whitespace from text fields", async () => {
const user = userEvent.setup();
- mockFetch.mockResolvedValue({
- ok: true,
- json: async () => ({ noteType: mockNoteType }),
- });
-
render(<EditNoteTypeModal {...defaultProps} />);
const nameInput = screen.getByLabelText("Name");
@@ -220,19 +207,19 @@ describe("EditNoteTypeModal", () => {
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith(
- "/api/note-types/note-type-123",
- expect.objectContaining({
- body: expect.stringContaining('"name":"Updated Basic"'),
+ 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();
- mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves
+ mockPut.mockImplementation(() => new Promise(() => {})); // Never resolves
render(<EditNoteTypeModal {...defaultProps} />);
@@ -253,11 +240,9 @@ describe("EditNoteTypeModal", () => {
it("displays API error message", async () => {
const user = userEvent.setup();
- mockFetch.mockResolvedValue({
- ok: false,
- status: 404,
- json: async () => ({ error: "Note type not found" }),
- });
+ mockHandleResponse.mockRejectedValue(
+ new ApiClientError("Note type not found", 404),
+ );
render(<EditNoteTypeModal {...defaultProps} />);
@@ -273,7 +258,7 @@ describe("EditNoteTypeModal", () => {
it("displays generic error on unexpected failure", async () => {
const user = userEvent.setup();
- mockFetch.mockRejectedValue(new Error("Network error"));
+ mockPut.mockRejectedValue(new Error("Network error"));
render(<EditNoteTypeModal {...defaultProps} />);
@@ -286,10 +271,12 @@ describe("EditNoteTypeModal", () => {
});
});
- it("displays error when not authenticated", async () => {
+ it("displays error when handleResponse throws", async () => {
const user = userEvent.setup();
- vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined);
+ mockHandleResponse.mockRejectedValue(
+ new ApiClientError("Not authenticated", 401),
+ );
render(<EditNoteTypeModal {...defaultProps} />);
@@ -339,11 +326,7 @@ describe("EditNoteTypeModal", () => {
const user = userEvent.setup();
const onClose = vi.fn();
- mockFetch.mockResolvedValue({
- ok: false,
- status: 404,
- json: async () => ({ error: "Some error" }),
- });
+ mockHandleResponse.mockRejectedValue(new ApiClientError("Some error", 404));
const { rerender } = render(
<EditNoteTypeModal {...defaultProps} onClose={onClose} />,
diff --git a/src/client/components/EditNoteTypeModal.tsx b/src/client/components/EditNoteTypeModal.tsx
index 014492e..27ef5d8 100644
--- a/src/client/components/EditNoteTypeModal.tsx
+++ b/src/client/components/EditNoteTypeModal.tsx
@@ -53,33 +53,16 @@ export function EditNoteTypeModal({
setIsSubmitting(true);
try {
- const authHeader = apiClient.getAuthHeader();
- if (!authHeader) {
- throw new ApiClientError("Not authenticated", 401);
- }
-
- const res = await fetch(`/api/note-types/${noteType.id}`, {
- method: "PUT",
- headers: {
- "Content-Type": "application/json",
- ...authHeader,
- },
- body: JSON.stringify({
+ 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,
- }),
+ },
});
-
- 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 apiClient.handleResponse(res);
onNoteTypeUpdated();
onClose();
diff --git a/src/client/components/ImportNotesModal.tsx b/src/client/components/ImportNotesModal.tsx
index b49b276..a7a88c5 100644
--- a/src/client/components/ImportNotesModal.tsx
+++ b/src/client/components/ImportNotesModal.tsx
@@ -73,34 +73,21 @@ export function ImportNotesModal({
const fetchNoteTypes = useCallback(async () => {
try {
- const authHeader = apiClient.getAuthHeader();
- if (!authHeader) {
- throw new ApiClientError("Not authenticated", 401);
- }
-
- const res = await fetch("/api/note-types", {
- headers: authHeader,
- });
-
- 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,
- );
- }
-
- const data = await res.json();
+ const res = await apiClient.rpc.api["note-types"].$get();
+ const data = await apiClient.handleResponse<{
+ noteTypes: { id: string; name: string }[];
+ }>(res);
// Fetch details for each note type to get fields
const noteTypesWithFields: NoteType[] = [];
for (const nt of data.noteTypes) {
- const detailRes = await fetch(`/api/note-types/${nt.id}`, {
- headers: authHeader,
+ const detailRes = await apiClient.rpc.api["note-types"][":id"].$get({
+ param: { id: nt.id },
});
if (detailRes.ok) {
- const detailData = await detailRes.json();
+ const detailData = await apiClient.handleResponse<{
+ noteType: NoteType;
+ }>(detailRes);
noteTypesWithFields.push(detailData.noteType);
}
}
@@ -247,35 +234,16 @@ export function ImportNotesModal({
setError(null);
try {
- const authHeader = apiClient.getAuthHeader();
- if (!authHeader) {
- throw new ApiClientError("Not authenticated", 401);
- }
-
- const res = await fetch(`/api/decks/${deckId}/notes/import`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- ...authHeader,
- },
- body: JSON.stringify({
+ const res = await apiClient.rpc.api.decks[":deckId"].notes.import.$post({
+ param: { deckId },
+ json: {
notes: validatedRows.map((row) => ({
noteTypeId: row.noteTypeId,
fields: row.fields,
})),
- }),
+ },
});
-
- 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,
- );
- }
-
- const result = await res.json();
+ const result = await apiClient.handleResponse<ImportResult>(res);
setImportResult(result);
setPhase("complete");
onImportComplete();
diff --git a/src/client/components/NoteTypeEditor.test.tsx b/src/client/components/NoteTypeEditor.test.tsx
index 49b35c6..a628859 100644
--- a/src/client/components/NoteTypeEditor.test.tsx
+++ b/src/client/components/NoteTypeEditor.test.tsx
@@ -4,11 +4,38 @@
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";
-import { apiClient } from "../api/client";
+
+const mockNoteTypeGet = vi.fn();
+const mockNoteTypePut = vi.fn();
+const mockFieldPost = vi.fn();
+const mockFieldPut = vi.fn();
+const mockFieldDelete = vi.fn();
+const mockFieldsReorder = vi.fn();
+const mockHandleResponse = vi.fn();
vi.mock("../api/client", () => ({
apiClient: {
- getAuthHeader: vi.fn(),
+ 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),
},
ApiClientError: class ApiClientError extends Error {
constructor(
@@ -22,13 +49,10 @@ vi.mock("../api/client", () => ({
},
}));
+import { ApiClientError } from "../api/client";
// Import after mock is set up
import { NoteTypeEditor } from "./NoteTypeEditor";
-// Mock fetch globally
-const mockFetch = vi.fn();
-global.fetch = mockFetch;
-
describe("NoteTypeEditor", () => {
const mockNoteTypeWithFields = {
id: "note-type-123",
@@ -63,9 +87,12 @@ describe("NoteTypeEditor", () => {
beforeEach(() => {
vi.clearAllMocks();
- vi.mocked(apiClient.getAuthHeader).mockReturnValue({
- Authorization: "Bearer access-token",
- });
+ mockNoteTypeGet.mockResolvedValue({ ok: true });
+ mockNoteTypePut.mockResolvedValue({ ok: true });
+ mockFieldPost.mockResolvedValue({ ok: true });
+ mockFieldPut.mockResolvedValue({ ok: true });
+ mockFieldDelete.mockResolvedValue({ ok: true });
+ mockFieldsReorder.mockResolvedValue({ ok: true });
});
afterEach(() => {
@@ -80,9 +107,8 @@ describe("NoteTypeEditor", () => {
});
it("renders modal and fetches note type when open", async () => {
- mockFetch.mockResolvedValue({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
+ mockHandleResponse.mockResolvedValueOnce({
+ noteType: mockNoteTypeWithFields,
});
render(<NoteTypeEditor {...defaultProps} />);
@@ -90,12 +116,9 @@ describe("NoteTypeEditor", () => {
expect(screen.getByRole("dialog")).toBeDefined();
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith(
- "/api/note-types/note-type-123",
- expect.objectContaining({
- headers: { Authorization: "Bearer access-token" },
- }),
- );
+ expect(mockNoteTypeGet).toHaveBeenCalledWith({
+ param: { id: "note-type-123" },
+ });
});
await waitFor(() => {
@@ -104,9 +127,8 @@ describe("NoteTypeEditor", () => {
});
it("displays note type data after loading", async () => {
- mockFetch.mockResolvedValue({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
+ mockHandleResponse.mockResolvedValueOnce({
+ noteType: mockNoteTypeWithFields,
});
render(<NoteTypeEditor {...defaultProps} />);
@@ -132,30 +154,18 @@ describe("NoteTypeEditor", () => {
});
it("displays loading state while fetching", async () => {
- let resolvePromise: ((value: Response) => void) | undefined;
- const fetchPromise = new Promise<Response>((resolve) => {
- resolvePromise = resolve;
- });
- mockFetch.mockReturnValue(fetchPromise);
+ mockNoteTypeGet.mockImplementation(() => new Promise(() => {})); // Never resolves
render(<NoteTypeEditor {...defaultProps} />);
- // Should show loading spinner
+ // Should show dialog
expect(screen.getByRole("dialog")).toBeDefined();
-
- // Resolve the promise to clean up
- resolvePromise?.({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- } as Response);
});
it("displays error when fetch fails", async () => {
- mockFetch.mockResolvedValue({
- ok: false,
- status: 404,
- json: async () => ({ error: "Note type not found" }),
- });
+ mockHandleResponse.mockRejectedValueOnce(
+ new ApiClientError("Note type not found", 404),
+ );
render(<NoteTypeEditor {...defaultProps} />);
@@ -166,25 +176,12 @@ describe("NoteTypeEditor", () => {
});
});
- it("displays error when not authenticated", async () => {
- vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined);
-
- render(<NoteTypeEditor {...defaultProps} />);
-
- await waitFor(() => {
- expect(screen.getByRole("alert").textContent).toContain(
- "Not authenticated",
- );
- });
- });
-
it("calls onClose when Cancel is clicked", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
- mockFetch.mockResolvedValue({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
+ mockHandleResponse.mockResolvedValueOnce({
+ noteType: mockNoteTypeWithFields,
});
render(<NoteTypeEditor {...defaultProps} onClose={onClose} />);
@@ -202,9 +199,8 @@ describe("NoteTypeEditor", () => {
const user = userEvent.setup();
const onClose = vi.fn();
- mockFetch.mockResolvedValue({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
+ mockHandleResponse.mockResolvedValueOnce({
+ noteType: mockNoteTypeWithFields,
});
render(<NoteTypeEditor {...defaultProps} onClose={onClose} />);
@@ -224,16 +220,10 @@ describe("NoteTypeEditor", () => {
const onClose = vi.fn();
const onNoteTypeUpdated = vi.fn();
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- })
+ mockHandleResponse
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields })
.mockResolvedValueOnce({
- ok: true,
- json: async () => ({
- noteType: { ...mockNoteTypeWithFields, name: "Updated Basic" },
- }),
+ noteType: { ...mockNoteTypeWithFields, name: "Updated Basic" },
});
render(
@@ -256,18 +246,14 @@ describe("NoteTypeEditor", () => {
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith("/api/note-types/note-type-123", {
- method: "PUT",
- headers: {
- "Content-Type": "application/json",
- Authorization: "Bearer access-token",
- },
- body: JSON.stringify({
+ expect(mockNoteTypePut).toHaveBeenCalledWith({
+ param: { id: "note-type-123" },
+ json: {
name: "Updated Basic",
frontTemplate: "{{Front}}",
backTemplate: "{{Back}}",
isReversible: false,
- }),
+ },
});
});
@@ -278,22 +264,16 @@ describe("NoteTypeEditor", () => {
it("adds a new field", async () => {
const user = userEvent.setup();
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- })
+ mockHandleResponse
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields })
.mockResolvedValueOnce({
- ok: true,
- json: async () => ({
- field: {
- id: "field-3",
- noteTypeId: "note-type-123",
- name: "Hint",
- order: 2,
- fieldType: "text",
- },
- }),
+ field: {
+ id: "field-3",
+ noteTypeId: "note-type-123",
+ name: "Hint",
+ order: 2,
+ fieldType: "text",
+ },
});
render(<NoteTypeEditor {...defaultProps} />);
@@ -307,21 +287,14 @@ describe("NoteTypeEditor", () => {
await user.click(screen.getByRole("button", { name: "Add" }));
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith(
- "/api/note-types/note-type-123/fields",
- {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Authorization: "Bearer access-token",
- },
- body: JSON.stringify({
- name: "Hint",
- order: 2,
- fieldType: "text",
- }),
+ expect(mockFieldPost).toHaveBeenCalledWith({
+ param: { id: "note-type-123" },
+ json: {
+ name: "Hint",
+ order: 2,
+ fieldType: "text",
},
- );
+ });
});
await waitFor(() => {
@@ -332,15 +305,9 @@ describe("NoteTypeEditor", () => {
it("deletes a field", async () => {
const user = userEvent.setup();
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ success: true }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields })
+ .mockResolvedValueOnce({ success: true });
render(<NoteTypeEditor {...defaultProps} />);
@@ -357,31 +324,20 @@ describe("NoteTypeEditor", () => {
await user.click(deleteButton);
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith(
- "/api/note-types/note-type-123/fields/field-1",
- {
- method: "DELETE",
- headers: { Authorization: "Bearer access-token" },
- },
- );
+ expect(mockFieldDelete).toHaveBeenCalledWith({
+ param: { id: "note-type-123", fieldId: "field-1" },
+ });
});
});
it("displays error when field deletion fails", async () => {
const user = userEvent.setup();
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- })
- .mockResolvedValueOnce({
- ok: false,
- status: 409,
- json: async () => ({
- error: "Cannot delete field with existing values",
- }),
- });
+ mockHandleResponse
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields })
+ .mockRejectedValueOnce(
+ new ApiClientError("Cannot delete field with existing values", 409),
+ );
render(<NoteTypeEditor {...defaultProps} />);
@@ -412,31 +368,25 @@ describe("NoteTypeEditor", () => {
it("moves a field up", async () => {
const user = userEvent.setup();
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- })
+ mockHandleResponse
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields })
.mockResolvedValueOnce({
- ok: true,
- json: async () => ({
- 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",
- },
- ],
- }),
+ 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} />);
@@ -454,50 +404,37 @@ describe("NoteTypeEditor", () => {
await user.click(secondMoveUpButton);
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith(
- "/api/note-types/note-type-123/fields/reorder",
- {
- method: "PUT",
- headers: {
- "Content-Type": "application/json",
- Authorization: "Bearer access-token",
- },
- body: JSON.stringify({
- fieldIds: ["field-2", "field-1"],
- }),
+ expect(mockFieldsReorder).toHaveBeenCalledWith({
+ param: { id: "note-type-123" },
+ json: {
+ fieldIds: ["field-2", "field-1"],
},
- );
+ });
});
});
it("moves a field down", async () => {
const user = userEvent.setup();
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- })
+ mockHandleResponse
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields })
.mockResolvedValueOnce({
- ok: true,
- json: async () => ({
- 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",
- },
- ],
- }),
+ 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} />);
@@ -514,41 +451,28 @@ describe("NoteTypeEditor", () => {
await user.click(firstMoveDownButton);
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith(
- "/api/note-types/note-type-123/fields/reorder",
- {
- method: "PUT",
- headers: {
- "Content-Type": "application/json",
- Authorization: "Bearer access-token",
- },
- body: JSON.stringify({
- fieldIds: ["field-2", "field-1"],
- }),
+ expect(mockFieldsReorder).toHaveBeenCalledWith({
+ param: { id: "note-type-123" },
+ json: {
+ fieldIds: ["field-2", "field-1"],
},
- );
+ });
});
});
it("edits a field name", async () => {
const user = userEvent.setup();
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- })
+ mockHandleResponse
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields })
.mockResolvedValueOnce({
- ok: true,
- json: async () => ({
- field: {
- id: "field-1",
- noteTypeId: "note-type-123",
- name: "Question",
- order: 0,
- fieldType: "text",
- },
- }),
+ field: {
+ id: "field-1",
+ noteTypeId: "note-type-123",
+ name: "Question",
+ order: 0,
+ fieldType: "text",
+ },
});
render(<NoteTypeEditor {...defaultProps} />);
@@ -569,26 +493,18 @@ describe("NoteTypeEditor", () => {
await user.tab();
await waitFor(() => {
- expect(mockFetch).toHaveBeenCalledWith(
- "/api/note-types/note-type-123/fields/field-1",
- {
- method: "PUT",
- headers: {
- "Content-Type": "application/json",
- Authorization: "Bearer access-token",
- },
- body: JSON.stringify({
- name: "Question",
- }),
+ expect(mockFieldPut).toHaveBeenCalledWith({
+ param: { id: "note-type-123", fieldId: "field-1" },
+ json: {
+ name: "Question",
},
- );
+ });
});
});
it("shows available fields in template help text", async () => {
- mockFetch.mockResolvedValue({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
+ mockHandleResponse.mockResolvedValueOnce({
+ noteType: mockNoteTypeWithFields,
});
render(<NoteTypeEditor {...defaultProps} />);
@@ -599,9 +515,8 @@ describe("NoteTypeEditor", () => {
});
it("disables move up button for first field", async () => {
- mockFetch.mockResolvedValue({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
+ mockHandleResponse.mockResolvedValueOnce({
+ noteType: mockNoteTypeWithFields,
});
render(<NoteTypeEditor {...defaultProps} />);
@@ -616,9 +531,8 @@ describe("NoteTypeEditor", () => {
});
it("disables move down button for last field", async () => {
- mockFetch.mockResolvedValue({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
+ mockHandleResponse.mockResolvedValueOnce({
+ noteType: mockNoteTypeWithFields,
});
render(<NoteTypeEditor {...defaultProps} />);
@@ -636,9 +550,8 @@ describe("NoteTypeEditor", () => {
});
it("disables Add button when new field name is empty", async () => {
- mockFetch.mockResolvedValue({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
+ mockHandleResponse.mockResolvedValueOnce({
+ noteType: mockNoteTypeWithFields,
});
render(<NoteTypeEditor {...defaultProps} />);
@@ -654,16 +567,10 @@ describe("NoteTypeEditor", () => {
it("toggles reversible option", async () => {
const user = userEvent.setup();
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: async () => ({ noteType: mockNoteTypeWithFields }),
- })
+ mockHandleResponse
+ .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields })
.mockResolvedValueOnce({
- ok: true,
- json: async () => ({
- noteType: { ...mockNoteTypeWithFields, isReversible: true },
- }),
+ noteType: { ...mockNoteTypeWithFields, isReversible: true },
});
render(<NoteTypeEditor {...defaultProps} />);
@@ -682,12 +589,12 @@ describe("NoteTypeEditor", () => {
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
- expect(mockFetch).toHaveBeenLastCalledWith(
- "/api/note-types/note-type-123",
- expect.objectContaining({
- body: expect.stringContaining('"isReversible":true'),
+ expect(mockNoteTypePut).toHaveBeenCalledWith({
+ param: { id: "note-type-123" },
+ json: expect.objectContaining({
+ isReversible: true,
}),
- );
+ });
});
});
});
diff --git a/src/client/components/NoteTypeEditor.tsx b/src/client/components/NoteTypeEditor.tsx
index 01a4777..2487c62 100644
--- a/src/client/components/NoteTypeEditor.tsx
+++ b/src/client/components/NoteTypeEditor.tsx
@@ -71,26 +71,13 @@ export function NoteTypeEditor({
setError(null);
try {
- const authHeader = apiClient.getAuthHeader();
- if (!authHeader) {
- throw new ApiClientError("Not authenticated", 401);
- }
-
- const res = await fetch(`/api/note-types/${noteTypeId}`, {
- headers: authHeader,
+ const res = await apiClient.rpc.api["note-types"][":id"].$get({
+ param: { id: noteTypeId },
});
-
- 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,
- );
- }
-
- const data = await res.json();
- const fetchedNoteType = data.noteType as NoteTypeWithFields;
+ const data = await apiClient.handleResponse<{
+ noteType: NoteTypeWithFields;
+ }>(res);
+ const fetchedNoteType = data.noteType;
setNoteType(fetchedNoteType);
setName(fetchedNoteType.name);
setFrontTemplate(fetchedNoteType.frontTemplate);
@@ -138,33 +125,16 @@ export function NoteTypeEditor({
setIsSubmitting(true);
try {
- const authHeader = apiClient.getAuthHeader();
- if (!authHeader) {
- throw new ApiClientError("Not authenticated", 401);
- }
-
- const res = await fetch(`/api/note-types/${noteType.id}`, {
- method: "PUT",
- headers: {
- "Content-Type": "application/json",
- ...authHeader,
- },
- body: JSON.stringify({
+ 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,
- }),
+ },
});
-
- 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 apiClient.handleResponse(res);
onNoteTypeUpdated();
onClose();
@@ -186,37 +156,20 @@ export function NoteTypeEditor({
setIsAddingField(true);
try {
- const authHeader = apiClient.getAuthHeader();
- if (!authHeader) {
- throw new ApiClientError("Not authenticated", 401);
- }
-
const newOrder =
fields.length > 0 ? Math.max(...fields.map((f) => f.order)) + 1 : 0;
- const res = await fetch(`/api/note-types/${noteType.id}/fields`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- ...authHeader,
- },
- body: JSON.stringify({
+ const res = await apiClient.rpc.api["note-types"][":id"].fields.$post({
+ param: { id: noteType.id },
+ json: {
name: newFieldName.trim(),
order: newOrder,
fieldType: "text",
- }),
+ },
});
-
- 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,
- );
- }
-
- const data = await res.json();
+ const data = await apiClient.handleResponse<{ field: NoteFieldType }>(
+ res,
+ );
setFields([...fields, data.field]);
setNewFieldName("");
} catch (err) {
@@ -236,35 +189,17 @@ export function NoteTypeEditor({
setFieldError(null);
try {
- const authHeader = apiClient.getAuthHeader();
- if (!authHeader) {
- throw new ApiClientError("Not authenticated", 401);
- }
-
- const res = await fetch(
- `/api/note-types/${noteType.id}/fields/${fieldId}`,
- {
- method: "PUT",
- headers: {
- "Content-Type": "application/json",
- ...authHeader,
- },
- body: JSON.stringify({
- name: editingFieldName.trim(),
- }),
+ const res = await apiClient.rpc.api["note-types"][":id"].fields[
+ ":fieldId"
+ ].$put({
+ param: { id: noteType.id, fieldId },
+ json: {
+ name: editingFieldName.trim(),
},
+ });
+ const data = await apiClient.handleResponse<{ field: NoteFieldType }>(
+ res,
);
-
- 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,
- );
- }
-
- const data = await res.json();
setFields(fields.map((f) => (f.id === fieldId ? data.field : f)));
setEditingFieldId(null);
setEditingFieldName("");
@@ -283,28 +218,12 @@ export function NoteTypeEditor({
setFieldError(null);
try {
- const authHeader = apiClient.getAuthHeader();
- if (!authHeader) {
- throw new ApiClientError("Not authenticated", 401);
- }
-
- const res = await fetch(
- `/api/note-types/${noteType.id}/fields/${fieldId}`,
- {
- method: "DELETE",
- headers: authHeader,
- },
- );
-
- 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,
- );
- }
-
+ 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) {
@@ -334,30 +253,15 @@ export function NoteTypeEditor({
setFieldError(null);
try {
- const authHeader = apiClient.getAuthHeader();
- if (!authHeader) {
- throw new ApiClientError("Not authenticated", 401);
- }
-
- const res = await fetch(`/api/note-types/${noteType.id}/fields/reorder`, {
- method: "PUT",
- headers: {
- "Content-Type": "application/json",
- ...authHeader,
- },
- body: JSON.stringify({ fieldIds }),
+ const res = await apiClient.rpc.api["note-types"][
+ ":id"
+ ].fields.reorder.$put({
+ param: { id: noteType.id },
+ json: { fieldIds },
});
-
- 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,
- );
- }
-
- const data = await res.json();
+ const data = await apiClient.handleResponse<{ fields: NoteFieldType[] }>(
+ res,
+ );
setFields(
data.fields.sort(
(a: NoteFieldType, b: NoteFieldType) => a.order - b.order,