fix: allow to remove project description (#2338)

fixes https://github.com/paperclipai/paperclip/issues/2336

## Thinking Path

<!--
Required. Trace your reasoning from the top of the project down to this
  specific change. Start with what Paperclip is, then narrow through the
  subsystem, the problem, and why this PR exists. Use blockquote style.
  Aim for 5–8 steps. See CONTRIBUTING.md for full examples.
-->

- Paperclip allows to manage projects
- During the project creation you can optionally enter a description
- In the project overview or configuration you can edit the description
- However, you cannot remove the description
- The user should be able to remove the project description because it's
an optional property
- This pull request fixes the frontend bug that prevented the user to
remove/clear the project description

## What Changed

<!-- Bullet list of concrete changes. One bullet per logical unit. -->

- project description can be cleared in "project configuration" and
"project overview"

## Verification

<!--
  How can a reviewer confirm this works? Include test commands, manual
  steps, or both. For UI changes, include before/after screenshots.
-->

In project configuration or project overview:

- In the description field remove/clear the text

## Risks

<!--
  What could go wrong? Mention migration safety, breaking changes,
  behavioral shifts, or "Low risk" if genuinely minor.
-->

- none

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Nicola 2026-04-06 22:18:38 +02:00 committed by GitHub
parent b6e40fec54
commit 8f722c5751
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 191 additions and 34 deletions

View file

@ -1,27 +1,171 @@
// @vitest-environment jsdom
import { act } from "react";
import { act, forwardRef, useImperativeHandle, useRef } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { queueContainedBlurCommit } from "./InlineEditor";
vi.mock("./MarkdownEditor", () => ({
MarkdownEditor: () => null,
}));
vi.mock("../hooks/useAutosaveIndicator", () => ({
useAutosaveIndicator: () => ({
state: "idle",
markDirty: () => {},
reset: () => {},
runSave: async (save: () => Promise<void>) => {
await save();
},
MarkdownEditor: forwardRef<
{ focus: () => void },
{ value: string; onChange: (value: string) => void }
>(function MarkdownEditorMock(props, ref) {
const taRef = useRef<HTMLTextAreaElement>(null);
useImperativeHandle(ref, () => ({
focus: () => taRef.current?.focus(),
}));
return (
<textarea
ref={taRef}
data-testid="multiline-md-mock"
value={props.value}
onChange={(e) => props.onChange(e.target.value)}
/>
);
}),
}));
import { InlineEditor, queueContainedBlurCommit } from "./InlineEditor";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
/** Lets React detect a DOM value change on controlled textareas (see React #10140). */
function setNativeTextareaValue(textarea: HTMLTextAreaElement, value: string) {
const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set;
const previous = textarea.value;
valueSetter?.call(textarea, value);
const tracker = (textarea as HTMLTextAreaElement & { _valueTracker?: { setValue: (v: string) => void } })
._valueTracker;
tracker?.setValue(previous);
textarea.dispatchEvent(new Event("input", { bubbles: true }));
}
/** Matches `queueContainedBlurCommit` (double rAF before commit). Microtasks alone do not run these. */
function flushDoubleRequestAnimationFrame(): Promise<void> {
return new Promise((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
resolve();
});
});
});
}
describe("InlineEditor", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it("calls onSave with empty string when nullable and the field is cleared (single-line)", () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const root = createRoot(container);
act(() => {
root.render(<InlineEditor value="hello" nullable onSave={onSave} />);
});
const display = container.querySelector("span");
expect(display).not.toBeNull();
expect(display?.textContent).toBe("hello");
act(() => {
display!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const textarea = container.querySelector("textarea");
expect(textarea).not.toBeNull();
act(() => {
setNativeTextareaValue(textarea!, "");
});
act(() => {
textarea!.blur();
});
expect(onSave).toHaveBeenCalledTimes(1);
expect(onSave).toHaveBeenCalledWith("");
act(() => {
root.unmount();
});
});
it("does not call onSave when nullable is false/omitted and the field is cleared", () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const root = createRoot(container);
act(() => {
root.render(<InlineEditor value="hello" onSave={onSave} />);
});
const display = container.querySelector("span");
expect(display).not.toBeNull();
act(() => {
display!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const textarea = container.querySelector("textarea");
expect(textarea).not.toBeNull();
act(() => {
setNativeTextareaValue(textarea!, "");
});
act(() => {
textarea!.blur();
});
expect(onSave).not.toHaveBeenCalled();
act(() => {
root.unmount();
});
});
it("multiline nullable clear uses autosave path (shows Saved after blur)", async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const root = createRoot(container);
const outside = document.createElement("button");
document.body.appendChild(outside);
act(() => {
root.render(<InlineEditor value="hello" multiline nullable onSave={onSave} />);
});
const textarea = container.querySelector<HTMLTextAreaElement>('[data-testid="multiline-md-mock"]');
expect(textarea).not.toBeNull();
act(() => {
textarea!.focus();
});
act(() => {
setNativeTextareaValue(textarea!, "");
});
act(() => {
outside.focus();
});
await act(async () => {
await flushDoubleRequestAnimationFrame();
});
expect(onSave).toHaveBeenCalledTimes(1);
expect(onSave).toHaveBeenCalledWith("");
expect(container.textContent).toContain("Saved");
act(() => {
root.unmount();
});
outside.remove();
});
});
describe("queueContainedBlurCommit", () => {
let container: HTMLDivElement;
let inside: HTMLTextAreaElement;

View file

@ -12,6 +12,7 @@ interface InlineEditorProps {
multiline?: boolean;
imageUploadHandler?: (file: File) => Promise<string>;
mentions?: MentionOption[];
nullable?: boolean;
}
/** Shared padding so display and edit modes occupy the exact same box. */
@ -43,6 +44,7 @@ export function InlineEditor({
className,
placeholder = "Click to edit...",
multiline = false,
nullable = false,
imageUploadHandler,
mentions,
}: InlineEditorProps) {
@ -102,16 +104,36 @@ export function InlineEditor({
}, [editing, multiline]);
const commit = useCallback(async (nextValue = draft) => {
const trimmed = nextValue.trim();
if (trimmed && trimmed !== value) {
await Promise.resolve(onSave(trimmed));
const valueToSave = nextValue.trim();
const valueChanged = valueToSave !== value;
const shouldSave = nullable
? valueChanged
: Boolean(valueToSave && valueChanged);
if (shouldSave) {
await Promise.resolve(onSave(valueToSave));
} else {
setDraft(value);
}
if (!multiline) {
setEditing(false);
}
}, [draft, multiline, onSave, value]);
}, [draft, multiline, nullable, onSave, value]);
/** Multiline blur/submit: show autosave indicator when persisting */
const finalizeMultilineBlurOrSubmit = useCallback(() => {
const trimmed = draft.trim();
if (trimmed === value) {
reset();
void commit();
return;
}
if (!trimmed && !nullable) {
reset();
void commit();
return;
}
void runSave(() => commit());
}, [commit, draft, nullable, reset, runSave, value]);
const cancelPendingBlurCommit = useCallback(() => {
if (blurCommitFrameRef.current === null) return;
@ -127,15 +149,9 @@ export function InlineEditor({
clearTimeout(autosaveDebounceRef.current);
}
setMultilineFocused(false);
const trimmed = draft.trim();
if (!trimmed || trimmed === value) {
reset();
void commit();
return;
}
void runSave(() => commit());
finalizeMultilineBlurOrSubmit();
});
}, [cancelPendingBlurCommit, commit, draft, reset, runSave, value]);
}, [cancelPendingBlurCommit, finalizeMultilineBlurOrSubmit]);
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter" && !multiline) {
@ -163,7 +179,8 @@ export function InlineEditor({
if (!multiline) return;
if (!multilineFocused) return;
const trimmed = draft.trim();
if (!trimmed || trimmed === value) {
// Nullable: empty draft can still be a real edit (clearing); only skip debounce when unchanged or empty is invalid.
if (trimmed === value || (!trimmed && !nullable)) {
if (autosaveState !== "saved") {
reset();
}
@ -182,7 +199,7 @@ export function InlineEditor({
clearTimeout(autosaveDebounceRef.current);
}
};
}, [autosaveState, commit, draft, markDirty, multiline, multilineFocused, reset, runSave, value]);
}, [autosaveState, commit, draft, markDirty, multiline, multilineFocused, nullable, reset, runSave, value]);
if (multiline) {
return (
@ -213,13 +230,7 @@ export function InlineEditor({
imageUploadHandler={imageUploadHandler}
mentions={mentions}
onSubmit={() => {
const trimmed = draft.trim();
if (!trimmed || trimmed === value) {
reset();
void commit();
return;
}
void runSave(() => commit());
finalizeMultilineBlurOrSubmit();
}}
/>
<div className="flex min-h-4 items-center justify-end pr-1">

View file

@ -494,6 +494,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
<InlineEditor
value={project.description ?? ""}
onSave={(description) => commitField("description", { description })}
nullable
as="p"
className="text-sm text-muted-foreground"
placeholder="Add a description..."

View file

@ -73,6 +73,7 @@ function OverviewContent({
<InlineEditor
value={project.description ?? ""}
onSave={(description) => onUpdate({ description })}
nullable
as="p"
className="text-sm text-muted-foreground"
placeholder="Add a description..."