From 38b8fc0e9927c4146b4c8b309b2bcc644abd63d0 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Wed, 25 Feb 2026 23:02:35 +0900 Subject: feat(decks): add default note type setting per deck Allow each deck to specify a default note type that is auto-selected when creating new notes. Includes DB schema migration, server API updates, sync layer support, and UI for editing the default in the deck settings modal. Co-Authored-By: Claude Opus 4.6 --- src/client/components/CreateNoteModal.tsx | 15 ++++--- src/client/components/EditDeckModal.test.tsx | 58 +++++++++++++++++++++------ src/client/components/EditDeckModal.tsx | 60 +++++++++++++++++++++++++++- 3 files changed, 114 insertions(+), 19 deletions(-) (limited to 'src/client/components') diff --git a/src/client/components/CreateNoteModal.tsx b/src/client/components/CreateNoteModal.tsx index 912aea8..cc39bf6 100644 --- a/src/client/components/CreateNoteModal.tsx +++ b/src/client/components/CreateNoteModal.tsx @@ -27,6 +27,7 @@ interface NoteTypeSummary { interface CreateNoteModalProps { isOpen: boolean; deckId: string; + defaultNoteTypeId?: string | null; onClose: () => void; onNoteCreated: () => void; } @@ -34,6 +35,7 @@ interface CreateNoteModalProps { export function CreateNoteModal({ isOpen, deckId, + defaultNoteTypeId, onClose, onNoteCreated, }: CreateNoteModalProps) { @@ -88,10 +90,13 @@ export function CreateNoteModal({ setNoteTypes(data.noteTypes); setHasLoadedNoteTypes(true); - // Auto-select first note type if available - const firstNoteType = data.noteTypes[0]; - if (firstNoteType) { - await fetchNoteTypeDetails(firstNoteType.id); + // Auto-select default note type if specified, otherwise first + const targetNoteType = + (defaultNoteTypeId && + data.noteTypes.find((nt) => nt.id === defaultNoteTypeId)) || + data.noteTypes[0]; + if (targetNoteType) { + await fetchNoteTypeDetails(targetNoteType.id); } } catch (err) { if (err instanceof ApiClientError) { @@ -102,7 +107,7 @@ export function CreateNoteModal({ } finally { setIsLoadingNoteTypes(false); } - }, [fetchNoteTypeDetails]); + }, [fetchNoteTypeDetails, defaultNoteTypeId]); useEffect(() => { if (isOpen && !hasLoadedNoteTypes) { diff --git a/src/client/components/EditDeckModal.test.tsx b/src/client/components/EditDeckModal.test.tsx index b22cb1d..248c74f 100644 --- a/src/client/components/EditDeckModal.test.tsx +++ b/src/client/components/EditDeckModal.test.tsx @@ -8,6 +8,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mockPut = vi.fn(); const mockHandleResponse = vi.fn(); +const mockGetNoteTypes = vi.fn(); + vi.mock("../api/client", () => ({ apiClient: { rpc: { @@ -17,6 +19,9 @@ vi.mock("../api/client", () => ({ $put: (args: unknown) => mockPut(args), }, }, + "note-types": { + $get: () => mockGetNoteTypes(), + }, }, }, handleResponse: (res: unknown) => mockHandleResponse(res), @@ -42,6 +47,7 @@ describe("EditDeckModal", () => { id: "deck-123", name: "Test Deck", description: "Test description", + defaultNoteTypeId: null as string | null, }; const defaultProps = { @@ -51,15 +57,25 @@ describe("EditDeckModal", () => { onDeckUpdated: vi.fn(), }; + const noteTypesResponse = { ok: true, _type: "noteTypes" }; + const putResponse = { ok: true, _type: "put" }; + beforeEach(() => { vi.clearAllMocks(); - mockPut.mockResolvedValue({ ok: true }); - mockHandleResponse.mockResolvedValue({ - deck: { - id: "deck-123", - name: "Test Deck", - description: "Test description", - }, + 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, + }, + }); }); }); @@ -187,6 +203,7 @@ describe("EditDeckModal", () => { json: { name: "Updated Deck", description: "Test description", + defaultNoteTypeId: null, }, }); }); @@ -220,6 +237,7 @@ describe("EditDeckModal", () => { json: { name: "Test Deck", description: "New description", + defaultNoteTypeId: null, }, }); }); @@ -243,6 +261,7 @@ describe("EditDeckModal", () => { json: { name: "Test Deck", description: null, + defaultNoteTypeId: null, }, }); }); @@ -266,6 +285,7 @@ describe("EditDeckModal", () => { json: { name: "Deck", description: "Description", + defaultNoteTypeId: null, }, }); }); @@ -299,12 +319,16 @@ describe("EditDeckModal", () => { it("displays API error message", async () => { const user = userEvent.setup(); + render(); + + // 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(); - await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { @@ -333,12 +357,16 @@ describe("EditDeckModal", () => { it("displays error when handleResponse throws", async () => { const user = userEvent.setup(); + render(); + + // Wait for note types to load, then override handleResponse for the PUT + await waitFor(() => { + expect(mockGetNoteTypes).toHaveBeenCalled(); + }); mockHandleResponse.mockRejectedValue( new ApiClientError("Not authenticated", 401), ); - render(); - await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { @@ -374,12 +402,16 @@ describe("EditDeckModal", () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockHandleResponse.mockRejectedValue(new ApiClientError("Some error", 400)); - const { rerender } = render( , ); + // 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(() => { diff --git a/src/client/components/EditDeckModal.tsx b/src/client/components/EditDeckModal.tsx index 8e95295..9a79de8 100644 --- a/src/client/components/EditDeckModal.tsx +++ b/src/client/components/EditDeckModal.tsx @@ -1,10 +1,16 @@ -import { type FormEvent, useEffect, useState } from "react"; +import { type FormEvent, useCallback, useEffect, useState } from "react"; import { ApiClientError, apiClient } from "../api"; interface Deck { id: string; name: string; description: string | null; + defaultNoteTypeId: string | null; +} + +interface NoteTypeSummary { + id: string; + name: string; } interface EditDeckModalProps { @@ -22,18 +28,45 @@ export function EditDeckModal({ }: EditDeckModalProps) { const [name, setName] = useState(""); const [description, setDescription] = useState(""); + const [defaultNoteTypeId, setDefaultNoteTypeId] = useState( + null, + ); + const [noteTypes, setNoteTypes] = useState([]); + const [isLoadingNoteTypes, setIsLoadingNoteTypes] = useState(false); const [error, setError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + 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); } }, [deck]); + useEffect(() => { + if (isOpen) { + fetchNoteTypes(); + } + }, [isOpen, fetchNoteTypes]); + const handleClose = () => { setError(null); onClose(); @@ -52,6 +85,7 @@ export function EditDeckModal({ json: { name: name.trim(), description: description.trim() || null, + defaultNoteTypeId: defaultNoteTypeId || null, }, }); await apiClient.handleResponse(res); @@ -147,6 +181,30 @@ export function EditDeckModal({ /> +
+ + +
+