From 7a0dc3bea1cc5752d2be1b6eea060ea17f54a0c8 Mon Sep 17 00:00:00 2001 From: Henry Date: Thu, 3 Sep 2026 19:44:26 +0000 Subject: [PATCH] feat(web): let the dependency picker find tasks by PR or MR number (#3311) Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 Co-authored-by: Carlos Florencio --- .../task-create-dialog-dependencies.test.tsx | 227 ++++++++++++++++-- .../task-create-dialog-dependencies.tsx | 103 +++++++- ...sk-create-dialog-dependency-search.test.ts | 136 +++++++++++ .../task-create-dialog-dependency-search.ts | 52 ++++ .../create-task-dependency-selector.spec.ts | 123 +++++++++- ...le-create-task-dependency-selector.spec.ts | 21 +- apps/web/src/locales/en/task.json | 3 + apps/web/src/locales/pseudo/task.json | 3 + apps/web/src/locales/pt-pt/task.json | 3 + apps/web/src/locales/zh-cn/task.json | 3 + apps/web/src/locales/zh-hk/task.json | 3 + apps/web/src/locales/zh-tw/task.json | 3 + ...dencies-create-dialog-advanced-settings.md | 10 +- ...ncies-create-dialog-dependency-selector.md | 26 +- 14 files changed, 678 insertions(+), 38 deletions(-) create mode 100644 apps/web/components/task-create-dialog-dependency-search.test.ts create mode 100644 apps/web/components/task-create-dialog-dependency-search.ts diff --git a/apps/web/components/task-create-dialog-dependencies.test.tsx b/apps/web/components/task-create-dialog-dependencies.test.tsx index 99954ae2ec..fe236b56c4 100644 --- a/apps/web/components/task-create-dialog-dependencies.test.tsx +++ b/apps/web/components/task-create-dialog-dependencies.test.tsx @@ -3,11 +3,31 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-li import { afterEach, describe, expect, it, vi } from "vitest"; import { TooltipProvider } from "@kandev/ui/tooltip"; import type { KanbanState } from "@/lib/state/slices/kanban/types"; +import type { TaskMR } from "@/lib/types/gitlab"; +import type { TaskPR } from "@/lib/types/github"; import { TaskCreateDependencies } from "./task-create-dialog-dependencies"; +const workspaceHydrationMocks = vi.hoisted(() => ({ + useWorkspacePRs: vi.fn(), + useWorkspaceMRs: vi.fn(), +})); + +vi.mock("@/hooks/domains/github/use-task-pr", () => workspaceHydrationMocks); +vi.mock("@/hooks/domains/gitlab/use-task-mr", () => workspaceHydrationMocks); + +const WORKSPACE_ID = "workspace-1"; + type MockStore = { kanban: { tasks: KanbanState["tasks"] }; kanbanMulti: { snapshots: Record }; + workspaces: { activeId: string | null }; + workspaceContextGeneration: number; + taskMRs: { byWorkspaceId: Record> }; + taskPRs: { + byTaskId: Record; + workspaceId?: string | null; + workspaceContextGeneration?: number; + }; }; const ALPHA_ID = "task-alpha"; @@ -15,15 +35,54 @@ const BETA_ID = "task-beta"; const ALPHA_TITLE = "Alpha task"; const BETA_TITLE = "Beta task"; const NO_DEPENDENCY_LABEL = "No dependency"; -const SEARCH_TASKS_LABEL = "Search tasks..."; +const SEARCH_TASKS_LABEL = "Search tasks or #PR/MR number..."; const INFO_LABEL = "About task dependencies"; const TWO_DEPENDENCIES_LABEL = "2 dependencies"; const TRIGGER_TEST_ID = "task-create-dependencies-trigger"; +const POPOVER_TEST_ID = "task-create-dependencies-popover"; -let mockStore: MockStore = { - kanban: { tasks: [] }, - kanbanMulti: { snapshots: {} }, -}; +function emptyStore(): MockStore { + return { + kanban: { tasks: [] }, + kanbanMulti: { snapshots: {} }, + workspaces: { activeId: WORKSPACE_ID }, + workspaceContextGeneration: 0, + taskMRs: { byWorkspaceId: {} }, + taskPRs: { + byTaskId: {}, + workspaceId: WORKSPACE_ID, + workspaceContextGeneration: 0, + }, + }; +} + +function taskPR(overrides: Partial = {}): TaskPR { + return { + id: "pr-assoc-1", + workspace_id: WORKSPACE_ID, + task_id: ALPHA_ID, + owner: "kdlbs", + repo: "kandev", + pr_number: 3295, + pr_url: "https://github.com/kdlbs/kandev/pull/3295", + pr_title: "fix flaky test", + head_branch: "fix", + base_branch: "main", + author_login: "nova28", + state: "merged", + review_state: "", + checks_state: "", + mergeable_state: "mergeable", + review_count: 0, + pending_review_count: 0, + comment_count: 0, + unresolved_review_threads: 0, + checks_total: 0, + ...overrides, + } as TaskPR; +} + +let mockStore: MockStore = emptyStore(); vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (state: MockStore) => unknown) => selector(mockStore), @@ -58,15 +117,24 @@ function ControlledDependencies({ initialValue = [] }: { initialValue?: string[] return ; } +function optionTestId(taskId: string): string { + return `task-create-dependency-option-${taskId}`; +} + afterEach(() => { cleanup(); - mockStore = { - kanban: { tasks: [] }, - kanbanMulti: { snapshots: {} }, - }; + vi.clearAllMocks(); + mockStore = emptyStore(); }); describe("TaskCreateDependencies", () => { + it("hydrates change-request associations for the active workspace", () => { + renderDependencies(); + + expect(workspaceHydrationMocks.useWorkspacePRs).toHaveBeenCalledWith(WORKSPACE_ID); + expect(workspaceHydrationMocks.useWorkspaceMRs).toHaveBeenCalledWith(WORKSPACE_ID); + }); + it("starts with a no-dependency selector and its dependency icon", () => { renderDependencies(); @@ -77,6 +145,7 @@ describe("TaskCreateDependencies", () => { it("shows searchable non-archived tasks with task icons and teaching help", async () => { mockStore = { + ...emptyStore(), kanban: { tasks: [ task(ALPHA_ID, ALPHA_TITLE), @@ -93,14 +162,14 @@ describe("TaskCreateDependencies", () => { renderDependencies(); fireEvent.click(screen.getByTestId(TRIGGER_TEST_ID)); - const picker = screen.getByTestId("task-create-dependencies-popover"); + const picker = screen.getByTestId(POPOVER_TEST_ID); expect(within(picker).getByPlaceholderText(SEARCH_TASKS_LABEL)).toBeTruthy(); expect(within(picker).getByTestId("task-create-dependency-info")).toBeTruthy(); - expect(within(picker).getByTestId(`task-create-dependency-option-${ALPHA_ID}`)).toBeTruthy(); - expect(within(picker).getByTestId(`task-create-dependency-option-${BETA_ID}`)).toBeTruthy(); + expect(within(picker).getByTestId(optionTestId(ALPHA_ID))).toBeTruthy(); + expect(within(picker).getByTestId(optionTestId(BETA_ID))).toBeTruthy(); expect(within(picker).queryByText("Archived task")).toBeNull(); expect( - within(within(picker).getByTestId(`task-create-dependency-option-${ALPHA_ID}`)).getByTestId( + within(within(picker).getByTestId(optionTestId(ALPHA_ID))).getByTestId( "task-create-dependency-task-icon", ), ).toBeTruthy(); @@ -121,10 +190,10 @@ describe("TaskCreateDependencies", () => { it("toggles multiple predecessors and exposes a localized count", () => { mockStore = { + ...emptyStore(), kanban: { tasks: [task(ALPHA_ID, ALPHA_TITLE), task(BETA_ID, BETA_TITLE)], }, - kanbanMulti: { snapshots: {} }, }; render( @@ -134,22 +203,22 @@ describe("TaskCreateDependencies", () => { ); fireEvent.click(screen.getByTestId("task-create-dependencies-trigger")); - fireEvent.click(screen.getByTestId(`task-create-dependency-option-${ALPHA_ID}`)); + fireEvent.click(screen.getByTestId(optionTestId(ALPHA_ID))); expect(screen.getByTestId(TRIGGER_TEST_ID).textContent).toContain(ALPHA_TITLE); - fireEvent.click(screen.getByTestId(`task-create-dependency-option-${BETA_ID}`)); + fireEvent.click(screen.getByTestId(optionTestId(BETA_ID))); expect(screen.getByTestId(TRIGGER_TEST_ID).textContent).toContain(TWO_DEPENDENCIES_LABEL); - fireEvent.click(screen.getByTestId(`task-create-dependency-option-${ALPHA_ID}`)); + fireEvent.click(screen.getByTestId(optionTestId(ALPHA_ID))); expect(screen.getByTestId(TRIGGER_TEST_ID).textContent).toContain(BETA_TITLE); }); it("clears all selected predecessors from the no-dependency entry", () => { mockStore = { + ...emptyStore(), kanban: { tasks: [task(ALPHA_ID, ALPHA_TITLE), task(BETA_ID, BETA_TITLE)], }, - kanbanMulti: { snapshots: {} }, }; render( @@ -164,3 +233,125 @@ describe("TaskCreateDependencies", () => { expect(screen.getByTestId(TRIGGER_TEST_ID).textContent).toContain(NO_DEPENDENCY_LABEL); }); }); + +describe("TaskCreateDependencies change-request search", () => { + it("narrows to the PR-linked task when searching by PR number and shows its badge", () => { + const prTask = task(ALPHA_ID, ALPHA_TITLE, { + statusSummary: { pull_request: { number: 3295, state: "merged" } } as never, + }); + mockStore = { + ...emptyStore(), + kanban: { + tasks: [prTask, task(BETA_ID, BETA_TITLE)], + }, + }; + + renderDependencies(); + fireEvent.click(screen.getByTestId(TRIGGER_TEST_ID)); + + const picker = screen.getByTestId(POPOVER_TEST_ID); + const alphaOption = within(picker).getByTestId(optionTestId(ALPHA_ID)); + expect(within(alphaOption).getByText("#3295")).toBeTruthy(); + + const search = within(picker).getByPlaceholderText(SEARCH_TASKS_LABEL); + fireEvent.change(search, { target: { value: "3295" } }); + expect(within(picker).getByTestId(optionTestId(ALPHA_ID))).toBeTruthy(); + expect(within(picker).queryByTestId(optionTestId(BETA_ID))).toBeNull(); + }); + + it("narrows to the MR-linked task when searching with a leading #", () => { + const mrTask = task(ALPHA_ID, ALPHA_TITLE); + mockStore = { + ...emptyStore(), + kanban: { tasks: [mrTask, task(BETA_ID, BETA_TITLE)] }, + taskMRs: { + byWorkspaceId: { + [WORKSPACE_ID]: { + [ALPHA_ID]: [ + { + id: "mr-1", + task_id: ALPHA_ID, + host: "gitlab.com", + project_path: "kdlbs/kandev", + mr_iid: 42, + mr_url: "https://gitlab.com/kdlbs/kandev/-/merge_requests/42", + mr_title: "Fix", + head_branch: "fix", + base_branch: "main", + author_username: "nova28", + state: "open", + } as TaskMR, + ], + }, + }, + }, + }; + + renderDependencies(); + fireEvent.click(screen.getByTestId(TRIGGER_TEST_ID)); + + const picker = screen.getByTestId(POPOVER_TEST_ID); + const search = within(picker).getByPlaceholderText(SEARCH_TASKS_LABEL); + fireEvent.change(search, { target: { value: "#42" } }); + expect(within(picker).getByTestId(optionTestId(ALPHA_ID))).toBeTruthy(); + expect(within(picker).queryByTestId(optionTestId(BETA_ID))).toBeNull(); + }); + + it("lists unfiltered candidates most-recently-updated first", () => { + mockStore = { + ...emptyStore(), + kanban: { + tasks: [ + task(ALPHA_ID, ALPHA_TITLE, { updatedAt: "2026-01-01T00:00:00Z" }), + task(BETA_ID, BETA_TITLE, { updatedAt: "2026-06-01T00:00:00Z" }), + ], + }, + }; + + renderDependencies(); + fireEvent.click(screen.getByTestId(TRIGGER_TEST_ID)); + + const picker = screen.getByTestId(POPOVER_TEST_ID); + const options = within(picker) + .getAllByTestId(/task-create-dependency-option-/) + .map((option) => option.getAttribute("data-testid")); + expect(options).toEqual([optionTestId(BETA_ID), optionTestId(ALPHA_ID)]); + }); +}); + +describe("TaskCreateDependencies multi-PR change requests", () => { + it("shows a badge for every GitHub PR linked to a multi-repo task and finds it by any number", () => { + const prTask = task(ALPHA_ID, ALPHA_TITLE, { + statusSummary: { pull_request: { number: 1001, state: "open" } } as never, + }); + mockStore = { + ...emptyStore(), + kanban: { + tasks: [prTask, task(BETA_ID, BETA_TITLE)], + }, + taskPRs: { + byTaskId: { + [ALPHA_ID]: [ + taskPR({ id: "pr-assoc-1", pr_number: 1001, repo: "kandev" }), + taskPR({ id: "pr-assoc-2", pr_number: 1002, repo: "kandev-docs" }), + ], + }, + workspaceId: WORKSPACE_ID, + workspaceContextGeneration: 0, + }, + }; + + renderDependencies(); + fireEvent.click(screen.getByTestId(TRIGGER_TEST_ID)); + + const picker = screen.getByTestId(POPOVER_TEST_ID); + const alphaOption = within(picker).getByTestId(optionTestId(ALPHA_ID)); + expect(within(alphaOption).getByText("#1001")).toBeTruthy(); + expect(within(alphaOption).getByText("#1002")).toBeTruthy(); + + const search = within(picker).getByPlaceholderText(SEARCH_TASKS_LABEL); + fireEvent.change(search, { target: { value: "1002" } }); + expect(within(picker).getByTestId(optionTestId(ALPHA_ID))).toBeTruthy(); + expect(within(picker).queryByTestId(optionTestId(BETA_ID))).toBeNull(); + }); +}); diff --git a/apps/web/components/task-create-dialog-dependencies.tsx b/apps/web/components/task-create-dialog-dependencies.tsx index ec79a18ca1..0a3aaa2e50 100644 --- a/apps/web/components/task-create-dialog-dependencies.tsx +++ b/apps/web/components/task-create-dialog-dependencies.tsx @@ -21,15 +21,28 @@ import { import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useAppStore } from "@/components/state-provider"; +import { useWorkspacePRs } from "@/hooks/domains/github/use-task-pr"; +import { useWorkspaceMRs } from "@/hooks/domains/gitlab/use-task-mr"; import { useTaskCreateDialogPopoverContainer } from "@/hooks/use-task-create-dialog-popover-container"; import type { KanbanState } from "@/lib/state/slices/kanban/types"; +import type { TaskMR } from "@/lib/types/gitlab"; +import type { TaskPR } from "@/lib/types/github"; import { cn } from "@/lib/utils"; +import { + changeRequestNumbers, + compareDependencyCandidates, + dependencyOptionValue, +} from "./task-create-dialog-dependency-search"; // Keep stable empty fallbacks here. Creating a fresh array/object in a store // selector makes Object.is report a change on every read and can cause a // render loop, which previously blanked the dependency chip. const NO_TASKS: KanbanState["tasks"] = []; const NO_SNAPSHOTS: Record = {}; +const NO_MRS: TaskMR[] = []; +const NO_MRS_BY_TASK_ID: Record = {}; +const NO_PRS: TaskPR[] = []; +const NO_PRS_BY_TASK_ID: Record = {}; export type TaskCreateDependenciesProps = { /** Selected predecessor task IDs. */ @@ -56,32 +69,60 @@ function useBoardTasks(): KanbanState["tasks"] { }, [boardTasks, snapshots]); } +function useMRsByTaskId(): Record { + const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId); + const byWorkspaceId = useAppStore((state) => state.taskMRs.byWorkspaceId); + return (activeWorkspaceId && byWorkspaceId[activeWorkspaceId]) || NO_MRS_BY_TASK_ID; +} + +function usePRsByTaskId(): Record { + return useAppStore((state) => { + const activeWorkspaceId = state.workspaces.activeId; + if ( + !activeWorkspaceId || + state.taskPRs.workspaceId !== activeWorkspaceId || + state.taskPRs.workspaceContextGeneration !== state.workspaceContextGeneration + ) { + return NO_PRS_BY_TASK_ID; + } + return state.taskPRs.byTaskId; + }); +} + function DependencyTriggerLabel({ value, selectedTitle, + selectedNumber, t, }: { value: string[]; selectedTitle?: string; - t: (key: string, options?: { count?: number }) => string; + selectedNumber?: number; + t: (key: string, options?: { count?: number; number?: number }) => string; }) { if (value.length === 0) return t("task:noDependency"); - if (value.length === 1 && selectedTitle) return selectedTitle; + if (value.length === 1 && selectedTitle) { + if (selectedNumber === undefined) return selectedTitle; + return `${t("task:dependencyChangeRequestLabel", { number: selectedNumber })} · ${selectedTitle}`; + } return t("task:dependencyCount", { count: value.length }); } function DependencyOption({ task, + numbers, selected, onSelect, }: { task: KanbanState["tasks"][number]; + numbers: number[]; selected: boolean; onSelect: () => void; }) { + const { t } = useTranslation(); return (