Skip to content

Commit 149bee4

Browse files
authored
feat(archive): Add undo button to task archived toast (#2665)
1 parent 3ae1fd4 commit 149bee4

4 files changed

Lines changed: 170 additions & 2 deletions

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import type { UseUnarchiveTask } from "./useUnarchiveTask";
3+
4+
const toastSuccess = vi.hoisted(() => vi.fn());
5+
const toastError = vi.hoisted(() => vi.fn());
6+
7+
vi.mock("@posthog/ui/primitives/toast", () => ({
8+
toast: { success: toastSuccess, error: toastError },
9+
}));
10+
vi.mock("@posthog/ui/shell/logger", () => ({
11+
logger: { scope: () => ({ error: vi.fn(), info: vi.fn(), warn: vi.fn() }) },
12+
}));
13+
14+
import { undoArchive } from "./undoArchive";
15+
16+
type Restore = UseUnarchiveTask["restore"];
17+
18+
describe("undoArchive", () => {
19+
beforeEach(() => {
20+
toastSuccess.mockClear();
21+
toastError.mockClear();
22+
});
23+
24+
it("restores on the first attempt and confirms with a toast", async () => {
25+
const restore = vi
26+
.fn()
27+
.mockResolvedValue({ kind: "restored", navigateToTaskId: "task-1" });
28+
29+
await undoArchive("task-1", restore as unknown as Restore);
30+
31+
expect(restore).toHaveBeenCalledTimes(1);
32+
expect(restore).toHaveBeenCalledWith("task-1", true);
33+
expect(toastSuccess).toHaveBeenCalledWith("Task archive undone");
34+
expect(toastError).not.toHaveBeenCalled();
35+
});
36+
37+
it("retries with branch recreation when the branch is missing", async () => {
38+
const restore = vi
39+
.fn()
40+
.mockResolvedValueOnce({
41+
kind: "branch-not-found",
42+
taskId: "task-1",
43+
branchName: "feat/x",
44+
})
45+
.mockResolvedValueOnce({ kind: "restored", navigateToTaskId: "task-1" });
46+
47+
await undoArchive("task-1", restore as unknown as Restore);
48+
49+
expect(restore).toHaveBeenNthCalledWith(1, "task-1", true);
50+
expect(restore).toHaveBeenNthCalledWith(2, "task-1", true, {
51+
recreateBranch: true,
52+
});
53+
expect(toastSuccess).toHaveBeenCalledWith("Task archive undone");
54+
expect(toastError).not.toHaveBeenCalled();
55+
});
56+
57+
it("shows an error toast when the branch is still missing after retry", async () => {
58+
const restore = vi.fn().mockResolvedValue({
59+
kind: "branch-not-found",
60+
taskId: "task-1",
61+
branchName: "feat/x",
62+
});
63+
64+
await undoArchive("task-1", restore as unknown as Restore);
65+
66+
expect(restore).toHaveBeenCalledTimes(2);
67+
expect(toastError).toHaveBeenCalledWith(
68+
"Failed to restore task: branch 'feat/x' not found",
69+
);
70+
expect(toastSuccess).not.toHaveBeenCalled();
71+
});
72+
73+
it("surfaces the error message when restore reports an error", async () => {
74+
const restore = vi
75+
.fn()
76+
.mockResolvedValue({ kind: "error", message: "server boom" });
77+
78+
await undoArchive("task-1", restore as unknown as Restore);
79+
80+
expect(toastError).toHaveBeenCalledWith(
81+
"Failed to restore task: server boom",
82+
);
83+
});
84+
85+
it("shows a generic error toast when restore throws", async () => {
86+
const restore = vi.fn().mockRejectedValue(new Error("network down"));
87+
88+
await undoArchive("task-1", restore as unknown as Restore);
89+
90+
expect(toastError).toHaveBeenCalledWith("Failed to restore task");
91+
expect(toastSuccess).not.toHaveBeenCalled();
92+
});
93+
94+
it("ignores a repeated undo while the first is still in flight", async () => {
95+
let resolveRestore: (value: unknown) => void = () => {};
96+
const restore = vi.fn().mockImplementation(
97+
() =>
98+
new Promise((resolve) => {
99+
resolveRestore = resolve;
100+
}),
101+
);
102+
103+
const first = undoArchive("task-1", restore as unknown as Restore);
104+
const second = undoArchive("task-1", restore as unknown as Restore);
105+
resolveRestore({ kind: "restored", navigateToTaskId: "task-1" });
106+
await Promise.all([first, second]);
107+
108+
expect(restore).toHaveBeenCalledTimes(1);
109+
expect(toastSuccess).toHaveBeenCalledTimes(1);
110+
});
111+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { toast } from "@posthog/ui/primitives/toast";
2+
import { logger } from "@posthog/ui/shell/logger";
3+
import type { UseUnarchiveTask } from "./useUnarchiveTask";
4+
5+
const log = logger.scope("undo-archive");
6+
7+
const inFlight = new Set<string>();
8+
9+
export async function undoArchive(
10+
taskId: string,
11+
restore: UseUnarchiveTask["restore"],
12+
): Promise<void> {
13+
if (inFlight.has(taskId)) return;
14+
inFlight.add(taskId);
15+
try {
16+
let outcome = await restore(taskId, true);
17+
// Undo is a transient toast action: silently recreate a missing branch
18+
// rather than interrupting with the Archived tasks page's confirm dialog.
19+
if (outcome.kind === "branch-not-found") {
20+
outcome = await restore(taskId, true, { recreateBranch: true });
21+
}
22+
if (outcome.kind === "restored") {
23+
toast.success("Task archive undone");
24+
return;
25+
}
26+
const reason =
27+
outcome.kind === "branch-not-found"
28+
? `branch '${outcome.branchName}' not found`
29+
: outcome.message;
30+
log.error("Failed to restore archived task", { taskId, reason });
31+
toast.error(`Failed to restore task: ${reason}`);
32+
} catch (error) {
33+
log.error("Failed to restore archived task", error);
34+
toast.error("Failed to restore task");
35+
} finally {
36+
inFlight.delete(taskId);
37+
}
38+
}

packages/ui/src/features/archive/useArchiveTask.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,13 @@ import { openTaskInput } from "@posthog/ui/router/useOpenTask";
2929
import { logger } from "@posthog/ui/shell/logger";
3030
import { type QueryClient, useQueryClient } from "@tanstack/react-query";
3131
import { useMemo } from "react";
32+
import { undoArchive } from "./undoArchive";
33+
import { useUnarchiveTask } from "./useUnarchiveTask";
3234

3335
const log = logger.scope("archive-task");
3436

37+
const UNDO_TOAST_DURATION_MS = 8000;
38+
3539
export interface ArchiveCacheKeys {
3640
archivedTaskIdsQueryKey: readonly unknown[];
3741
archiveListQueryKey: readonly unknown[];
@@ -172,14 +176,26 @@ export async function archiveTasksImperative(
172176
export function useArchiveTask() {
173177
const queryClient = useQueryClient();
174178
const keys = useArchiveCacheKeys();
179+
const { restore } = useUnarchiveTask();
175180

176181
const archiveTask = async ({ taskId }: { taskId: string }) => {
177182
// Non-optimistic: keep the row in place (with a spinner) until the archive
178183
// is confirmed, rather than removing it instantly and rolling back on error.
179184
await archiveTaskImperative(taskId, queryClient, keys, {
180185
optimistic: false,
181186
});
182-
toast.success("Task archived");
187+
const toastId = `archive-undo-${taskId}`;
188+
toast.success("Task archived", {
189+
id: toastId,
190+
duration: UNDO_TOAST_DURATION_MS,
191+
action: {
192+
label: "Undo",
193+
onClick: () => {
194+
toast.dismiss(toastId);
195+
void undoArchive(taskId, restore);
196+
},
197+
},
198+
});
183199
};
184200

185201
return { archiveTask };

packages/ui/src/primitives/toast.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ function ToastComponent(props: ToastProps) {
8686
}
8787

8888
export const toast = {
89+
dismiss: (id?: string | number) => sonnerToast.dismiss(id),
90+
8991
loading: (title: ReactNode, description?: string) => {
9092
return sonnerToast.custom((id) => (
9193
<ToastComponent
@@ -103,6 +105,7 @@ export const toast = {
103105
description?: string;
104106
id?: string | number;
105107
action?: ToastAction;
108+
duration?: number;
106109
},
107110
) => {
108111
return sonnerToast.custom(
@@ -115,7 +118,7 @@ export const toast = {
115118
action={options?.action}
116119
/>
117120
),
118-
{ id: options?.id },
121+
{ id: options?.id, duration: options?.duration },
119122
);
120123
},
121124

0 commit comments

Comments
 (0)