Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 717632e

Browse files
authored
fix(spaces): make archiving a task remove it from the space
Archiving is a local, per-device record, but the space feed rendered the cloud task list raw — so an archived task's card stayed in the feed (and survived reload) while only the sidebar row disappeared. Both archive entry points therefore looked like they did nothing beyond a toast. The channel feed hook now drops archived tasks for every surface that reads it, and a rejected archive in the space sidebar shows an error toast instead of being swallowed by a bare `void`. Generated-By: PostHog Code Task-Id: 35aa501b-d1d6-44f7-8582-30183992f0fd
1 parent d4ace51 commit 717632e

6 files changed

Lines changed: 174 additions & 10 deletions

File tree

packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ import type { ChannelItemModel } from "@posthog/core/canvas/channelItems";
22
import type { TaskRunStatus } from "@posthog/shared/domain-types";
33
import { Theme } from "@radix-ui/themes";
44
import { fireEvent, render, screen } from "@testing-library/react";
5-
import { describe, expect, it, vi } from "vitest";
5+
import { beforeEach, describe, expect, it, vi } from "vitest";
66
import { ChannelItemRow } from "./ChannelItemRow";
77

88
const actions = {
99
open: () => {},
1010
togglePin: () => {},
11-
archive: () => {},
11+
archive: vi.fn(),
1212
};
1313

