feat(comment): Undo в тосте резолва (reopen) + сортировка Resolved по времени резолва (#542) #548
@@ -239,6 +239,8 @@
|
||||
"Comment re-opened successfully": "Comment re-opened successfully",
|
||||
"Comment unresolved successfully": "Comment unresolved successfully",
|
||||
"Failed to resolve comment": "Failed to resolve comment",
|
||||
"Failed to re-open comment": "Failed to re-open comment",
|
||||
"Comment no longer exists": "Comment no longer exists",
|
||||
"Resolve comment": "Resolve comment",
|
||||
"Unresolve comment": "Unresolve comment",
|
||||
"Resolve Comment Thread": "Resolve Comment Thread",
|
||||
|
||||
@@ -239,6 +239,8 @@
|
||||
"Comment re-opened successfully": "Комментарий успешно открыт повторно",
|
||||
"Comment unresolved successfully": "Комментарий успешно переведён в нерешённые",
|
||||
"Failed to resolve comment": "Не удалось разрешить комментарий",
|
||||
"Failed to re-open comment": "Не удалось переоткрыть комментарий",
|
||||
"Comment no longer exists": "Комментарий больше не существует",
|
||||
"Resolve comment": "Решить комментарий",
|
||||
"Unresolve comment": "Снять статус решённого с комментария",
|
||||
"Resolve Comment Thread": "Решить ветку комментариев",
|
||||
|
||||
@@ -27,11 +27,15 @@ vi.mock("@/features/space/queries/space-query.ts", () => ({
|
||||
import {
|
||||
buildChildrenByParent,
|
||||
CommentEditorWithActions,
|
||||
sortResolvedByResolvedAt,
|
||||
} from "./comment-list-with-tabs";
|
||||
|
||||
const c = (id: string, parentCommentId: string | null = null): IComment =>
|
||||
({ id, parentCommentId }) as IComment;
|
||||
|
||||
const resolvedAtComment = (id: string, resolvedAt: unknown): IComment =>
|
||||
({ id, resolvedAt }) as unknown as IComment;
|
||||
|
||||
describe("buildChildrenByParent (childrenByParent grouping)", () => {
|
||||
it("returns an empty map for undefined or empty input", () => {
|
||||
expect(buildChildrenByParent(undefined).size).toBe(0);
|
||||
@@ -71,6 +75,48 @@ describe("buildChildrenByParent (childrenByParent grouping)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortResolvedByResolvedAt (Resolved tab order, #542)", () => {
|
||||
it("orders by resolvedAt DESC — newest resolve first — with ISO-STRING values", () => {
|
||||
// At runtime resolvedAt is an ISO string (axios JSON / WS subscription), so
|
||||
// the sort must coerce with new Date(...) before .getTime().
|
||||
const older = resolvedAtComment("older", "2026-07-10T10:00:00.000Z");
|
||||
const newest = resolvedAtComment("newest", "2026-07-12T10:00:00.000Z");
|
||||
const middle = resolvedAtComment("middle", "2026-07-11T10:00:00.000Z");
|
||||
|
||||
const out = sortResolvedByResolvedAt([older, newest, middle]);
|
||||
expect(out.map((x) => x.id)).toEqual(["newest", "middle", "older"]);
|
||||
});
|
||||
|
||||
it("also handles Date instances (optimistic onMutate window)", () => {
|
||||
const older = resolvedAtComment("older", new Date("2026-01-01T00:00:00Z"));
|
||||
const newer = resolvedAtComment("newer", new Date("2026-06-01T00:00:00Z"));
|
||||
expect(sortResolvedByResolvedAt([older, newer]).map((x) => x.id)).toEqual([
|
||||
"newer",
|
||||
"older",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not mutate the input array", () => {
|
||||
const a = resolvedAtComment("a", "2026-01-01T00:00:00.000Z");
|
||||
const b = resolvedAtComment("b", "2026-02-01T00:00:00.000Z");
|
||||
const input = [a, b];
|
||||
sortResolvedByResolvedAt(input);
|
||||
expect(input.map((x) => x.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("keeps stable order for equal resolvedAt timestamps", () => {
|
||||
const ts = "2026-03-03T03:03:03.000Z";
|
||||
const x = resolvedAtComment("x", ts);
|
||||
const y = resolvedAtComment("y", ts);
|
||||
const z = resolvedAtComment("z", ts);
|
||||
expect(sortResolvedByResolvedAt([x, y, z]).map((c) => c.id)).toEqual([
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function renderReplyEditor() {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
|
||||
@@ -53,6 +53,22 @@ export function buildChildrenByParent(
|
||||
return m;
|
||||
}
|
||||
|
||||
// Sort the Resolved tab by resolve time, newest first, on a COPY (never mutate
|
||||
// the react-query cache array). `resolvedAt` is typed `Date` but at runtime it
|
||||
// is an ISO STRING (from the axios-JSON onSuccess and the WS subscription) — a
|
||||
// real Date only during the optimistic onMutate window — so it MUST be coerced
|
||||
// with `new Date(...)` before `.getTime()`, or a raw `.getTime()` on the string
|
||||
// throws / yields NaN. ES2019's stable sort preserves order for equal
|
||||
// timestamps. Callers pass a list already filtered to a truthy `resolvedAt`, so
|
||||
// the non-null assertion is safe.
|
||||
// Exported for unit testing.
|
||||
export function sortResolvedByResolvedAt(resolved: IComment[]): IComment[] {
|
||||
return [...resolved].sort(
|
||||
(a, b) =>
|
||||
new Date(b.resolvedAt!).getTime() - new Date(a.resolvedAt!).getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { pageSlug } = useParams();
|
||||
@@ -91,7 +107,10 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
(comment: IComment) => comment.resolvedAt,
|
||||
);
|
||||
|
||||
return { activeComments: active, resolvedComments: resolved };
|
||||
return {
|
||||
activeComments: active,
|
||||
resolvedComments: sortResolvedByResolvedAt(resolved),
|
||||
};
|
||||
}, [comments]);
|
||||
|
||||
// Index replies by their parent once, instead of an O(n^2) filter per thread.
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
InfiniteData,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* Coverage for the resolve/reopen mutation (#542): the Undo-in-toast reopen and
|
||||
* its double-click guard, the terminal 404 branch (drop from cache + clear the
|
||||
* inline mark, no rollback), and the directional error copy.
|
||||
*/
|
||||
|
||||
// A fake TipTap editor injected via the mocked pageEditorAtom, so we can assert
|
||||
// the mutation clears the inline comment mark (unsetComment / setCommentResolved).
|
||||
const editorMock = vi.hoisted(() => ({
|
||||
current: {
|
||||
isDestroyed: false,
|
||||
commands: { unsetComment: vi.fn(), setCommentResolved: vi.fn() },
|
||||
} as {
|
||||
isDestroyed: boolean;
|
||||
commands: {
|
||||
unsetComment: (id: string) => void;
|
||||
setCommentResolved: (id: string, v: boolean) => void;
|
||||
};
|
||||
} | null,
|
||||
}));
|
||||
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: vi.fn(), hide: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("jotai", () => ({
|
||||
atom: (v: unknown) => v,
|
||||
useAtomValue: () => editorMock.current,
|
||||
}));
|
||||
|
||||
vi.mock("@/features/comment/services/comment-service", () => ({
|
||||
applySuggestion: vi.fn(),
|
||||
dismissSuggestion: vi.fn(),
|
||||
createComment: vi.fn(),
|
||||
updateComment: vi.fn(),
|
||||
deleteComment: vi.fn(),
|
||||
resolveComment: vi.fn(),
|
||||
getPageComments: vi.fn(),
|
||||
}));
|
||||
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { resolveComment } from "@/features/comment/services/comment-service";
|
||||
import {
|
||||
useResolveCommentMutation,
|
||||
RESOLVE_UNDO_AUTOCLOSE_MS,
|
||||
RQ_KEY,
|
||||
} from "@/features/comment/queries/comment-query";
|
||||
import { IComment } from "@/features/comment/types/comment.types";
|
||||
|
||||
const PAGE_ID = "page-1";
|
||||
|
||||
function seededClient(comment: IComment) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
});
|
||||
const seed: InfiniteData<any> = {
|
||||
pageParams: [undefined],
|
||||
pages: [
|
||||
{ items: [comment], meta: { hasNextPage: false, nextCursor: null } },
|
||||
],
|
||||
};
|
||||
queryClient.setQueryData(RQ_KEY(PAGE_ID), seed);
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
return { queryClient, wrapper };
|
||||
}
|
||||
|
||||
function items(queryClient: QueryClient): IComment[] {
|
||||
const cache = queryClient.getQueryData(RQ_KEY(PAGE_ID)) as
|
||||
| InfiniteData<any>
|
||||
| undefined;
|
||||
return cache?.pages.flatMap((p) => p.items) ?? [];
|
||||
}
|
||||
|
||||
const comment = (over?: Partial<IComment>): IComment =>
|
||||
({
|
||||
id: "c-1",
|
||||
pageId: PAGE_ID,
|
||||
content: "{}",
|
||||
creatorId: "u-1",
|
||||
workspaceId: "ws-1",
|
||||
createdAt: new Date(),
|
||||
resolvedAt: null,
|
||||
...over,
|
||||
}) as IComment;
|
||||
|
||||
// Pull the inline Undo button's onClick out of the success toast's message tree.
|
||||
function undoOnClickFromToast(): () => void {
|
||||
const call = vi
|
||||
.mocked(notifications.show)
|
||||
.mock.calls.map((c) => c[0])
|
||||
.find((arg: any) => arg?.autoClose === RESOLVE_UNDO_AUTOCLOSE_MS);
|
||||
expect(call).toBeTruthy();
|
||||
const message: any = (call as any).message;
|
||||
// message = Group( Text, Button ); grab the Button element's onClick.
|
||||
const children = message.props.children as any[];
|
||||
const button = children[1];
|
||||
return button.props.onClick;
|
||||
}
|
||||
|
||||
describe("useResolveCommentMutation — Undo toast (#542)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
editorMock.current = {
|
||||
isDestroyed: false,
|
||||
commands: { unsetComment: vi.fn(), setCommentResolved: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
it("resolve shows an Undo toast with autoClose=10000ms; reopen shows NO Undo", async () => {
|
||||
vi.mocked(resolveComment).mockImplementation(async (data) =>
|
||||
comment({
|
||||
resolvedAt: data.resolved ? (new Date() as any) : null,
|
||||
}),
|
||||
);
|
||||
const { wrapper } = seededClient(comment());
|
||||
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await result.current.mutateAsync({
|
||||
commentId: "c-1",
|
||||
pageId: PAGE_ID,
|
||||
resolved: true,
|
||||
});
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
const resolveToast = vi
|
||||
.mocked(notifications.show)
|
||||
.mock.calls.map((c) => c[0])
|
||||
.find((a: any) => a?.autoClose === RESOLVE_UNDO_AUTOCLOSE_MS);
|
||||
expect(resolveToast).toBeTruthy();
|
||||
expect((resolveToast as any).id).toBe("resolve-undo-c-1");
|
||||
expect((resolveToast as any).autoClose).toBe(10000);
|
||||
|
||||
// Now a reopen → plain toast, no autoClose/Undo, no id.
|
||||
vi.clearAllMocks();
|
||||
await result.current.mutateAsync({
|
||||
commentId: "c-1",
|
||||
pageId: PAGE_ID,
|
||||
resolved: false,
|
||||
});
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
const calls = vi.mocked(notifications.show).mock.calls.map((c) => c[0]);
|
||||
expect(
|
||||
calls.some((a: any) => a?.autoClose === RESOLVE_UNDO_AUTOCLOSE_MS),
|
||||
).toBe(false);
|
||||
expect(calls).toContainEqual({ message: "Comment re-opened successfully" });
|
||||
});
|
||||
|
||||
it("double/fast Undo click fires reopen EXACTLY once (guard)", async () => {
|
||||
vi.mocked(resolveComment).mockImplementation(async (data) =>
|
||||
comment({ resolvedAt: data.resolved ? (new Date() as any) : null }),
|
||||
);
|
||||
const { wrapper } = seededClient(comment());
|
||||
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await result.current.mutateAsync({
|
||||
commentId: "c-1",
|
||||
pageId: PAGE_ID,
|
||||
resolved: true,
|
||||
});
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
const onClick = undoOnClickFromToast();
|
||||
// Fire twice synchronously (notifications.hide is not synchronous).
|
||||
onClick();
|
||||
onClick();
|
||||
|
||||
await waitFor(() => {
|
||||
const reopenCalls = vi
|
||||
.mocked(resolveComment)
|
||||
.mock.calls.filter(([d]) => d.resolved === false);
|
||||
expect(reopenCalls).toHaveLength(1);
|
||||
});
|
||||
// The mark was cleared once via setCommentResolved(id, false).
|
||||
expect(editorMock.current!.commands.setCommentResolved).toHaveBeenCalledWith(
|
||||
"c-1",
|
||||
false,
|
||||
);
|
||||
// The toast was hidden.
|
||||
expect(notifications.hide).toHaveBeenCalledWith("resolve-undo-c-1");
|
||||
});
|
||||
|
||||
it("404 → drops the comment from cache, clears the inline mark, no rollback, no Undo", async () => {
|
||||
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 404 } });
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await result.current
|
||||
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID, resolved: true })
|
||||
.catch(() => undefined);
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
// Removed from cache (NOT rolled back to a phantom).
|
||||
expect(items(queryClient)).toHaveLength(0);
|
||||
// Inline mark cleared via unsetComment (mandatory — no panel row left to do it).
|
||||
expect(editorMock.current!.commands.unsetComment).toHaveBeenCalledWith(
|
||||
"c-1",
|
||||
);
|
||||
// Neutral message, red, and crucially NOT the success copy and NO Undo toast.
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
message: "Comment no longer exists",
|
||||
color: "red",
|
||||
});
|
||||
const calls = vi.mocked(notifications.show).mock.calls.map((c) => c[0]);
|
||||
expect(
|
||||
calls.some((a: any) => a?.autoClose === RESOLVE_UNDO_AUTOCLOSE_MS),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("404 does not crash when the editor is gone (read-only / panel closed)", async () => {
|
||||
editorMock.current = null;
|
||||
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 404 } });
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await result.current
|
||||
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID, resolved: true })
|
||||
.catch(() => undefined);
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
expect(items(queryClient)).toHaveLength(0);
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
message: "Comment no longer exists",
|
||||
color: "red",
|
||||
});
|
||||
});
|
||||
|
||||
it("non-404 error on REOPEN shows 'Failed to re-open comment' and rolls back", async () => {
|
||||
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 500 } });
|
||||
// Seed a RESOLVED comment (the reopen target).
|
||||
const resolved = comment({ resolvedAt: new Date() as any });
|
||||
const { queryClient, wrapper } = seededClient(resolved);
|
||||
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await result.current
|
||||
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID, resolved: false })
|
||||
.catch(() => undefined);
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
message: "Failed to re-open comment",
|
||||
color: "red",
|
||||
});
|
||||
expect(notifications.show).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "Failed to resolve comment" }),
|
||||
);
|
||||
// Rolled back: the comment is still present and still resolved.
|
||||
expect(items(queryClient)).toHaveLength(1);
|
||||
expect(items(queryClient)[0].resolvedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reopen via Undo FAILS (non-404) → inline mark is NOT left cleared (doc↔panel stay consistent)", async () => {
|
||||
// First resolve succeeds → produces the Undo toast (no mark change on resolve).
|
||||
vi.mocked(resolveComment).mockResolvedValueOnce(
|
||||
comment({ resolvedAt: new Date() as any }),
|
||||
);
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await result.current.mutateAsync({
|
||||
commentId: "c-1",
|
||||
pageId: PAGE_ID,
|
||||
resolved: true,
|
||||
});
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
// Now the reopen fired by Undo fails with a 500.
|
||||
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 500 } });
|
||||
const onClick = undoOnClickFromToast();
|
||||
onClick();
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
// Core F1 guarantee: the mark-clear now lives in the reopen onSuccess, so a
|
||||
// FAILED reopen must never flip the inline mark to unresolved — otherwise the
|
||||
// doc would show an active highlight the panel still treats as resolved and
|
||||
// the collab mark would diverge with nothing committed on the server.
|
||||
expect(
|
||||
editorMock.current!.commands.setCommentResolved,
|
||||
).not.toHaveBeenCalledWith("c-1", false);
|
||||
// Cache rolled back: the comment stays resolved and present.
|
||||
expect(items(queryClient)).toHaveLength(1);
|
||||
expect(items(queryClient)[0].resolvedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reopen success with a null editorRef degrades gracefully (no throw, no-op)", async () => {
|
||||
// Read-only view / panel closed: pageEditorAtom is null on the success path.
|
||||
editorMock.current = null;
|
||||
vi.mocked(resolveComment).mockResolvedValue(comment({ resolvedAt: null }));
|
||||
const resolved = comment({ resolvedAt: new Date() as any });
|
||||
const { queryClient, wrapper } = seededClient(resolved);
|
||||
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await result.current.mutateAsync({
|
||||
commentId: "c-1",
|
||||
pageId: PAGE_ID,
|
||||
resolved: false,
|
||||
});
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
// No crash from the reopen mark-clear; the plain reopen toast is still shown.
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
message: "Comment re-opened successfully",
|
||||
});
|
||||
// Cache updated to reopened (resolvedAt cleared by the server payload).
|
||||
expect(items(queryClient)).toHaveLength(1);
|
||||
expect(items(queryClient)[0].resolvedAt).toBeFalsy();
|
||||
});
|
||||
|
||||
it("non-404 error on RESOLVE shows 'Failed to resolve comment' and rolls back", async () => {
|
||||
vi.mocked(resolveComment).mockRejectedValue({ response: { status: 500 } });
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
const { result } = renderHook(() => useResolveCommentMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await result.current
|
||||
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID, resolved: true })
|
||||
.catch(() => undefined);
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
message: "Failed to resolve comment",
|
||||
color: "red",
|
||||
});
|
||||
// Rolled back to open (previousCache), still present.
|
||||
expect(items(queryClient)).toHaveLength(1);
|
||||
expect(items(queryClient)[0].resolvedAt).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -20,12 +20,19 @@ import {
|
||||
ISuggestionOutcome,
|
||||
} from "@/features/comment/types/comment.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { Button, Group, Text } from "@mantine/core";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
|
||||
export const RQ_KEY = (pageId: string) => ["comments", pageId];
|
||||
|
||||
// How long the resolve success toast (with its inline Undo) stays up before it
|
||||
// auto-closes. Policy constant — no env override.
|
||||
export const RESOLVE_UNDO_AUTOCLOSE_MS = 10000;
|
||||
|
||||
export function useCommentsQuery(params: ICommentParams) {
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: RQ_KEY(params.pageId),
|
||||
@@ -376,7 +383,25 @@ export function useResolveCommentMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
// Keep the live editor in a ref: the toast's Undo (and the 404 branch) must
|
||||
// clear the inline comment mark AFTER the originating CommentListItem has
|
||||
// unmounted (resolving pulls the comment out of the Open list, so its item is
|
||||
// already gone by the time the 10s toast is clicked). In read-only view
|
||||
// pageEditorAtom is null and the mark converges via the server's
|
||||
// COMMENT_MARK_UPDATE job instead.
|
||||
const editor = useAtomValue(pageEditorAtom);
|
||||
const editorRef = useRef(editor);
|
||||
editorRef.current = editor;
|
||||
|
||||
// Self-reference the mutation so the toast's Undo can re-invoke it (reopen)
|
||||
// long after the triggering component unmounted. Declared BEFORE useMutation
|
||||
// and assigned AFTER; the onClick reads mutationRef.current at CALL time, not
|
||||
// definition time, so there is no initialization cycle.
|
||||
const mutationRef = useRef<{
|
||||
mutate: (vars: IResolveComment) => void;
|
||||
} | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: IResolveComment) => resolveComment(data),
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({ queryKey: RQ_KEY(variables.pageId) });
|
||||
@@ -401,7 +426,39 @@ export function useResolveCommentMutation() {
|
||||
|
||||
return { previousCache };
|
||||
},
|
||||
onError: (_err, variables, context) => {
|
||||
onError: (err: any, variables, context) => {
|
||||
// Terminal 404: the comment was really deleted (missing comment or deleted
|
||||
// page — access denial is 403, resolve is idempotent so no 400). Do NOT
|
||||
// roll back (that would resurrect a phantom row in Resolved); instead drop
|
||||
// it from the cache and clear its now-orphaned inline mark. Mirrors
|
||||
// handleDeleteComment and the dismiss-mutation 404 branch.
|
||||
if (err?.response?.status === 404) {
|
||||
const cache = queryClient.getQueryData(RQ_KEY(variables.pageId)) as
|
||||
| InfiniteData<IPagination<IComment>>
|
||||
| undefined;
|
||||
if (cache) {
|
||||
queryClient.setQueryData(
|
||||
RQ_KEY(variables.pageId),
|
||||
removeCommentFromCache(cache, variables.commentId),
|
||||
);
|
||||
}
|
||||
const ed = editorRef.current;
|
||||
if (ed && !ed.isDestroyed) {
|
||||
try {
|
||||
ed.commands.unsetComment(variables.commentId);
|
||||
} catch {
|
||||
/* editor gone / mark already removed */
|
||||
}
|
||||
}
|
||||
notifications.show({
|
||||
message: t("Comment no longer exists"),
|
||||
color: "red",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Generic failure: roll back the optimistic update and show a DIRECTIONAL
|
||||
// error (resolve vs. reopen), not always "resolve".
|
||||
if (context?.previousCache) {
|
||||
queryClient.setQueryData(
|
||||
RQ_KEY(variables.pageId),
|
||||
@@ -409,7 +466,9 @@ export function useResolveCommentMutation() {
|
||||
);
|
||||
}
|
||||
notifications.show({
|
||||
message: t("Failed to resolve comment"),
|
||||
message: variables.resolved
|
||||
? t("Failed to resolve comment")
|
||||
: t("Failed to re-open comment"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
@@ -430,11 +489,72 @@ export function useResolveCommentMutation() {
|
||||
);
|
||||
}
|
||||
|
||||
// Reopen keeps the plain toast without an Undo.
|
||||
if (!variables.resolved) {
|
||||
// Clear the inline mark ONLY after the server confirms the reopen, so a
|
||||
// failed reopen never leaves an active highlight the panel still treats
|
||||
// as resolved. Mirrors the 404 branch's editor-liveness guard/try-catch.
|
||||
// The button-triggered reopen already set the mark, so this is an
|
||||
// idempotent no-op there.
|
||||
const ed = editorRef.current;
|
||||
if (ed && !ed.isDestroyed) {
|
||||
try {
|
||||
ed.commands.setCommentResolved(variables.commentId, false);
|
||||
} catch {
|
||||
/* editor gone — server COMMENT_MARK_UPDATE converges it */
|
||||
}
|
||||
}
|
||||
notifications.show({ message: t("Comment re-opened successfully") });
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve: attach an inline Undo (reopen) to the success toast. Built with
|
||||
// React.createElement because this is a .ts module (no JSX).
|
||||
const { commentId, pageId } = variables;
|
||||
const notificationId = `resolve-undo-${commentId}`;
|
||||
// Double-click guard: notifications.hide is NOT synchronous, so the button
|
||||
// stays clickable for a frame or two — without this a fast double-click
|
||||
// would fire reopen twice.
|
||||
let done = false;
|
||||
notifications.show({
|
||||
message: variables.resolved
|
||||
? t("Comment resolved successfully")
|
||||
: t("Comment re-opened successfully"),
|
||||
id: notificationId,
|
||||
autoClose: RESOLVE_UNDO_AUTOCLOSE_MS,
|
||||
message: React.createElement(
|
||||
Group,
|
||||
{ justify: "space-between", wrap: "nowrap", gap: "md" },
|
||||
React.createElement(
|
||||
Text,
|
||||
{ size: "sm" },
|
||||
t("Comment resolved successfully"),
|
||||
),
|
||||
React.createElement(
|
||||
Button,
|
||||
{
|
||||
variant: "subtle",
|
||||
size: "compact-sm",
|
||||
onClick: () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
// Reopen via the SAME mutation (read at click time — the
|
||||
// originating item is already unmounted).
|
||||
mutationRef.current?.mutate({
|
||||
commentId,
|
||||
pageId,
|
||||
resolved: false,
|
||||
});
|
||||
// The inline mark is cleared in the reopen mutation's onSuccess
|
||||
// (bound to server confirmation), NOT here — clearing it eagerly
|
||||
// would desync the doc from the panel if reopen then fails.
|
||||
notifications.hide(notificationId);
|
||||
},
|
||||
},
|
||||
t("Undo"),
|
||||
),
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
mutationRef.current = mutation;
|
||||
return mutation;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user