feat(comment): undo button in resolve toast + sort resolved tab by resolve time
Part 1 (#542): add an inline Undo (reopen) button to the resolve success toast in useResolveCommentMutation. Undo re-invokes the same mutation via a self-ref (declared before useMutation, read at click time — no cycle) and clears the inline comment mark through an editor ref (survives the originating CommentListItem unmounting). A closured `done` flag guards a fast double-click (notifications.hide is async). Reopen keeps the plain toast (no Undo). onError gains a terminal 404 branch: drop the comment from the cache and unsetComment its orphaned mark (no rollback → no phantom in Resolved, neutral "Comment no longer exists"). The generic-failure copy is now directional (resolve vs. re-open). Autoclose policy const RESOLVE_UNDO_AUTOCLOSE_MS=10000. Part 2: sort the Resolved tab by resolvedAt DESC via an exported sortResolvedByResolvedAt helper; coerce with new Date(...) because resolvedAt is an ISO string at runtime (Date only in the optimistic window). i18n: add "Failed to re-open comment" and "Comment no longer exists" to en-US/ru-RU. Tests: 6 resolve-undo cases + 4 sort cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user