aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/components
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-25 23:02:35 +0900
committernsfisis <nsfisis@gmail.com>2026-02-25 23:02:35 +0900
commit38b8fc0e9927c4146b4c8b309b2bcc644abd63d0 (patch)
treef76ba23251645e552fccd201362064b06de50bdd /src/client/components
parent7a77e72bb49ed3990a0c4581292a37a8a4f35231 (diff)
downloadkioku-38b8fc0e9927c4146b4c8b309b2bcc644abd63d0.tar.gz
kioku-38b8fc0e9927c4146b4c8b309b2bcc644abd63d0.tar.zst
kioku-38b8fc0e9927c4146b4c8b309b2bcc644abd63d0.zip
feat(decks): add default note type setting per deckHEADmain
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 <noreply@anthropic.com>
Diffstat (limited to 'src/client/components')
-rw-r--r--src/client/components/CreateNoteModal.tsx15
-rw-r--r--src/client/components/EditDeckModal.test.tsx58
-rw-r--r--src/client/components/EditDeckModal.tsx60
3 files changed, 114 insertions, 19 deletions
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(<EditDeckModal {...defaultProps} />);
+
+ // 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(() => {
@@ -333,12 +357,16 @@ 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),
);
- render(<EditDeckModal {...defaultProps} />);
-
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(
<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(() => {
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<string | null>(
+ null,
+ );
+ const [noteTypes, setNoteTypes] = useState<NoteTypeSummary[]>([]);
+ const [isLoadingNoteTypes, setIsLoadingNoteTypes] = useState(false);
const [error, setError] = useState<string | null>(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({
/>
</div>
+ <div>
+ <label
+ htmlFor="edit-deck-default-note-type"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Default Note Type{" "}
+ <span className="text-muted font-normal">(optional)</span>
+ </label>
+ <select
+ id="edit-deck-default-note-type"
+ value={defaultNoteTypeId ?? ""}
+ onChange={(e) => setDefaultNoteTypeId(e.target.value || null)}
+ disabled={isSubmitting || isLoadingNoteTypes}
+ 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>
+ {noteTypes.map((nt) => (
+ <option key={nt.id} value={nt.id}>
+ {nt.name}
+ </option>
+ ))}
+ </select>
+ </div>
+
<div className="flex gap-3 justify-end pt-2">
<button
type="button"