diff --git a/packages/ui/src/features/tasks/taskKeys.ts b/packages/ui/src/features/tasks/taskKeys.ts index 894907235..d805aec7f 100644 --- a/packages/ui/src/features/tasks/taskKeys.ts +++ b/packages/ui/src/features/tasks/taskKeys.ts @@ -1,12 +1,19 @@ +export interface TaskListFilters { + repository?: string; + createdBy?: number; + originProduct?: string; + internal?: boolean; +} + export const taskKeys = { all: ["tasks"] as const, lists: () => [...taskKeys.all, "list"] as const, - list: (filters?: { - repository?: string; - createdBy?: number; - originProduct?: string; - internal?: boolean; - }) => [...taskKeys.lists(), filters] as const, + list: (filters?: TaskListFilters) => [...taskKeys.lists(), filters] as const, + // Extract the filters object from a `list` query key. Keeps knowledge of the + // key's shape (filters live in the last slot) here, next to `list`, instead + // of letting consumers reach in by positional index. + filtersOf: (queryKey: readonly unknown[]): TaskListFilters | undefined => + queryKey[queryKey.length - 1] as TaskListFilters | undefined, allSummaries: () => [...taskKeys.all, "summaries"] as const, summaries: (ids: string[]) => [...taskKeys.allSummaries(), [...ids].sort()] as const, diff --git a/packages/ui/src/features/tasks/useTaskCrudMutations.test.tsx b/packages/ui/src/features/tasks/useTaskCrudMutations.test.tsx index 0bb0397a8..c63b4c9c6 100644 --- a/packages/ui/src/features/tasks/useTaskCrudMutations.test.tsx +++ b/packages/ui/src/features/tasks/useTaskCrudMutations.test.tsx @@ -1,3 +1,4 @@ +import type { Task } from "@posthog/shared/domain-types"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderHook } from "@testing-library/react"; import type { ReactNode } from "react"; @@ -27,7 +28,8 @@ vi.mock("@posthog/di/react", () => ({ useService: () => deletionService, })); -import { useDeleteTask } from "./useTaskCrudMutations"; +import { taskKeys } from "./taskKeys"; +import { useCreateTask, useDeleteTask } from "./useTaskCrudMutations"; function wrapper({ children }: { children: ReactNode }) { const queryClient = new QueryClient(); @@ -36,6 +38,20 @@ function wrapper({ children }: { children: ReactNode }) { ); } +function createTask(overrides: Partial = {}): Task { + return { + id: "new-task", + task_number: 1, + slug: "new-task", + title: "New task", + description: "New task", + created_at: "2026-06-15T00:00:00.000Z", + updated_at: "2026-06-15T00:00:00.000Z", + origin_product: "user_created", + ...overrides, + }; +} + describe("useDeleteTask.deleteWithConfirm", () => { beforeEach(() => { vi.clearAllMocks(); @@ -71,3 +87,66 @@ describe("useDeleteTask.deleteWithConfirm", () => { expect(ok).toBe(false); }); }); + +describe("useCreateTask.invalidateTasks", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([ + { name: "plain list", filters: undefined, expectedLength: 1 }, + { + name: "repository-scoped list", + filters: { repository: "owner/repo" }, + expectedLength: 1, + }, + { + name: "slack-origin list", + filters: { originProduct: "slack" }, + expectedLength: 0, + }, + ])( + "seeds the $name with the new task ($expectedLength entr(y/ies))", + ({ filters, expectedLength }) => { + const queryClient = new QueryClient(); + const key = taskKeys.list(filters); + queryClient.setQueryData(key, []); + + const localWrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + const { result } = renderHook(() => useCreateTask(), { + wrapper: localWrapper, + }); + + result.current.invalidateTasks(createTask()); + + // Origin-less lists mirror the new task; origin-scoped lists (read by the + // sidebar to brand icons by id membership) must not be seeded. + expect(queryClient.getQueryData(key)).toHaveLength( + expectedLength, + ); + }, + ); + + it("still invalidates every list, including the slack-origin one", () => { + const queryClient = new QueryClient(); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + + const localWrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useCreateTask(), { + wrapper: localWrapper, + }); + + result.current.invalidateTasks(createTask()); + + // Seeding is scoped, but the refetch is not: all lists (slack included) are + // invalidated so they reconcile with the server. A future refactor must not + // "fix" the no-seed expectation above by dropping a list from refetch. + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: taskKeys.lists() }); + }); +}); diff --git a/packages/ui/src/features/tasks/useTaskCrudMutations.ts b/packages/ui/src/features/tasks/useTaskCrudMutations.ts index 8e977f03f..70111964e 100644 --- a/packages/ui/src/features/tasks/useTaskCrudMutations.ts +++ b/packages/ui/src/features/tasks/useTaskCrudMutations.ts @@ -18,8 +18,22 @@ export function useCreateTask() { const invalidateTasks = (newTask?: Task) => { if (newTask) { + // Only seed list caches that aren't scoped to a specific origin_product. + // An origin-scoped list (e.g. the slack-origin list behind useSlackTasks) + // is read by the sidebar to brand a task's icon by id membership, so + // seeding a freshly created, non-slack task into it would make that task + // briefly render as a Slack task until the list refetches. Origin-less + // lists, by contrast, should mirror every new task. queryClient.setQueriesData( - { queryKey: taskKeys.lists() }, + { + queryKey: taskKeys.lists(), + predicate: (query) => { + const isOriginScopedList = Boolean( + taskKeys.filtersOf(query.queryKey)?.originProduct, + ); + return !isOriginScopedList; + }, + }, (old) => insertTaskDedup(old, newTask), ); }