mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 11:20:37 +09:00
Improve issue assignee update responsiveness
This commit is contained in:
parent
bac5afa647
commit
038dd2bb82
5 changed files with 491 additions and 10 deletions
|
|
@ -5,7 +5,7 @@ import { __liveUpdatesTestUtils } from "./LiveUpdatesProvider";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
|
||||||
describe("LiveUpdatesProvider issue invalidation", () => {
|
describe("LiveUpdatesProvider issue invalidation", () => {
|
||||||
it("refreshes touched inbox queries for issue activity", () => {
|
it("refreshes touched inbox queries and only the changed issue data for issue updates", () => {
|
||||||
const invalidations: unknown[] = [];
|
const invalidations: unknown[] = [];
|
||||||
const queryClient = {
|
const queryClient = {
|
||||||
invalidateQueries: (input: unknown) => {
|
invalidateQueries: (input: unknown) => {
|
||||||
|
|
@ -20,6 +20,7 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
||||||
{
|
{
|
||||||
entityType: "issue",
|
entityType: "issue",
|
||||||
entityId: "issue-1",
|
entityId: "issue-1",
|
||||||
|
action: "issue.updated",
|
||||||
details: null,
|
details: null,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -33,6 +34,58 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
||||||
expect(invalidations).toContainEqual({
|
expect(invalidations).toContainEqual({
|
||||||
queryKey: queryKeys.issues.listUnreadTouchedByMe("company-1"),
|
queryKey: queryKeys.issues.listUnreadTouchedByMe("company-1"),
|
||||||
});
|
});
|
||||||
|
expect(invalidations).toContainEqual({
|
||||||
|
queryKey: queryKeys.issues.detail("issue-1"),
|
||||||
|
});
|
||||||
|
expect(invalidations).toContainEqual({
|
||||||
|
queryKey: queryKeys.issues.activity("issue-1"),
|
||||||
|
});
|
||||||
|
expect(invalidations).not.toContainEqual({
|
||||||
|
queryKey: queryKeys.issues.comments("issue-1"),
|
||||||
|
});
|
||||||
|
expect(invalidations).not.toContainEqual({
|
||||||
|
queryKey: queryKeys.issues.runs("issue-1"),
|
||||||
|
});
|
||||||
|
expect(invalidations).not.toContainEqual({
|
||||||
|
queryKey: queryKeys.issues.documents("issue-1"),
|
||||||
|
});
|
||||||
|
expect(invalidations).not.toContainEqual({
|
||||||
|
queryKey: queryKeys.issues.attachments("issue-1"),
|
||||||
|
});
|
||||||
|
expect(invalidations).not.toContainEqual({
|
||||||
|
queryKey: queryKeys.issues.approvals("issue-1"),
|
||||||
|
});
|
||||||
|
expect(invalidations).not.toContainEqual({
|
||||||
|
queryKey: queryKeys.issues.liveRuns("issue-1"),
|
||||||
|
});
|
||||||
|
expect(invalidations).not.toContainEqual({
|
||||||
|
queryKey: queryKeys.issues.activeRun("issue-1"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still refreshes comments when a comment activity event arrives", () => {
|
||||||
|
const invalidations: unknown[] = [];
|
||||||
|
const queryClient = {
|
||||||
|
invalidateQueries: (input: unknown) => {
|
||||||
|
invalidations.push(input);
|
||||||
|
},
|
||||||
|
getQueryData: () => undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
__liveUpdatesTestUtils.invalidateActivityQueries(
|
||||||
|
queryClient as never,
|
||||||
|
"company-1",
|
||||||
|
{
|
||||||
|
entityType: "issue",
|
||||||
|
entityId: "issue-1",
|
||||||
|
action: "issue.comment_added",
|
||||||
|
details: null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(invalidations).toContainEqual({
|
||||||
|
queryKey: queryKeys.issues.comments("issue-1"),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -487,6 +487,7 @@ function invalidateActivityQueries(
|
||||||
|
|
||||||
const entityType = readString(payload.entityType);
|
const entityType = readString(payload.entityType);
|
||||||
const entityId = readString(payload.entityId);
|
const entityId = readString(payload.entityId);
|
||||||
|
const action = readString(payload.action);
|
||||||
|
|
||||||
if (entityType === "issue") {
|
if (entityType === "issue") {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||||
|
|
@ -498,14 +499,10 @@ function invalidateActivityQueries(
|
||||||
const issueRefs = resolveIssueQueryRefs(queryClient, companyId, entityId, details);
|
const issueRefs = resolveIssueQueryRefs(queryClient, companyId, entityId, details);
|
||||||
for (const ref of issueRefs) {
|
for (const ref of issueRefs) {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(ref) });
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(ref) });
|
if (action === "issue.comment_added") {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(ref) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(ref) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(ref) });
|
}
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.approvals(ref) });
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(ref) });
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(ref) });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Issue } from "@paperclipai/shared";
|
||||||
import {
|
import {
|
||||||
|
applyOptimisticIssueFieldUpdate,
|
||||||
|
applyOptimisticIssueFieldUpdateToCollection,
|
||||||
applyOptimisticIssueCommentUpdate,
|
applyOptimisticIssueCommentUpdate,
|
||||||
createOptimisticIssueComment,
|
createOptimisticIssueComment,
|
||||||
isQueuedIssueComment,
|
isQueuedIssueComment,
|
||||||
|
matchesIssueRef,
|
||||||
mergeIssueComments,
|
mergeIssueComments,
|
||||||
upsertIssueComment,
|
upsertIssueComment,
|
||||||
} from "./optimistic-issue-comments";
|
} from "./optimistic-issue-comments";
|
||||||
|
|
@ -177,6 +181,267 @@ describe("optimistic issue comments", () => {
|
||||||
expect(next?.assigneeUserId).toBe("board-2");
|
expect(next?.assigneeUserId).toBe("board-2");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies optimistic field updates for issue property edits", () => {
|
||||||
|
const next = applyOptimisticIssueFieldUpdate(
|
||||||
|
{
|
||||||
|
id: "issue-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
projectWorkspaceId: "workspace-1",
|
||||||
|
goalId: null,
|
||||||
|
parentId: null,
|
||||||
|
title: "Fix property pane",
|
||||||
|
description: null,
|
||||||
|
status: "todo",
|
||||||
|
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: "exec-1",
|
||||||
|
executionWorkspacePreference: "shared_workspace",
|
||||||
|
executionWorkspaceSettings: null,
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
cancelledAt: null,
|
||||||
|
hiddenAt: null,
|
||||||
|
labelIds: ["label-1", "label-2"],
|
||||||
|
labels: [
|
||||||
|
{
|
||||||
|
id: "label-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
name: "One",
|
||||||
|
color: "#111111",
|
||||||
|
createdAt: new Date("2026-03-28T14:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "label-2",
|
||||||
|
companyId: "company-1",
|
||||||
|
name: "Two",
|
||||||
|
color: "#222222",
|
||||||
|
createdAt: new Date("2026-03-28T14:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
blockedBy: [
|
||||||
|
{
|
||||||
|
id: "issue-2",
|
||||||
|
identifier: "PAP-2",
|
||||||
|
title: "First blocker",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
assigneeUserId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "issue-3",
|
||||||
|
identifier: "PAP-3",
|
||||||
|
title: "Second blocker",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
assigneeUserId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
blocks: [],
|
||||||
|
project: {
|
||||||
|
id: "project-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
urlKey: "project-one",
|
||||||
|
goalId: null,
|
||||||
|
goalIds: [],
|
||||||
|
goals: [],
|
||||||
|
name: "Project one",
|
||||||
|
description: null,
|
||||||
|
status: "in_progress",
|
||||||
|
leadAgentId: null,
|
||||||
|
targetDate: null,
|
||||||
|
color: null,
|
||||||
|
env: null,
|
||||||
|
pauseReason: null,
|
||||||
|
pausedAt: null,
|
||||||
|
executionWorkspacePolicy: null,
|
||||||
|
codebase: {
|
||||||
|
workspaceId: null,
|
||||||
|
repoUrl: null,
|
||||||
|
repoRef: null,
|
||||||
|
defaultRef: null,
|
||||||
|
repoName: null,
|
||||||
|
localFolder: null,
|
||||||
|
managedFolder: "/tmp/paperclip",
|
||||||
|
effectiveLocalFolder: "/tmp/paperclip",
|
||||||
|
origin: "local_folder",
|
||||||
|
},
|
||||||
|
workspaces: [],
|
||||||
|
primaryWorkspace: null,
|
||||||
|
archivedAt: null,
|
||||||
|
createdAt: new Date("2026-03-28T14:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
|
||||||
|
},
|
||||||
|
currentExecutionWorkspace: {
|
||||||
|
id: "exec-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
projectWorkspaceId: null,
|
||||||
|
sourceIssueId: "issue-1",
|
||||||
|
mode: "shared_workspace",
|
||||||
|
strategyType: "project_primary",
|
||||||
|
branchName: null,
|
||||||
|
status: "active",
|
||||||
|
name: "Execution workspace",
|
||||||
|
cwd: "/tmp/paperclip",
|
||||||
|
repoUrl: null,
|
||||||
|
baseRef: null,
|
||||||
|
providerType: "local_fs",
|
||||||
|
providerRef: null,
|
||||||
|
derivedFromExecutionWorkspaceId: null,
|
||||||
|
lastUsedAt: new Date("2026-03-28T14:00:00.000Z"),
|
||||||
|
cleanupEligibleAt: null,
|
||||||
|
cleanupReason: null,
|
||||||
|
config: null,
|
||||||
|
metadata: null,
|
||||||
|
createdAt: new Date("2026-03-28T14:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
|
||||||
|
openedAt: new Date("2026-03-28T14:00:00.000Z"),
|
||||||
|
closedAt: null,
|
||||||
|
},
|
||||||
|
createdAt: new Date("2026-03-28T14:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: "in_review",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
assigneeUserId: "board-2",
|
||||||
|
labelIds: ["label-2"],
|
||||||
|
blockedByIssueIds: ["issue-3"],
|
||||||
|
projectId: "project-2",
|
||||||
|
executionWorkspaceId: "exec-2",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(next?.status).toBe("in_review");
|
||||||
|
expect(next?.assigneeAgentId).toBeNull();
|
||||||
|
expect(next?.assigneeUserId).toBe("board-2");
|
||||||
|
expect(next?.labelIds).toEqual(["label-2"]);
|
||||||
|
expect(next?.labels?.map((label) => label.id)).toEqual(["label-2"]);
|
||||||
|
expect(next?.blockedBy?.map((relation) => relation.id)).toEqual(["issue-3"]);
|
||||||
|
expect(next?.projectId).toBe("project-2");
|
||||||
|
expect(next?.project).toBeNull();
|
||||||
|
expect(next?.executionWorkspaceId).toBe("exec-2");
|
||||||
|
expect(next?.currentExecutionWorkspace).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches issues by either uuid or identifier reference", () => {
|
||||||
|
expect(matchesIssueRef({ id: "issue-1", identifier: "PAP-1" } as const, ["issue-1"])).toBe(true);
|
||||||
|
expect(matchesIssueRef({ id: "issue-1", identifier: "PAP-1" } as const, ["PAP-1"])).toBe(true);
|
||||||
|
expect(matchesIssueRef({ id: "issue-1", identifier: "PAP-1" } as const, ["issue-2", "PAP-2"])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies optimistic field updates across cached issue collections", () => {
|
||||||
|
const issues: Issue[] = [
|
||||||
|
{
|
||||||
|
id: "issue-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: null,
|
||||||
|
projectWorkspaceId: null,
|
||||||
|
goalId: null,
|
||||||
|
parentId: null,
|
||||||
|
title: "Fix property pane",
|
||||||
|
description: null,
|
||||||
|
status: "todo",
|
||||||
|
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,
|
||||||
|
labelIds: [],
|
||||||
|
labels: [],
|
||||||
|
blockedBy: [],
|
||||||
|
blocks: [],
|
||||||
|
createdAt: new Date("2026-03-28T14:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "issue-2",
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: null,
|
||||||
|
projectWorkspaceId: null,
|
||||||
|
goalId: null,
|
||||||
|
parentId: null,
|
||||||
|
title: "Leave me alone",
|
||||||
|
description: null,
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: "agent-2",
|
||||||
|
assigneeUserId: null,
|
||||||
|
checkoutRunId: null,
|
||||||
|
executionRunId: null,
|
||||||
|
executionAgentNameKey: null,
|
||||||
|
executionLockedAt: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: "board-1",
|
||||||
|
issueNumber: 2,
|
||||||
|
identifier: "PAP-2",
|
||||||
|
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,
|
||||||
|
labelIds: [],
|
||||||
|
labels: [],
|
||||||
|
blockedBy: [],
|
||||||
|
blocks: [],
|
||||||
|
createdAt: new Date("2026-03-28T14:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const next = applyOptimisticIssueFieldUpdateToCollection(issues, ["PAP-1"], { assigneeAgentId: "agent-9" });
|
||||||
|
|
||||||
|
expect(next?.[0]?.assigneeAgentId).toBe("agent-9");
|
||||||
|
expect(next?.[1]?.assigneeAgentId).toBe("agent-2");
|
||||||
|
});
|
||||||
|
|
||||||
it("treats comments without a run id as queued when they arrive during an active run", () => {
|
it("treats comments without a run id as queued when they arrive during an active run", () => {
|
||||||
expect(
|
expect(
|
||||||
isQueuedIssueComment({
|
isQueuedIssueComment({
|
||||||
|
|
|
||||||
|
|
@ -128,3 +128,85 @@ export function applyOptimisticIssueCommentUpdate(
|
||||||
|
|
||||||
return nextIssue;
|
return nextIssue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function applyOptimisticIssueFieldUpdate(
|
||||||
|
issue: Issue | undefined,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
if (!issue) return issue;
|
||||||
|
|
||||||
|
const nextIssue: Issue = {
|
||||||
|
...issue,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
const hasOwn = (key: string) => Object.prototype.hasOwnProperty.call(data, key);
|
||||||
|
const assign = <K extends keyof Issue>(key: K) => {
|
||||||
|
if (hasOwn(key)) {
|
||||||
|
nextIssue[key] = data[key] as Issue[K];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
assign("status");
|
||||||
|
assign("priority");
|
||||||
|
assign("assigneeAgentId");
|
||||||
|
assign("assigneeUserId");
|
||||||
|
assign("projectId");
|
||||||
|
assign("projectWorkspaceId");
|
||||||
|
assign("executionWorkspaceId");
|
||||||
|
assign("executionWorkspacePreference");
|
||||||
|
assign("executionWorkspaceSettings");
|
||||||
|
assign("hiddenAt");
|
||||||
|
|
||||||
|
if (hasOwn("labelIds") && Array.isArray(data.labelIds)) {
|
||||||
|
const nextLabelIds = data.labelIds.filter((value): value is string => typeof value === "string");
|
||||||
|
nextIssue.labelIds = nextLabelIds;
|
||||||
|
if (issue.labels) {
|
||||||
|
nextIssue.labels = issue.labels.filter((label) => nextLabelIds.includes(label.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasOwn("blockedByIssueIds") && Array.isArray(data.blockedByIssueIds) && issue.blockedBy) {
|
||||||
|
const nextBlockedByIds = new Set(
|
||||||
|
data.blockedByIssueIds.filter((value): value is string => typeof value === "string"),
|
||||||
|
);
|
||||||
|
nextIssue.blockedBy = issue.blockedBy.filter((relation) => nextBlockedByIds.has(relation.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasOwn("projectId")) {
|
||||||
|
nextIssue.project = issue.project?.id === nextIssue.projectId ? issue.project : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasOwn("executionWorkspaceId")) {
|
||||||
|
nextIssue.currentExecutionWorkspace =
|
||||||
|
issue.currentExecutionWorkspace?.id === nextIssue.executionWorkspaceId
|
||||||
|
? issue.currentExecutionWorkspace
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextIssue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesIssueRef(
|
||||||
|
issue: Pick<Issue, "id" | "identifier">,
|
||||||
|
refs: Iterable<string>,
|
||||||
|
) {
|
||||||
|
const refSet = refs instanceof Set ? refs : new Set(refs);
|
||||||
|
return refSet.has(issue.id) || (!!issue.identifier && refSet.has(issue.identifier));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyOptimisticIssueFieldUpdateToCollection(
|
||||||
|
issues: Issue[] | undefined,
|
||||||
|
refs: Iterable<string>,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
if (!issues) return issues;
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
const nextIssues = issues.map((issue) => {
|
||||||
|
if (!matchesIssueRef(issue, refs)) return issue;
|
||||||
|
changed = true;
|
||||||
|
return applyOptimisticIssueFieldUpdate(issue, data) ?? issue;
|
||||||
|
});
|
||||||
|
|
||||||
|
return changed ? nextIssues : issues;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,12 @@ import {
|
||||||
} from "../lib/issueDetailBreadcrumb";
|
} from "../lib/issueDetailBreadcrumb";
|
||||||
import { hasBlockingShortcutDialog, resolveInboxQuickArchiveKeyAction } from "../lib/keyboardShortcuts";
|
import { hasBlockingShortcutDialog, resolveInboxQuickArchiveKeyAction } from "../lib/keyboardShortcuts";
|
||||||
import {
|
import {
|
||||||
|
applyOptimisticIssueFieldUpdate,
|
||||||
|
applyOptimisticIssueFieldUpdateToCollection,
|
||||||
applyOptimisticIssueCommentUpdate,
|
applyOptimisticIssueCommentUpdate,
|
||||||
createOptimisticIssueComment,
|
createOptimisticIssueComment,
|
||||||
isQueuedIssueComment,
|
isQueuedIssueComment,
|
||||||
|
matchesIssueRef,
|
||||||
mergeIssueComments,
|
mergeIssueComments,
|
||||||
upsertIssueComment,
|
upsertIssueComment,
|
||||||
type IssueCommentReassignment,
|
type IssueCommentReassignment,
|
||||||
|
|
@ -687,6 +690,42 @@ export function IssueDetail() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const invalidateIssueCollections = () => {
|
||||||
|
if (selectedCompanyId) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(selectedCompanyId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyOptimisticIssueCacheUpdate = useCallback((refs: Iterable<string>, data: Record<string, unknown>) => {
|
||||||
|
queryClient.setQueriesData<Issue>(
|
||||||
|
{ queryKey: ["issues", "detail"] },
|
||||||
|
(cached) => (cached && matchesIssueRef(cached, refs) ? applyOptimisticIssueFieldUpdate(cached, data) : cached),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!selectedCompanyId) return;
|
||||||
|
queryClient.setQueryData<Issue[] | undefined>(
|
||||||
|
queryKeys.issues.list(selectedCompanyId),
|
||||||
|
(cached) => applyOptimisticIssueFieldUpdateToCollection(cached, refs, data),
|
||||||
|
);
|
||||||
|
}, [queryClient, selectedCompanyId]);
|
||||||
|
|
||||||
|
const mergeIssueResponseIntoCaches = useCallback((refs: Iterable<string>, nextIssue: Issue) => {
|
||||||
|
queryClient.setQueriesData<Issue>(
|
||||||
|
{ queryKey: ["issues", "detail"] },
|
||||||
|
(cached) => (cached && matchesIssueRef(cached, refs) ? { ...cached, ...nextIssue } : cached),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!selectedCompanyId) return;
|
||||||
|
queryClient.setQueryData<Issue[] | undefined>(
|
||||||
|
queryKeys.issues.list(selectedCompanyId),
|
||||||
|
(cached) => cached?.map((item) => (matchesIssueRef(item, refs) ? { ...item, ...nextIssue } : item)),
|
||||||
|
);
|
||||||
|
}, [queryClient, selectedCompanyId]);
|
||||||
|
|
||||||
const markIssueRead = useMutation({
|
const markIssueRead = useMutation({
|
||||||
mutationFn: (id: string) => issuesApi.markRead(id),
|
mutationFn: (id: string) => issuesApi.markRead(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|
@ -701,8 +740,53 @@ export function IssueDetail() {
|
||||||
|
|
||||||
const updateIssue = useMutation({
|
const updateIssue = useMutation({
|
||||||
mutationFn: (data: Record<string, unknown>) => issuesApi.update(issueId!, data),
|
mutationFn: (data: Record<string, unknown>) => issuesApi.update(issueId!, data),
|
||||||
onSuccess: () => {
|
onMutate: async (data) => {
|
||||||
invalidateIssue();
|
await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
||||||
|
if (selectedCompanyId) {
|
||||||
|
await queryClient.cancelQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId!));
|
||||||
|
const issueRefs = new Set<string>([issueId!]);
|
||||||
|
if (previousIssue?.id) issueRefs.add(previousIssue.id);
|
||||||
|
if (previousIssue?.identifier) issueRefs.add(previousIssue.identifier);
|
||||||
|
|
||||||
|
const previousDetailQueries = queryClient
|
||||||
|
.getQueriesData<Issue>({ queryKey: ["issues", "detail"] })
|
||||||
|
.filter(([, cachedIssue]) => cachedIssue && matchesIssueRef(cachedIssue, issueRefs));
|
||||||
|
const previousList = selectedCompanyId
|
||||||
|
? queryClient.getQueryData<Issue[]>(queryKeys.issues.list(selectedCompanyId))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
applyOptimisticIssueCacheUpdate(issueRefs, data);
|
||||||
|
|
||||||
|
return { previousDetailQueries, previousList, selectedCompanyId };
|
||||||
|
},
|
||||||
|
onSuccess: ({ comment: _comment, ...nextIssue }) => {
|
||||||
|
const issueRefs = new Set<string>([issueId!, nextIssue.id]);
|
||||||
|
if (nextIssue.identifier) issueRefs.add(nextIssue.identifier);
|
||||||
|
mergeIssueResponseIntoCaches(issueRefs, nextIssue);
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) });
|
||||||
|
invalidateIssueCollections();
|
||||||
|
},
|
||||||
|
onError: (err, _variables, context) => {
|
||||||
|
for (const [queryKey, previousIssue] of context?.previousDetailQueries ?? []) {
|
||||||
|
queryClient.setQueryData(queryKey, previousIssue);
|
||||||
|
}
|
||||||
|
if (context?.selectedCompanyId) {
|
||||||
|
queryClient.setQueryData(queryKeys.issues.list(context.selectedCompanyId), context.previousList);
|
||||||
|
}
|
||||||
|
pushToast({
|
||||||
|
title: "Issue update failed",
|
||||||
|
body: err instanceof Error ? err.message : "Unable to save issue changes",
|
||||||
|
tone: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
||||||
|
if (selectedCompanyId) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const handleIssuePropertiesUpdate = useCallback((data: Record<string, unknown>) => {
|
const handleIssuePropertiesUpdate = useCallback((data: Record<string, unknown>) => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue