Skip to content
Open
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
44 changes: 44 additions & 0 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2286,6 +2286,50 @@ export class PostHogAPIClient {
return (await response.json()) as TaskChannel[];
}

// Rename a public channel by id, so its task feed follows the new name.
async renameTaskChannel(id: string, name: string): Promise<TaskChannel> {
const teamId = await this.getTeamId();
const urlPath = `/api/projects/${teamId}/task_channels/${encodeURIComponent(id)}/`;
const response = await this.api.fetcher.fetch({
method: "patch",
url: new URL(`${this.api.baseUrl}${urlPath}`),
path: urlPath,
overrides: { body: JSON.stringify({ name }) },
});
if (!response.ok) {
// The backend rejects invalid/taken names with a JSON `detail` message.
const detail = await response
.json()
.then((body) => (body as { detail?: string }).detail)
.catch(() => undefined);
throw new Error(
`Failed to rename task channel: ${detail ?? response.statusText}`,
);
}
return (await response.json()) as TaskChannel;
}

// Delete a public channel by id. Soft-delete server-side, so the channel's
// tasks and messages are recoverable.
async deleteTaskChannel(id: string): Promise<void> {
const teamId = await this.getTeamId();
const urlPath = `/api/projects/${teamId}/task_channels/${encodeURIComponent(id)}/`;
const response = await this.api.fetcher.fetch({
method: "delete",
url: new URL(`${this.api.baseUrl}${urlPath}`),
path: urlPath,
});
if (!response.ok) {
const detail = await response
.json()
.then((body) => (body as { detail?: string }).detail)
.catch(() => undefined);
throw new Error(
`Failed to delete task channel: ${detail ?? response.statusText}`,
);
}
}

// Resolve-or-create a public channel by name (idempotent server-side).
async resolveTaskChannel(name: string): Promise<TaskChannel> {
const teamId = await this.getTeamId();
Expand Down
6 changes: 3 additions & 3 deletions packages/ui/src/features/canvas/components/ChannelsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ function useChannelActions(channel: Channel): {
),
]);

