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

Commit dd419cf

Browse files
authored
feat: sync task pins with PostHog (#3925)
1 parent 4a1d040 commit dd419cf

6 files changed

Lines changed: 224 additions & 31 deletions

File tree

packages/api-client/src/posthog-client.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1250,6 +1250,59 @@ describe("PostHogAPIClient", () => {
12501250
});
12511251
});
12521252

1253+
describe("task pins", () => {
1254+
function buildClient(fetch: ReturnType<typeof vi.fn>) {
1255+
const client = new PostHogAPIClient(
1256+
"http://localhost:8000",
1257+
async () => "token",
1258+
async () => "token",
1259+
123,
1260+
);
1261+
(
1262+
client as unknown as {
1263+
api: { baseUrl: string; fetcher: { fetch: typeof fetch } };
1264+
}
1265+
).api = { baseUrl: "http://localhost:8000", fetcher: { fetch } };
1266+
return client;
1267+
}
1268+
1269+
it("loads pinned task ids", async () => {
1270+
const fetch = vi.fn().mockResolvedValue({
1271+
ok: true,
1272+
json: async () => ({ task_ids: ["task-1", "task-2"] }),
1273+
});
1274+
1275+
await expect(buildClient(fetch).getPinnedTaskIds()).resolves.toEqual([
1276+
"task-1",
1277+
"task-2",
1278+
]);
1279+
expect(fetch).toHaveBeenCalledWith(
1280+
expect.objectContaining({
1281+
method: "get",
1282+
path: "/api/projects/123/tasks/pinned/",
1283+
}),
1284+
);
1285+
});
1286+
1287+
it("sets pin state idempotently", async () => {
1288+
const fetch = vi.fn().mockResolvedValue({
1289+
ok: true,
1290+
json: async () => ({ task_id: "task-1", pinned: true }),
1291+
});
1292+
1293+
await expect(
1294+
buildClient(fetch).setTaskPinned("task-1", true),
1295+
).resolves.toBe(true);
1296+
expect(fetch).toHaveBeenCalledWith(
1297+
expect.objectContaining({
1298+
method: "post",
1299+
path: "/api/projects/123/tasks/task-1/pin/",
1300+
overrides: { body: JSON.stringify({ pinned: true }) },
1301+
}),
1302+
);
1303+
});
1304+
});
1305+
12531306
describe("getSignalReportArtefacts", () => {
12541307
function makeClient(fetch: ReturnType<typeof vi.fn>) {
12551308
const client = new PostHogAPIClient(

packages/api-client/src/posthog-client.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2470,6 +2470,37 @@ export class PostHogAPIClient {
24702470
return normalizeTaskResponse(data, { teamId });
24712471
}
24722472

2473+
async getPinnedTaskIds(): Promise<string[]> {
2474+
const teamId = await this.getTeamId();
2475+
const urlPath = `/api/projects/${teamId}/tasks/pinned/`;
2476+
const response = await this.api.fetcher.fetch({
2477+
method: "get",
2478+
url: new URL(`${this.api.baseUrl}${urlPath}`),
2479+
path: urlPath,
2480+
});
2481+
if (!response.ok) {
2482+
throw new Error(`Failed to fetch pinned tasks: ${response.statusText}`);
2483+
}
2484+
const data = (await response.json()) as { task_ids: string[] };
2485+
return data.task_ids;
2486+
}
2487+
2488+
async setTaskPinned(taskId: string, pinned: boolean): Promise<boolean> {
2489+
const teamId = await this.getTeamId();
2490+
const urlPath = `/api/projects/${teamId}/tasks/${taskId}/pin/`;
2491+
const response = await this.api.fetcher.fetch({
2492+
method: "post",
2493+
url: new URL(`${this.api.baseUrl}${urlPath}`),
2494+
path: urlPath,
2495+
overrides: { body: JSON.stringify({ pinned }) },
2496+
});
2497+
if (!response.ok) {
2498+
throw new Error(`Failed to update task pin: ${response.statusText}`);
2499+
}
2500+
const data = (await response.json()) as { pinned: boolean };
2501+
return data.pinned;
2502+
}
2503+
24732504
async listTaskAutomations(options?: {
24742505
limit?: number;
24752506
offset?: number;

packages/ui/src/features/archive/useArchiveTask.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ function makeOrchestrationDeps(
104104
getPinnedTaskIds: () => pinnedTasksApi.getPinnedTaskIds(),
105105
unpin: (taskId) => pinnedTasksApi.unpin(taskId),
106106
togglePin: async (taskId) => {
107-
await pinnedTasksApi.togglePin(taskId);
107+
await pinnedTasksApi.setPinned(taskId, true);
108108
},
109109
navigateAwayFromTaskIfActive: (taskId) => {
110110
if (options?.skipNavigate) return;

packages/ui/src/features/sidebar/taskMetaApi.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
HOST_TRPC_CLIENT,
88
type HostTrpcClient,
99
} from "@posthog/host-router/client";
10+
import { getAuthenticatedClient } from "@posthog/ui/features/auth/authClientImperative";
1011
import {
1112
IMPERATIVE_QUERY_CLIENT,
1213
type ImperativeQueryClient,
@@ -43,21 +44,24 @@ export const taskViewedApi = {
4344

4445
export const pinnedTasksApi = {
4546
async getPinnedTaskIds(): Promise<string[]> {
46-
return workspace().getPinnedTaskIds.query();
47+
const client = await getAuthenticatedClient();
48+
if (!client) return [];
49+
return client.getPinnedTaskIds();
4750
},
4851

49-
async togglePin(
52+
async setPinned(
5053
taskId: string,
54+
pinned: boolean,
5155
): Promise<{ taskId: string; isPinned: boolean }> {
52-
const result = await workspace().togglePin.mutate({ taskId });
53-
return { taskId, isPinned: result.isPinned };
56+
const client = await getAuthenticatedClient();
57+
if (!client) return { taskId, isPinned: false };
58+
const isPinned = await client.setTaskPinned(taskId, pinned);
59+
return { taskId, isPinned };
5460
},
5561

5662
async unpin(taskId: string): Promise<void> {
57-
const result = await workspace().togglePin.mutate({ taskId });
58-
if (result.isPinned) {
59-
await workspace().togglePin.mutate({ taskId });
60-
}
63+
const client = await getAuthenticatedClient();
64+
if (client) await client.setTaskPinned(taskId, false);
6165
},
6266

6367
isPinned(pinnedTaskIds: Set<string>, taskId: string): boolean {
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2+
import { act, renderHook, waitFor } from "@testing-library/react";
3+
import type { PropsWithChildren } from "react";
4+
import { beforeEach, describe, expect, it, vi } from "vitest";
5+
import { pinnedTasksApi } from "./taskMetaApi";
6+
import { usePinnedTasks } from "./usePinnedTasks";
7+
8+
const authClient = vi.hoisted(() => ({
9+
getPinnedTaskIds: vi.fn(),
10+
}));
11+
12+
vi.mock("@posthog/ui/features/auth/authClient", () => ({
13+
useOptionalAuthenticatedClient: () => authClient,
14+
}));
15+
16+
vi.mock("./taskMetaApi", () => ({
17+
pinnedTasksApi: {
18+
getPinnedTaskIds: vi.fn(),
19+
setPinned: vi.fn(),
20+
unpin: vi.fn(),
21+
},
22+
}));
23+
24+
const mockedApi = vi.mocked(pinnedTasksApi);
25+
26+
describe("usePinnedTasks", () => {
27+
beforeEach(() => vi.clearAllMocks());
28+
29+
function renderPinnedTasks() {
30+
const client = new QueryClient({
31+
defaultOptions: { queries: { retry: false } },
32+
});
33+
const wrapper = ({ children }: PropsWithChildren) => (
34+
<QueryClientProvider client={client}>{children}</QueryClientProvider>
35+
);
36+
return { ...renderHook(() => usePinnedTasks(), { wrapper }), client };
37+
}
38+
39+
it("hydrates pins from the authenticated API", async () => {
40+
authClient.getPinnedTaskIds.mockResolvedValue(["task-1"]);
41+
42+
const { result, client } = renderPinnedTasks();
43+
44+
await waitFor(() => expect(result.current.isLoading).toBe(false));
45+
expect(result.current.isPinned("task-1")).toBe(true);
46+
expect(authClient.getPinnedTaskIds).toHaveBeenCalledOnce();
47+
expect(
48+
client.getQueryCache().find({ queryKey: ["task-pins"] })?.meta,
49+
).toMatchObject({ authScoped: true });
50+
});
51+
52+
it("persists pin and unpin actions", async () => {
53+
authClient.getPinnedTaskIds.mockResolvedValue([]);
54+
mockedApi.setPinned.mockResolvedValue({
55+
taskId: "task-1",
56+
isPinned: true,
57+
});
58+
mockedApi.unpin.mockResolvedValue();
59+
const { result } = renderPinnedTasks();
60+
await waitFor(() => expect(result.current.isLoading).toBe(false));
61+
62+
await act(() => result.current.togglePin("task-1"));
63+
expect(mockedApi.setPinned).toHaveBeenCalledWith("task-1", true);
64+
await waitFor(() => expect(result.current.isPinned("task-1")).toBe(true));
65+
66+
await act(() => result.current.unpin("task-1"));
67+
expect(mockedApi.unpin).toHaveBeenCalledWith("task-1");
68+
await waitFor(() => expect(result.current.isPinned("task-1")).toBe(false));
69+
});
70+
71+
it("preserves rapid toggle order", async () => {
72+
authClient.getPinnedTaskIds.mockResolvedValue([]);
73+
mockedApi.setPinned
74+
.mockResolvedValueOnce({ taskId: "task-1", isPinned: true })
75+
.mockResolvedValueOnce({ taskId: "task-1", isPinned: false });
76+
const { result } = renderPinnedTasks();
77+
await waitFor(() => expect(result.current.isLoading).toBe(false));
78+
79+
await act(() =>
80+
Promise.all([
81+
result.current.togglePin("task-1"),
82+
result.current.togglePin("task-1"),
83+
]),
84+
);
85+
86+
expect(mockedApi.setPinned.mock.calls).toEqual([
87+
["task-1", true],
88+
["task-1", false],
89+
]);
90+
await waitFor(() => expect(result.current.isPinned("task-1")).toBe(false));
91+
});
92+
});

packages/ui/src/features/sidebar/usePinnedTasks.ts

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,33 @@
1-
import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react";
2-
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
1+
import { useMutation, useQueryClient } from "@tanstack/react-query";
32
import { useCallback, useMemo, useRef } from "react";
3+
import { useAuthenticatedQuery } from "../../hooks/useAuthenticatedQuery";
4+
import { pinnedTasksApi } from "./taskMetaApi";
5+
6+
const PINNED_TASKS_QUERY_KEY = ["task-pins"] as const;
47

58
export function usePinnedTasks() {
6-
const trpc = useHostTRPC();
7-
const hostClient = useHostTRPCClient();
89
const queryClient = useQueryClient();
9-
const pinnedQueryKey = trpc.workspace.getPinnedTaskIds.queryKey();
10+
const pinnedQueryKey = PINNED_TASKS_QUERY_KEY;
1011

11-
const { data: pinnedTaskIds = [], isLoading } = useQuery(
12-
trpc.workspace.getPinnedTaskIds.queryOptions(undefined, {
13-
staleTime: 30_000,
14-
}),
12+
const { data: pinnedTaskIds = [], isLoading } = useAuthenticatedQuery(
13+
pinnedQueryKey,
14+
(client) => client.getPinnedTaskIds(),
15+
{ staleTime: 30_000 },
1516
);
1617

1718
const pinnedSet = useMemo(() => new Set(pinnedTaskIds), [pinnedTaskIds]);
1819

1920
const togglePinMutation = useMutation({
20-
mutationFn: ({ taskId }: { taskId: string }) =>
21-
hostClient.workspace.togglePin.mutate({ taskId }),
22-
onMutate: async ({ taskId }) => {
21+
scope: { id: "task-pins" },
22+
mutationFn: ({ taskId, pinned }: { taskId: string; pinned: boolean }) =>
23+
pinnedTasksApi.setPinned(taskId, pinned),
24+
onMutate: async ({ taskId, pinned }) => {
2325
await queryClient.cancelQueries({ queryKey: pinnedQueryKey });
2426
const previous = queryClient.getQueryData<string[]>(pinnedQueryKey);
2527
const wasPinned = previous?.includes(taskId);
2628
queryClient.setQueryData<string[]>(pinnedQueryKey, (old) => {
27-
if (!old) return wasPinned ? [] : [taskId];
28-
return wasPinned ? old.filter((id) => id !== taskId) : [...old, taskId];
29+
const filtered = old?.filter((id) => id !== taskId) ?? [];
30+
return pinned ? [...filtered, taskId] : filtered;
2931
});
3032
return { previous, wasPinned, taskId };
3133
},
@@ -52,16 +54,27 @@ export function usePinnedTasks() {
5254
pinnedSetRef.current = pinnedSet;
5355

5456
const togglePin = useCallback(async (taskId: string) => {
55-
await togglePinMutationRef.current.mutateAsync({ taskId });
57+
const pinned = !pinnedSetRef.current.has(taskId);
58+
const nextPinnedSet = new Set(pinnedSetRef.current);
59+
if (pinned) nextPinnedSet.add(taskId);
60+
else nextPinnedSet.delete(taskId);
61+
pinnedSetRef.current = nextPinnedSet;
62+
await togglePinMutationRef.current.mutateAsync({
63+
taskId,
64+
pinned,
65+
});
5666
}, []);
5767

58-
const unpin = useCallback(async (taskId: string) => {
59-
if (!pinnedSetRef.current.has(taskId)) return;
60-
const result = await togglePinMutationRef.current.mutateAsync({ taskId });
61-
if (result.isPinned) {
62-
await togglePinMutationRef.current.mutateAsync({ taskId });
63-
}
64-
}, []);
68+
const unpin = useCallback(
69+
async (taskId: string) => {
70+
if (!pinnedSetRef.current.has(taskId)) return;
71+
await pinnedTasksApi.unpin(taskId);
72+
queryClient.setQueryData<string[]>(pinnedQueryKey, (old) =>
73+
old?.filter((id) => id !== taskId),
74+
);
75+
},
76+
[queryClient, pinnedQueryKey],
77+
);
6578

6679
const isPinned = useCallback(
6780
(taskId: string) => pinnedSet.has(taskId),

0 commit comments

Comments
 (0)