diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-02 11:46:13 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-02 11:46:13 +0900 |
| commit | 023d0fcfce575030ee503c5f60df8c28dba7ab07 (patch) | |
| tree | 2f8ab3915f338232619ec66bb272e4756a96e021 | |
| parent | 13a3d16ffc88845d7bc65fb0778da9aaff53b653 (diff) | |
| download | kioku-023d0fcfce575030ee503c5f60df8c28dba7ab07.tar.gz kioku-023d0fcfce575030ee503c5f60df8c28dba7ab07.tar.zst kioku-023d0fcfce575030ee503c5f60df8c28dba7ab07.zip | |
feat(decks): make deck CRUD work fully offline-first
Create / Edit / Delete deck modals now write through localDeckRepository
and fire-and-forget syncActionAtom so the change is pushed when the
network is up. EditDeckModal reads its note-type list from the
local-first noteTypesAtom instead of fetching, and the "reconnect to..."
guards on the submit buttons are gone — the user can keep working while
offline.
Soft-delete intentionally does NOT cascade to notes/cards, matching the
server's existing deck.softDelete: the deck disappears from listings and
its children become unreachable that way.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| -rw-r--r-- | src/client/components/CreateDeckModal.test.tsx | 221 | ||||
| -rw-r--r-- | src/client/components/CreateDeckModal.tsx | 53 | ||||
| -rw-r--r-- | src/client/components/DeleteDeckModal.test.tsx | 115 | ||||
| -rw-r--r-- | src/client/components/DeleteDeckModal.tsx | 29 | ||||
| -rw-r--r-- | src/client/components/EditDeckModal.test.tsx | 222 | ||||
| -rw-r--r-- | src/client/components/EditDeckModal.tsx | 111 | ||||
| -rw-r--r-- | src/client/db/repositories.ts | 4 | ||||
| -rw-r--r-- | src/client/pages/HomePage.test.tsx | 107 |
8 files changed, 272 insertions, 590 deletions
diff --git a/src/client/components/CreateDeckModal.test.tsx b/src/client/components/CreateDeckModal.test.tsx index fcaa572..e4a2bbc 100644 --- a/src/client/components/CreateDeckModal.test.tsx +++ b/src/client/components/CreateDeckModal.test.tsx @@ -3,46 +3,24 @@ */ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { atom } from "jotai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { apiClient } from "../api/client"; -vi.mock("../api/client", () => ({ - apiClient: { - getAuthHeader: vi.fn(), - rpc: { - api: { - decks: { - $post: vi.fn(), - }, - }, - }, - }, - ApiClientError: class ApiClientError extends Error { - constructor( - message: string, - public status: number, - public code?: string, - ) { - super(message); - this.name = "ApiClientError"; - } +const mockCreate = vi.fn(); +const mockTriggerSync = vi.fn(() => Promise.resolve(null)); + +vi.mock("../db/repositories", () => ({ + localDeckRepository: { + create: (...args: unknown[]) => mockCreate(...args), }, })); -// Import after mock is set up -import { CreateDeckModal } from "./CreateDeckModal"; +vi.mock("../atoms", () => ({ + syncActionAtom: atom(null, () => mockTriggerSync()), + userAtom: atom({ id: "user-1", username: "alice" }), +})); -// Helper to create mock responses -function mockResponse(data: { - ok: boolean; - status?: number; - // biome-ignore lint/suspicious/noExplicitAny: Test helper needs flexible typing - json: () => Promise<any>; -}) { - return data as unknown as Awaited< - ReturnType<typeof apiClient.rpc.api.decks.$post> - >; -} +import { CreateDeckModal } from "./CreateDeckModal"; describe("CreateDeckModal", () => { const defaultProps = { @@ -53,8 +31,12 @@ describe("CreateDeckModal", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue({ - Authorization: "Bearer access-token", + mockCreate.mockResolvedValue({ + id: "deck-1", + userId: "user-1", + name: "Test Deck", + description: null, + defaultNoteTypeId: null, }); }); @@ -93,8 +75,7 @@ describe("CreateDeckModal", () => { const user = userEvent.setup(); render(<CreateDeckModal {...defaultProps} />); - const nameInput = screen.getByLabelText("Name"); - await user.type(nameInput, "My Deck"); + await user.type(screen.getByLabelText("Name"), "My Deck"); const createButton = screen.getByRole("button", { name: "Create Deck" }); expect(createButton).toHaveProperty("disabled", false); @@ -110,47 +91,11 @@ describe("CreateDeckModal", () => { expect(onClose).toHaveBeenCalledTimes(1); }); - it("calls onClose when clicking outside the modal", async () => { - const user = userEvent.setup(); - const onClose = vi.fn(); - render(<CreateDeckModal {...defaultProps} onClose={onClose} />); - - // Click on the backdrop (the dialog element itself) - const dialog = screen.getByRole("dialog"); - await user.click(dialog); - - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("does not call onClose when clicking inside the modal content", async () => { - const user = userEvent.setup(); - const onClose = vi.fn(); - render(<CreateDeckModal {...defaultProps} onClose={onClose} />); - - // Click on an element inside the modal - await user.click(screen.getByLabelText("Name")); - - expect(onClose).not.toHaveBeenCalled(); - }); - - it("creates deck with name only", async () => { + it("creates deck via local repository with name only", async () => { const user = userEvent.setup(); const onClose = vi.fn(); const onDeckCreated = vi.fn(); - vi.mocked(apiClient.rpc.api.decks.$post).mockResolvedValue( - mockResponse({ - ok: true, - json: async () => ({ - deck: { - id: "deck-1", - name: "Test Deck", - description: null, - }, - }), - }), - ); - render( <CreateDeckModal isOpen={true} @@ -163,41 +108,21 @@ describe("CreateDeckModal", () => { await user.click(screen.getByRole("button", { name: "Create Deck" })); await waitFor(() => { - expect(apiClient.rpc.api.decks.$post).toHaveBeenCalledWith( - { json: { name: "Test Deck", description: null } }, - { headers: { Authorization: "Bearer access-token" } }, - ); + expect(mockCreate).toHaveBeenCalledWith({ + userId: "user-1", + name: "Test Deck", + description: null, + defaultNoteTypeId: null, + }); }); - expect(onDeckCreated).toHaveBeenCalledTimes(1); expect(onClose).toHaveBeenCalledTimes(1); }); - it("creates deck with name and description", async () => { + it("includes description when provided", async () => { const user = userEvent.setup(); - const onClose = vi.fn(); - const onDeckCreated = vi.fn(); - - vi.mocked(apiClient.rpc.api.decks.$post).mockResolvedValue( - mockResponse({ - ok: true, - json: async () => ({ - deck: { - id: "deck-1", - name: "Test Deck", - description: "A test description", - }, - }), - }), - ); - render( - <CreateDeckModal - isOpen={true} - onClose={onClose} - onDeckCreated={onDeckCreated} - />, - ); + render(<CreateDeckModal {...defaultProps} />); await user.type(screen.getByLabelText("Name"), "Test Deck"); await user.type( @@ -207,26 +132,18 @@ describe("CreateDeckModal", () => { await user.click(screen.getByRole("button", { name: "Create Deck" })); await waitFor(() => { - expect(apiClient.rpc.api.decks.$post).toHaveBeenCalledWith( - { json: { name: "Test Deck", description: "A test description" } }, - { headers: { Authorization: "Bearer access-token" } }, - ); + expect(mockCreate).toHaveBeenCalledWith({ + userId: "user-1", + name: "Test Deck", + description: "A test description", + defaultNoteTypeId: null, + }); }); - - expect(onDeckCreated).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledTimes(1); }); it("trims whitespace from name and description", async () => { const user = userEvent.setup(); - vi.mocked(apiClient.rpc.api.decks.$post).mockResolvedValue( - mockResponse({ - ok: true, - json: async () => ({ deck: { id: "deck-1" } }), - }), - ); - render(<CreateDeckModal {...defaultProps} />); await user.type(screen.getByLabelText("Name"), " Test Deck "); @@ -237,19 +154,32 @@ describe("CreateDeckModal", () => { await user.click(screen.getByRole("button", { name: "Create Deck" })); await waitFor(() => { - expect(apiClient.rpc.api.decks.$post).toHaveBeenCalledWith( - { json: { name: "Test Deck", description: "Description" } }, - { headers: { Authorization: "Bearer access-token" } }, - ); + expect(mockCreate).toHaveBeenCalledWith({ + userId: "user-1", + name: "Test Deck", + description: "Description", + defaultNoteTypeId: null, + }); + }); + }); + + it("triggers a background sync after a successful create", async () => { + const user = userEvent.setup(); + + render(<CreateDeckModal {...defaultProps} />); + + await user.type(screen.getByLabelText("Name"), "Test Deck"); + await user.click(screen.getByRole("button", { name: "Create Deck" })); + + await waitFor(() => { + expect(mockTriggerSync).toHaveBeenCalled(); }); }); it("shows loading state during submission", async () => { const user = userEvent.setup(); - vi.mocked(apiClient.rpc.api.decks.$post).mockImplementation( - () => new Promise(() => {}), // Never resolves - ); + mockCreate.mockImplementation(() => new Promise(() => {})); render(<CreateDeckModal {...defaultProps} />); @@ -272,35 +202,10 @@ describe("CreateDeckModal", () => { ); }); - it("displays API error message", async () => { + it("displays a generic error when the local write fails", async () => { const user = userEvent.setup(); - vi.mocked(apiClient.rpc.api.decks.$post).mockResolvedValue( - mockResponse({ - ok: false, - status: 400, - json: async () => ({ error: "Deck name already exists" }), - }), - ); - - render(<CreateDeckModal {...defaultProps} />); - - await user.type(screen.getByLabelText("Name"), "Test Deck"); - await user.click(screen.getByRole("button", { name: "Create Deck" })); - - await waitFor(() => { - expect(screen.getByRole("alert").textContent).toContain( - "Deck name already exists", - ); - }); - }); - - it("displays generic error on unexpected failure", async () => { - const user = userEvent.setup(); - - vi.mocked(apiClient.rpc.api.decks.$post).mockRejectedValue( - new Error("Network error"), - ); + mockCreate.mockRejectedValue(new Error("disk full")); render(<CreateDeckModal {...defaultProps} />); @@ -326,17 +231,14 @@ describe("CreateDeckModal", () => { />, ); - // Type something in the form await user.type(screen.getByLabelText("Name"), "Test Deck"); await user.type( screen.getByLabelText("Description (optional)"), "Test Description", ); - // Click cancel to close await user.click(screen.getByRole("button", { name: "Cancel" })); - // Reopen the modal rerender( <CreateDeckModal isOpen={true} @@ -345,7 +247,6 @@ describe("CreateDeckModal", () => { />, ); - // Form should be reset expect(screen.getByLabelText("Name")).toHaveProperty("value", ""); expect(screen.getByLabelText("Description (optional)")).toHaveProperty( "value", @@ -357,13 +258,6 @@ describe("CreateDeckModal", () => { const user = userEvent.setup(); const onClose = vi.fn(); - vi.mocked(apiClient.rpc.api.decks.$post).mockResolvedValue( - mockResponse({ - ok: true, - json: async () => ({ deck: { id: "deck-1" } }), - }), - ); - const { rerender } = render( <CreateDeckModal isOpen={true} @@ -372,7 +266,6 @@ describe("CreateDeckModal", () => { />, ); - // Create a deck await user.type(screen.getByLabelText("Name"), "Test Deck"); await user.click(screen.getByRole("button", { name: "Create Deck" })); @@ -380,7 +273,6 @@ describe("CreateDeckModal", () => { expect(onClose).toHaveBeenCalled(); }); - // Reopen the modal rerender( <CreateDeckModal isOpen={true} @@ -389,7 +281,6 @@ describe("CreateDeckModal", () => { />, ); - // Form should be reset expect(screen.getByLabelText("Name")).toHaveProperty("value", ""); expect(screen.getByLabelText("Description (optional)")).toHaveProperty( "value", diff --git a/src/client/components/CreateDeckModal.tsx b/src/client/components/CreateDeckModal.tsx index 34d46e7..11dc712 100644 --- a/src/client/components/CreateDeckModal.tsx +++ b/src/client/components/CreateDeckModal.tsx @@ -1,7 +1,7 @@ -import { useAtomValue } from "jotai"; +import { useAtomValue, useSetAtom } from "jotai"; import { type FormEvent, useState } from "react"; -import { ApiClientError, apiClient } from "../api"; -import { isOnlineAtom } from "../atoms"; +import { syncActionAtom, userAtom } from "../atoms"; +import { localDeckRepository } from "../db/repositories"; interface CreateDeckModalProps { isOpen: boolean; @@ -18,7 +18,8 @@ export function CreateDeckModal({ const [description, setDescription] = useState(""); const [error, setError] = useState<string | null>(null); const [isSubmitting, setIsSubmitting] = useState(false); - const isOnline = useAtomValue(isOnlineAtom); + const user = useAtomValue(userAtom); + const triggerSync = useSetAtom(syncActionAtom); const resetForm = () => { setName(""); @@ -33,40 +34,29 @@ export function CreateDeckModal({ const handleSubmit = async (e: FormEvent) => { e.preventDefault(); + if (!user) { + setError("You must be signed in to create a deck."); + return; + } setError(null); setIsSubmitting(true); try { - const res = await apiClient.rpc.api.decks.$post( - { - json: { - name: name.trim(), - description: description.trim() || null, - }, - }, - { - headers: apiClient.getAuthHeader(), - }, - ); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ApiClientError( - (errorBody as { error?: string }).error || - `Request failed with status ${res.status}`, - res.status, - ); - } + await localDeckRepository.create({ + userId: user.id, + name: name.trim(), + description: description.trim() || null, + defaultNoteTypeId: null, + }); resetForm(); onDeckCreated(); onClose(); - } catch (err) { - if (err instanceof ApiClientError) { - setError(err.message); - } else { - setError("Failed to create deck. Please try again."); - } + // Fire-and-forget: server push will be retried by the sync engine if it + // fails (e.g. offline), so we deliberately do not await or surface errors. + void triggerSync().catch(() => {}); + } catch { + setError("Failed to create deck. Please try again."); } finally { setIsSubmitting(false); } @@ -163,8 +153,7 @@ export function CreateDeckModal({ </button> <button type="submit" - disabled={isSubmitting || !name.trim() || !isOnline} - title={!isOnline ? "Reconnect to create a deck" : undefined} + disabled={isSubmitting || !name.trim()} className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" > {isSubmitting ? "Creating..." : "Create Deck"} diff --git a/src/client/components/DeleteDeckModal.test.tsx b/src/client/components/DeleteDeckModal.test.tsx index 4441064..c091ad1 100644 --- a/src/client/components/DeleteDeckModal.test.tsx +++ b/src/client/components/DeleteDeckModal.test.tsx @@ -3,38 +3,22 @@ */ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { atom } from "jotai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mockDelete = vi.fn(); -const mockHandleResponse = vi.fn(); +const mockTriggerSync = vi.fn(() => Promise.resolve(null)); -vi.mock("../api/client", () => ({ - apiClient: { - rpc: { - api: { - decks: { - ":id": { - $delete: (args: unknown) => mockDelete(args), - }, - }, - }, - }, - handleResponse: (res: unknown) => mockHandleResponse(res), - }, - ApiClientError: class ApiClientError extends Error { - constructor( - message: string, - public status: number, - public code?: string, - ) { - super(message); - this.name = "ApiClientError"; - } +vi.mock("../db/repositories", () => ({ + localDeckRepository: { + delete: (...args: unknown[]) => mockDelete(...args), }, })); -import { ApiClientError } from "../api/client"; -// Import after mock is set up +vi.mock("../atoms", () => ({ + syncActionAtom: atom(null, () => mockTriggerSync()), +})); + import { DeleteDeckModal } from "./DeleteDeckModal"; describe("DeleteDeckModal", () => { @@ -52,8 +36,7 @@ describe("DeleteDeckModal", () => { beforeEach(() => { vi.clearAllMocks(); - mockDelete.mockResolvedValue({ ok: true }); - mockHandleResponse.mockResolvedValue({}); + mockDelete.mockResolvedValue(true); }); afterEach(() => { @@ -105,30 +88,7 @@ describe("DeleteDeckModal", () => { expect(onClose).toHaveBeenCalledTimes(1); }); - it("calls onClose when clicking outside the modal", async () => { - const user = userEvent.setup(); - const onClose = vi.fn(); - render(<DeleteDeckModal {...defaultProps} onClose={onClose} />); - - // Click on the backdrop (the dialog element itself) - const dialog = screen.getByRole("dialog"); - await user.click(dialog); - - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("does not call onClose when clicking inside the modal content", async () => { - const user = userEvent.setup(); - const onClose = vi.fn(); - render(<DeleteDeckModal {...defaultProps} onClose={onClose} />); - - // Click on an element inside the modal - await user.click(screen.getByText("Test Deck")); - - expect(onClose).not.toHaveBeenCalled(); - }); - - it("deletes deck when Delete is clicked", async () => { + it("deletes deck via local repository when Delete is clicked", async () => { const user = userEvent.setup(); const onClose = vi.fn(); const onDeckDeleted = vi.fn(); @@ -145,19 +105,29 @@ describe("DeleteDeckModal", () => { await user.click(screen.getByRole("button", { name: "Delete" })); await waitFor(() => { - expect(mockDelete).toHaveBeenCalledWith({ - param: { id: "deck-123" }, - }); + expect(mockDelete).toHaveBeenCalledWith("deck-123"); }); expect(onDeckDeleted).toHaveBeenCalledTimes(1); expect(onClose).toHaveBeenCalledTimes(1); }); + it("triggers a background sync after a successful delete", async () => { + const user = userEvent.setup(); + + render(<DeleteDeckModal {...defaultProps} />); + + await user.click(screen.getByRole("button", { name: "Delete" })); + + await waitFor(() => { + expect(mockTriggerSync).toHaveBeenCalled(); + }); + }); + it("shows loading state during deletion", async () => { const user = userEvent.setup(); - mockDelete.mockImplementation(() => new Promise(() => {})); // Never resolves + mockDelete.mockImplementation(() => new Promise(() => {})); render(<DeleteDeckModal {...defaultProps} />); @@ -174,26 +144,10 @@ describe("DeleteDeckModal", () => { ); }); - it("displays API error message", async () => { + it("shows an error when the deck no longer exists locally", async () => { const user = userEvent.setup(); - mockHandleResponse.mockRejectedValue( - new ApiClientError("Deck not found", 404), - ); - - render(<DeleteDeckModal {...defaultProps} />); - - await user.click(screen.getByRole("button", { name: "Delete" })); - - await waitFor(() => { - expect(screen.getByRole("alert").textContent).toContain("Deck not found"); - }); - }); - - it("displays generic error on unexpected failure", async () => { - const user = userEvent.setup(); - - mockDelete.mockRejectedValue(new Error("Network error")); + mockDelete.mockResolvedValue(false); render(<DeleteDeckModal {...defaultProps} />); @@ -201,17 +155,15 @@ describe("DeleteDeckModal", () => { await waitFor(() => { expect(screen.getByRole("alert").textContent).toContain( - "Failed to delete deck. Please try again.", + "Deck not found.", ); }); }); - it("displays error when handleResponse throws", async () => { + it("displays a generic error when the local write fails", async () => { const user = userEvent.setup(); - mockHandleResponse.mockRejectedValue( - new ApiClientError("Not authenticated", 401), - ); + mockDelete.mockRejectedValue(new Error("disk full")); render(<DeleteDeckModal {...defaultProps} />); @@ -219,7 +171,7 @@ describe("DeleteDeckModal", () => { await waitFor(() => { expect(screen.getByRole("alert").textContent).toContain( - "Not authenticated", + "Failed to delete deck. Please try again.", ); }); }); @@ -228,23 +180,20 @@ describe("DeleteDeckModal", () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockHandleResponse.mockRejectedValue(new ApiClientError("Some error", 404)); + mockDelete.mockRejectedValueOnce(new Error("Some error")); const { rerender } = render( <DeleteDeckModal {...defaultProps} onClose={onClose} />, ); - // Trigger error await user.click(screen.getByRole("button", { name: "Delete" })); await waitFor(() => { expect(screen.getByRole("alert")).toBeDefined(); }); - // Close and reopen the modal await user.click(screen.getByRole("button", { name: "Cancel" })); rerender(<DeleteDeckModal {...defaultProps} onClose={onClose} />); - // Error should be cleared expect(screen.queryByRole("alert")).toBeNull(); }); diff --git a/src/client/components/DeleteDeckModal.tsx b/src/client/components/DeleteDeckModal.tsx index 954431e..5e166fc 100644 --- a/src/client/components/DeleteDeckModal.tsx +++ b/src/client/components/DeleteDeckModal.tsx @@ -1,7 +1,7 @@ -import { useAtomValue } from "jotai"; +import { useSetAtom } from "jotai"; import { useState } from "react"; -import { ApiClientError, apiClient } from "../api"; -import { isOnlineAtom } from "../atoms"; +import { syncActionAtom } from "../atoms"; +import { localDeckRepository } from "../db/repositories"; interface Deck { id: string; @@ -23,7 +23,7 @@ export function DeleteDeckModal({ }: DeleteDeckModalProps) { const [error, setError] = useState<string | null>(null); const [isDeleting, setIsDeleting] = useState(false); - const isOnline = useAtomValue(isOnlineAtom); + const triggerSync = useSetAtom(syncActionAtom); const handleClose = () => { setError(null); @@ -37,19 +37,17 @@ export function DeleteDeckModal({ setIsDeleting(true); try { - const res = await apiClient.rpc.api.decks[":id"].$delete({ - param: { id: deck.id }, - }); - await apiClient.handleResponse(res); + const deleted = await localDeckRepository.delete(deck.id); + if (!deleted) { + setError("Deck not found."); + return; + } onDeckDeleted(); onClose(); - } catch (err) { - if (err instanceof ApiClientError) { - setError(err.message); - } else { - setError("Failed to delete deck. Please try again."); - } + void triggerSync().catch(() => {}); + } catch { + setError("Failed to delete deck. Please try again."); } finally { setIsDeleting(false); } @@ -132,8 +130,7 @@ export function DeleteDeckModal({ <button type="button" onClick={handleDelete} - disabled={isDeleting || !isOnline} - title={!isOnline ? "Reconnect to delete" : undefined} + disabled={isDeleting} className="px-4 py-2 bg-error hover:bg-error/90 text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed min-w-[100px]" > {isDeleting ? "Deleting..." : "Delete"} diff --git a/src/client/components/EditDeckModal.test.tsx b/src/client/components/EditDeckModal.test.tsx index 248c74f..15a30a1 100644 --- a/src/client/components/EditDeckModal.test.tsx +++ b/src/client/components/EditDeckModal.test.tsx @@ -3,43 +3,23 @@ */ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { atom } from "jotai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const mockPut = vi.fn(); -const mockHandleResponse = vi.fn(); +const mockUpdate = vi.fn(); +const mockTriggerSync = vi.fn(() => Promise.resolve(null)); -const mockGetNoteTypes = vi.fn(); - -vi.mock("../api/client", () => ({ - apiClient: { - rpc: { - api: { - decks: { - ":id": { - $put: (args: unknown) => mockPut(args), - }, - }, - "note-types": { - $get: () => mockGetNoteTypes(), - }, - }, - }, - handleResponse: (res: unknown) => mockHandleResponse(res), - }, - ApiClientError: class ApiClientError extends Error { - constructor( - message: string, - public status: number, - public code?: string, - ) { - super(message); - this.name = "ApiClientError"; - } +vi.mock("../db/repositories", () => ({ + localDeckRepository: { + update: (...args: unknown[]) => mockUpdate(...args), }, })); -import { ApiClientError } from "../api/client"; -// Import after mock is set up +vi.mock("../atoms", () => ({ + syncActionAtom: atom(null, () => mockTriggerSync()), + noteTypesAtom: atom({ data: [] as { id: string; name: string }[] }), +})); + import { EditDeckModal } from "./EditDeckModal"; describe("EditDeckModal", () => { @@ -57,25 +37,14 @@ describe("EditDeckModal", () => { onDeckUpdated: vi.fn(), }; - const noteTypesResponse = { ok: true, _type: "noteTypes" }; - const putResponse = { ok: true, _type: "put" }; - beforeEach(() => { vi.clearAllMocks(); - mockPut.mockResolvedValue(putResponse); - mockGetNoteTypes.mockResolvedValue(noteTypesResponse); - mockHandleResponse.mockImplementation((res: unknown) => { - if (res === noteTypesResponse) { - return Promise.resolve({ noteTypes: [] }); - } - return Promise.resolve({ - deck: { - id: "deck-123", - name: "Test Deck", - description: "Test description", - defaultNoteTypeId: null, - }, - }); + mockUpdate.mockResolvedValue({ + id: "deck-123", + userId: "user-1", + name: "Test Deck", + description: "Test description", + defaultNoteTypeId: null, }); }); @@ -155,30 +124,7 @@ describe("EditDeckModal", () => { expect(onClose).toHaveBeenCalledTimes(1); }); - it("calls onClose when clicking outside the modal", async () => { - const user = userEvent.setup(); - const onClose = vi.fn(); - render(<EditDeckModal {...defaultProps} onClose={onClose} />); - - // Click on the backdrop (the dialog element itself) - const dialog = screen.getByRole("dialog"); - await user.click(dialog); - - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("does not call onClose when clicking inside the modal content", async () => { - const user = userEvent.setup(); - const onClose = vi.fn(); - render(<EditDeckModal {...defaultProps} onClose={onClose} />); - - // Click on an element inside the modal - await user.click(screen.getByLabelText("Name")); - - expect(onClose).not.toHaveBeenCalled(); - }); - - it("updates deck with new name", async () => { + it("updates deck via local repository with new name", async () => { const user = userEvent.setup(); const onClose = vi.fn(); const onDeckUpdated = vi.fn(); @@ -198,47 +144,10 @@ describe("EditDeckModal", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockPut).toHaveBeenCalledWith({ - param: { id: "deck-123" }, - json: { - name: "Updated Deck", - description: "Test description", - defaultNoteTypeId: null, - }, - }); - }); - - expect(onDeckUpdated).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("updates deck with new description", async () => { - const user = userEvent.setup(); - const onClose = vi.fn(); - const onDeckUpdated = vi.fn(); - - render( - <EditDeckModal - isOpen={true} - deck={mockDeck} - onClose={onClose} - onDeckUpdated={onDeckUpdated} - />, - ); - - const descInput = screen.getByLabelText("Description (optional)"); - await user.clear(descInput); - await user.type(descInput, "New description"); - await user.click(screen.getByRole("button", { name: "Save Changes" })); - - await waitFor(() => { - expect(mockPut).toHaveBeenCalledWith({ - param: { id: "deck-123" }, - json: { - name: "Test Deck", - description: "New description", - defaultNoteTypeId: null, - }, + expect(mockUpdate).toHaveBeenCalledWith("deck-123", { + name: "Updated Deck", + description: "Test description", + defaultNoteTypeId: null, }); }); @@ -256,13 +165,10 @@ describe("EditDeckModal", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockPut).toHaveBeenCalledWith({ - param: { id: "deck-123" }, - json: { - name: "Test Deck", - description: null, - defaultNoteTypeId: null, - }, + expect(mockUpdate).toHaveBeenCalledWith("deck-123", { + name: "Test Deck", + description: null, + defaultNoteTypeId: null, }); }); }); @@ -280,21 +186,30 @@ describe("EditDeckModal", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockPut).toHaveBeenCalledWith({ - param: { id: "deck-123" }, - json: { - name: "Deck", - description: "Description", - defaultNoteTypeId: null, - }, + expect(mockUpdate).toHaveBeenCalledWith("deck-123", { + name: "Deck", + description: "Description", + defaultNoteTypeId: null, }); }); }); + it("triggers a background sync after a successful update", async () => { + const user = userEvent.setup(); + + render(<EditDeckModal {...defaultProps} />); + + await user.click(screen.getByRole("button", { name: "Save Changes" })); + + await waitFor(() => { + expect(mockTriggerSync).toHaveBeenCalled(); + }); + }); + it("shows loading state during submission", async () => { const user = userEvent.setup(); - mockPut.mockImplementation(() => new Promise(() => {})); // Never resolves + mockUpdate.mockImplementation(() => new Promise(() => {})); render(<EditDeckModal {...defaultProps} />); @@ -316,32 +231,26 @@ describe("EditDeckModal", () => { ); }); - it("displays API error message", async () => { + it("shows an error when the deck no longer exists locally", async () => { const user = userEvent.setup(); - render(<EditDeckModal {...defaultProps} />); + mockUpdate.mockResolvedValue(undefined); - // Wait for note types to load, then override handleResponse for the PUT - await waitFor(() => { - expect(mockGetNoteTypes).toHaveBeenCalled(); - }); - mockHandleResponse.mockRejectedValue( - new ApiClientError("Deck name already exists", 400), - ); + render(<EditDeckModal {...defaultProps} />); await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { expect(screen.getByRole("alert").textContent).toContain( - "Deck name already exists", + "Deck not found.", ); }); }); - it("displays generic error on unexpected failure", async () => { + it("displays a generic error when the local write fails", async () => { const user = userEvent.setup(); - mockPut.mockRejectedValue(new Error("Network error")); + mockUpdate.mockRejectedValue(new Error("disk full")); render(<EditDeckModal {...defaultProps} />); @@ -354,28 +263,6 @@ describe("EditDeckModal", () => { }); }); - it("displays error when handleResponse throws", async () => { - const user = userEvent.setup(); - - render(<EditDeckModal {...defaultProps} />); - - // Wait for note types to load, then override handleResponse for the PUT - await waitFor(() => { - expect(mockGetNoteTypes).toHaveBeenCalled(); - }); - mockHandleResponse.mockRejectedValue( - new ApiClientError("Not authenticated", 401), - ); - - await user.click(screen.getByRole("button", { name: "Save Changes" })); - - await waitFor(() => { - expect(screen.getByRole("alert").textContent).toContain( - "Not authenticated", - ); - }); - }); - it("updates form when deck prop changes", () => { const { rerender } = render(<EditDeckModal {...defaultProps} />); @@ -402,27 +289,20 @@ describe("EditDeckModal", () => { const user = userEvent.setup(); const onClose = vi.fn(); + mockUpdate.mockRejectedValueOnce(new Error("Some error")); + const { rerender } = render( <EditDeckModal {...defaultProps} onClose={onClose} />, ); - // Wait for note types to load, then override handleResponse for the PUT - await waitFor(() => { - expect(mockGetNoteTypes).toHaveBeenCalled(); - }); - mockHandleResponse.mockRejectedValue(new ApiClientError("Some error", 400)); - - // Trigger error await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { expect(screen.getByRole("alert")).toBeDefined(); }); - // Close and reopen the modal await user.click(screen.getByRole("button", { name: "Cancel" })); rerender(<EditDeckModal {...defaultProps} onClose={onClose} />); - // Error should be cleared expect(screen.queryByRole("alert")).toBeNull(); }); }); diff --git a/src/client/components/EditDeckModal.tsx b/src/client/components/EditDeckModal.tsx index e9c2b7b..dc7ec11 100644 --- a/src/client/components/EditDeckModal.tsx +++ b/src/client/components/EditDeckModal.tsx @@ -1,7 +1,7 @@ -import { useAtomValue } from "jotai"; -import { type FormEvent, useCallback, useEffect, useState } from "react"; -import { ApiClientError, apiClient } from "../api"; -import { isOnlineAtom } from "../atoms"; +import { useAtomValue, useSetAtom } from "jotai"; +import { type FormEvent, useEffect, useState } from "react"; +import { noteTypesAtom, syncActionAtom } from "../atoms"; +import { localDeckRepository } from "../db/repositories"; interface Deck { id: string; @@ -10,11 +10,6 @@ interface Deck { defaultNoteTypeId: string | null; } -interface NoteTypeSummary { - id: string; - name: string; -} - interface EditDeckModalProps { isOpen: boolean; deck: Deck | null; @@ -22,54 +17,43 @@ interface EditDeckModalProps { onDeckUpdated: () => void; } -export function EditDeckModal({ - isOpen, +export function EditDeckModal(props: EditDeckModalProps) { + if (!props.isOpen || !props.deck) { + return null; + } + // Render the body only when actually open so the suspense-driven note types + // query does not fire on every host render (e.g. HomePage keeps the modal + // mounted at all times). + return <EditDeckModalContent {...props} deck={props.deck} />; +} + +interface EditDeckModalContentProps extends EditDeckModalProps { + deck: Deck; +} + +function EditDeckModalContent({ deck, onClose, onDeckUpdated, -}: EditDeckModalProps) { - const [name, setName] = useState(""); - const [description, setDescription] = useState(""); +}: EditDeckModalContentProps) { + const [name, setName] = useState(deck.name); + const [description, setDescription] = useState(deck.description ?? ""); const [defaultNoteTypeId, setDefaultNoteTypeId] = useState<string | null>( - null, + deck.defaultNoteTypeId, ); - const [noteTypes, setNoteTypes] = useState<NoteTypeSummary[]>([]); - const [isLoadingNoteTypes, setIsLoadingNoteTypes] = useState(false); const [error, setError] = useState<string | null>(null); const [isSubmitting, setIsSubmitting] = useState(false); - const isOnline = useAtomValue(isOnlineAtom); + const noteTypesQuery = useAtomValue(noteTypesAtom); + const noteTypes = noteTypesQuery.data ?? []; + const triggerSync = useSetAtom(syncActionAtom); - const fetchNoteTypes = useCallback(async () => { - setIsLoadingNoteTypes(true); - try { - const res = await apiClient.rpc.api["note-types"].$get(); - const data = await apiClient.handleResponse<{ - noteTypes: NoteTypeSummary[]; - }>(res); - setNoteTypes(data.noteTypes); - } catch { - // Non-critical: note type list is optional - } finally { - setIsLoadingNoteTypes(false); - } - }, []); - - // Sync form state when deck changes useEffect(() => { - if (deck) { - setName(deck.name); - setDescription(deck.description ?? ""); - setDefaultNoteTypeId(deck.defaultNoteTypeId); - setError(null); - } + setName(deck.name); + setDescription(deck.description ?? ""); + setDefaultNoteTypeId(deck.defaultNoteTypeId); + setError(null); }, [deck]); - useEffect(() => { - if (isOpen) { - fetchNoteTypes(); - } - }, [isOpen, fetchNoteTypes]); - const handleClose = () => { setError(null); onClose(); @@ -77,39 +61,31 @@ export function EditDeckModal({ const handleSubmit = async (e: FormEvent) => { e.preventDefault(); - if (!deck) return; setError(null); setIsSubmitting(true); try { - const res = await apiClient.rpc.api.decks[":id"].$put({ - param: { id: deck.id }, - json: { - name: name.trim(), - description: description.trim() || null, - defaultNoteTypeId: defaultNoteTypeId || null, - }, + const updated = await localDeckRepository.update(deck.id, { + name: name.trim(), + description: description.trim() || null, + defaultNoteTypeId: defaultNoteTypeId || null, }); - await apiClient.handleResponse(res); + if (!updated) { + setError("Deck not found."); + return; + } onDeckUpdated(); onClose(); - } catch (err) { - if (err instanceof ApiClientError) { - setError(err.message); - } else { - setError("Failed to update deck. Please try again."); - } + void triggerSync().catch(() => {}); + } catch { + setError("Failed to update deck. Please try again."); } finally { setIsSubmitting(false); } }; - if (!isOpen || !deck) { - return null; - } - return ( <div role="dialog" @@ -196,7 +172,7 @@ export function EditDeckModal({ id="edit-deck-default-note-type" value={defaultNoteTypeId ?? ""} onChange={(e) => setDefaultNoteTypeId(e.target.value || null)} - disabled={isSubmitting || isLoadingNoteTypes} + disabled={isSubmitting} className="w-full px-4 py-2.5 bg-ivory border border-border rounded-lg text-slate transition-all duration-200 hover:border-muted focus:border-primary focus:ring-2 focus:ring-primary/10 disabled:opacity-50 disabled:cursor-not-allowed" > <option value="">None</option> @@ -219,8 +195,7 @@ export function EditDeckModal({ </button> <button type="submit" - disabled={isSubmitting || !name.trim() || !isOnline} - title={!isOnline ? "Reconnect to save changes" : undefined} + disabled={isSubmitting || !name.trim()} className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" > {isSubmitting ? "Saving..." : "Save Changes"} diff --git a/src/client/db/repositories.ts b/src/client/db/repositories.ts index 8d9a1fe..9f1d27c 100644 --- a/src/client/db/repositories.ts +++ b/src/client/db/repositories.ts @@ -63,7 +63,9 @@ export const localDeckRepository = { */ async update( id: string, - data: Partial<Pick<LocalDeck, "name" | "description">>, + data: Partial< + Pick<LocalDeck, "name" | "description" | "defaultNoteTypeId"> + >, ): Promise<LocalDeck | undefined> { const deck = await db.decks.get(id); if (!deck) return undefined; diff --git a/src/client/pages/HomePage.test.tsx b/src/client/pages/HomePage.test.tsx index 7628a75..23e2e25 100644 --- a/src/client/pages/HomePage.test.tsx +++ b/src/client/pages/HomePage.test.tsx @@ -11,7 +11,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Router } from "wouter"; import { memoryLocation } from "wouter/memory-location"; import { apiClient } from "../api/client"; -import { authLoadingAtom, type Deck } from "../atoms"; +import { authLoadingAtom, type Deck, userAtom } from "../atoms"; +import { db } from "../db"; import { HomePage } from "./HomePage"; const mockDeckPut = vi.fn(); @@ -63,18 +64,6 @@ vi.mock("../queryClient", () => ({ const mockFetch = vi.fn(); global.fetch = mockFetch; -// Helper to create mock responses compatible with Hono's ClientResponse -function mockPostResponse(data: { - ok: boolean; - status?: number; - // biome-ignore lint/suspicious/noExplicitAny: Test helper needs flexible typing - json: () => Promise<any>; -}) { - return data as unknown as Awaited< - ReturnType<typeof apiClient.rpc.api.decks.$post> - >; -} - const mockDecks = [ { id: "deck-1", @@ -102,6 +91,23 @@ const mockDecks = [ }, ]; +async function seedDecksInLocalDb(decks: Deck[], userId: string) { + for (const deck of decks) { + await db.decks.put({ + id: deck.id, + userId, + name: deck.name, + description: deck.description, + defaultNoteTypeId: deck.defaultNoteTypeId, + createdAt: new Date(deck.createdAt), + updatedAt: new Date(deck.updatedAt), + deletedAt: null, + syncVersion: 0, + _synced: true, + }); + } +} + function renderWithProviders({ path = "/", initialDecks, @@ -112,9 +118,15 @@ function renderWithProviders({ const { hook } = memoryLocation({ path }); const store = createStore(); store.set(authLoadingAtom, false); + store.set(userAtom, { + id: "user-1", + username: "alice", + }); store.set(queryClientAtom, testQueryClient); - // If initialDecks provided, seed query cache to skip Suspense + // Seed query caches to skip Suspense for the home page and the edit modal + // (which subscribes to noteTypesAtom whenever it is rendered). + testQueryClient.setQueryData(["noteTypes"], []); if (initialDecks !== undefined) { testQueryClient.setQueryData(["decks"], initialDecks); } @@ -129,8 +141,18 @@ function renderWithProviders({ } describe("HomePage", () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); + // fake-indexeddb persists across tests within the same file, and the + // logout flow can call db.delete(); make sure each test starts with an + // open, empty database. + if (!db.isOpen()) { + await db.open(); + } + await db.decks.clear(); + await db.cards.clear(); + await db.notes.clear(); + await db.noteFieldValues.clear(); testQueryClient = new QueryClient({ defaultOptions: { queries: { staleTime: Number.POSITIVE_INFINITY, retry: false }, @@ -309,27 +331,8 @@ describe("HomePage", () => { expect(screen.queryByRole("dialog")).toBeNull(); }); - it("submits the new deck via the create endpoint", async () => { + it("creates a deck locally and closes the modal", async () => { const user = userEvent.setup(); - const newDeck = { - id: "deck-new", - name: "New Deck", - description: "A new deck", - defaultNoteTypeId: null, - dueCardCount: 0, - newCardCount: 0, - totalCardCount: 0, - reviewCardCount: 0, - createdAt: "2024-01-03T00:00:00Z", - updatedAt: "2024-01-03T00:00:00Z", - }; - - vi.mocked(apiClient.rpc.api.decks.$post).mockResolvedValue( - mockPostResponse({ - ok: true, - json: async () => ({ deck: newDeck }), - }), - ); renderWithProviders({ initialDecks: [] }); @@ -345,7 +348,11 @@ describe("HomePage", () => { expect(screen.queryByRole("dialog")).toBeNull(); }); - expect(apiClient.rpc.api.decks.$post).toHaveBeenCalledTimes(1); + const persisted = await db.decks + .filter((d) => d.name === "New Deck") + .first(); + expect(persisted?.description).toBe("A new deck"); + expect(persisted?._synced).toBe(false); }); }); @@ -390,18 +397,10 @@ describe("HomePage", () => { expect(screen.queryByRole("dialog")).toBeNull(); }); - it("submits the edited deck via the update endpoint", async () => { + it("updates the deck locally and closes the modal", async () => { const user = userEvent.setup(); - const updatedDeck = { - ...mockDecks[0], - name: "Updated Japanese", - }; - - mockDeckPut.mockResolvedValue({ - ok: true, - json: async () => ({ deck: updatedDeck }), - }); + await seedDecksInLocalDb(mockDecks, "user-1"); renderWithProviders({ initialDecks: mockDecks }); const editButtons = screen.getAllByRole("button", { name: "Edit deck" }); @@ -417,7 +416,9 @@ describe("HomePage", () => { expect(screen.queryByRole("dialog")).toBeNull(); }); - expect(mockDeckPut).toHaveBeenCalledTimes(1); + const persisted = await db.decks.get("deck-1"); + expect(persisted?.name).toBe("Updated Japanese"); + expect(persisted?._synced).toBe(false); }); }); @@ -469,14 +470,10 @@ describe("HomePage", () => { expect(screen.queryByRole("dialog")).toBeNull(); }); - it("submits the delete via the delete endpoint", async () => { + it("soft-deletes the deck locally and closes the modal", async () => { const user = userEvent.setup(); - mockDeckDelete.mockResolvedValue({ - ok: true, - json: async () => ({ success: true }), - }); - + await seedDecksInLocalDb(mockDecks, "user-1"); renderWithProviders({ initialDecks: mockDecks }); const deleteButtons = screen.getAllByRole("button", { @@ -499,7 +496,9 @@ describe("HomePage", () => { expect(screen.queryByRole("dialog")).toBeNull(); }); - expect(mockDeckDelete).toHaveBeenCalledTimes(1); + const persisted = await db.decks.get("deck-1"); + expect(persisted?.deletedAt).not.toBeNull(); + expect(persisted?._synced).toBe(false); }); }); }); |