await deleteChannel(channel.id);
await deleteChannel(channel.id, channel.name);
removeStar();
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
action_type: "delete",
Expand Down Expand Up @@ -444,8 +444,8 @@ function ChannelSection({ channel }: { channel: Channel }) {
Every canvas saved in this channel is permanently deleted.
</li>
<li>
Filed tasks are removed from the channel, but the tasks
themselves are not deleted.
Tasks in this channel are deleted with it. Their data is
retained, so they can be restored later.
</li>
</ul>
</AlertDialogDescription>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function RenameChannelModal({
const submit = async () => {
if (!trimmed || unchanged || validationError || isRenaming) return;
try {
await renameChannel(channel.id, trimmed);
await renameChannel(channel.id, trimmed, channel.name);
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
action_type: "rename",
surface: "sidebar",
Expand Down
210 changes: 210 additions & 0 deletions packages/ui/src/features/canvas/hooks/useChannels.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Schemas } from "@posthog/api-client";
import type { TaskChannel } from "@posthog/shared/domain-types";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
Expand All @@ -7,6 +8,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mockClient = vi.hoisted(() => ({
getDesktopFileSystemChannels: vi.fn(),
createDesktopFileSystemChannel: vi.fn(),
renameDesktopFileSystemChannel: vi.fn(),
deleteDesktopFileSystem: vi.fn(),
getTaskChannels: vi.fn(),
renameTaskChannel: vi.fn(),
deleteTaskChannel: vi.fn(),
getTasks: vi.fn(),
deleteTask: vi.fn(),
}));
vi.mock("@posthog/ui/features/auth/authClient", () => ({
useOptionalAuthenticatedClient: () => mockClient,
Expand Down Expand Up @@ -99,3 +107,205 @@ describe("useChannelMutations", () => {
expect(list.result.current.channels.map((c) => c.id)).toEqual(["1"]);
});
});

describe("useChannelMutations rename", () => {
function taskChannel(
id: string,
name: string,
channel_type: TaskChannel["channel_type"] = "public",
): TaskChannel {
return { id, name, channel_type, created_at: "2026-01-01T00:00:00Z" };
Comment on lines +112 to +117

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 taskChannel factory defined twice

The taskChannel helper function is defined identically inside both describe("useChannelMutations rename") and describe("useChannelMutations delete"), violating OnceAndOnlyOnce. Lifting it to module scope (alongside the existing folder helper at line 25) would remove the duplication without changing any test behavior.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

}

beforeEach(() => {
vi.clearAllMocks();
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
});

it("renames the backend task channel alongside the folder", async () => {
// The feed's backend channel is looked up by name, so a folder rename
// must carry it along or the channel's tasks/messages are orphaned.
mockClient.getTaskChannels.mockResolvedValue([
taskChannel("bc-1", "mobile"),
taskChannel("bc-2", "web"),
]);
mockClient.renameTaskChannel.mockResolvedValue(
taskChannel("bc-1", "mobile-app"),
);
mockClient.renameDesktopFileSystemChannel.mockResolvedValue(
folder("1", "mobile-app"),
);

const mutations = renderHook(() => useChannelMutations(), { wrapper });
await act(async () => {
await mutations.result.current.renameChannel("1", "mobile-app", "mobile");
});

expect(mockClient.renameTaskChannel).toHaveBeenCalledWith(
"bc-1",
"mobile-app",
);
expect(mockClient.renameDesktopFileSystemChannel).toHaveBeenCalledWith(
"1",
"mobile-app",
);
});

it("skips the backend rename when no backend channel has the old name", async () => {
mockClient.getTaskChannels.mockResolvedValue([taskChannel("bc-2", "web")]);
mockClient.renameDesktopFileSystemChannel.mockResolvedValue(
folder("1", "mobile-app"),
);

const mutations = renderHook(() => useChannelMutations(), { wrapper });
await act(async () => {
await mutations.result.current.renameChannel("1", "mobile-app", "mobile");
});

expect(mockClient.renameTaskChannel).not.toHaveBeenCalled();
expect(mockClient.renameDesktopFileSystemChannel).toHaveBeenCalledWith(
"1",
"mobile-app",
);
});

it("does not rename the folder when the backend rename fails", async () => {
mockClient.getTaskChannels.mockResolvedValue([
taskChannel("bc-1", "mobile"),
]);
mockClient.renameTaskChannel.mockRejectedValue(new Error("name taken"));

const mutations = renderHook(() => useChannelMutations(), { wrapper });
await expect(
act(() =>
mutations.result.current.renameChannel("1", "mobile-app", "mobile"),
),
).rejects.toThrow("name taken");

expect(mockClient.renameDesktopFileSystemChannel).not.toHaveBeenCalled();
});

it("reverts the backend rename when the folder rename fails", async () => {
mockClient.getTaskChannels.mockResolvedValue([
taskChannel("bc-1", "mobile"),
]);
mockClient.renameTaskChannel.mockResolvedValue(
taskChannel("bc-1", "mobile-app"),
);
mockClient.renameDesktopFileSystemChannel.mockRejectedValue(
new Error("folder rename failed"),
);

const mutations = renderHook(() => useChannelMutations(), { wrapper });
await expect(
act(() =>
mutations.result.current.renameChannel("1", "mobile-app", "mobile"),
),
).rejects.toThrow("folder rename failed");

expect(mockClient.renameTaskChannel).toHaveBeenNthCalledWith(
1,
"bc-1",
"mobile-app",
);
expect(mockClient.renameTaskChannel).toHaveBeenNthCalledWith(
2,
"bc-1",
"mobile",
);
});
});

describe("useChannelMutations delete", () => {
function taskChannel(
id: string,
name: string,
channel_type: TaskChannel["channel_type"] = "public",
): TaskChannel {
return { id, name, channel_type, created_at: "2026-01-01T00:00:00Z" };
}

beforeEach(() => {
vi.clearAllMocks();
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
});

it("soft-deletes the backend channel's tasks and the channel before the folder", async () => {
mockClient.getTaskChannels.mockResolvedValue([
taskChannel("bc-1", "mobile"),
taskChannel("bc-2", "web"),
]);
mockClient.getTasks.mockResolvedValue([{ id: "t-1" }, { id: "t-2" }]);
mockClient.deleteTask.mockResolvedValue(undefined);
mockClient.deleteTaskChannel.mockResolvedValue(undefined);
mockClient.deleteDesktopFileSystem.mockResolvedValue(undefined);

const mutations = renderHook(() => useChannelMutations(), { wrapper });
await act(async () => {
await mutations.result.current.deleteChannel("1", "mobile");
});

expect(mockClient.getTasks).toHaveBeenCalledWith({ channel: "bc-1" });
expect(mockClient.deleteTask).toHaveBeenCalledWith("t-1");
expect(mockClient.deleteTask).toHaveBeenCalledWith("t-2");
expect(mockClient.deleteTaskChannel).toHaveBeenCalledWith("bc-1");
expect(mockClient.deleteDesktopFileSystem).toHaveBeenCalledWith("1");
});

it("deletes just the folder when no backend channel matches the name", async () => {
mockClient.getTaskChannels.mockResolvedValue([taskChannel("bc-2", "web")]);
mockClient.deleteDesktopFileSystem.mockResolvedValue(undefined);

const mutations = renderHook(() => useChannelMutations(), { wrapper });
await act(async () => {
await mutations.result.current.deleteChannel("1", "mobile");
});

expect(mockClient.getTasks).not.toHaveBeenCalled();
expect(mockClient.deleteTaskChannel).not.toHaveBeenCalled();
expect(mockClient.deleteDesktopFileSystem).toHaveBeenCalledWith("1");
});

it("still deletes the backend channel when a task soft delete fails", async () => {
// Task deletes are best-effort: a straggler stays attached to the
// soft-deleted channel (recoverable) rather than blocking the delete.
mockClient.getTaskChannels.mockResolvedValue([
taskChannel("bc-1", "mobile"),
]);
mockClient.getTasks.mockResolvedValue([{ id: "t-1" }, { id: "t-2" }]);
mockClient.deleteTask
.mockRejectedValueOnce(new Error("task delete failed"))
.mockResolvedValueOnce(undefined);
mockClient.deleteTaskChannel.mockResolvedValue(undefined);
mockClient.deleteDesktopFileSystem.mockResolvedValue(undefined);

const mutations = renderHook(() => useChannelMutations(), { wrapper });
await act(async () => {
await mutations.result.current.deleteChannel("1", "mobile");
});

expect(mockClient.deleteTaskChannel).toHaveBeenCalledWith("bc-1");
expect(mockClient.deleteDesktopFileSystem).toHaveBeenCalledWith("1");
});

it("does not delete the folder when the backend channel delete fails", async () => {
mockClient.getTaskChannels.mockResolvedValue([
taskChannel("bc-1", "mobile"),
]);
mockClient.getTasks.mockResolvedValue([]);
mockClient.deleteTaskChannel.mockRejectedValue(
new Error("channel delete failed"),
);

const mutations = renderHook(() => useChannelMutations(), { wrapper });
await expect(
act(() => mutations.result.current.deleteChannel("1", "mobile")),
).rejects.toThrow("channel delete failed");

expect(mockClient.deleteDesktopFileSystem).not.toHaveBeenCalled();
});
});
Loading
Loading