Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
aac0c31097 fix(editor): add dirty check and allow clearing description
Two editor bugs fixed:

1. Descriptions saved unnecessarily on every document change (no dirty
   check). Added onCreate baseline capture + string comparison in the
   debounced onUpdate handler so mutations only fire when content
   actually changes.

2. Clearing a description didn't persist — empty string was converted
   to undefined via `md || undefined`, causing the field to be omitted
   from the API request. Changed to `md` so empty strings reach the
   backend and clear the description via COALESCE.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 11:43:09 +08:00
3 changed files with 28 additions and 3 deletions

View File

@@ -112,6 +112,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
const onBlurRef = useRef(onBlur);
const onUploadFileRef = useRef(onUploadFile);
const prevContentRef = useRef(defaultValue);
const lastEmittedRef = useRef<string | null>(null);
// Keep refs in sync without recreating editor
onUpdateRef.current = onUpdate;
@@ -127,6 +128,9 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
// Explicit for clarity — the real perf win is useEditorState in BubbleMenu.
shouldRerenderOnTransaction: false,
editable,
onCreate: ({ editor: ed }) => {
lastEmittedRef.current = stripBlobUrls(ed.getMarkdown());
},
content: defaultValue ? preprocessMarkdown(defaultValue) : "",
contentType: defaultValue ? "markdown" : undefined,
extensions: createEditorExtensions({
@@ -141,7 +145,10 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
if (!onUpdateRef.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
onUpdateRef.current?.(stripBlobUrls(ed.getMarkdown()));
const md = stripBlobUrls(ed.getMarkdown());
if (md === lastEmittedRef.current) return;
lastEmittedRef.current = md;
onUpdateRef.current?.(md);
}, debounceMs);
},
onBlur: () => {

View File

@@ -1,6 +1,6 @@
import { forwardRef, useRef, useState, useImperativeHandle } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue, TimelineEntry } from "@multica/core/types";
import { WorkspaceIdProvider } from "@multica/core/hooks";
@@ -474,4 +474,22 @@ describe("IssueDetail (shared)", () => {
expect(screen.getByText("I can help with this")).toBeInTheDocument();
});
it("sends empty description when editor is cleared", async () => {
renderIssueDetail();
await waitFor(() => {
expect(screen.getByDisplayValue("Add JWT auth to the backend")).toBeInTheDocument();
});
const editor = screen.getByPlaceholderText("Add description...");
fireEvent.change(editor, { target: { value: "" } });
await waitFor(() => {
expect(mockApiObj.updateIssue).toHaveBeenCalledWith(
"issue-1",
expect.objectContaining({ description: "" }),
);
});
});
});

View File

@@ -1032,7 +1032,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
key={id}
defaultValue={issue.description || ""}
placeholder="Add description..."
onUpdate={(md) => handleUpdateField({ description: md || undefined })}
onUpdate={(md) => handleUpdateField({ description: md })}
onUploadFile={handleDescriptionUpload}
debounceMs={1500}
/>