diff --git a/ui/src/lib/inboxArchiveCache.ts b/ui/src/lib/inboxArchiveCache.ts index 1f8dd12ffa..9ce2a50767 100644 --- a/ui/src/lib/inboxArchiveCache.ts +++ b/ui/src/lib/inboxArchiveCache.ts @@ -154,6 +154,7 @@ export function filterLocalInboxArchivedQueryData(queryKey: QueryKey, dat function inboxIssueQueryPrefixes(companyId: string) { return [ + [...queryKeys.issues.list(companyId), "compact"], queryKeys.issues.listMineByMe(companyId), queryKeys.issues.listTouchedByMe(companyId), queryKeys.issues.listUnreadTouchedByMe(companyId), diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 6758db200f..4e189cd580 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -34,6 +34,7 @@ const mockIssuesApi = vi.hoisted(() => ({ createTreeHold: vi.fn(), releaseTreeHold: vi.fn(), archiveFromInbox: vi.fn(), + unarchiveFromInbox: vi.fn(), addComment: vi.fn(), cancelComment: vi.fn(), upsertFeedbackVote: vi.fn(), @@ -1038,6 +1039,7 @@ describe("IssueDetail", () => { mockIssuesApi.listFeedbackVotes.mockResolvedValue([]); mockIssuesApi.markRead.mockResolvedValue({ id: "issue-1", lastReadAt: new Date().toISOString() }); mockIssuesApi.archiveFromInbox.mockResolvedValue({ id: "issue-1", archivedAt: new Date() }); + mockIssuesApi.unarchiveFromInbox.mockResolvedValue({ ok: true }); mockIssuesApi.getTreeControlState.mockResolvedValue({ activePauseHold: null }); mockIssuesApi.listTreeHolds.mockResolvedValue([]); mockActivityApi.forIssue.mockResolvedValue([]); @@ -1192,7 +1194,7 @@ describe("IssueDetail", () => { mockIssuesApi.update.mockReset(); }); - it("removes an inbox-origin archived issue from cached inbox variants before navigating back", async () => { + it("removes an inbox-origin archived issue and restores it when the toast Undo action is pressed", async () => { const issue = createIssue({ id: "issue-1", identifier: "PAP-1", title: "Archive me from detail" }); const otherIssue = createIssue({ id: "issue-2", identifier: "PAP-2", title: "Keep me in inbox" }); const archiveRequest = createDeferred<{ id: string; archivedAt: Date }>(); @@ -1205,6 +1207,12 @@ describe("IssueDetail", () => { "with-routine-executions", "live-descendant-summary", ] as const; + const compactKey = [ + ...queryKeys.issues.list("company-1"), + "compact", + "with-routine-executions", + "live-descendant-summary", + ] as const; const touchedKey = [ ...queryKeys.issues.listTouchedByMe("company-1"), "with-routine-executions", @@ -1212,6 +1220,7 @@ describe("IssueDetail", () => { ] as const; const unreadKey = queryKeys.issues.listUnreadTouchedByMe("company-1"); queryClient.setQueryData(mineKey, [issue, otherIssue]); + queryClient.setQueryData(compactKey, [issue, otherIssue]); queryClient.setQueryData(touchedKey, [issue, otherIssue]); queryClient.setQueryData(unreadKey, [issue, otherIssue]); @@ -1236,6 +1245,7 @@ describe("IssueDetail", () => { await waitForAssertion(() => { expect(queryClient.getQueryData(mineKey)?.map((item) => item.id)).toEqual(["issue-2"]); + expect(queryClient.getQueryData(compactKey)?.map((item) => item.id)).toEqual(["issue-2"]); expect(queryClient.getQueryData(touchedKey)?.map((item) => item.id)).toEqual(["issue-2"]); expect(queryClient.getQueryData(unreadKey)?.map((item) => item.id)).toEqual(["issue-2"]); expect(mockNavigate).not.toHaveBeenCalled(); @@ -1247,7 +1257,101 @@ describe("IssueDetail", () => { await flushReact(); expect(mockNavigate).toHaveBeenCalledWith("/inbox/mine", { replace: true }); - expect(mockPushToast).toHaveBeenCalledWith({ title: "Task archived from inbox", tone: "success" }); + const archiveToast = mockPushToast.mock.calls + .map(([toast]) => toast) + .find((toast) => toast.title === "Task archived from inbox"); + expect(archiveToast).toMatchObject({ + title: "Task archived from inbox", + tone: "success", + action: { label: "Undo" }, + }); + expect(archiveToast?.action?.onClick).toEqual(expect.any(Function)); + + const staleInboxFetch = createDeferred(); + const staleInboxRequest = queryClient.fetchQuery({ + queryKey: mineKey, + queryFn: () => staleInboxFetch.promise, + }).catch(() => undefined); + const staleCompactFetch = createDeferred(); + const staleCompactRequest = queryClient.fetchQuery({ + queryKey: compactKey, + queryFn: () => staleCompactFetch.promise, + }).catch(() => undefined); + await waitForAssertion(() => { + expect(queryClient.isFetching({ queryKey: mineKey })).toBe(1); + expect(queryClient.isFetching({ queryKey: compactKey })).toBe(1); + }); + + await act(async () => { + archiveToast.action.onClick(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + staleInboxFetch.resolve([otherIssue]); + staleCompactFetch.resolve([otherIssue]); + await staleInboxRequest; + await staleCompactRequest; + await waitForAssertion(() => { + expect(mockIssuesApi.unarchiveFromInbox).toHaveBeenCalledWith("issue-1"); + expect(queryClient.getQueryData(mineKey)?.map((item) => item.id)).toEqual(["issue-1", "issue-2"]); + expect(queryClient.getQueryData(compactKey)?.map((item) => item.id)).toEqual(["issue-1", "issue-2"]); + expect(queryClient.getQueryData(touchedKey)?.map((item) => item.id)).toEqual(["issue-1", "issue-2"]); + expect(queryClient.getQueryData(unreadKey)?.map((item) => item.id)).toEqual(["issue-1", "issue-2"]); + expect(mockPushToast).toHaveBeenCalledWith({ title: "Task restored to inbox", tone: "success" }); + }); + }); + + it("keeps an archived task hidden and reports an error when toast Undo fails", async () => { + const issue = createIssue({ id: "issue-1", identifier: "PAP-1", title: "Archive me from detail" }); + mockLocation.state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox"); + mockIssuesApi.get.mockResolvedValue(issue); + mockIssuesApi.unarchiveFromInbox.mockRejectedValue(new Error("Inbox policy denied")); + + const mineKey = [ + ...queryKeys.issues.listMineByMe("company-1"), + "with-routine-executions", + "live-descendant-summary", + ] as const; + queryClient.setQueryData(mineKey, [issue]); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + + const archiveButton = container.querySelector( + 'button[aria-label="Archive from inbox"]', + ); + expect(archiveButton).not.toBeNull(); + await act(async () => { + archiveButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + await waitForAssertion(() => { + expect(queryClient.getQueryData(mineKey)).toEqual([]); + }); + + const archiveToast = mockPushToast.mock.calls + .map(([toast]) => toast) + .find((toast) => toast.title === "Task archived from inbox"); + expect(archiveToast?.action?.onClick).toEqual(expect.any(Function)); + + await act(async () => { + archiveToast.action.onClick(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + await waitForAssertion(() => { + expect(mockIssuesApi.unarchiveFromInbox).toHaveBeenCalledWith("issue-1"); + expect(queryClient.getQueryData(mineKey)).toEqual([]); + expect(mockPushToast).toHaveBeenCalledWith({ + title: "Undo failed", + body: "Inbox policy denied", + tone: "error", + }); + }); }); it("shows assignee and originating avatars in the issue header metadata", async () => { diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 408a0bf3e1..5696c9f015 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -2172,6 +2172,37 @@ export function IssueDetail() { queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) }); } }, [queryClient, selectedCompanyId]); + const undoInboxArchive = useCallback(async ( + id: string, + companyId: string | undefined, + previousData: InboxIssueCacheSnapshot, + ) => { + if (companyId) { + await cancelInboxIssueQueries(queryClient, companyId); + clearLocalInboxArchive(companyId, id); + restoreIssueToInboxCaches(queryClient, previousData, id); + } + + try { + await issuesApi.unarchiveFromInbox(id); + pushToast({ title: "Task restored to inbox", tone: "success" }); + } catch (error) { + if (companyId) { + beginLocalInboxArchive(companyId, id); + removeIssueFromInboxCaches(queryClient, companyId, id); + boundLocalInboxArchive(companyId, id); + } + pushToast({ + title: "Undo failed", + body: error instanceof Error ? error.message : "Unable to restore this task to the inbox", + tone: "error", + }); + } finally { + if (companyId) { + await invalidateInboxIssueQueries(queryClient, companyId); + } + } + }, [pushToast, queryClient]); const upsertInteractionInCache = useCallback((interaction: IssueThreadInteraction) => { queryClient.setQueryData( queryKeys.issues.interactions(issueId!), @@ -3204,13 +3235,22 @@ export function IssueDetail() { removeIssueFromInboxCaches(queryClient, selectedCompanyId, id); return { companyId: selectedCompanyId, previousData }; }, - onSuccess: (_data, id) => { + onSuccess: (_data, id, context) => { if (selectedCompanyId) { removeIssueFromInboxCaches(queryClient, selectedCompanyId, id); } invalidateIssueCollections(); navigate(sourceBreadcrumb.href.startsWith("/inbox") ? sourceBreadcrumb.href : "/inbox", { replace: true }); - pushToast({ title: "Task archived from inbox", tone: "success" }); + pushToast({ + title: "Task archived from inbox", + tone: "success", + action: { + label: "Undo", + onClick: () => { + void undoInboxArchive(id, context?.companyId, context?.previousData ?? []); + }, + }, + }); }, onError: (err, id, context) => { if (context?.companyId) clearLocalInboxArchive(context.companyId, id);