Add optimistic issue comment rendering
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
3986eb615c
commit
52bb4ea37a
5 changed files with 407 additions and 22 deletions
|
|
@ -11,6 +11,10 @@ import type {
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
|
|
||||||
|
export type IssueUpdateResponse = Issue & {
|
||||||
|
comment?: IssueComment | null;
|
||||||
|
};
|
||||||
|
|
||||||
export const issuesApi = {
|
export const issuesApi = {
|
||||||
list: (
|
list: (
|
||||||
companyId: string,
|
companyId: string,
|
||||||
|
|
@ -60,7 +64,8 @@ export const issuesApi = {
|
||||||
api.delete<{ id: string; archivedAt: Date } | { ok: true }>(`/issues/${id}/inbox-archive`),
|
api.delete<{ id: string; archivedAt: Date } | { ok: true }>(`/issues/${id}/inbox-archive`),
|
||||||
create: (companyId: string, data: Record<string, unknown>) =>
|
create: (companyId: string, data: Record<string, unknown>) =>
|
||||||
api.post<Issue>(`/companies/${companyId}/issues`, data),
|
api.post<Issue>(`/companies/${companyId}/issues`, data),
|
||||||
update: (id: string, data: Record<string, unknown>) => api.patch<Issue>(`/issues/${id}`, data),
|
update: (id: string, data: Record<string, unknown>) =>
|
||||||
|
api.patch<IssueUpdateResponse>(`/issues/${id}`, data),
|
||||||
remove: (id: string) => api.delete<Issue>(`/issues/${id}`),
|
remove: (id: string) => api.delete<Issue>(`/issues/${id}`),
|
||||||
checkout: (id: string, agentId: string) =>
|
checkout: (id: string, agentId: string) =>
|
||||||
api.post<Issue>(`/issues/${id}/checkout`, {
|
api.post<Issue>(`/issues/${id}/checkout`, {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ import { PluginSlotOutlet } from "@/plugins/slots";
|
||||||
interface CommentWithRunMeta extends IssueComment {
|
interface CommentWithRunMeta extends IssueComment {
|
||||||
runId?: string | null;
|
runId?: string | null;
|
||||||
runAgentId?: string | null;
|
runAgentId?: string | null;
|
||||||
|
clientId?: string;
|
||||||
|
clientStatus?: "pending";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LinkedRunItem {
|
interface LinkedRunItem {
|
||||||
|
|
@ -169,11 +171,14 @@ const TimelineList = memo(function TimelineList({
|
||||||
|
|
||||||
const comment = item.comment;
|
const comment = item.comment;
|
||||||
const isHighlighted = highlightCommentId === comment.id;
|
const isHighlighted = highlightCommentId === comment.id;
|
||||||
|
const isPending = comment.clientStatus === "pending";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={comment.id}
|
key={comment.id}
|
||||||
id={`comment-${comment.id}`}
|
id={`comment-${comment.id}`}
|
||||||
className={`border p-3 overflow-hidden min-w-0 rounded-sm transition-colors duration-1000 ${isHighlighted ? "border-primary/50 bg-primary/5" : "border-border"}`}
|
className={`border p-3 overflow-hidden min-w-0 rounded-sm transition-colors duration-1000 ${
|
||||||
|
isHighlighted ? "border-primary/50 bg-primary/5" : "border-border"
|
||||||
|
} ${isPending ? "opacity-80" : ""}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
{comment.authorAgentId ? (
|
{comment.authorAgentId ? (
|
||||||
|
|
@ -187,7 +192,7 @@ const TimelineList = memo(function TimelineList({
|
||||||
<Identity name="You" size="sm" />
|
<Identity name="You" size="sm" />
|
||||||
)}
|
)}
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
{companyId ? (
|
{companyId && !isPending ? (
|
||||||
<PluginSlotOutlet
|
<PluginSlotOutlet
|
||||||
slotTypes={["commentContextMenuItem"]}
|
slotTypes={["commentContextMenuItem"]}
|
||||||
entityType="comment"
|
entityType="comment"
|
||||||
|
|
@ -203,17 +208,21 @@ const TimelineList = memo(function TimelineList({
|
||||||
missingBehavior="placeholder"
|
missingBehavior="placeholder"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<a
|
{isPending ? (
|
||||||
href={`#comment-${comment.id}`}
|
<span className="text-xs text-muted-foreground">Sending...</span>
|
||||||
className="text-xs text-muted-foreground hover:text-foreground hover:underline transition-colors"
|
) : (
|
||||||
>
|
<a
|
||||||
{formatDateTime(comment.createdAt)}
|
href={`#comment-${comment.id}`}
|
||||||
</a>
|
className="text-xs text-muted-foreground hover:text-foreground hover:underline transition-colors"
|
||||||
|
>
|
||||||
|
{formatDateTime(comment.createdAt)}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
<CopyMarkdownButton text={comment.body} />
|
<CopyMarkdownButton text={comment.body} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
|
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
|
||||||
{companyId ? (
|
{companyId && !isPending ? (
|
||||||
<div className="mt-2 space-y-2">
|
<div className="mt-2 space-y-2">
|
||||||
<PluginSlotOutlet
|
<PluginSlotOutlet
|
||||||
slotTypes={["commentAnnotation"]}
|
slotTypes={["commentAnnotation"]}
|
||||||
|
|
@ -231,7 +240,7 @@ const TimelineList = memo(function TimelineList({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{comment.runId && (
|
{comment.runId && !isPending && (
|
||||||
<div className="mt-2 pt-2 border-t border-border/60">
|
<div className="mt-2 pt-2 border-t border-border/60">
|
||||||
{comment.runAgentId ? (
|
{comment.runAgentId ? (
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -373,6 +382,8 @@ export function CommentThread({
|
||||||
if (draftKey) clearDraft(draftKey);
|
if (draftKey) clearDraft(draftKey);
|
||||||
setReopen(true);
|
setReopen(true);
|
||||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||||
|
} catch {
|
||||||
|
// Parent mutation handlers surface the failure and keep the draft intact.
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
140
ui/src/lib/optimistic-issue-comments.test.ts
Normal file
140
ui/src/lib/optimistic-issue-comments.test.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
applyOptimisticIssueCommentUpdate,
|
||||||
|
createOptimisticIssueComment,
|
||||||
|
mergeIssueComments,
|
||||||
|
upsertIssueComment,
|
||||||
|
} from "./optimistic-issue-comments";
|
||||||
|
|
||||||
|
describe("optimistic issue comments", () => {
|
||||||
|
it("creates a pending optimistic comment for the current user", () => {
|
||||||
|
const comment = createOptimisticIssueComment({
|
||||||
|
companyId: "company-1",
|
||||||
|
issueId: "issue-1",
|
||||||
|
body: "Working on it",
|
||||||
|
authorUserId: "board-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(comment.id).toMatch(/^optimistic-/);
|
||||||
|
expect(comment.clientId).toBe(comment.id);
|
||||||
|
expect(comment.clientStatus).toBe("pending");
|
||||||
|
expect(comment.authorUserId).toBe("board-1");
|
||||||
|
expect(comment.authorAgentId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges optimistic comments into the server thread in chronological order", () => {
|
||||||
|
const merged = mergeIssueComments(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "comment-2",
|
||||||
|
companyId: "company-1",
|
||||||
|
issueId: "issue-1",
|
||||||
|
authorAgentId: null,
|
||||||
|
authorUserId: "board-1",
|
||||||
|
body: "Second",
|
||||||
|
createdAt: new Date("2026-03-28T14:00:02.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-28T14:00:02.000Z"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "optimistic-1",
|
||||||
|
clientId: "optimistic-1",
|
||||||
|
clientStatus: "pending",
|
||||||
|
companyId: "company-1",
|
||||||
|
issueId: "issue-1",
|
||||||
|
authorAgentId: null,
|
||||||
|
authorUserId: "board-1",
|
||||||
|
body: "First",
|
||||||
|
createdAt: new Date("2026-03-28T14:00:01.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-28T14:00:01.000Z"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(merged.map((comment) => comment.id)).toEqual(["optimistic-1", "comment-2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upserts confirmed comments without creating duplicates", () => {
|
||||||
|
const next = upsertIssueComment(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "comment-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
issueId: "issue-1",
|
||||||
|
authorAgentId: null,
|
||||||
|
authorUserId: "board-1",
|
||||||
|
body: "Original",
|
||||||
|
createdAt: new Date("2026-03-28T14:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
id: "comment-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
issueId: "issue-1",
|
||||||
|
authorAgentId: null,
|
||||||
|
authorUserId: "board-1",
|
||||||
|
body: "Updated",
|
||||||
|
createdAt: new Date("2026-03-28T14:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-28T14:00:05.000Z"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(next).toHaveLength(1);
|
||||||
|
expect(next[0]?.body).toBe("Updated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies optimistic reopen and reassignment updates to the issue cache", () => {
|
||||||
|
const next = applyOptimisticIssueCommentUpdate(
|
||||||
|
{
|
||||||
|
id: "issue-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: null,
|
||||||
|
projectWorkspaceId: null,
|
||||||
|
goalId: null,
|
||||||
|
parentId: null,
|
||||||
|
title: "Fix comment flow",
|
||||||
|
description: null,
|
||||||
|
status: "done",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: "agent-1",
|
||||||
|
assigneeUserId: null,
|
||||||
|
checkoutRunId: null,
|
||||||
|
executionRunId: null,
|
||||||
|
executionAgentNameKey: null,
|
||||||
|
executionLockedAt: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: "board-1",
|
||||||
|
issueNumber: 1,
|
||||||
|
identifier: "PAP-1",
|
||||||
|
originKind: "manual",
|
||||||
|
originId: null,
|
||||||
|
originRunId: null,
|
||||||
|
requestDepth: 0,
|
||||||
|
billingCode: null,
|
||||||
|
assigneeAdapterOverrides: null,
|
||||||
|
executionWorkspaceId: null,
|
||||||
|
executionWorkspacePreference: null,
|
||||||
|
executionWorkspaceSettings: null,
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
cancelledAt: null,
|
||||||
|
hiddenAt: null,
|
||||||
|
createdAt: new Date("2026-03-28T14:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reopen: true,
|
||||||
|
reassignment: {
|
||||||
|
assigneeAgentId: null,
|
||||||
|
assigneeUserId: "board-2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(next?.status).toBe("todo");
|
||||||
|
expect(next?.assigneeAgentId).toBeNull();
|
||||||
|
expect(next?.assigneeUserId).toBe("board-2");
|
||||||
|
});
|
||||||
|
});
|
||||||
98
ui/src/lib/optimistic-issue-comments.ts
Normal file
98
ui/src/lib/optimistic-issue-comments.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import type { Issue, IssueComment } from "@paperclipai/shared";
|
||||||
|
|
||||||
|
export interface IssueCommentReassignment {
|
||||||
|
assigneeAgentId: string | null;
|
||||||
|
assigneeUserId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OptimisticIssueComment extends IssueComment {
|
||||||
|
clientId: string;
|
||||||
|
clientStatus: "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IssueTimelineComment = IssueComment | OptimisticIssueComment;
|
||||||
|
|
||||||
|
function toTimestamp(value: Date | string) {
|
||||||
|
return new Date(value).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortIssueComments<T extends { createdAt: Date | string; id: string }>(comments: T[]) {
|
||||||
|
return [...comments].sort((a, b) => {
|
||||||
|
const createdAtDiff = toTimestamp(a.createdAt) - toTimestamp(b.createdAt);
|
||||||
|
if (createdAtDiff !== 0) return createdAtDiff;
|
||||||
|
return a.id.localeCompare(b.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOptimisticIssueComment(params: {
|
||||||
|
companyId: string;
|
||||||
|
issueId: string;
|
||||||
|
body: string;
|
||||||
|
authorUserId: string | null;
|
||||||
|
}): OptimisticIssueComment {
|
||||||
|
const now = new Date();
|
||||||
|
const clientId = `optimistic-${crypto.randomUUID()}`;
|
||||||
|
return {
|
||||||
|
id: clientId,
|
||||||
|
clientId,
|
||||||
|
clientStatus: "pending",
|
||||||
|
companyId: params.companyId,
|
||||||
|
issueId: params.issueId,
|
||||||
|
authorAgentId: null,
|
||||||
|
authorUserId: params.authorUserId,
|
||||||
|
body: params.body,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeIssueComments(
|
||||||
|
comments: IssueComment[] | undefined,
|
||||||
|
optimisticComments: OptimisticIssueComment[],
|
||||||
|
): IssueTimelineComment[] {
|
||||||
|
const merged = [...(comments ?? [])];
|
||||||
|
const existingIds = new Set(merged.map((comment) => comment.id));
|
||||||
|
for (const comment of optimisticComments) {
|
||||||
|
if (!existingIds.has(comment.id)) {
|
||||||
|
merged.push(comment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sortIssueComments(merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertIssueComment(
|
||||||
|
comments: IssueComment[] | undefined,
|
||||||
|
nextComment: IssueComment,
|
||||||
|
): IssueComment[] {
|
||||||
|
const current = comments ?? [];
|
||||||
|
const existingIndex = current.findIndex((comment) => comment.id === nextComment.id);
|
||||||
|
if (existingIndex === -1) {
|
||||||
|
return sortIssueComments([...current, nextComment]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = [...current];
|
||||||
|
updated[existingIndex] = nextComment;
|
||||||
|
return sortIssueComments(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyOptimisticIssueCommentUpdate(
|
||||||
|
issue: Issue | undefined,
|
||||||
|
params: {
|
||||||
|
reopen?: boolean;
|
||||||
|
reassignment?: IssueCommentReassignment;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (!issue) return issue;
|
||||||
|
const nextIssue: Issue = { ...issue };
|
||||||
|
|
||||||
|
if (params.reopen === true && (issue.status === "done" || issue.status === "cancelled")) {
|
||||||
|
nextIssue.status = "todo";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.reassignment) {
|
||||||
|
nextIssue.assigneeAgentId = params.reassignment.assigneeAgentId;
|
||||||
|
nextIssue.assigneeUserId = params.reassignment.assigneeUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextIssue;
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,14 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees";
|
import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { createIssueDetailPath, readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
|
import { createIssueDetailPath, readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
|
||||||
|
import {
|
||||||
|
applyOptimisticIssueCommentUpdate,
|
||||||
|
createOptimisticIssueComment,
|
||||||
|
mergeIssueComments,
|
||||||
|
upsertIssueComment,
|
||||||
|
type IssueCommentReassignment,
|
||||||
|
type OptimisticIssueComment,
|
||||||
|
} from "../lib/optimistic-issue-comments";
|
||||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||||
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
||||||
import { InlineEditor } from "../components/InlineEditor";
|
import { InlineEditor } from "../components/InlineEditor";
|
||||||
|
|
@ -55,12 +63,9 @@ import {
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ActivityEvent } from "@paperclipai/shared";
|
import type { ActivityEvent } from "@paperclipai/shared";
|
||||||
import type { Agent, IssueAttachment } from "@paperclipai/shared";
|
import type { Agent, Issue, IssueAttachment, IssueComment } from "@paperclipai/shared";
|
||||||
|
|
||||||
type CommentReassignment = {
|
type CommentReassignment = IssueCommentReassignment;
|
||||||
assigneeAgentId: string | null;
|
|
||||||
assigneeUserId: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ACTION_LABELS: Record<string, string> = {
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
"issue.created": "created the issue",
|
"issue.created": "created the issue",
|
||||||
|
|
@ -213,6 +218,7 @@ export function IssueDetail() {
|
||||||
});
|
});
|
||||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||||
const [attachmentDragActive, setAttachmentDragActive] = useState(false);
|
const [attachmentDragActive, setAttachmentDragActive] = useState(false);
|
||||||
|
const [optimisticComments, setOptimisticComments] = useState<OptimisticIssueComment[]>([]);
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
|
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -386,8 +392,18 @@ export function IssueDetail() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const suggestedAssigneeValue = useMemo(
|
const suggestedAssigneeValue = useMemo(
|
||||||
() => suggestedCommentAssigneeValue(issue ?? {}, comments, currentUserId),
|
() =>
|
||||||
[issue, comments, currentUserId],
|
suggestedCommentAssigneeValue(
|
||||||
|
issue ?? {},
|
||||||
|
mergeIssueComments(comments ?? [], optimisticComments),
|
||||||
|
currentUserId,
|
||||||
|
),
|
||||||
|
[issue, comments, optimisticComments, currentUserId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const threadComments = useMemo(
|
||||||
|
() => mergeIssueComments(comments ?? [], optimisticComments),
|
||||||
|
[comments, optimisticComments],
|
||||||
);
|
);
|
||||||
|
|
||||||
const commentsWithRunMeta = useMemo(() => {
|
const commentsWithRunMeta = useMemo(() => {
|
||||||
|
|
@ -406,11 +422,11 @@ export function IssueDetail() {
|
||||||
runAgentId: evt.agentId ?? agentIdByRunId.get(evt.runId) ?? null,
|
runAgentId: evt.agentId ?? agentIdByRunId.get(evt.runId) ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return (comments ?? []).map((comment) => {
|
return threadComments.map((comment) => {
|
||||||
const meta = runMetaByCommentId.get(comment.id);
|
const meta = runMetaByCommentId.get(comment.id);
|
||||||
return meta ? { ...comment, ...meta } : comment;
|
return meta ? { ...comment, ...meta } : comment;
|
||||||
});
|
});
|
||||||
}, [activity, comments, linkedRuns]);
|
}, [activity, threadComments, linkedRuns]);
|
||||||
|
|
||||||
const issueCostSummary = useMemo(() => {
|
const issueCostSummary = useMemo(() => {
|
||||||
let input = 0;
|
let input = 0;
|
||||||
|
|
@ -491,7 +507,62 @@ export function IssueDetail() {
|
||||||
const addComment = useMutation({
|
const addComment = useMutation({
|
||||||
mutationFn: ({ body, reopen }: { body: string; reopen?: boolean }) =>
|
mutationFn: ({ body, reopen }: { body: string; reopen?: boolean }) =>
|
||||||
issuesApi.addComment(issueId!, body, reopen),
|
issuesApi.addComment(issueId!, body, reopen),
|
||||||
onSuccess: () => {
|
onMutate: async ({ body, reopen }) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
||||||
|
await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
||||||
|
|
||||||
|
const previousIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId!));
|
||||||
|
const optimisticComment = issue
|
||||||
|
? createOptimisticIssueComment({
|
||||||
|
companyId: issue.companyId,
|
||||||
|
issueId: issue.id,
|
||||||
|
body,
|
||||||
|
authorUserId: currentUserId,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (optimisticComment) {
|
||||||
|
setOptimisticComments((current) => [...current, optimisticComment]);
|
||||||
|
}
|
||||||
|
if (previousIssue) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
queryKeys.issues.detail(issueId!),
|
||||||
|
applyOptimisticIssueCommentUpdate(previousIssue, { reopen }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
optimisticCommentId: optimisticComment?.clientId ?? null,
|
||||||
|
previousIssue,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onSuccess: (comment, _variables, context) => {
|
||||||
|
if (context?.optimisticCommentId) {
|
||||||
|
setOptimisticComments((current) =>
|
||||||
|
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
queryClient.setQueryData<IssueComment[]>(
|
||||||
|
queryKeys.issues.comments(issueId!),
|
||||||
|
(current) => upsertIssueComment(current, comment),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (err, _variables, context) => {
|
||||||
|
if (context?.optimisticCommentId) {
|
||||||
|
setOptimisticComments((current) =>
|
||||||
|
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (context?.previousIssue) {
|
||||||
|
queryClient.setQueryData(queryKeys.issues.detail(issueId!), context.previousIssue);
|
||||||
|
}
|
||||||
|
pushToast({
|
||||||
|
title: "Comment failed",
|
||||||
|
body: err instanceof Error ? err.message : "Unable to post comment",
|
||||||
|
tone: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
invalidateIssue();
|
invalidateIssue();
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
||||||
},
|
},
|
||||||
|
|
@ -513,7 +584,67 @@ export function IssueDetail() {
|
||||||
assigneeUserId: reassignment.assigneeUserId,
|
assigneeUserId: reassignment.assigneeUserId,
|
||||||
...(reopen ? { status: "todo" } : {}),
|
...(reopen ? { status: "todo" } : {}),
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onMutate: async ({ body, reopen, reassignment }) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
||||||
|
await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
||||||
|
|
||||||
|
const previousIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId!));
|
||||||
|
const optimisticComment = issue
|
||||||
|
? createOptimisticIssueComment({
|
||||||
|
companyId: issue.companyId,
|
||||||
|
issueId: issue.id,
|
||||||
|
body,
|
||||||
|
authorUserId: currentUserId,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (optimisticComment) {
|
||||||
|
setOptimisticComments((current) => [...current, optimisticComment]);
|
||||||
|
}
|
||||||
|
if (previousIssue) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
queryKeys.issues.detail(issueId!),
|
||||||
|
applyOptimisticIssueCommentUpdate(previousIssue, { reopen, reassignment }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
optimisticCommentId: optimisticComment?.clientId ?? null,
|
||||||
|
previousIssue,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onSuccess: (result, _variables, context) => {
|
||||||
|
if (context?.optimisticCommentId) {
|
||||||
|
setOptimisticComments((current) =>
|
||||||
|
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { comment, ...nextIssue } = result;
|
||||||
|
queryClient.setQueryData(queryKeys.issues.detail(issueId!), nextIssue);
|
||||||
|
if (comment) {
|
||||||
|
queryClient.setQueryData<IssueComment[]>(
|
||||||
|
queryKeys.issues.comments(issueId!),
|
||||||
|
(current) => upsertIssueComment(current, comment),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err, _variables, context) => {
|
||||||
|
if (context?.optimisticCommentId) {
|
||||||
|
setOptimisticComments((current) =>
|
||||||
|
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (context?.previousIssue) {
|
||||||
|
queryClient.setQueryData(queryKeys.issues.detail(issueId!), context.previousIssue);
|
||||||
|
}
|
||||||
|
pushToast({
|
||||||
|
title: "Comment failed",
|
||||||
|
body: err instanceof Error ? err.message : "Unable to post comment",
|
||||||
|
tone: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
invalidateIssue();
|
invalidateIssue();
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue