diff --git a/packages/api-client/src/posthog-client.test.ts b/packages/api-client/src/posthog-client.test.ts index da93bc7960..471bccead2 100644 --- a/packages/api-client/src/posthog-client.test.ts +++ b/packages/api-client/src/posthog-client.test.ts @@ -1223,6 +1223,59 @@ describe("PostHogAPIClient", () => { }); }); + describe("task pins", () => { + function buildClient(fetch: ReturnType) { + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + ); + ( + client as unknown as { + api: { baseUrl: string; fetcher: { fetch: typeof fetch } }; + } + ).api = { baseUrl: "http://localhost:8000", fetcher: { fetch } }; + return client; + } + + it("loads pinned task ids", async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ task_ids: ["task-1", "task-2"] }), + }); + + await expect(buildClient(fetch).getPinnedTaskIds()).resolves.toEqual([ + "task-1", + "task-2", + ]); + expect(fetch).toHaveBeenCalledWith( + expect.objectContaining({ + method: "get", + path: "/api/projects/123/tasks/pinned/", + }), + ); + }); + + it("sets pin state idempotently", async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ task_id: "task-1", pinned: true }), + }); + + await expect( + buildClient(fetch).setTaskPinned("task-1", true), + ).resolves.toBe(true); + expect(fetch).toHaveBeenCalledWith( + expect.objectContaining({ + method: "post", + path: "/api/projects/123/tasks/task-1/pin/", + overrides: { body: JSON.stringify({ pinned: true }) }, + }), + ); + }); + }); + describe("getSignalReportArtefacts", () => { function makeClient(fetch: ReturnType) { const client = new PostHogAPIClient( diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index b2ef9fa8a8..bda20ec7ee 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -2470,6 +2470,37 @@ export class PostHogAPIClient { return normalizeTaskResponse(data, { teamId }); } + async getPinnedTaskIds(): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/tasks/pinned/`; + const response = await this.api.fetcher.fetch({ + method: "get", + url: new URL(`${this.api.baseUrl}${urlPath}`), + path: urlPath, + }); + if (!response.ok) { + throw new Error(`Failed to fetch pinned tasks: ${response.statusText}`); + } + const data = (await response.json()) as { task_ids: string[] }; + return data.task_ids; + } + + async setTaskPinned(taskId: string, pinned: boolean): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/tasks/${taskId}/pin/`; + const response = await this.api.fetcher.fetch({ + method: "post", + url: new URL(`${this.api.baseUrl}${urlPath}`), + path: urlPath, + overrides: { body: JSON.stringify({ pinned }) }, + }); + if (!response.ok) { + throw new Error(`Failed to update task pin: ${response.statusText}`); + } + const data = (await response.json()) as { pinned: boolean }; + return data.pinned; + } + async listTaskAutomations(options?: { limit?: number; offset?: number; diff --git a/packages/ui/src/features/archive/useArchiveTask.ts b/packages/ui/src/features/archive/useArchiveTask.ts index d89ce573f2..847fc5775c 100644 --- a/packages/ui/src/features/archive/useArchiveTask.ts +++ b/packages/ui/src/features/archive/useArchiveTask.ts @@ -104,7 +104,7 @@ function makeOrchestrationDeps( getPinnedTaskIds: () => pinnedTasksApi.getPinnedTaskIds(), unpin: (taskId) => pinnedTasksApi.unpin(taskId), togglePin: async (taskId) => { - await pinnedTasksApi.togglePin(taskId); + await pinnedTasksApi.setPinned(taskId, true); }, navigateAwayFromTaskIfActive: (taskId) => { if (options?.skipNavigate) return; diff --git a/packages/ui/src/features/sidebar/taskMetaApi.ts b/packages/ui/src/features/sidebar/taskMetaApi.ts index 8153eebad5..f1a7683e20 100644 --- a/packages/ui/src/features/sidebar/taskMetaApi.ts +++ b/packages/ui/src/features/sidebar/taskMetaApi.ts @@ -7,6 +7,7 @@ import { HOST_TRPC_CLIENT, type HostTrpcClient, } from "@posthog/host-router/client"; +import { getAuthenticatedClient } from "@posthog/ui/features/auth/authClientImperative"; import { IMPERATIVE_QUERY_CLIENT, type ImperativeQueryClient, @@ -43,21 +44,24 @@ export const taskViewedApi = { export const pinnedTasksApi = { async getPinnedTaskIds(): Promise { - return workspace().getPinnedTaskIds.query(); + const client = await getAuthenticatedClient(); + if (!client) return []; + return client.getPinnedTaskIds(); }, - async togglePin( + async setPinned( taskId: string, + pinned: boolean, ): Promise<{ taskId: string; isPinned: boolean }> { - const result = await workspace().togglePin.mutate({ taskId }); - return { taskId, isPinned: result.isPinned }; + const client = await getAuthenticatedClient(); + if (!client) return { taskId, isPinned: false }; + const isPinned = await client.setTaskPinned(taskId, pinned); + return { taskId, isPinned }; }, async unpin(taskId: string): Promise { - const result = await workspace().togglePin.mutate({ taskId }); - if (result.isPinned) { - await workspace().togglePin.mutate({ taskId }); - } + const client = await getAuthenticatedClient(); + if (client) await client.setTaskPinned(taskId, false); }, isPinned(pinnedTaskIds: Set, taskId: string): boolean { diff --git a/packages/ui/src/features/sidebar/usePinnedTasks.test.tsx b/packages/ui/src/features/sidebar/usePinnedTasks.test.tsx new file mode 100644 index 0000000000..fa3ae96c2d --- /dev/null +++ b/packages/ui/src/features/sidebar/usePinnedTasks.test.tsx @@ -0,0 +1,92 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { PropsWithChildren } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { pinnedTasksApi } from "./taskMetaApi"; +import { usePinnedTasks } from "./usePinnedTasks"; + +const authClient = vi.hoisted(() => ({ + getPinnedTaskIds: vi.fn(), +})); + +vi.mock("@posthog/ui/features/auth/authClient", () => ({ + useOptionalAuthenticatedClient: () => authClient, +})); + +vi.mock("./taskMetaApi", () => ({ + pinnedTasksApi: { + getPinnedTaskIds: vi.fn(), + setPinned: vi.fn(), + unpin: vi.fn(), + }, +})); + +const mockedApi = vi.mocked(pinnedTasksApi); + +describe("usePinnedTasks", () => { + beforeEach(() => vi.clearAllMocks()); + + function renderPinnedTasks() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + return { ...renderHook(() => usePinnedTasks(), { wrapper }), client }; + } + + it("hydrates pins from the authenticated API", async () => { + authClient.getPinnedTaskIds.mockResolvedValue(["task-1"]); + + const { result, client } = renderPinnedTasks(); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.isPinned("task-1")).toBe(true); + expect(authClient.getPinnedTaskIds).toHaveBeenCalledOnce(); + expect( + client.getQueryCache().find({ queryKey: ["task-pins"] })?.meta, + ).toMatchObject({ authScoped: true }); + }); + + it("persists pin and unpin actions", async () => { + authClient.getPinnedTaskIds.mockResolvedValue([]); + mockedApi.setPinned.mockResolvedValue({ + taskId: "task-1", + isPinned: true, + }); + mockedApi.unpin.mockResolvedValue(); + const { result } = renderPinnedTasks(); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(() => result.current.togglePin("task-1")); + expect(mockedApi.setPinned).toHaveBeenCalledWith("task-1", true); + await waitFor(() => expect(result.current.isPinned("task-1")).toBe(true)); + + await act(() => result.current.unpin("task-1")); + expect(mockedApi.unpin).toHaveBeenCalledWith("task-1"); + await waitFor(() => expect(result.current.isPinned("task-1")).toBe(false)); + }); + + it("preserves rapid toggle order", async () => { + authClient.getPinnedTaskIds.mockResolvedValue([]); + mockedApi.setPinned + .mockResolvedValueOnce({ taskId: "task-1", isPinned: true }) + .mockResolvedValueOnce({ taskId: "task-1", isPinned: false }); + const { result } = renderPinnedTasks(); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(() => + Promise.all([ + result.current.togglePin("task-1"), + result.current.togglePin("task-1"), + ]), + ); + + expect(mockedApi.setPinned.mock.calls).toEqual([ + ["task-1", true], + ["task-1", false], + ]); + await waitFor(() => expect(result.current.isPinned("task-1")).toBe(false)); + }); +}); diff --git a/packages/ui/src/features/sidebar/usePinnedTasks.ts b/packages/ui/src/features/sidebar/usePinnedTasks.ts index 5b14de4621..51b9ae34e0 100644 --- a/packages/ui/src/features/sidebar/usePinnedTasks.ts +++ b/packages/ui/src/features/sidebar/usePinnedTasks.ts @@ -1,31 +1,33 @@ -import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useCallback, useMemo, useRef } from "react"; +import { useAuthenticatedQuery } from "../../hooks/useAuthenticatedQuery"; +import { pinnedTasksApi } from "./taskMetaApi"; + +const PINNED_TASKS_QUERY_KEY = ["task-pins"] as const; export function usePinnedTasks() { - const trpc = useHostTRPC(); - const hostClient = useHostTRPCClient(); const queryClient = useQueryClient(); - const pinnedQueryKey = trpc.workspace.getPinnedTaskIds.queryKey(); + const pinnedQueryKey = PINNED_TASKS_QUERY_KEY; - const { data: pinnedTaskIds = [], isLoading } = useQuery( - trpc.workspace.getPinnedTaskIds.queryOptions(undefined, { - staleTime: 30_000, - }), + const { data: pinnedTaskIds = [], isLoading } = useAuthenticatedQuery( + pinnedQueryKey, + (client) => client.getPinnedTaskIds(), + { staleTime: 30_000 }, ); const pinnedSet = useMemo(() => new Set(pinnedTaskIds), [pinnedTaskIds]); const togglePinMutation = useMutation({ - mutationFn: ({ taskId }: { taskId: string }) => - hostClient.workspace.togglePin.mutate({ taskId }), - onMutate: async ({ taskId }) => { + scope: { id: "task-pins" }, + mutationFn: ({ taskId, pinned }: { taskId: string; pinned: boolean }) => + pinnedTasksApi.setPinned(taskId, pinned), + onMutate: async ({ taskId, pinned }) => { await queryClient.cancelQueries({ queryKey: pinnedQueryKey }); const previous = queryClient.getQueryData(pinnedQueryKey); const wasPinned = previous?.includes(taskId); queryClient.setQueryData(pinnedQueryKey, (old) => { - if (!old) return wasPinned ? [] : [taskId]; - return wasPinned ? old.filter((id) => id !== taskId) : [...old, taskId]; + const filtered = old?.filter((id) => id !== taskId) ?? []; + return pinned ? [...filtered, taskId] : filtered; }); return { previous, wasPinned, taskId }; }, @@ -52,16 +54,27 @@ export function usePinnedTasks() { pinnedSetRef.current = pinnedSet; const togglePin = useCallback(async (taskId: string) => { - await togglePinMutationRef.current.mutateAsync({ taskId }); + const pinned = !pinnedSetRef.current.has(taskId); + const nextPinnedSet = new Set(pinnedSetRef.current); + if (pinned) nextPinnedSet.add(taskId); + else nextPinnedSet.delete(taskId); + pinnedSetRef.current = nextPinnedSet; + await togglePinMutationRef.current.mutateAsync({ + taskId, + pinned, + }); }, []); - const unpin = useCallback(async (taskId: string) => { - if (!pinnedSetRef.current.has(taskId)) return; - const result = await togglePinMutationRef.current.mutateAsync({ taskId }); - if (result.isPinned) { - await togglePinMutationRef.current.mutateAsync({ taskId }); - } - }, []); + const unpin = useCallback( + async (taskId: string) => { + if (!pinnedSetRef.current.has(taskId)) return; + await pinnedTasksApi.unpin(taskId); + queryClient.setQueryData(pinnedQueryKey, (old) => + old?.filter((id) => id !== taskId), + ); + }, + [queryClient, pinnedQueryKey], + ); const isPinned = useCallback( (taskId: string) => pinnedSet.has(taskId),