Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): ChannelItemModel {
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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();

Expand Down
24 changes: 21 additions & 3 deletions packages/ui/src/features/canvas/components/ChannelSidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
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";

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,
Expand Down Expand Up @@ -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(),
}),
Expand Down Expand Up @@ -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([
Expand Down
78 changes: 78 additions & 0 deletions packages/ui/src/features/canvas/hooks/useChannelFeed.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>(),
}));

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<string>();
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"]);
});
});
15 changes: 11 additions & 4 deletions packages/ui/src/features/canvas/hooks/useChannelFeed.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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[];
Expand All @@ -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 };
}
37 changes: 37 additions & 0 deletions packages/ui/src/features/canvas/hooks/useChannelItems.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -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();
});
});
7 changes: 6 additions & 1 deletion packages/ui/src/features/canvas/hooks/useChannelItems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
Loading