fix(web): make the board search box match linked PR/MR numbers (#3391)
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -65,6 +65,111 @@ describe("filterTasks — plugin task filter predicate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterTasks — searchQuery matches linked PR/MR numbers", () => {
|
||||
const NO_REPO_FILTER = mapSelectedRepositoryIds([], []);
|
||||
|
||||
it("keeps a task matching only by its linked PR/MR number", () => {
|
||||
const snapshots = {
|
||||
wf: {
|
||||
tasks: [
|
||||
makeTask({ id: "1", title: "Fix EvaluateOnly", description: "unrelated" }),
|
||||
makeTask({ id: "2", title: "on_agent_error never fires", description: "" }),
|
||||
],
|
||||
steps: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = filterTasks(snapshots, "wf", NO_REPO_FILTER, {
|
||||
searchQuery: "3315",
|
||||
vcsSearchTextByTaskId: { "2": "#3315" },
|
||||
});
|
||||
|
||||
expect(result.map((t) => t.id)).toEqual(["2"]);
|
||||
});
|
||||
|
||||
it("matches a linked PR number by either a bare number or a #-prefixed query", () => {
|
||||
const snapshots = {
|
||||
wf: {
|
||||
tasks: [makeTask({ id: "1", title: "Task" })],
|
||||
steps: [],
|
||||
},
|
||||
};
|
||||
const opts = { vcsSearchTextByTaskId: { "1": "#3315" } };
|
||||
|
||||
expect(
|
||||
filterTasks(snapshots, "wf", NO_REPO_FILTER, { searchQuery: "3315", ...opts }).map(
|
||||
(t) => t.id,
|
||||
),
|
||||
).toEqual(["1"]);
|
||||
expect(
|
||||
filterTasks(snapshots, "wf", NO_REPO_FILTER, { searchQuery: "#3315", ...opts }).map(
|
||||
(t) => t.id,
|
||||
),
|
||||
).toEqual(["1"]);
|
||||
});
|
||||
|
||||
it("matches the status-summary PR number before associations load", () => {
|
||||
const snapshots = {
|
||||
wf: {
|
||||
tasks: [
|
||||
makeTask({
|
||||
id: "1",
|
||||
title: "Task",
|
||||
statusSummary: { pull_request: { number: 3295 } } as never,
|
||||
}),
|
||||
],
|
||||
steps: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = filterTasks(snapshots, "wf", NO_REPO_FILTER, {
|
||||
searchQuery: "#3295",
|
||||
});
|
||||
|
||||
expect(result.map((t) => t.id)).toEqual(["1"]);
|
||||
});
|
||||
|
||||
it("excludes a task whose linked PR number does not match the query", () => {
|
||||
const snapshots = {
|
||||
wf: {
|
||||
tasks: [makeTask({ id: "1", title: "Task" })],
|
||||
steps: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = filterTasks(snapshots, "wf", NO_REPO_FILTER, {
|
||||
searchQuery: "9999",
|
||||
vcsSearchTextByTaskId: { "1": "#3315" },
|
||||
});
|
||||
|
||||
expect(result.map((t) => t.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it("composes the PR/MR search arm with repository and plugin filters", () => {
|
||||
const snapshots = {
|
||||
wf: {
|
||||
tasks: [
|
||||
makeTask({ id: "1", title: "Task A", repositoryId: "repo-a" }),
|
||||
makeTask({ id: "2", title: "Task B", repositoryId: "repo-b" }),
|
||||
],
|
||||
steps: [],
|
||||
},
|
||||
};
|
||||
const repoFilter = mapSelectedRepositoryIds(
|
||||
[{ id: "repo-a", name: "A" } as never, { id: "repo-b", name: "B" } as never],
|
||||
["repo-a"],
|
||||
);
|
||||
|
||||
const result = filterTasks(snapshots, "wf", repoFilter, {
|
||||
searchQuery: "3315",
|
||||
vcsSearchTextByTaskId: { "1": "#3315", "2": "#3315" },
|
||||
matchesPluginTaskFilters: (taskId) => taskId === "1",
|
||||
});
|
||||
|
||||
expect(result.map((t) => t.id)).toEqual(["1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterTasks — hiddenStepIds (per-workflow step visibility filter)", () => {
|
||||
const NO_REPO_FILTER = mapSelectedRepositoryIds([], []);
|
||||
const snapshots = {
|
||||
|
||||
@@ -26,6 +26,10 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useSwimlaneCollapse } from "@/hooks/domains/kanban/use-swimlane-collapse";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import {
|
||||
buildTaskVcsSearchIndex,
|
||||
getTaskPRsByTaskIdForCurrentWorkspace,
|
||||
} from "@/lib/kanban/task-search-index";
|
||||
import { selectVisibleWorkflows } from "@/lib/kanban/workflow-swimlanes";
|
||||
import { reorderWorkflows } from "@/lib/api";
|
||||
import { SwimlaneSection } from "./swimlane-section";
|
||||
@@ -107,11 +111,14 @@ function renderEmptyState(emptyMessage: string) {
|
||||
const EMPTY_SELECTED_REPOSITORY_IDS: string[] = [];
|
||||
const EMPTY_WORKFLOW_STEPS: WorkflowSnapshotData["steps"] = [];
|
||||
const WORKFLOW_POINTER_SENSOR_OPTIONS = { activationConstraint: { distance: 8 } };
|
||||
/** Stable identity so a workspace with no GitLab MRs does not re-trigger downstream memos. */
|
||||
const EMPTY_TASK_MRS_BY_TASK_ID: Record<string, never[]> = {};
|
||||
|
||||
type WorkflowItemProps = {
|
||||
wf: { id: string; name: string };
|
||||
repoFilter: Set<string>;
|
||||
searchQuery: string;
|
||||
vcsSearchTextByTaskId?: Record<string, string>;
|
||||
matchesPluginTaskFilters?: (taskId: string) => boolean;
|
||||
ViewComponent: ComponentType<ViewContentProps>;
|
||||
hideHeader: boolean;
|
||||
@@ -187,6 +194,7 @@ const WorkflowItemContent = memo(function WorkflowItemContent({
|
||||
wf,
|
||||
repoFilter,
|
||||
searchQuery,
|
||||
vcsSearchTextByTaskId,
|
||||
matchesPluginTaskFilters,
|
||||
ViewComponent,
|
||||
hideHeader,
|
||||
@@ -200,7 +208,13 @@ const WorkflowItemContent = memo(function WorkflowItemContent({
|
||||
...viewProps
|
||||
}: WorkflowItemContentProps) {
|
||||
const { snapshot, tasks, occupancyTasks, hiddenStepIds, hiddenSet, autoHideEmpty } =
|
||||
useWorkflowSwimlaneData(wf.id, repoFilter, searchQuery, matchesPluginTaskFilters);
|
||||
useWorkflowSwimlaneData(
|
||||
wf.id,
|
||||
repoFilter,
|
||||
searchQuery,
|
||||
matchesPluginTaskFilters,
|
||||
vcsSearchTextByTaskId,
|
||||
);
|
||||
const snapshotSteps = snapshot?.steps ?? EMPTY_WORKFLOW_STEPS;
|
||||
const derivedAutoHiddenSet = useMemo(
|
||||
() => deriveAutoHiddenStepIds(snapshotSteps, occupancyTasks, autoHideEmpty, hiddenStepIds),
|
||||
@@ -291,6 +305,27 @@ function useWorkflowReorder(
|
||||
return { sensors, canSort, handleDragEnd };
|
||||
}
|
||||
|
||||
/** One lowercase `#<number>` haystack per task, built from its linked PRs/MRs in the active workspace. */
|
||||
function useVcsSearchIndex() {
|
||||
const taskPRsByTaskId = useAppStore((state) =>
|
||||
getTaskPRsByTaskIdForCurrentWorkspace(
|
||||
state.taskPRs,
|
||||
state.workspaces.activeId,
|
||||
state.workspaceContextGeneration,
|
||||
),
|
||||
);
|
||||
const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId);
|
||||
const taskMRsByTaskId = useAppStore(
|
||||
(state) =>
|
||||
(activeWorkspaceId && state.taskMRs.byWorkspaceId[activeWorkspaceId]) ||
|
||||
EMPTY_TASK_MRS_BY_TASK_ID,
|
||||
);
|
||||
return useMemo(
|
||||
() => buildTaskVcsSearchIndex(taskPRsByTaskId, taskMRsByTaskId),
|
||||
[taskMRsByTaskId, taskPRsByTaskId],
|
||||
);
|
||||
}
|
||||
|
||||
function getRenderedWorkflows(
|
||||
isMobileKanban: boolean,
|
||||
focusedWorkflowId: string | null,
|
||||
@@ -319,6 +354,7 @@ type WorkflowItemsProps = {
|
||||
workflows: { id: string; name: string }[];
|
||||
repoFilter: Set<string>;
|
||||
searchQuery: string;
|
||||
vcsSearchTextByTaskId?: Record<string, string>;
|
||||
matchesPluginTaskFilters?: (taskId: string) => boolean;
|
||||
ViewComponent: ComponentType<ViewContentProps>;
|
||||
hideHeaders: boolean;
|
||||
@@ -337,6 +373,7 @@ function WorkflowItems({
|
||||
workflows,
|
||||
repoFilter,
|
||||
searchQuery,
|
||||
vcsSearchTextByTaskId,
|
||||
matchesPluginTaskFilters,
|
||||
ViewComponent,
|
||||
hideHeaders,
|
||||
@@ -358,6 +395,7 @@ function WorkflowItems({
|
||||
wf={workflow}
|
||||
repoFilter={repoFilter}
|
||||
searchQuery={searchQuery}
|
||||
vcsSearchTextByTaskId={vcsSearchTextByTaskId}
|
||||
matchesPluginTaskFilters={matchesPluginTaskFilters}
|
||||
ViewComponent={ViewComponent}
|
||||
hideHeader={hideHeaders}
|
||||
@@ -401,6 +439,52 @@ function usePublishMobileFocus(focusedWorkflowId: string | null) {
|
||||
}, [focusedWorkflowId, setMobileKanbanFocusedWorkflow]);
|
||||
}
|
||||
|
||||
type RenderedWorkflowLayoutOptions = {
|
||||
workflowFilter: string | null;
|
||||
orderedWorkflows: { id: string; name: string }[];
|
||||
getFilteredTasks: (workflowId: string) => Task[];
|
||||
hasLiveHiddenSteps: (workflowId: string) => boolean;
|
||||
isMobileKanban: boolean;
|
||||
workflowOptions: MobileWorkflowNavigation["workflows"];
|
||||
onWorkflowChange: SwimlaneContainerProps["onWorkflowChange"];
|
||||
};
|
||||
|
||||
function useRenderedWorkflowLayout({
|
||||
workflowFilter,
|
||||
orderedWorkflows,
|
||||
getFilteredTasks,
|
||||
hasLiveHiddenSteps,
|
||||
isMobileKanban,
|
||||
workflowOptions,
|
||||
onWorkflowChange,
|
||||
}: RenderedWorkflowLayoutOptions) {
|
||||
const visibleWorkflows = useStableWorkflowList(
|
||||
selectVisibleWorkflows({
|
||||
workflowFilter,
|
||||
orderedWorkflows,
|
||||
hasTasks: (workflowId) => getFilteredTasks(workflowId).length > 0,
|
||||
hasLiveHiddenSteps,
|
||||
showEmptyBoard: isMobileKanban,
|
||||
}),
|
||||
);
|
||||
const focusedWorkflowId = visibleWorkflows[0]?.id ?? null;
|
||||
usePublishMobileFocus(isMobileKanban ? focusedWorkflowId : null);
|
||||
const renderedWorkflows = useStableWorkflowList(
|
||||
getRenderedWorkflows(isMobileKanban, focusedWorkflowId, visibleWorkflows),
|
||||
);
|
||||
const sortableWorkflowIds = useMemo(
|
||||
() => renderedWorkflows.map((workflow) => workflow.id),
|
||||
[renderedWorkflows],
|
||||
);
|
||||
const mobileWorkflowNavigation = useMobileWorkflowNavigation(
|
||||
isMobileKanban,
|
||||
focusedWorkflowId,
|
||||
workflowOptions,
|
||||
onWorkflowChange,
|
||||
);
|
||||
return { visibleWorkflows, renderedWorkflows, sortableWorkflowIds, mobileWorkflowNavigation };
|
||||
}
|
||||
|
||||
function useMobileWorkflowNavigation(
|
||||
isMobileKanban: boolean,
|
||||
focusedWorkflowId: string | null,
|
||||
@@ -426,6 +510,7 @@ export function SwimlaneContainer(containerProps: SwimlaneContainerProps) {
|
||||
const { isMobile } = useResponsiveBreakpoint();
|
||||
const { onToggleStepVisibility, onToggleAutoHideEmpty } = useKanbanDisplaySettings();
|
||||
const { isCollapsed, toggleCollapse } = useSwimlaneCollapse();
|
||||
const vcsSearchTextByTaskId = useVcsSearchIndex();
|
||||
const {
|
||||
snapshots,
|
||||
isLoading,
|
||||
@@ -439,6 +524,7 @@ export function SwimlaneContainer(containerProps: SwimlaneContainerProps) {
|
||||
selectedRepositoryIds,
|
||||
searchQuery ?? "",
|
||||
containerProps.matchesPluginTaskFilters,
|
||||
vcsSearchTextByTaskId,
|
||||
);
|
||||
const {
|
||||
sensors: workflowSensors,
|
||||
@@ -448,30 +534,16 @@ export function SwimlaneContainer(containerProps: SwimlaneContainerProps) {
|
||||
|
||||
const view = getEffectiveView(viewMode, isMobile);
|
||||
const isMobileKanban = isMobile && view.id === "kanban";
|
||||
const visibleWorkflows = useStableWorkflowList(
|
||||
selectVisibleWorkflows({
|
||||
const { visibleWorkflows, renderedWorkflows, sortableWorkflowIds, mobileWorkflowNavigation } =
|
||||
useRenderedWorkflowLayout({
|
||||
workflowFilter,
|
||||
orderedWorkflows,
|
||||
hasTasks: (workflowId) => getFilteredTasks(workflowId).length > 0,
|
||||
getFilteredTasks,
|
||||
hasLiveHiddenSteps,
|
||||
showEmptyBoard: isMobileKanban,
|
||||
}),
|
||||
);
|
||||
const focusedWorkflowId = visibleWorkflows[0]?.id ?? null;
|
||||
usePublishMobileFocus(isMobileKanban ? focusedWorkflowId : null);
|
||||
const renderedWorkflows = useStableWorkflowList(
|
||||
getRenderedWorkflows(isMobileKanban, focusedWorkflowId, visibleWorkflows),
|
||||
);
|
||||
const sortableWorkflowIds = useMemo(
|
||||
() => renderedWorkflows.map((workflow) => workflow.id),
|
||||
[renderedWorkflows],
|
||||
);
|
||||
const mobileWorkflowNavigation = useMobileWorkflowNavigation(
|
||||
isMobileKanban,
|
||||
focusedWorkflowId,
|
||||
workflowOptions,
|
||||
containerProps.onWorkflowChange,
|
||||
);
|
||||
isMobileKanban,
|
||||
workflowOptions,
|
||||
onWorkflowChange: containerProps.onWorkflowChange,
|
||||
});
|
||||
|
||||
const emptyMessage = getEmptyMessage({
|
||||
isLoading,
|
||||
@@ -502,6 +574,7 @@ export function SwimlaneContainer(containerProps: SwimlaneContainerProps) {
|
||||
workflows={renderedWorkflows}
|
||||
repoFilter={repoFilter}
|
||||
searchQuery={searchQuery ?? ""}
|
||||
vcsSearchTextByTaskId={vcsSearchTextByTaskId}
|
||||
matchesPluginTaskFilters={containerProps.matchesPluginTaskFilters}
|
||||
ViewComponent={view.component}
|
||||
hideHeaders={hideHeaders}
|
||||
|
||||
@@ -1,24 +1,8 @@
|
||||
import type { KanbanState } from "@/lib/state/slices/kanban/types";
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
import { taskPRInfoFromSummary } from "@/components/task/task-pr-info";
|
||||
export { changeRequestNumbers } from "@/lib/kanban/task-search-index";
|
||||
|
||||
type Task = KanbanState["tasks"][number];
|
||||
|
||||
/** GitHub PR + GitLab MR numbers linked to a task, de-duplicated and ascending. */
|
||||
export function changeRequestNumbers(
|
||||
task: Task,
|
||||
mrsForTask: TaskMR[],
|
||||
prsForTask: TaskPR[] = [],
|
||||
): number[] {
|
||||
const numbers = new Set<number>();
|
||||
const prNumber = taskPRInfoFromSummary(task.statusSummary)?.number;
|
||||
if (prNumber) numbers.add(prNumber);
|
||||
for (const pr of prsForTask) numbers.add(pr.pr_number);
|
||||
for (const mr of mrsForTask) numbers.add(mr.mr_iid);
|
||||
return [...numbers].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** cmdk search key: title, id, and both '#N' and bare 'N' for each change-request number. */
|
||||
export function dependencyOptionValue(task: Pick<Task, "id" | "title">, numbers: number[]): string {
|
||||
const parts = [task.title, task.id, ...numbers.map((n) => `#${n} ${n}`)];
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
|
||||
test.describe("Kanban board search matches linked PR/MR numbers", () => {
|
||||
test("searching a linked PR number finds the card by number alone, not by title", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const stepOptions = {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
};
|
||||
|
||||
// Title deliberately shares no words with the PR number so a match can
|
||||
// only come from the linked PR, proving the number itself is indexed.
|
||||
const prTask = await apiClient.createTask(
|
||||
seedData.workspaceId,
|
||||
"Board search PR-linked card",
|
||||
stepOptions,
|
||||
);
|
||||
const unrelatedTask = await apiClient.createTask(
|
||||
seedData.workspaceId,
|
||||
"Board search unrelated card",
|
||||
stepOptions,
|
||||
);
|
||||
|
||||
await apiClient.mockGitHubAssociateTaskPR({
|
||||
workspace_id: seedData.workspaceId,
|
||||
task_id: prTask.id,
|
||||
owner: "kandev-e2e",
|
||||
repo: "board-search-fixtures",
|
||||
pr_number: 93315,
|
||||
pr_url: "https://github.test/kandev-e2e/board-search-fixtures/pull/93315",
|
||||
pr_title: "Totally unrelated PR title",
|
||||
head_branch: "feature/board-search",
|
||||
base_branch: "main",
|
||||
author_login: "board-search-author",
|
||||
state: "open",
|
||||
});
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
|
||||
await expect(kanban.taskCard(prTask.id)).toBeVisible();
|
||||
await expect(kanban.taskCard(unrelatedTask.id)).toBeVisible();
|
||||
|
||||
const search = testPage.getByTestId("kanban-header-search").getByPlaceholder("Search tasks...");
|
||||
|
||||
await search.fill("93315");
|
||||
await expect(kanban.taskCard(prTask.id)).toBeVisible();
|
||||
await expect(kanban.taskCard(unrelatedTask.id)).toBeHidden();
|
||||
|
||||
// The leading "#" form must match too.
|
||||
await search.fill("#93315");
|
||||
await expect(kanban.taskCard(prTask.id)).toBeVisible();
|
||||
await expect(kanban.taskCard(unrelatedTask.id)).toBeHidden();
|
||||
|
||||
// A number that matches nothing hides both cards.
|
||||
await search.fill("77777");
|
||||
await expect(kanban.taskCard(prTask.id)).toBeHidden();
|
||||
await expect(kanban.taskCard(unrelatedTask.id)).toBeHidden();
|
||||
|
||||
await search.fill("");
|
||||
await expect(kanban.taskCard(prTask.id)).toBeVisible();
|
||||
await expect(kanban.taskCard(unrelatedTask.id)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ type TaskProjectionCacheEntry = {
|
||||
hiddenStepIds: string[] | undefined;
|
||||
repoFilter: Set<string>;
|
||||
searchQuery: string;
|
||||
vcsSearchTextByTaskId: Record<string, string> | undefined;
|
||||
matchesPluginTaskFilters: ((taskId: string) => boolean) | undefined;
|
||||
visibleTasks: Task[];
|
||||
};
|
||||
@@ -85,11 +86,78 @@ function useOrderedWorkflowLists(
|
||||
return { allOrderedWorkflows, orderedWorkflows };
|
||||
}
|
||||
|
||||
type TaskProjectionCacheOptions = {
|
||||
snapshots: Record<string, WorkflowSnapshotData>;
|
||||
hiddenWorkflowStepIds: Record<string, string[]>;
|
||||
repoFilter: Set<string>;
|
||||
searchQuery: string;
|
||||
vcsSearchTextByTaskId: Record<string, string> | undefined;
|
||||
matchesPluginTaskFilters: ((taskId: string) => boolean) | undefined;
|
||||
};
|
||||
|
||||
function useTaskProjectionCache({
|
||||
snapshots,
|
||||
hiddenWorkflowStepIds,
|
||||
repoFilter,
|
||||
searchQuery,
|
||||
vcsSearchTextByTaskId,
|
||||
matchesPluginTaskFilters,
|
||||
}: TaskProjectionCacheOptions) {
|
||||
const projectionCacheRef = useRef(new Map<string, TaskProjectionCacheEntry>());
|
||||
useEffect(() => {
|
||||
for (const workflowId of projectionCacheRef.current.keys()) {
|
||||
if (!(workflowId in snapshots)) projectionCacheRef.current.delete(workflowId);
|
||||
}
|
||||
}, [snapshots]);
|
||||
return useCallback(
|
||||
(workflowId: string) => {
|
||||
const snapshot = snapshots[workflowId];
|
||||
const hiddenStepIds = hiddenWorkflowStepIds[workflowId];
|
||||
const cached = projectionCacheRef.current.get(workflowId);
|
||||
if (
|
||||
cached?.snapshot === snapshot &&
|
||||
cached.hiddenStepIds === hiddenStepIds &&
|
||||
cached.repoFilter === repoFilter &&
|
||||
cached.searchQuery === searchQuery &&
|
||||
cached.vcsSearchTextByTaskId === vcsSearchTextByTaskId &&
|
||||
cached.matchesPluginTaskFilters === matchesPluginTaskFilters
|
||||
) {
|
||||
return cached.visibleTasks;
|
||||
}
|
||||
const visibleTasks = projectWorkflowTasks(snapshots, workflowId, repoFilter, {
|
||||
searchQuery,
|
||||
vcsSearchTextByTaskId,
|
||||
matchesPluginTaskFilters,
|
||||
hiddenStepIds: hiddenStepIds?.length ? new Set(hiddenStepIds) : undefined,
|
||||
}).visibleTasks;
|
||||
projectionCacheRef.current.set(workflowId, {
|
||||
snapshot,
|
||||
hiddenStepIds,
|
||||
repoFilter,
|
||||
searchQuery,
|
||||
vcsSearchTextByTaskId,
|
||||
matchesPluginTaskFilters,
|
||||
visibleTasks,
|
||||
});
|
||||
return visibleTasks;
|
||||
},
|
||||
[
|
||||
hiddenWorkflowStepIds,
|
||||
matchesPluginTaskFilters,
|
||||
repoFilter,
|
||||
searchQuery,
|
||||
vcsSearchTextByTaskId,
|
||||
snapshots,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export function useSwimlaneRenderData(
|
||||
workflowFilter: string | null | undefined,
|
||||
selectedRepositoryIds: string[],
|
||||
searchQuery: string,
|
||||
matchesPluginTaskFilters?: (taskId: string) => boolean,
|
||||
vcsSearchTextByTaskId?: Record<string, string>,
|
||||
) {
|
||||
const snapshots = useAppStore((state) => state.kanbanMulti.snapshots);
|
||||
const isLoading = useAppStore((state) => state.kanbanMulti.isLoading);
|
||||
@@ -114,43 +182,14 @@ export function useSwimlaneRenderData(
|
||||
snapshots,
|
||||
);
|
||||
|
||||
const projectionCacheRef = useRef(new Map<string, TaskProjectionCacheEntry>());
|
||||
useEffect(() => {
|
||||
for (const workflowId of projectionCacheRef.current.keys()) {
|
||||
if (!(workflowId in snapshots)) projectionCacheRef.current.delete(workflowId);
|
||||
}
|
||||
}, [snapshots]);
|
||||
const getFilteredTasks = useCallback(
|
||||
(workflowId: string) => {
|
||||
const snapshot = snapshots[workflowId];
|
||||
const hiddenStepIds = hiddenWorkflowStepIds[workflowId];
|
||||
const cached = projectionCacheRef.current.get(workflowId);
|
||||
if (
|
||||
cached?.snapshot === snapshot &&
|
||||
cached.hiddenStepIds === hiddenStepIds &&
|
||||
cached.repoFilter === repoFilter &&
|
||||
cached.searchQuery === searchQuery &&
|
||||
cached.matchesPluginTaskFilters === matchesPluginTaskFilters
|
||||
) {
|
||||
return cached.visibleTasks;
|
||||
}
|
||||
const visibleTasks = projectWorkflowTasks(snapshots, workflowId, repoFilter, {
|
||||
searchQuery,
|
||||
matchesPluginTaskFilters,
|
||||
hiddenStepIds: hiddenStepIds?.length ? new Set(hiddenStepIds) : undefined,
|
||||
}).visibleTasks;
|
||||
projectionCacheRef.current.set(workflowId, {
|
||||
snapshot,
|
||||
hiddenStepIds,
|
||||
repoFilter,
|
||||
searchQuery,
|
||||
matchesPluginTaskFilters,
|
||||
visibleTasks,
|
||||
});
|
||||
return visibleTasks;
|
||||
},
|
||||
[hiddenWorkflowStepIds, matchesPluginTaskFilters, repoFilter, searchQuery, snapshots],
|
||||
);
|
||||
const getFilteredTasks = useTaskProjectionCache({
|
||||
snapshots,
|
||||
hiddenWorkflowStepIds,
|
||||
repoFilter,
|
||||
searchQuery,
|
||||
vcsSearchTextByTaskId,
|
||||
matchesPluginTaskFilters,
|
||||
});
|
||||
|
||||
const hasLiveHiddenSteps = useCallback(
|
||||
(workflowId: string) => {
|
||||
@@ -196,6 +235,7 @@ export function useWorkflowSwimlaneData(
|
||||
repoFilter: Set<string>,
|
||||
searchQuery: string,
|
||||
matchesPluginTaskFilters?: (taskId: string) => boolean,
|
||||
vcsSearchTextByTaskId?: Record<string, string>,
|
||||
): {
|
||||
snapshot: WorkflowSnapshotData | undefined;
|
||||
tasks: Task[];
|
||||
@@ -221,10 +261,19 @@ export function useWorkflowSwimlaneData(
|
||||
if (!snapshot) return { visibleTasks: [], occupancyTasks: [] };
|
||||
return projectWorkflowTasks({ [workflowId]: snapshot }, workflowId, repoFilter, {
|
||||
searchQuery,
|
||||
vcsSearchTextByTaskId,
|
||||
matchesPluginTaskFilters,
|
||||
hiddenStepIds: hiddenSet,
|
||||
});
|
||||
}, [hiddenSet, matchesPluginTaskFilters, repoFilter, searchQuery, snapshot, workflowId]);
|
||||
}, [
|
||||
hiddenSet,
|
||||
matchesPluginTaskFilters,
|
||||
repoFilter,
|
||||
searchQuery,
|
||||
vcsSearchTextByTaskId,
|
||||
snapshot,
|
||||
workflowId,
|
||||
]);
|
||||
|
||||
return {
|
||||
snapshot,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Task } from "@/components/kanban-card";
|
||||
import { filterTasksByRepositories, mapSelectedRepositoryIds } from "@/lib/kanban/filters";
|
||||
import { changeRequestSearchText } from "@/lib/kanban/task-search-index";
|
||||
|
||||
type FilterTasksOptions = {
|
||||
searchQuery?: string;
|
||||
vcsSearchTextByTaskId?: Record<string, string>;
|
||||
matchesPluginTaskFilters?: (taskId: string) => boolean;
|
||||
hiddenStepIds?: Set<string>;
|
||||
};
|
||||
@@ -16,7 +18,8 @@ export function filterTasks(
|
||||
const snapshot = snapshots[workflowId];
|
||||
if (!snapshot) return [];
|
||||
let tasks = snapshot.tasks;
|
||||
const { hiddenStepIds, searchQuery, matchesPluginTaskFilters } = options ?? {};
|
||||
const { hiddenStepIds, searchQuery, vcsSearchTextByTaskId, matchesPluginTaskFilters } =
|
||||
options ?? {};
|
||||
if (hiddenStepIds && hiddenStepIds.size > 0) {
|
||||
const liveStepIds = new Set(snapshot.steps.map((step) => step.id));
|
||||
const effectiveHidden = new Set([...hiddenStepIds].filter((id) => liveStepIds.has(id)));
|
||||
@@ -30,7 +33,9 @@ export function filterTasks(
|
||||
tasks = tasks.filter(
|
||||
(task) =>
|
||||
task.title.toLowerCase().includes(query) ||
|
||||
(task.description && task.description.toLowerCase().includes(query)),
|
||||
(task.description && task.description.toLowerCase().includes(query)) ||
|
||||
changeRequestSearchText(task).toLowerCase().includes(query) ||
|
||||
(vcsSearchTextByTaskId?.[task.id]?.toLowerCase().includes(query) ?? false),
|
||||
);
|
||||
}
|
||||
if (matchesPluginTaskFilters) {
|
||||
@@ -41,6 +46,7 @@ export function filterTasks(
|
||||
|
||||
type WorkflowTaskProjectionOptions = {
|
||||
searchQuery: string;
|
||||
vcsSearchTextByTaskId?: Record<string, string>;
|
||||
matchesPluginTaskFilters?: (taskId: string) => boolean;
|
||||
hiddenStepIds?: Set<string>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildTaskVcsSearchIndex,
|
||||
getTaskPRsByTaskIdForCurrentWorkspace,
|
||||
} from "@/lib/kanban/task-search-index";
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
|
||||
function makePR(overrides: Partial<TaskPR> & { task_id: string; pr_number: number }): TaskPR {
|
||||
return {
|
||||
id: `pr-${overrides.pr_number}`,
|
||||
workspace_id: "ws-1",
|
||||
owner: "acme",
|
||||
repo: "widgets",
|
||||
pr_url: "",
|
||||
pr_title: "",
|
||||
head_branch: "",
|
||||
base_branch: "",
|
||||
...overrides,
|
||||
} as TaskPR;
|
||||
}
|
||||
|
||||
function makeMR(overrides: Partial<TaskMR> & { task_id: string; mr_iid: number }): TaskMR {
|
||||
return {
|
||||
id: `mr-${overrides.mr_iid}`,
|
||||
host: "gitlab.com",
|
||||
project_path: "acme/widgets",
|
||||
mr_url: "",
|
||||
mr_title: "",
|
||||
head_branch: "",
|
||||
base_branch: "",
|
||||
author_username: "",
|
||||
state: "open",
|
||||
approval_state: "",
|
||||
pipeline_state: "",
|
||||
merge_status: "",
|
||||
draft: false,
|
||||
...overrides,
|
||||
} as TaskMR;
|
||||
}
|
||||
|
||||
describe("buildTaskVcsSearchIndex", () => {
|
||||
it("indexes a GitHub-only task by its PR number", () => {
|
||||
const index = buildTaskVcsSearchIndex(
|
||||
{ "task-1": [makePR({ task_id: "task-1", pr_number: 3315 })] },
|
||||
{},
|
||||
);
|
||||
|
||||
expect(index["task-1"]).toContain("#3315");
|
||||
});
|
||||
|
||||
it("indexes a GitLab-only task by its MR iid", () => {
|
||||
const index = buildTaskVcsSearchIndex(
|
||||
{},
|
||||
{ "task-1": [makeMR({ task_id: "task-1", mr_iid: 42 })] },
|
||||
);
|
||||
|
||||
expect(index["task-1"]).toContain("#42");
|
||||
});
|
||||
|
||||
it("merges both PR and MR tokens for a task linked to both", () => {
|
||||
const index = buildTaskVcsSearchIndex(
|
||||
{ "task-1": [makePR({ task_id: "task-1", pr_number: 3315 })] },
|
||||
{ "task-1": [makeMR({ task_id: "task-1", mr_iid: 42 })] },
|
||||
);
|
||||
|
||||
expect(index["task-1"]).toContain("#3315");
|
||||
expect(index["task-1"]).toContain("#42");
|
||||
});
|
||||
|
||||
it("includes every linked PR for a multi-repo task", () => {
|
||||
const index = buildTaskVcsSearchIndex(
|
||||
{
|
||||
"task-1": [
|
||||
makePR({ task_id: "task-1", pr_number: 100, repository_id: "repo-a" }),
|
||||
makePR({ task_id: "task-1", pr_number: 200, repository_id: "repo-b" }),
|
||||
],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(index["task-1"]).toContain("#100");
|
||||
expect(index["task-1"]).toContain("#200");
|
||||
});
|
||||
|
||||
it("omits an entry for a task with no linked PRs or MRs", () => {
|
||||
const index = buildTaskVcsSearchIndex({ "task-1": [] }, {});
|
||||
|
||||
expect(index["task-1"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns an empty index for missing/empty inputs", () => {
|
||||
expect(buildTaskVcsSearchIndex({}, {})).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTaskPRsByTaskIdForCurrentWorkspace", () => {
|
||||
const byTaskId = { "task-1": [makePR({ task_id: "task-1", pr_number: 3315 })] };
|
||||
|
||||
it("returns the PR map when its scope matches the active workspace", () => {
|
||||
const taskPRs = {
|
||||
byTaskId,
|
||||
workspaceId: "ws-1",
|
||||
workspaceContextGeneration: 4,
|
||||
};
|
||||
|
||||
expect(getTaskPRsByTaskIdForCurrentWorkspace(taskPRs, "ws-1", 4)).toBe(byTaskId);
|
||||
});
|
||||
|
||||
it("returns an empty map when its workspace scope is stale", () => {
|
||||
const taskPRs = {
|
||||
byTaskId,
|
||||
workspaceId: "ws-1",
|
||||
workspaceContextGeneration: 4,
|
||||
};
|
||||
|
||||
expect(getTaskPRsByTaskIdForCurrentWorkspace(taskPRs, "ws-2", 5)).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { TaskPRsState } from "@/lib/state/slices/github/types";
|
||||
import type { TaskStatusSummary } from "@/lib/types/task-status-summary";
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
|
||||
type TaskWithStatusSummary = {
|
||||
statusSummary?: TaskStatusSummary | null;
|
||||
};
|
||||
|
||||
const EMPTY_TASK_PRS_BY_TASK_ID: Record<string, TaskPR[]> = {};
|
||||
|
||||
/** Return GitHub associations only when their store scope matches the active workspace. */
|
||||
export function getTaskPRsByTaskIdForCurrentWorkspace(
|
||||
taskPRs: Pick<TaskPRsState, "byTaskId" | "workspaceId" | "workspaceContextGeneration">,
|
||||
activeWorkspaceId: string | null,
|
||||
workspaceContextGeneration: number,
|
||||
): Record<string, TaskPR[]> {
|
||||
if (
|
||||
!activeWorkspaceId ||
|
||||
taskPRs.workspaceId !== activeWorkspaceId ||
|
||||
taskPRs.workspaceContextGeneration !== workspaceContextGeneration
|
||||
) {
|
||||
return EMPTY_TASK_PRS_BY_TASK_ID;
|
||||
}
|
||||
return taskPRs.byTaskId;
|
||||
}
|
||||
|
||||
/** GitHub PR and GitLab MR numbers linked to a task, de-duplicated and ascending. */
|
||||
export function changeRequestNumbers(
|
||||
task: TaskWithStatusSummary,
|
||||
mrsForTask: TaskMR[] = [],
|
||||
prsForTask: TaskPR[] = [],
|
||||
): number[] {
|
||||
const numbers = new Set<number>();
|
||||
const summaryPRNumber = task.statusSummary?.pull_request?.number;
|
||||
if (summaryPRNumber) numbers.add(summaryPRNumber);
|
||||
for (const pr of prsForTask) numbers.add(pr.pr_number);
|
||||
for (const mr of mrsForTask) numbers.add(mr.mr_iid);
|
||||
return [...numbers].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function changeRequestSearchText(
|
||||
task: TaskWithStatusSummary,
|
||||
mrsForTask: TaskMR[] = [],
|
||||
prsForTask: TaskPR[] = [],
|
||||
): string {
|
||||
return changeRequestNumbers(task, mrsForTask, prsForTask)
|
||||
.map((number) => `#${number}`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* One haystack per task built from its linked PR/MR numbers, tokenized
|
||||
* as `#<number>` so both a bare number and a `#`-prefixed number substring-match.
|
||||
*/
|
||||
export function buildTaskVcsSearchIndex(
|
||||
taskPRsByTaskId: Record<string, TaskPR[]>,
|
||||
taskMRsByTaskId: Record<string, TaskMR[]>,
|
||||
): Record<string, string> {
|
||||
const index: Record<string, string> = {};
|
||||
const taskIds = new Set([...Object.keys(taskPRsByTaskId), ...Object.keys(taskMRsByTaskId)]);
|
||||
|
||||
for (const taskId of taskIds) {
|
||||
const text = changeRequestSearchText({}, taskMRsByTaskId[taskId], taskPRsByTaskId[taskId]);
|
||||
if (text) index[taskId] = text;
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
Reference in New Issue
Block a user