From 717632eaebdb574318ee812832016c9b0dfe7872 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:54:14 +0000 Subject: [PATCH] fix(spaces): make archiving a task remove it from the space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../canvas/components/ChannelItemRow.test.tsx | 23 +++++- .../canvas/components/ChannelSidebar.test.tsx | 24 +++++- .../canvas/hooks/useChannelFeed.test.ts | 78 +++++++++++++++++++ .../features/canvas/hooks/useChannelFeed.ts | 15 +++- .../canvas/hooks/useChannelItems.test.tsx | 37 +++++++++ .../features/canvas/hooks/useChannelItems.tsx | 7 +- 6 files changed, 174 insertions(+), 10 deletions(-) create mode 100644 packages/ui/src/features/canvas/hooks/useChannelFeed.test.ts diff --git a/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx b/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx index 2981229c1f..f241f83958 100644 --- a/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx @@ -2,13 +2,13 @@ import type { ChannelItemModel } from "@posthog/core/canvas/channelItems"; import type { TaskRunStatus } from "@posthog/shared/domain-types"; import { Theme } from "@radix-ui/themes"; import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { ChannelItemRow } from "./ChannelItemRow"; const actions = { open: () => {}, togglePin: () => {}, - archive: () => {}, + archive: vi.fn(), }; function item(overrides: Partial = {}): ChannelItemModel { @@ -37,6 +37,10 @@ function renderRow(model: ChannelItemModel) { } describe("ChannelItemRow", () => { + beforeEach(() => { + actions.archive.mockClear(); + }); + it.each([ ["queued" as const, true], ["in_progress" as const, true], @@ -75,6 +79,21 @@ describe("ChannelItemRow", () => { expect(running.querySelector("svg")).not.toBeNull(); }); + it("archives the task from the row's own icon", () => { + const model = item(); + renderRow(model); + + fireEvent.click(screen.getByRole("button", { name: "Archive task" })); + + expect(actions.archive).toHaveBeenCalledWith(model); + }); + + it("offers no archive on a canvas, which cannot be archived", () => { + renderRow(item({ key: "canvas:c1", kind: "canvas", id: "c1" })); + + expect(screen.queryByRole("button", { name: "Archive task" })).toBeNull(); + }); + it("opens the task context menu from the row", () => { const onContextMenu = vi.fn(); diff --git a/packages/ui/src/features/canvas/components/ChannelSidebar.test.tsx b/packages/ui/src/features/canvas/components/ChannelSidebar.test.tsx index 53fb6a5c3d..ff9d656cfb 100644 --- a/packages/ui/src/features/canvas/components/ChannelSidebar.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSidebar.test.tsx @@ -1,6 +1,6 @@ import type { ChannelItemModel } from "@posthog/core/canvas/channelItems"; import { Theme } from "@radix-ui/themes"; -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -8,12 +8,14 @@ const mocks = vi.hoisted(() => ({ items: [] as ChannelItemModel[], isLoading: false, channelMissing: false, + archive: vi.fn(), + showContextMenu: vi.fn(), })); vi.mock("@posthog/ui/features/canvas/hooks/useChannelItems", () => ({ useChannelItems: () => ({ items: mocks.items, - actions: { open: vi.fn(), togglePin: vi.fn(), archive: vi.fn() }, + actions: { open: vi.fn(), togglePin: vi.fn(), archive: mocks.archive }, me: { uuid: "me-uuid" }, isLoading: mocks.isLoading, channelMissing: mocks.channelMissing, @@ -41,7 +43,7 @@ vi.mock("@posthog/ui/features/canvas/components/ChannelsFab", () => ({ // WebsiteLayout.test.tsx does for the same reason. vi.mock("@posthog/ui/features/tasks/useTaskContextMenu", () => ({ useTaskContextMenu: () => ({ - showContextMenu: vi.fn(), + showContextMenu: mocks.showContextMenu, editingTaskId: null, setEditingTaskId: vi.fn(), }), @@ -91,6 +93,22 @@ describe("ChannelSidebar", () => { mocks.items = []; mocks.isLoading = false; mocks.channelMissing = false; + mocks.archive.mockClear(); + mocks.showContextMenu.mockClear(); + }); + + // The row's second archive entry point: the native context menu hands its + // "Archive" click back through onArchive. + it("archives the task from the row context menu", () => { + mocks.items = [item()]; + renderSidebar(); + + fireEvent.contextMenu(screen.getByText("Investigate signup drop-off")); + + const [menuItem, , options] = mocks.showContextMenu.mock.calls[0] ?? []; + expect(menuItem).toMatchObject({ id: "task-1" }); + options?.onArchive?.(); + expect(mocks.archive).toHaveBeenCalledWith(mocks.items[0]); }); it.each([ diff --git a/packages/ui/src/features/canvas/hooks/useChannelFeed.test.ts b/packages/ui/src/features/canvas/hooks/useChannelFeed.test.ts new file mode 100644 index 0000000000..05804327c6 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useChannelFeed.test.ts @@ -0,0 +1,78 @@ +import type { Task } from "@posthog/shared/domain-types"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useChannelFeed } from "./useChannelFeed"; + +const mocks = vi.hoisted(() => ({ + archivedTaskIds: new Set(), +})); + +vi.mock("@posthog/ui/hooks/useAuthenticatedQuery", () => ({ + useAuthenticatedQuery: vi.fn(() => ({ data: [], isLoading: false })), +})); +vi.mock("@posthog/ui/features/archive/useArchivedTaskIds", () => ({ + useArchivedTaskIds: () => mocks.archivedTaskIds, +})); + +function task(id: string, createdAt: string): Task { + return { + id, + task_number: null, + slug: id, + title: id, + description: "", + created_at: createdAt, + updated_at: createdAt, + origin_product: "code", + }; +} + +describe("useChannelFeed", () => { + beforeEach(() => { + mocks.archivedTaskIds = new Set(); + vi.mocked(useAuthenticatedQuery).mockReturnValue({ + data: [], + isLoading: false, + } as never); + }); + + function feedOf(tasks: Task[]) { + vi.mocked(useAuthenticatedQuery).mockReturnValue({ + data: tasks, + isLoading: false, + } as never); + return renderHook(() => useChannelFeed("channel-id")).result.current.tasks; + } + + it("orders the feed oldest first", () => { + const tasks = feedOf([ + task("newer", "2026-07-28T12:00:00Z"), + task("older", "2026-07-27T12:00:00Z"), + ]); + + expect(tasks.map((t) => t.id)).toEqual(["older", "newer"]); + }); + + // Archiving is local, so the cloud feed keeps returning the task: without + // this filter an archived task's card never leaves the space. + it("drops archived tasks", () => { + mocks.archivedTaskIds = new Set(["archived"]); + + const tasks = feedOf([ + task("archived", "2026-07-27T12:00:00Z"), + task("kept", "2026-07-28T12:00:00Z"), + ]); + + expect(tasks.map((t) => t.id)).toEqual(["kept"]); + }); + + it("leaves the query cache untouched when filtering", () => { + const data = [task("archived", "2026-07-27T12:00:00Z")]; + mocks.archivedTaskIds = new Set(["archived"]); + + feedOf(data); + + expect(data.map((t) => t.id)).toEqual(["archived"]); + }); +}); diff --git a/packages/ui/src/features/canvas/hooks/useChannelFeed.ts b/packages/ui/src/features/canvas/hooks/useChannelFeed.ts index 3973ab8826..c4b1c83bff 100644 --- a/packages/ui/src/features/canvas/hooks/useChannelFeed.ts +++ b/packages/ui/src/features/canvas/hooks/useChannelFeed.ts @@ -1,4 +1,5 @@ import type { Task } from "@posthog/shared/domain-types"; +import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; import { useMemo } from "react"; import { @@ -18,6 +19,11 @@ export function channelFeedQueryKey(channelId: string | undefined) { /** * A channel's task feed, oldest first (Slack ordering — the composer sits at * the bottom and new cards land above it). + * + * Archived tasks are dropped here rather than in each view. Archiving is a + * local, per-device record, so the cloud feed keeps returning an archived task + * forever: any surface that renders this list raw shows a card the user has + * already archived, which reads as the archive having done nothing. */ export function useChannelFeed(channelId: string | undefined): { tasks: Task[]; @@ -34,12 +40,13 @@ export function useChannelFeed(channelId: string | undefined): { staleTime: SPACE_QUERY_STALE_TIME_MS, }, ); + const archivedTaskIds = useArchivedTaskIds(); const tasks = useMemo( () => - [...(query.data ?? [])].sort((a, b) => - a.created_at.localeCompare(b.created_at), - ), - [query.data], + (query.data ?? []) + .filter((task) => !archivedTaskIds.has(task.id)) + .sort((a, b) => a.created_at.localeCompare(b.created_at)), + [query.data, archivedTaskIds], ); return { tasks, isLoading: query.isLoading }; } diff --git a/packages/ui/src/features/canvas/hooks/useChannelItems.test.tsx b/packages/ui/src/features/canvas/hooks/useChannelItems.test.tsx index df9ec64424..2a531e2108 100644 --- a/packages/ui/src/features/canvas/hooks/useChannelItems.test.tsx +++ b/packages/ui/src/features/canvas/hooks/useChannelItems.test.tsx @@ -20,6 +20,11 @@ const mocks = vi.hoisted(() => ({ togglePin: vi.fn(), archiveTask: vi.fn(), navigate: vi.fn(), + toastError: vi.fn(), +})); + +vi.mock("@posthog/ui/primitives/toast", () => ({ + toast: { error: mocks.toastError }, })); vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({ @@ -214,4 +219,36 @@ describe("useChannelItems", () => { rerender(); expect(result.current.items.map((item) => item.id)).toEqual(["task-1"]); }); + + // A rejected archive used to be swallowed by a bare `void`: the row stayed + // put, no toast fired, and the click looked like it did nothing. + it("says so when archiving fails", async () => { + mocks.channels = { + channels: [{ id: "c1", name: "eng", path: "/eng" }], + isLoading: false, + }; + mocks.archiveTask.mockRejectedValueOnce( + new Error("Couldn't stop the task"), + ); + + const { result } = renderHook(() => useChannelItems("c1")); + result.current.actions.archive({ id: "task-1" } as never); + await vi.waitFor(() => + expect(mocks.toastError).toHaveBeenCalledWith("Couldn't archive task"), + ); + }); + + it("stays quiet when archiving succeeds", async () => { + mocks.channels = { + channels: [{ id: "c1", name: "eng", path: "/eng" }], + isLoading: false, + }; + mocks.archiveTask.mockResolvedValueOnce(undefined); + + const { result } = renderHook(() => useChannelItems("c1")); + result.current.actions.archive({ id: "task-1" } as never); + await vi.waitFor(() => expect(mocks.archiveTask).toHaveBeenCalled()); + + expect(mocks.toastError).not.toHaveBeenCalled(); + }); }); diff --git a/packages/ui/src/features/canvas/hooks/useChannelItems.tsx b/packages/ui/src/features/canvas/hooks/useChannelItems.tsx index eb2c022a1e..0d6d299850 100644 --- a/packages/ui/src/features/canvas/hooks/useChannelItems.tsx +++ b/packages/ui/src/features/canvas/hooks/useChannelItems.tsx @@ -140,8 +140,13 @@ export function useChannelItems(channelId: string): { toast.error("Couldn't update pin"); }); }, + // An archive that rejects (a cloud run that won't stop, a task already + // archived) has no other tell: the row stays put and the success toast + // never fires, so without this the click looks like it did nothing. archive: (item) => { - void archiveTask({ taskId: item.id }); + archiveTask({ taskId: item.id }).catch(() => { + toast.error("Couldn't archive task"); + }); }, }), [channelId, navigate, setCanvasPinned, togglePin, archiveTask],