From 023d0fcfce575030ee503c5f60df8c28dba7ab07 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 2 May 2026 11:46:13 +0900 Subject: feat(decks): make deck CRUD work fully offline-first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/client/components/CreateDeckModal.test.tsx | 223 +++++++----------------- src/client/components/CreateDeckModal.tsx | 53 +++--- src/client/components/DeleteDeckModal.test.tsx | 117 ++++--------- src/client/components/DeleteDeckModal.tsx | 29 ++-- src/client/components/EditDeckModal.test.tsx | 224 ++++++------------------- src/client/components/EditDeckModal.tsx | 111 +++++------- src/client/db/repositories.ts | 4 +- src/client/pages/HomePage.test.tsx | 107 ++++++------ 8 files changed, 275 insertions(+), 593 deletions(-) (limited to 'src') 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; -}) { - return data as unknown as Awaited< - ReturnType - >; -} +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(); - 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(); - - // 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(); - - // 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( { 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( - , - ); + render(); 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(); 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(); + + 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(); @@ -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(); - - 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(); @@ -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( { />, ); - // 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( { />, ); - // 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( { />, ); - // 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(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({