diff --git a/apps/client/src/features/comment/components/comment-content-view.test.tsx b/apps/client/src/features/comment/components/comment-content-view.test.tsx index 597c60d0..96e01961 100644 --- a/apps/client/src/features/comment/components/comment-content-view.test.tsx +++ b/apps/client/src/features/comment/components/comment-content-view.test.tsx @@ -72,6 +72,15 @@ describe("CommentContentView", () => { expect(container.querySelector("s")?.textContent).toBe("st"); }); + it("renders the underline mark as (not the editor fallback)", () => { + const { container } = renderView( + doc([para([text("un", [{ type: "underline" }])])]), + ); + expect(container.querySelector("u")?.textContent).toBe("un"); + // Underline is a supported mark, so no degrade to the editor fallback. + expect(screen.queryByTestId("comment-editor-fallback")).toBeNull(); + }); + it("renders the code mark as ", () => { const { container } = renderView( doc([para([text("co", [{ type: "code" }])])]), diff --git a/apps/client/src/features/comment/components/comment-content-view.tsx b/apps/client/src/features/comment/components/comment-content-view.tsx index 7e6feea9..3255cd24 100644 --- a/apps/client/src/features/comment/components/comment-content-view.tsx +++ b/apps/client/src/features/comment/components/comment-content-view.tsx @@ -62,6 +62,11 @@ function renderMarks( return {acc}; case "strike": return {acc}; + case "underline": + // StarterKit enables the Underline extension by default (Mod-u) and + // CommentEditor does not disable it, so real comments can carry this + // mark. Render it here rather than degrading the whole comment. + return {acc}; case "code": return {acc}; case "link": { diff --git a/apps/client/src/features/comment/components/comment-list-item.test.tsx b/apps/client/src/features/comment/components/comment-list-item.test.tsx index f41f952c..5b217520 100644 --- a/apps/client/src/features/comment/components/comment-list-item.test.tsx +++ b/apps/client/src/features/comment/components/comment-list-item.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect, vi } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { MantineProvider } from "@mantine/core"; import { IComment } from "@/features/comment/types/comment.types"; @@ -9,10 +9,11 @@ import { IComment } from "@/features/comment/types/comment.types"; // component renders in isolation. We only assert the AI-badge rendering branch. const applyMutateAsync = vi.fn(); const dismissMutateAsync = vi.fn(); +const updateMutateAsync = vi.fn(); vi.mock("@/features/comment/queries/comment-query", () => ({ useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }), useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }), - useUpdateCommentMutation: () => ({ mutateAsync: vi.fn() }), + useUpdateCommentMutation: () => ({ mutateAsync: updateMutateAsync }), useApplySuggestionMutation: () => ({ mutateAsync: applyMutateAsync, isPending: false, @@ -23,10 +24,42 @@ vi.mock("@/features/comment/queries/comment-query", () => ({ }), })); +// The document the mocked editor emits via onUpdate when the edit form is open. +// Duplicated inside the mock factory (below) to keep the factory self-contained. +const EDITED_DOC = { + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: "edited via editor" }] }, + ], +}; + // CommentEditor pulls in the full TipTap editor stack; replace it with a stub. -vi.mock("@/features/comment/components/comment-editor", () => ({ - default: () =>
, -})); +// In edit mode the stub exposes buttons that fire the real onUpdate/onSave props +// so the edit->save/cancel flow can be driven without a live editor. +vi.mock("@/features/comment/components/comment-editor", () => { + const doc = { + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: "edited via editor" }] }, + ], + }; + return { + default: ({ onUpdate, onSave }: any) => ( +
+
+ ), + }; +}); // CommentContentView (used for the read-only body) imports the mention view, // which pulls page-query -> main.tsx (createRoot). Stub the queries so the item @@ -297,6 +330,113 @@ describe("canShowDismiss predicate", () => { }); }); +describe("CommentListItem — edit -> save/cancel flow (#340 F3)", () => { + const body = (t: string) => + JSON.stringify({ + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: t }] }], + }); + + // The edit menu item is gated on the viewer owning the comment + // (currentUser.id === creatorId). currentUserAtom is atomWithStorage-backed, + // so seed localStorage to make the viewer the owner (creatorId "user-1"). + beforeEach(() => { + updateMutateAsync.mockClear(); + localStorage.setItem( + "currentUser", + JSON.stringify({ user: { id: "user-1", name: "Owner" } }), + ); + }); + afterEach(() => { + localStorage.clear(); + }); + + async function openEditor() { + // Open the comment menu, then click "Edit comment" to toggle into edit mode. + fireEvent.click(screen.getByLabelText("Comment menu")); + fireEvent.click(await screen.findByText("Edit comment")); + // Edit form (mocked editor + actions) is now mounted. + await screen.findByTestId("comment-editor"); + } + + it("saves the edited content and, on cache update, shows the new body", async () => { + const { rerender } = renderItem( + baseComment({ content: body("original body") }), + ); + // Static body first. + expect(screen.getByText("original body")).toBeDefined(); + + await openEditor(); + + // Editor emits an update (populates editContentRef), then Save is clicked. + fireEvent.click(screen.getByTestId("editor-emit-update")); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + // mutateAsync is called with the stringified edited doc. + expect(updateMutateAsync).toHaveBeenCalledWith({ + commentId: "c-1", + content: JSON.stringify(EDITED_DOC), + }); + + // On success the form closes (isEditing -> false); the static body renders + // from the comment.content prop again. + await waitFor(() => + expect(screen.queryByTestId("comment-editor")).toBeNull(), + ); + + // Simulate the cache invalidation swapping in a new comment object with the + // updated content — the static body reflects it. + rerender( + + + , + ); + expect(screen.getByText("updated body after save")).toBeDefined(); + expect(screen.queryByText("original body")).toBeNull(); + }); + + it("cancel restores the static body and does not call the update mutation", async () => { + renderItem(baseComment({ content: body("original body") })); + await openEditor(); + + // Type something (editContentRef set), then cancel. + fireEvent.click(screen.getByTestId("editor-emit-update")); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + // Editor unmounts, static body restored, no save happened. + await waitFor(() => + expect(screen.queryByTestId("comment-editor")).toBeNull(), + ); + expect(screen.getByText("original body")).toBeDefined(); + expect(updateMutateAsync).not.toHaveBeenCalled(); + }); + + it("saving without editing sends the existing content (editContentRef cleared after cancel)", async () => { + renderItem(baseComment({ content: body("original body") })); + + // Cancel path clears editContentRef... + await openEditor(); + fireEvent.click(screen.getByTestId("editor-emit-update")); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + await waitFor(() => + expect(screen.queryByTestId("comment-editor")).toBeNull(), + ); + + // ...so re-opening and saving WITHOUT an update falls back to comment.content. + await openEditor(); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + expect(updateMutateAsync).toHaveBeenCalledWith({ + commentId: "c-1", + content: JSON.stringify(body("original body")), + }); + }); +}); + describe("CommentListItem — read-only body renders statically", () => { it("renders the comment body as static text without a TipTap editor", () => { renderItem( diff --git a/apps/client/src/features/comment/components/comment-list-with-tabs.test.tsx b/apps/client/src/features/comment/components/comment-list-with-tabs.test.tsx new file mode 100644 index 00000000..cdc3bcb4 --- /dev/null +++ b/apps/client/src/features/comment/components/comment-list-with-tabs.test.tsx @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { IComment } from "@/features/comment/types/comment.types"; + +// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts. + +// CommentEditor pulls in the full TipTap editor stack; replace it with a stub so +// the lazy reply editor's mount transition can be observed without the editor. +vi.mock("@/features/comment/components/comment-editor", () => ({ + default: () =>
, +})); + +// page-query -> main.tsx (createRoot) is a module side effect; stub the queries +// pulled in transitively so importing the module is side-effect free. +vi.mock("@/features/page/queries/page-query.ts", () => ({ + usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }), +})); +vi.mock("@/features/share/queries/share-query.ts", () => ({ + useSharePageQuery: () => ({ data: undefined }), +})); +// space-query -> main.tsx (createRoot) is another module side effect; stub it. +vi.mock("@/features/space/queries/space-query.ts", () => ({ + useGetSpaceBySlugQuery: () => ({ data: undefined }), +})); + +import { + buildChildrenByParent, + CommentEditorWithActions, +} from "./comment-list-with-tabs"; + +const c = (id: string, parentCommentId: string | null = null): IComment => + ({ id, parentCommentId }) as IComment; + +describe("buildChildrenByParent (childrenByParent grouping)", () => { + it("returns an empty map for undefined or empty input", () => { + expect(buildChildrenByParent(undefined).size).toBe(0); + expect(buildChildrenByParent([]).size).toBe(0); + }); + + it("does not index a top-level comment (parentCommentId null)", () => { + const map = buildChildrenByParent([c("p1", null)]); + expect(map.size).toBe(0); + expect(map.has("p1")).toBe(false); + }); + + it("groups replies under the correct parent, including reply-to-reply nesting", () => { + const p1 = c("p1", null); + const r1 = c("r1", "p1"); + const r2 = c("r2", "r1"); // a reply to a reply + const map = buildChildrenByParent([p1, r1, r2]); + expect(map.get("p1")).toEqual([r1]); + expect(map.get("r1")).toEqual([r2]); + // The top-level comment itself is never a key. + expect(map.has("p1") && map.get("p1")?.length).toBe(1); + }); + + it("still groups a reply whose parent is not present in items", () => { + const orphan = c("o1", "missing-parent"); + const map = buildChildrenByParent([orphan]); + expect(map.get("missing-parent")).toEqual([orphan]); + }); + + it("preserves insertion order among sibling replies", () => { + const map = buildChildrenByParent([ + c("a", "p1"), + c("b", "p1"), + c("d", "p1"), + ]); + expect(map.get("p1")?.map((x) => x.id)).toEqual(["a", "b", "d"]); + }); +}); + +function renderReplyEditor() { + return render( + + + , + ); +} + +describe("CommentEditorWithActions — lazy reply editor activation", () => { + it("shows only the stub initially (no editor instance mounted)", () => { + renderReplyEditor(); + expect(screen.getByRole("button")).toBeDefined(); + expect(screen.queryByTestId("comment-editor")).toBeNull(); + }); + + it("mounts the real editor when the stub is clicked and keeps it mounted", () => { + renderReplyEditor(); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByTestId("comment-editor")).toBeDefined(); + // The stub button is replaced by the editor subtree. + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("mounts the editor when the stub receives focus", () => { + renderReplyEditor(); + fireEvent.focus(screen.getByRole("button")); + expect(screen.getByTestId("comment-editor")).toBeDefined(); + }); + + it("mounts the editor on Enter keydown of the stub", () => { + renderReplyEditor(); + fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" }); + expect(screen.getByTestId("comment-editor")).toBeDefined(); + }); +}); diff --git a/apps/client/src/features/comment/components/comment-list-with-tabs.tsx b/apps/client/src/features/comment/components/comment-list-with-tabs.tsx index 5ee19a3e..b5e62e8e 100644 --- a/apps/client/src/features/comment/components/comment-list-with-tabs.tsx +++ b/apps/client/src/features/comment/components/comment-list-with-tabs.tsx @@ -35,6 +35,24 @@ interface CommentListWithTabsProps { onClose?: () => void; } +// Index replies by their parent id once (O(n)), instead of an O(n^2) filter per +// thread. Replies whose parent is not in `items` are still grouped under their +// parentCommentId (they simply won't be reached by the top-level walk). +// Exported for unit testing. +export function buildChildrenByParent( + items: IComment[] | undefined, +): Map { + const m = new Map(); + for (const c of items ?? []) { + if (c.parentCommentId) { + const arr = m.get(c.parentCommentId); + if (arr) arr.push(c); + else m.set(c.parentCommentId, [c]); + } + } + return m; +} + function CommentListWithTabs({ onClose }: CommentListWithTabsProps) { const { t } = useTranslation(); const { pageSlug } = useParams(); @@ -79,17 +97,10 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) { // Index replies by their parent once, instead of an O(n^2) filter per thread. // The map ref changes on any comments update, so MemoizedChildComments re-runs // (cheap) and re-looks-up, while memoized CommentListItems skip unchanged items. - const childrenByParent = useMemo(() => { - const m = new Map(); - for (const c of comments?.items ?? []) { - if (c.parentCommentId) { - const arr = m.get(c.parentCommentId); - if (arr) arr.push(c); - else m.set(c.parentCommentId, [c]); - } - } - return m; - }, [comments?.items]); + const childrenByParent = useMemo( + () => buildChildrenByParent(comments?.items), + [comments?.items], + ); const [isPageCommentLoading, setIsPageCommentLoading] = useState(false); @@ -217,8 +228,10 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) { Resolved -> Open switch. keepMounted={false} style={{ flex: "1 1 auto", @@ -278,7 +291,10 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) { type="scroll" >
- + {/* keepMounted keeps the Open panel alive even while Resolved is + active, so a lazily-mounted reply editor's draft (and an + in-progress edit) is not discarded on tab switch. */} + {activeComments.length === 0 ? (
@@ -368,7 +384,7 @@ const ChildComments = ({ const MemoizedChildComments = memo(ChildComments); -const CommentEditorWithActions = ({ +export const CommentEditorWithActions = ({ commentId, onSave, placeholder = undefined,