Skip to content
Closed
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 @@ -2294,6 +2294,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 @@ -119,7 +119,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 @@ -452,8 +452,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
202 changes: 202 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 All @@ -25,6 +33,14 @@ function folder(id: string, path: string): Schemas.FileSystem {
};
}

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" };
}

let queryClient: QueryClient;
function wrapper({ children }: { children: ReactNode }) {
return (
Expand Down Expand Up @@ -99,3 +115,189 @@ describe("useChannelMutations", () => {
expect(list.result.current.channels.map((c) => c.id)).toEqual(["1"]);
});
});

describe("useChannelMutations rename", () => {
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", () => {
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