1414
function item(overrides: Partial<ChannelItemModel> = {}): ChannelItemModel {
@@ -37,6 +37,10 @@ function renderRow(model: ChannelItemModel) {
3737
}
3838

3939
describe("ChannelItemRow", () => {
40+
beforeEach(() => {
41+
actions.archive.mockClear();
42+
});
43+
4044
it.each([
4145
["queued" as const, true],
4246
["in_progress" as const, true],
@@ -75,6 +79,21 @@ describe("ChannelItemRow", () => {
7579
expect(running.querySelector("svg")).not.toBeNull();
7680
});
7781

82+
it("archives the task from the row's own icon", () => {
83+
const model = item();
84+
renderRow(model);
85+
86+
fireEvent.click(screen.getByRole("button", { name: "Archive task" }));
87+
88+
expect(actions.archive).toHaveBeenCalledWith(model);
89+
});
90+
91+
it("offers no archive on a canvas, which cannot be archived", () => {
92+
renderRow(item({ key: "canvas:c1", kind: "canvas", id: "c1" }));
93+
94+
expect(screen.queryByRole("button", { name: "Archive task" })).toBeNull();
95+
});
96+
7897
it("opens the task context menu from the row", () => {
7998
const onContextMenu = vi.fn();
8099

packages/ui/src/features/canvas/components/ChannelSidebar.test.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
import type { ChannelItemModel } from "@posthog/core/canvas/channelItems";
22
import { Theme } from "@radix-ui/themes";
3-
import { render, screen } from "@testing-library/react";
3+
import { fireEvent, render, screen } from "@testing-library/react";
44
import userEvent from "@testing-library/user-event";
55
import { beforeEach, describe, expect, it, vi } from "vitest";
66

77
const mocks = vi.hoisted(() => ({
88
items: [] as ChannelItemModel[],
99
isLoading: false,
1010
channelMissing: false,
11+
archive: vi.fn(),
12+
showContextMenu: vi.fn(),
1113
}));
1214

1315
vi.mock("@posthog/ui/features/canvas/hooks/useChannelItems", () => ({
1416
useChannelItems: () => ({
1517
items: mocks.items,
16-
actions: { open: vi.fn(), togglePin: vi.fn(), archive: vi.fn() },
18+
actions: { open: vi.fn(), togglePin: vi.fn(), archive: mocks.archive },
1719
me: { uuid: "me-uuid" },
1820
isLoading: mocks.isLoading,
1921
channelMissing: mocks.channelMissing,
@@ -41,7 +43,7 @@ vi.mock("@posthog/ui/features/canvas/components/ChannelsFab", () => ({
4143
// WebsiteLayout.test.tsx does for the same reason.
4244
vi.mock("@posthog/ui/features/tasks/useTaskContextMenu", () => ({
4345
useTaskContextMenu: () => ({
44-
showContextMenu: vi.fn(),
46+
showContextMenu: mocks.showContextMenu,
4547
editingTaskId: null,
4648
setEditingTaskId: vi.fn(),
4749
}),
@@ -91,6 +93,22 @@ describe("ChannelSidebar", () => {
9193
mocks.items = [];
9294
mocks.isLoading = false;
9395
mocks.channelMissing = false;
96+
mocks.archive.mockClear();
97+
mocks.showContextMenu.mockClear();
98+
});
99+
100+
// The row's second archive entry point: the native context menu hands its
101+
// "Archive" click back through onArchive.
102+
it("archives the task from the row context menu", () => {
103+
mocks.items = [item()];
104+
renderSidebar();
105+
106+
fireEvent.contextMenu(screen.getByText("Investigate signup drop-off"));
107+
108+
const [menuItem, , options] = mocks.showContextMenu.mock.calls[0] ?? [];
109+
expect(menuItem).toMatchObject({ id: "task-1" });
110+
options?.onArchive?.();
111+
expect(mocks.archive).toHaveBeenCalledWith(mocks.items[0]);
94112
});
95113

96114
it.each([
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { Task } from "@posthog/shared/domain-types";
2+
import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery";
3+
import { renderHook } from "@testing-library/react";
4+
import { beforeEach, describe, expect, it, vi } from "vitest";
5+
import { useChannelFeed } from "./useChannelFeed";
6+
7+
const mocks = vi.hoisted(() => ({
8+
archivedTaskIds: new Set<string>(),
9+
}));
10+
11+
vi.mock("@posthog/ui/hooks/useAuthenticatedQuery", () => ({
12+
useAuthenticatedQuery: vi.fn(() => ({ data: [], isLoading: false })),
13+
}));
14+
vi.mock("@posthog/ui/features/archive/useArchivedTaskIds", () => ({
15+
useArchivedTaskIds: () => mocks.archivedTaskIds,
16+
}));
17+
18+
function task(id: string, createdAt: string): Task {
19+
return {
20+
id,
21+
task_number: null,
22+
slug: id,
23+
title: id,
24+
description: "",
25+
created_at: createdAt,
26+
updated_at: createdAt,
27+
origin_product: "code",
28+
};
29+
}
30+
31+
describe("useChannelFeed", () => {
32+
beforeEach(() => {
33+
mocks.archivedTaskIds = new Set<string>();
34+
vi.mocked(useAuthenticatedQuery).mockReturnValue({
35+
data: [],
36+
isLoading: false,
37+
} as never);
38+
});
39+
40+
function feedOf(tasks: Task[]) {
41+
vi.mocked(useAuthenticatedQuery).mockReturnValue({
42+
data: tasks,
43+
isLoading: false,
44+
} as never);
45+
return renderHook(() => useChannelFeed("channel-id")).result.current.tasks;
46+
}
47+
48+
it("orders the feed oldest first", () => {
49+
const tasks = feedOf([
50+
task("newer", "2026-07-28T12:00:00Z"),
51+
task("older", "2026-07-27T12:00:00Z"),
52+
]);
53+
54+
expect(tasks.map((t) => t.id)).toEqual(["older", "newer"]);
55+
});
56+
57+
// Archiving is local, so the cloud feed keeps returning the task: without
58+
// this filter an archived task's card never leaves the space.
59+
it("drops archived tasks", () => {
60+
mocks.archivedTaskIds = new Set(["archived"]);
61+
62+
const tasks = feedOf([
63+
task("archived", "2026-07-27T12:00:00Z"),
64+
task("kept", "2026-07-28T12:00:00Z"),
65+
]);
66+
67+
expect(tasks.map((t) => t.id)).toEqual(["kept"]);
68+
});
69+
70+
it("leaves the query cache untouched when filtering", () => {
71+
const data = [task("archived", "2026-07-27T12:00:00Z")];
72+
mocks.archivedTaskIds = new Set(["archived"]);
73+
74+
feedOf(data);
75+
76+
expect(data.map((t) => t.id)).toEqual(["archived"]);
77+
});
78+
});

packages/ui/src/features/canvas/hooks/useChannelFeed.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Task } from "@posthog/shared/domain-types";
2+
import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds";
23
import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery";
34
import { useMemo } from "react";
45
import {
@@ -18,6 +19,11 @@ export function channelFeedQueryKey(channelId: string | undefined) {
1819
/**
1920
* A channel's task feed, oldest first (Slack ordering — the composer sits at
2021
* the bottom and new cards land above it).
22+
*
23+
* Archived tasks are dropped here rather than in each view. Archiving is a
24+
* local, per-device record, so the cloud feed keeps returning an archived task
25+
* forever: any surface that renders this list raw shows a card the user has
26+
* already archived, which reads as the archive having done nothing.
2127
*/
2228
export function useChannelFeed(channelId: string | undefined): {
2329
tasks: Task[];
@@ -34,12 +40,13 @@ export function useChannelFeed(channelId: string | undefined): {
3440
staleTime: SPACE_QUERY_STALE_TIME_MS,
3541
},
3642
);
43+
const archivedTaskIds = useArchivedTaskIds();
3744
const tasks = useMemo(
3845
() =>
39-
[...(query.data ?? [])].sort((a, b) =>
40-
a.created_at.localeCompare(b.created_at),
41-
),
42-
[query.data],
46+
(query.data ?? [])
47+
.filter((task) => !archivedTaskIds.has(task.id))
48+
.sort((a, b) => a.created_at.localeCompare(b.created_at)),
49+
[query.data, archivedTaskIds],
4350
);
4451
return { tasks, isLoading: query.isLoading };
4552
}

packages/ui/src/features/canvas/hooks/useChannelItems.test.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ const mocks = vi.hoisted(() => ({
2020
togglePin: vi.fn(),
2121
archiveTask: vi.fn(),
2222
navigate: vi.fn(),
23+
toastError: vi.fn(),
24+
}));
25+
26+
vi.mock("@posthog/ui/primitives/toast", () => ({
27+
toast: { error: mocks.toastError },
2328
}));
2429

2530
vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({
@@ -214,4 +219,36 @@ describe("useChannelItems", () => {
214219
rerender();
215220
expect(result.current.items.map((item) => item.id)).toEqual(["task-1"]);
216221
});
222+
223+
// A rejected archive used to be swallowed by a bare `void`: the row stayed
224+
// put, no toast fired, and the click looked like it did nothing.
225+
it("says so when archiving fails", async () => {
226+
mocks.channels = {
227+
channels: [{ id: "c1", name: "eng", path: "/eng" }],
228+
isLoading: false,
229+
};
230+
mocks.archiveTask.mockRejectedValueOnce(
231+
new Error("Couldn't stop the task"),
232+
);
233+
234+
const { result } = renderHook(() => useChannelItems("c1"));
235+
result.current.actions.archive({ id: "task-1" } as never);
236+
await vi.waitFor(() =>
237+
expect(mocks.toastError).toHaveBeenCalledWith("Couldn't archive task"),
238+
);
239+
});
240+
241+
it("stays quiet when archiving succeeds", async () => {
242+
mocks.channels = {
243+
channels: [{ id: "c1", name: "eng", path: "/eng" }],
244+
isLoading: false,
245+
};
246+
mocks.archiveTask.mockResolvedValueOnce(undefined);
247+
248+
const { result } = renderHook(() => useChannelItems("c1"));
249+
result.current.actions.archive({ id: "task-1" } as never);
250+
await vi.waitFor(() => expect(mocks.archiveTask).toHaveBeenCalled());
251+
252+
expect(mocks.toastError).not.toHaveBeenCalled();
253+
});
217254
});

packages/ui/src/features/canvas/hooks/useChannelItems.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,13 @@ export function useChannelItems(channelId: string): {
140140
toast.error("Couldn't update pin");
141141
});
142142
},
143+
// An archive that rejects (a cloud run that won't stop, a task already
144+
// archived) has no other tell: the row stays put and the success toast
145+
// never fires, so without this the click looks like it did nothing.
143146
archive: (item) => {
144-
void archiveTask({ taskId: item.id });
147+
archiveTask({ taskId: item.id }).catch(() => {
148+
toast.error("Couldn't archive task");
149+
});
145150
},
146151
}),
147152
[channelId, navigate, setCanvasPinned, togglePin, archiveTask],

0 commit comments

Comments
 (0)