aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-31 02:49:55 +0900
committernsfisis <nsfisis@gmail.com>2025-12-31 02:49:55 +0900
commit6aa496b60879526b51fdf736c38f09aede283bde (patch)
treeeaeb4bc61ae10cd81f76e48047ad5f2193a53c38
parentb51d4efaa1e5e0417d4306c02797f424938766cb (diff)
downloadkioku-6aa496b60879526b51fdf736c38f09aede283bde.tar.gz
kioku-6aa496b60879526b51fdf736c38f09aede283bde.tar.zst
kioku-6aa496b60879526b51fdf736c38f09aede283bde.zip
feat(client): add NoteTypeEditor component with field management
Add a comprehensive editor for note types that allows users to: - Edit note type name and templates - Add, remove, and reorder fields - Toggle the reversible card option The editor fetches note type details including fields from the API, enabling full CRUD operations for note type configuration. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
-rw-r--r--docs/dev/roadmap.md2
-rw-r--r--src/client/components/DeleteNoteTypeModal.test.tsx4
-rw-r--r--src/client/components/EditNoteTypeModal.test.tsx27
-rw-r--r--src/client/components/NoteTypeEditor.test.tsx693
-rw-r--r--src/client/components/NoteTypeEditor.tsx682
-rw-r--r--src/client/pages/NoteTypesPage.test.tsx90
-rw-r--r--src/client/pages/NoteTypesPage.tsx16
7 files changed, 1469 insertions, 45 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md
index 916eee3..6398c60 100644
--- a/docs/dev/roadmap.md
+++ b/docs/dev/roadmap.md
@@ -232,7 +232,7 @@ Create these as default note types for each user:
**Tasks:**
- [x] Create NoteType list page (`/note-types`)
-- [ ] Create NoteType editor component
+- [x] Create NoteType editor component
- Edit name
- Manage fields (add/remove/reorder)
- Edit front/back templates (mustache syntax)
diff --git a/src/client/components/DeleteNoteTypeModal.test.tsx b/src/client/components/DeleteNoteTypeModal.test.tsx
index d5d536a..b7159ab 100644
--- a/src/client/components/DeleteNoteTypeModal.test.tsx
+++ b/src/client/components/DeleteNoteTypeModal.test.tsx
@@ -203,7 +203,9 @@ describe("DeleteNoteTypeModal", () => {
mockFetch.mockResolvedValue({
ok: false,
status: 409,
- json: async () => ({ error: "Cannot delete note type with existing notes" }),
+ json: async () => ({
+ error: "Cannot delete note type with existing notes",
+ }),
});
render(<DeleteNoteTypeModal {...defaultProps} />);
diff --git a/src/client/components/EditNoteTypeModal.test.tsx b/src/client/components/EditNoteTypeModal.test.tsx
index c8064bd..61130e2 100644
--- a/src/client/components/EditNoteTypeModal.test.tsx
+++ b/src/client/components/EditNoteTypeModal.test.tsx
@@ -185,22 +185,19 @@ 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({
- name: "Updated Basic",
- frontTemplate: "{{Front}}",
- backTemplate: "{{Back}}",
- isReversible: true,
- }),
+ expect(mockFetch).toHaveBeenCalledWith("/api/note-types/note-type-123", {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: "Bearer access-token",
},
- );
+ body: JSON.stringify({
+ name: "Updated Basic",
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ isReversible: true,
+ }),
+ });
});
expect(onNoteTypeUpdated).toHaveBeenCalledTimes(1);
diff --git a/src/client/components/NoteTypeEditor.test.tsx b/src/client/components/NoteTypeEditor.test.tsx
new file mode 100644
index 0000000..49b35c6
--- /dev/null
+++ b/src/client/components/NoteTypeEditor.test.tsx
@@ -0,0 +1,693 @@
+/**
+ * @vitest-environment jsdom
+ */
+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";
+
+vi.mock("../api/client", () => ({
+ apiClient: {
+ getAuthHeader: vi.fn(),
+ },
+ ApiClientError: class ApiClientError extends Error {
+ constructor(
+ message: string,
+ public status: number,
+ public code?: string,
+ ) {
+ super(message);
+ this.name = "ApiClientError";
+ }
+ },
+}));
+
+// 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",
+ name: "Basic",
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ isReversible: false,
+ fields: [
+ {
+ id: "field-1",
+ noteTypeId: "note-type-123",
+ name: "Front",
+ order: 0,
+ fieldType: "text",
+ },
+ {
+ id: "field-2",
+ noteTypeId: "note-type-123",
+ name: "Back",
+ order: 1,
+ fieldType: "text",
+ },
+ ],
+ };
+
+ const defaultProps = {
+ isOpen: true,
+ noteTypeId: "note-type-123",
+ onClose: vi.fn(),
+ onNoteTypeUpdated: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(apiClient.getAuthHeader).mockReturnValue({
+ Authorization: "Bearer access-token",
+ });
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it("does not render when closed", () => {
+ render(<NoteTypeEditor {...defaultProps} isOpen={false} />);
+
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+
+ it("renders modal and fetches note type when open", async () => {
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({ noteType: mockNoteTypeWithFields }),
+ });
+
+ render(<NoteTypeEditor {...defaultProps} />);
+
+ expect(screen.getByRole("dialog")).toBeDefined();
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledWith(
+ "/api/note-types/note-type-123",
+ expect.objectContaining({
+ headers: { Authorization: "Bearer access-token" },
+ }),
+ );
+ });
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("Name")).toHaveProperty("value", "Basic");
+ });
+ });
+
+ it("displays note type data after loading", async () => {
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({ noteType: mockNoteTypeWithFields }),
+ });
+
+ render(<NoteTypeEditor {...defaultProps} />);
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("Name")).toHaveProperty("value", "Basic");
+ });
+
+ expect(screen.getByLabelText("Front Template")).toHaveProperty(
+ "value",
+ "{{Front}}",
+ );
+ expect(screen.getByLabelText("Back Template")).toHaveProperty(
+ "value",
+ "{{Back}}",
+ );
+ expect(screen.getByLabelText("Create reversed cards")).toHaveProperty(
+ "checked",
+ false,
+ );
+ expect(screen.getByText("Front")).toBeDefined();
+ expect(screen.getByText("Back")).toBeDefined();
+ });
+
+ it("displays loading state while fetching", async () => {
+ let resolvePromise: ((value: Response) => void) | undefined;
+ const fetchPromise = new Promise<Response>((resolve) => {
+ resolvePromise = resolve;
+ });
+ mockFetch.mockReturnValue(fetchPromise);
+
+ render(<NoteTypeEditor {...defaultProps} />);
+
+ // Should show loading spinner
+ 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" }),
+ });
+
+ render(<NoteTypeEditor {...defaultProps} />);
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert").textContent).toContain(
+ "Note type not found",
+ );
+ });
+ });
+
+ 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 }),
+ });
+
+ render(<NoteTypeEditor {...defaultProps} onClose={onClose} />);
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("Name")).toBeDefined();
+ });
+
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls onClose when clicking outside the modal", async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({ noteType: mockNoteTypeWithFields }),
+ });
+
+ render(<NoteTypeEditor {...defaultProps} onClose={onClose} />);
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("Name")).toBeDefined();
+ });
+
+ const dialog = screen.getByRole("dialog");
+ await user.click(dialog);
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("updates note type when Save Changes is clicked", async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+ const onNoteTypeUpdated = vi.fn();
+
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ noteType: mockNoteTypeWithFields }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ noteType: { ...mockNoteTypeWithFields, name: "Updated Basic" },
+ }),
+ });
+
+ render(
+ <NoteTypeEditor
+ isOpen={true}
+ noteTypeId="note-type-123"
+ onClose={onClose}
+ onNoteTypeUpdated={onNoteTypeUpdated}
+ />,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("Name")).toHaveProperty("value", "Basic");
+ });
+
+ const nameInput = screen.getByLabelText("Name");
+ await user.clear(nameInput);
+ await user.type(nameInput, "Updated Basic");
+
+ 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({
+ name: "Updated Basic",
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ isReversible: false,
+ }),
+ });
+ });
+
+ expect(onNoteTypeUpdated).toHaveBeenCalledTimes(1);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("adds a new field", async () => {
+ const user = userEvent.setup();
+
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ noteType: mockNoteTypeWithFields }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ field: {
+ id: "field-3",
+ noteTypeId: "note-type-123",
+ name: "Hint",
+ order: 2,
+ fieldType: "text",
+ },
+ }),
+ });
+
+ render(<NoteTypeEditor {...defaultProps} />);
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("Name")).toBeDefined();
+ });
+
+ const newFieldInput = screen.getByPlaceholderText("New field name");
+ await user.type(newFieldInput, "Hint");
+ 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",
+ }),
+ },
+ );
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText("Hint")).toBeDefined();
+ });
+ });
+
+ it("deletes a field", async () => {
+ const user = userEvent.setup();
+
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ noteType: mockNoteTypeWithFields }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ success: true }),
+ });
+
+ render(<NoteTypeEditor {...defaultProps} />);
+
+ await waitFor(() => {
+ expect(screen.getByText("Front")).toBeDefined();
+ });
+
+ // Find the delete button for the "Front" field (first delete button)
+ const deleteButtons = screen.getAllByTitle("Delete field");
+ expect(deleteButtons.length).toBeGreaterThan(0);
+ const deleteButton = deleteButtons.at(0);
+ if (!deleteButton) throw new Error("Delete button not found");
+
+ 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" },
+ },
+ );
+ });
+ });
+
+ 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",
+ }),
+ });
+
+ render(<NoteTypeEditor {...defaultProps} />);
+
+ await waitFor(() => {
+ expect(screen.getByText("Front")).toBeDefined();
+ });
+
+ // Find the delete button for the "Front" field (first delete button)
+ const deleteButtons = screen.getAllByTitle("Delete field");
+ expect(deleteButtons.length).toBeGreaterThan(0);
+ const deleteButton = deleteButtons.at(0);
+ if (!deleteButton) throw new Error("Delete button not found");
+
+ await user.click(deleteButton);
+
+ await waitFor(() => {
+ const alerts = screen.getAllByRole("alert");
+ expect(
+ alerts.some((alert) =>
+ alert.textContent?.includes(
+ "Cannot delete field with existing values",
+ ),
+ ),
+ ).toBe(true);
+ });
+ });
+
+ it("moves a field up", async () => {
+ const user = userEvent.setup();
+
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ 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",
+ },
+ ],
+ }),
+ });
+
+ render(<NoteTypeEditor {...defaultProps} />);
+
+ await waitFor(() => {
+ expect(screen.getByText("Back")).toBeDefined();
+ });
+
+ // Find the "move up" button for the "Back" field (second field)
+ const moveUpButtons = screen.getAllByTitle("Move up");
+ expect(moveUpButtons.length).toBeGreaterThan(1);
+ // The first field's move up button is disabled, so click the second one (Back field)
+ const secondMoveUpButton = moveUpButtons.at(1);
+ if (!secondMoveUpButton) throw new Error("Move up button not found");
+ 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"],
+ }),
+ },
+ );
+ });
+ });
+
+ it("moves a field down", async () => {
+ const user = userEvent.setup();
+
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ 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",
+ },
+ ],
+ }),
+ });
+
+ render(<NoteTypeEditor {...defaultProps} />);
+
+ await waitFor(() => {
+ expect(screen.getByText("Front")).toBeDefined();
+ });
+
+ // Find the "move down" button for the "Front" field (first field)
+ const moveDownButtons = screen.getAllByTitle("Move down");
+ expect(moveDownButtons.length).toBeGreaterThan(0);
+ const firstMoveDownButton = moveDownButtons.at(0);
+ if (!firstMoveDownButton) throw new Error("Move down button not found");
+ 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"],
+ }),
+ },
+ );
+ });
+ });
+
+ it("edits a field name", async () => {
+ const user = userEvent.setup();
+
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ noteType: mockNoteTypeWithFields }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ field: {
+ id: "field-1",
+ noteTypeId: "note-type-123",
+ name: "Question",
+ order: 0,
+ fieldType: "text",
+ },
+ }),
+ });
+
+ render(<NoteTypeEditor {...defaultProps} />);
+
+ await waitFor(() => {
+ expect(screen.getByText("Front")).toBeDefined();
+ });
+
+ // Click on the field name to start editing
+ await user.click(screen.getByText("Front"));
+
+ // Now there should be an input field
+ const editInput = screen.getByDisplayValue("Front");
+ await user.clear(editInput);
+ await user.type(editInput, "Question");
+
+ // Blur to save
+ 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",
+ }),
+ },
+ );
+ });
+ });
+
+ it("shows available fields in template help text", async () => {
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({ noteType: mockNoteTypeWithFields }),
+ });
+
+ render(<NoteTypeEditor {...defaultProps} />);
+
+ await waitFor(() => {
+ expect(screen.getByText(/\{\{Front\}\}, \{\{Back\}\}/)).toBeDefined();
+ });
+ });
+
+ it("disables move up button for first field", async () => {
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({ noteType: mockNoteTypeWithFields }),
+ });
+
+ render(<NoteTypeEditor {...defaultProps} />);
+
+ await waitFor(() => {
+ expect(screen.getByText("Front")).toBeDefined();
+ });
+
+ const moveUpButtons = screen.getAllByTitle("Move up");
+ // First field's move up button should be disabled
+ expect(moveUpButtons[0]).toHaveProperty("disabled", true);
+ });
+
+ it("disables move down button for last field", async () => {
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({ noteType: mockNoteTypeWithFields }),
+ });
+
+ render(<NoteTypeEditor {...defaultProps} />);
+
+ await waitFor(() => {
+ expect(screen.getByText("Back")).toBeDefined();
+ });
+
+ const moveDownButtons = screen.getAllByTitle("Move down");
+ // Last field's move down button should be disabled
+ expect(moveDownButtons[moveDownButtons.length - 1]).toHaveProperty(
+ "disabled",
+ true,
+ );
+ });
+
+ it("disables Add button when new field name is empty", async () => {
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({ noteType: mockNoteTypeWithFields }),
+ });
+
+ render(<NoteTypeEditor {...defaultProps} />);
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("Name")).toBeDefined();
+ });
+
+ const addButton = screen.getByRole("button", { name: "Add" });
+ expect(addButton).toHaveProperty("disabled", true);
+ });
+
+ it("toggles reversible option", async () => {
+ const user = userEvent.setup();
+
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ noteType: mockNoteTypeWithFields }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ noteType: { ...mockNoteTypeWithFields, isReversible: true },
+ }),
+ });
+
+ render(<NoteTypeEditor {...defaultProps} />);
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("Create reversed cards")).toBeDefined();
+ });
+
+ const checkbox = screen.getByLabelText("Create reversed cards");
+ expect(checkbox).toHaveProperty("checked", false);
+
+ await user.click(checkbox);
+
+ expect(checkbox).toHaveProperty("checked", true);
+
+ 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'),
+ }),
+ );
+ });
+ });
+});
diff --git a/src/client/components/NoteTypeEditor.tsx b/src/client/components/NoteTypeEditor.tsx
new file mode 100644
index 0000000..33b9dba
--- /dev/null
+++ b/src/client/components/NoteTypeEditor.tsx
@@ -0,0 +1,682 @@
+import {
+ faChevronDown,
+ faChevronUp,
+ faGripVertical,
+ faPlus,
+ faTrash,
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import {
+ type FormEvent,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+import { ApiClientError, apiClient } from "../api";
+
+interface NoteFieldType {
+ id: string;
+ noteTypeId: string;
+ name: string;
+ order: number;
+ fieldType: string;
+}
+
+interface NoteType {
+ id: string;
+ name: string;
+ frontTemplate: string;
+ backTemplate: string;
+ isReversible: boolean;
+}
+
+interface NoteTypeWithFields extends NoteType {
+ fields: NoteFieldType[];
+}
+
+interface NoteTypeEditorProps {
+ isOpen: boolean;
+ noteTypeId: string | null;
+ onClose: () => void;
+ onNoteTypeUpdated: () => void;
+}
+
+export function NoteTypeEditor({
+ isOpen,
+ noteTypeId,
+ onClose,
+ onNoteTypeUpdated,
+}: NoteTypeEditorProps) {
+ const [noteType, setNoteType] = useState<NoteTypeWithFields | null>(null);
+ const [name, setName] = useState("");
+ const [frontTemplate, setFrontTemplate] = useState("");
+ const [backTemplate, setBackTemplate] = useState("");
+ const [isReversible, setIsReversible] = useState(false);
+ const [fields, setFields] = useState<NoteFieldType[]>([]);
+ const [error, setError] = useState<string | null>(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [newFieldName, setNewFieldName] = useState("");
+ const [isAddingField, setIsAddingField] = useState(false);
+ const [editingFieldId, setEditingFieldId] = useState<string | null>(null);
+ const [editingFieldName, setEditingFieldName] = useState("");
+ const [fieldError, setFieldError] = useState<string | null>(null);
+ const editInputRef = useRef<HTMLInputElement>(null);
+
+ const fetchNoteType = useCallback(async () => {
+ if (!noteTypeId) return;
+
+ setIsLoading(true);
+ 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,
+ });
+
+ 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;
+ setNoteType(fetchedNoteType);
+ setName(fetchedNoteType.name);
+ setFrontTemplate(fetchedNoteType.frontTemplate);
+ setBackTemplate(fetchedNoteType.backTemplate);
+ setIsReversible(fetchedNoteType.isReversible);
+ setFields(fetchedNoteType.fields.sort((a, b) => a.order - b.order) || []);
+ } catch (err) {
+ if (err instanceof ApiClientError) {
+ setError(err.message);
+ } else {
+ setError("Failed to load note type. Please try again.");
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ }, [noteTypeId]);
+
+ useEffect(() => {
+ if (isOpen && noteTypeId) {
+ fetchNoteType();
+ }
+ }, [isOpen, noteTypeId, fetchNoteType]);
+
+ useEffect(() => {
+ if (editingFieldId && editInputRef.current) {
+ editInputRef.current.focus();
+ }
+ }, [editingFieldId]);
+
+ const handleClose = () => {
+ setError(null);
+ setFieldError(null);
+ setNewFieldName("");
+ setIsAddingField(false);
+ setEditingFieldId(null);
+ setNoteType(null);
+ onClose();
+ };
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ if (!noteType) return;
+
+ setError(null);
+ 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({
+ 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,
+ );
+ }
+
+ onNoteTypeUpdated();
+ onClose();
+ } catch (err) {
+ if (err instanceof ApiClientError) {
+ setError(err.message);
+ } else {
+ setError("Failed to update note type. Please try again.");
+ }
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleAddField = async () => {
+ if (!noteType || !newFieldName.trim()) return;
+
+ setFieldError(null);
+ 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({
+ 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();
+ setFields([...fields, data.field]);
+ setNewFieldName("");
+ } catch (err) {
+ if (err instanceof ApiClientError) {
+ setFieldError(err.message);
+ } else {
+ setFieldError("Failed to add field. Please try again.");
+ }
+ } finally {
+ setIsAddingField(false);
+ }
+ };
+
+ const handleUpdateFieldName = async (fieldId: string) => {
+ if (!noteType || !editingFieldName.trim()) return;
+
+ 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(),
+ }),
+ },
+ );
+
+ 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("");
+ } catch (err) {
+ if (err instanceof ApiClientError) {
+ setFieldError(err.message);
+ } else {
+ setFieldError("Failed to update field. Please try again.");
+ }
+ }
+ };
+
+ const handleDeleteField = async (fieldId: string) => {
+ if (!noteType) return;
+
+ 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,
+ );
+ }
+
+ setFields(fields.filter((f) => f.id !== fieldId));
+ } catch (err) {
+ if (err instanceof ApiClientError) {
+ setFieldError(err.message);
+ } else {
+ setFieldError("Failed to delete field. Please try again.");
+ }
+ }
+ };
+
+ const handleMoveField = async (fieldId: string, direction: "up" | "down") => {
+ if (!noteType) return;
+
+ const fieldIndex = fields.findIndex((f) => f.id === fieldId);
+ if (fieldIndex === -1) return;
+
+ const newIndex = direction === "up" ? fieldIndex - 1 : fieldIndex + 1;
+ if (newIndex < 0 || newIndex >= fields.length) return;
+
+ const newFields = [...fields];
+ const temp = newFields[fieldIndex];
+ newFields[fieldIndex] = newFields[newIndex] as NoteFieldType;
+ newFields[newIndex] = temp as NoteFieldType;
+
+ const fieldIds = newFields.map((f) => f.id);
+
+ 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 }),
+ });
+
+ 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(
+ data.fields.sort(
+ (a: NoteFieldType, b: NoteFieldType) => a.order - b.order,
+ ),
+ );
+ } catch (err) {
+ if (err instanceof ApiClientError) {
+ setFieldError(err.message);
+ } else {
+ setFieldError("Failed to reorder fields. Please try again.");
+ }
+ }
+ };
+
+ const startEditingField = (field: NoteFieldType) => {
+ setEditingFieldId(field.id);
+ setEditingFieldName(field.name);
+ };
+
+ const cancelEditingField = () => {
+ setEditingFieldId(null);
+ setEditingFieldName("");
+ };
+
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+ <div
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="note-type-editor-title"
+ className="fixed inset-0 bg-ink/40 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in"
+ onClick={(e) => {
+ if (e.target === e.currentTarget) {
+ handleClose();
+ }
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") {
+ handleClose();
+ }
+ }}
+ >
+ <div className="bg-white rounded-2xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-hidden animate-scale-in flex flex-col">
+ <div className="p-6 border-b border-border/50">
+ <h2
+ id="note-type-editor-title"
+ className="font-display text-xl font-medium text-ink"
+ >
+ Edit Note Type
+ </h2>
+ </div>
+
+ <div className="p-6 overflow-y-auto flex-1">
+ {isLoading && (
+ <div className="flex items-center justify-center py-12">
+ <div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
+ </div>
+ )}
+
+ {error && !isLoading && (
+ <div
+ role="alert"
+ className="bg-error/5 text-error text-sm px-4 py-3 rounded-lg border border-error/20 mb-4"
+ >
+ {error}
+ </div>
+ )}
+
+ {noteType && !isLoading && (
+ <form onSubmit={handleSubmit} className="space-y-6">
+ {/* Basic Info Section */}
+ <div className="space-y-4">
+ <div>
+ <label
+ htmlFor="note-type-name"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Name
+ </label>
+ <input
+ id="note-type-name"
+ type="text"
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ required
+ maxLength={255}
+ disabled={isSubmitting}
+ className="w-full px-4 py-2.5 bg-ivory border border-border rounded-lg text-slate placeholder-muted transition-all duration-200 hover:border-muted focus:border-primary focus:ring-2 focus:ring-primary/10 disabled:opacity-50 disabled:cursor-not-allowed"
+ />
+ </div>
+
+ <div className="flex items-center gap-3">
+ <input
+ id="is-reversible"
+ type="checkbox"
+ checked={isReversible}
+ onChange={(e) => setIsReversible(e.target.checked)}
+ disabled={isSubmitting}
+ className="w-4 h-4 text-primary bg-ivory border-border rounded focus:ring-primary/20 focus:ring-2 disabled:opacity-50"
+ />
+ <label
+ htmlFor="is-reversible"
+ className="text-sm font-medium text-slate"
+ >
+ Create reversed cards
+ </label>
+ </div>
+ <p className="text-muted text-xs -mt-2 ml-7">
+ Only affects new notes; existing cards are not modified
+ </p>
+ </div>
+
+ {/* Fields Section */}
+ <div className="border-t border-border/50 pt-6">
+ <h3 className="text-sm font-medium text-slate mb-3">Fields</h3>
+
+ {fieldError && (
+ <div
+ role="alert"
+ className="bg-error/5 text-error text-sm px-4 py-3 rounded-lg border border-error/20 mb-3"
+ >
+ {fieldError}
+ </div>
+ )}
+
+ <div className="space-y-2 mb-4">
+ {fields.map((field, index) => (
+ <div
+ key={field.id}
+ className="flex items-center gap-2 p-3 bg-ivory rounded-lg border border-border/50 group"
+ >
+ <FontAwesomeIcon
+ icon={faGripVertical}
+ className="w-3 h-3 text-muted"
+ aria-hidden="true"
+ />
+
+ {editingFieldId === field.id ? (
+ <input
+ type="text"
+ value={editingFieldName}
+ onChange={(e) => setEditingFieldName(e.target.value)}
+ onBlur={() => handleUpdateFieldName(field.id)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleUpdateFieldName(field.id);
+ } else if (e.key === "Escape") {
+ cancelEditingField();
+ }
+ }}
+ ref={editInputRef}
+ className="flex-1 px-2 py-1 bg-white border border-primary rounded text-sm text-slate focus:outline-none focus:ring-2 focus:ring-primary/20"
+ />
+ ) : (
+ <button
+ type="button"
+ onClick={() => startEditingField(field)}
+ className="flex-1 text-left text-sm text-slate hover:text-ink"
+ >
+ {field.name}
+ </button>
+ )}
+
+ <div className="flex items-center gap-1">
+ <button
+ type="button"
+ onClick={() => handleMoveField(field.id, "up")}
+ disabled={index === 0}
+ className="p-1.5 text-muted hover:text-slate hover:bg-white rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
+ title="Move up"
+ >
+ <FontAwesomeIcon
+ icon={faChevronUp}
+ className="w-3 h-3"
+ aria-hidden="true"
+ />
+ </button>
+ <button
+ type="button"
+ onClick={() => handleMoveField(field.id, "down")}
+ disabled={index === fields.length - 1}
+ className="p-1.5 text-muted hover:text-slate hover:bg-white rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
+ title="Move down"
+ >
+ <FontAwesomeIcon
+ icon={faChevronDown}
+ className="w-3 h-3"
+ aria-hidden="true"
+ />
+ </button>
+ <button
+ type="button"
+ onClick={() => handleDeleteField(field.id)}
+ className="p-1.5 text-muted hover:text-error hover:bg-error/5 rounded transition-colors"
+ title="Delete field"
+ >
+ <FontAwesomeIcon
+ icon={faTrash}
+ className="w-3 h-3"
+ aria-hidden="true"
+ />
+ </button>
+ </div>
+ </div>
+ ))}
+ </div>
+
+ {/* Add Field */}
+ <div className="flex items-center gap-2">
+ <input
+ type="text"
+ value={newFieldName}
+ onChange={(e) => setNewFieldName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleAddField();
+ }
+ }}
+ placeholder="New field name"
+ disabled={isAddingField}
+ className="flex-1 px-3 py-2 bg-white border border-border rounded-lg text-sm text-slate placeholder-muted transition-all duration-200 hover:border-muted focus:border-primary focus:ring-2 focus:ring-primary/10 disabled:opacity-50"
+ />
+ <button
+ type="button"
+ onClick={handleAddField}
+ disabled={isAddingField || !newFieldName.trim()}
+ className="inline-flex items-center gap-1.5 px-3 py-2 bg-primary hover:bg-primary-dark text-white text-sm font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ <FontAwesomeIcon
+ icon={faPlus}
+ className="w-3 h-3"
+ aria-hidden="true"
+ />
+ Add
+ </button>
+ </div>
+ </div>
+
+ {/* Templates Section */}
+ <div className="border-t border-border/50 pt-6">
+ <h3 className="text-sm font-medium text-slate mb-3">
+ Templates
+ </h3>
+ <p className="text-muted text-xs mb-4">
+ Use {"{{FieldName}}"} to insert field values. Available
+ fields:{" "}
+ {fields.length > 0
+ ? fields.map((f) => `{{${f.name}}}`).join(", ")
+ : "(no fields yet)"}
+ </p>
+
+ <div className="space-y-4">
+ <div>
+ <label
+ htmlFor="front-template"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Front Template
+ </label>
+ <input
+ id="front-template"
+ type="text"
+ value={frontTemplate}
+ onChange={(e) => setFrontTemplate(e.target.value)}
+ required
+ maxLength={1000}
+ disabled={isSubmitting}
+ className="w-full px-4 py-2.5 bg-ivory border border-border rounded-lg text-slate placeholder-muted font-mono text-sm transition-all duration-200 hover:border-muted focus:border-primary focus:ring-2 focus:ring-primary/10 disabled:opacity-50 disabled:cursor-not-allowed"
+ />
+ </div>
+
+ <div>
+ <label
+ htmlFor="back-template"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Back Template
+ </label>
+ <input
+ id="back-template"
+ type="text"
+ value={backTemplate}
+ onChange={(e) => setBackTemplate(e.target.value)}
+ required
+ maxLength={1000}
+ disabled={isSubmitting}
+ className="w-full px-4 py-2.5 bg-ivory border border-border rounded-lg text-slate placeholder-muted font-mono text-sm transition-all duration-200 hover:border-muted focus:border-primary focus:ring-2 focus:ring-primary/10 disabled:opacity-50 disabled:cursor-not-allowed"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* Actions */}
+ <div className="flex gap-3 justify-end pt-2 border-t border-border/50">
+ <button
+ type="button"
+ onClick={handleClose}
+ disabled={isSubmitting}
+ className="px-4 py-2 text-slate hover:bg-ivory rounded-lg transition-colors disabled:opacity-50"
+ >
+ Cancel
+ </button>
+ <button
+ type="submit"
+ 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"}
+ </button>
+ </div>
+ </form>
+ )}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/src/client/pages/NoteTypesPage.test.tsx b/src/client/pages/NoteTypesPage.test.tsx
index 0cf8615..c2df7f5 100644
--- a/src/client/pages/NoteTypesPage.test.tsx
+++ b/src/client/pages/NoteTypesPage.test.tsx
@@ -95,9 +95,7 @@ describe("NoteTypesPage", () => {
renderWithProviders();
- expect(
- screen.getByRole("heading", { name: "Note Types" }),
- ).toBeDefined();
+ expect(screen.getByRole("heading", { name: "Note Types" })).toBeDefined();
expect(screen.getByRole("link", { name: "Back to Home" })).toBeDefined();
});
@@ -137,9 +135,7 @@ describe("NoteTypesPage", () => {
renderWithProviders();
await waitFor(() => {
- expect(
- screen.getByRole("heading", { name: "Basic" }),
- ).toBeDefined();
+ expect(screen.getByRole("heading", { name: "Basic" })).toBeDefined();
});
expect(
screen.getByRole("heading", { name: "Basic (and reversed card)" }),
@@ -279,9 +275,7 @@ describe("NoteTypesPage", () => {
expect(screen.getByText("No note types yet")).toBeDefined();
});
- await user.click(
- screen.getByRole("button", { name: /New Note Type/i }),
- );
+ await user.click(screen.getByRole("button", { name: /New Note Type/i }));
expect(screen.getByRole("dialog")).toBeDefined();
expect(
@@ -322,9 +316,7 @@ describe("NoteTypesPage", () => {
});
// Open modal
- await user.click(
- screen.getByRole("button", { name: /New Note Type/i }),
- );
+ await user.click(screen.getByRole("button", { name: /New Note Type/i }));
// Fill in form
await user.type(screen.getByLabelText("Name"), "New Note Type");
@@ -367,10 +359,35 @@ describe("NoteTypesPage", () => {
it("opens edit modal when Edit button is clicked", async () => {
const user = userEvent.setup();
- mockFetch.mockResolvedValue({
- ok: true,
- json: async () => ({ noteTypes: mockNoteTypes }),
- });
+ const mockNoteTypeWithFields = {
+ ...mockNoteTypes[0],
+ fields: [
+ {
+ id: "field-1",
+ noteTypeId: "note-type-1",
+ name: "Front",
+ order: 0,
+ fieldType: "text",
+ },
+ {
+ id: "field-2",
+ noteTypeId: "note-type-1",
+ name: "Back",
+ order: 1,
+ fieldType: "text",
+ },
+ ],
+ };
+
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ noteTypes: mockNoteTypes }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ noteType: mockNoteTypeWithFields }),
+ });
renderWithProviders();
@@ -387,11 +404,33 @@ describe("NoteTypesPage", () => {
expect(
screen.getByRole("heading", { name: "Edit Note Type" }),
).toBeDefined();
- expect(screen.getByLabelText("Name")).toHaveProperty("value", "Basic");
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("Name")).toHaveProperty("value", "Basic");
+ });
});
it("edits note type and refreshes list", async () => {
const user = userEvent.setup();
+ const mockNoteTypeWithFields = {
+ ...mockNoteTypes[0],
+ fields: [
+ {
+ id: "field-1",
+ noteTypeId: "note-type-1",
+ name: "Front",
+ order: 0,
+ fieldType: "text",
+ },
+ {
+ id: "field-2",
+ noteTypeId: "note-type-1",
+ name: "Back",
+ order: 1,
+ fieldType: "text",
+ },
+ ],
+ };
const updatedNoteType = {
...mockNoteTypes[0],
name: "Updated Basic",
@@ -404,11 +443,17 @@ describe("NoteTypesPage", () => {
})
.mockResolvedValueOnce({
ok: true,
+ json: async () => ({ noteType: mockNoteTypeWithFields }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
json: async () => ({ noteType: updatedNoteType }),
})
.mockResolvedValueOnce({
ok: true,
- json: async () => ({ noteTypes: [updatedNoteType, mockNoteTypes[1]] }),
+ json: async () => ({
+ noteTypes: [updatedNoteType, mockNoteTypes[1]],
+ }),
});
renderWithProviders();
@@ -423,6 +468,11 @@ describe("NoteTypesPage", () => {
});
await user.click(editButtons.at(0) as HTMLElement);
+ // Wait for the editor to load
+ await waitFor(() => {
+ expect(screen.getByLabelText("Name")).toHaveProperty("value", "Basic");
+ });
+
// Update name
const nameInput = screen.getByLabelText("Name");
await user.clear(nameInput);
@@ -539,9 +589,7 @@ describe("NoteTypesPage", () => {
// Note type list should be refreshed without deleted note type
await waitFor(() => {
- expect(
- screen.queryByRole("heading", { name: "Basic" }),
- ).toBeNull();
+ expect(screen.queryByRole("heading", { name: "Basic" })).toBeNull();
});
expect(
screen.getByRole("heading", { name: "Basic (and reversed card)" }),
diff --git a/src/client/pages/NoteTypesPage.tsx b/src/client/pages/NoteTypesPage.tsx
index 0a34f5b..020b16c 100644
--- a/src/client/pages/NoteTypesPage.tsx
+++ b/src/client/pages/NoteTypesPage.tsx
@@ -13,7 +13,7 @@ import { Link } from "wouter";
import { ApiClientError, apiClient } from "../api";
import { CreateNoteTypeModal } from "../components/CreateNoteTypeModal";
import { DeleteNoteTypeModal } from "../components/DeleteNoteTypeModal";
-import { EditNoteTypeModal } from "../components/EditNoteTypeModal";
+import { NoteTypeEditor } from "../components/NoteTypeEditor";
interface NoteType {
id: string;
@@ -30,7 +30,9 @@ export function NoteTypesPage() {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
- const [editingNoteType, setEditingNoteType] = useState<NoteType | null>(null);
+ const [editingNoteTypeId, setEditingNoteTypeId] = useState<string | null>(
+ null,
+ );
const [deletingNoteType, setDeletingNoteType] = useState<NoteType | null>(
null,
);
@@ -217,7 +219,7 @@ export function NoteTypesPage() {
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
- onClick={() => setEditingNoteType(noteType)}
+ onClick={() => setEditingNoteTypeId(noteType.id)}
className="p-2 text-muted hover:text-slate hover:bg-ivory rounded-lg transition-colors"
title="Edit note type"
>
@@ -254,10 +256,10 @@ export function NoteTypesPage() {
onNoteTypeCreated={fetchNoteTypes}
/>
- <EditNoteTypeModal
- isOpen={editingNoteType !== null}
- noteType={editingNoteType}
- onClose={() => setEditingNoteType(null)}
+ <NoteTypeEditor
+ isOpen={editingNoteTypeId !== null}
+ noteTypeId={editingNoteTypeId}
+ onClose={() => setEditingNoteTypeId(null)}
onNoteTypeUpdated={fetchNoteTypes}
/>