From 1ce7c17ad0fbee70b8bf9054077a69c868234bcb Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 22 Apr 2026 07:23:03 -0500 Subject: [PATCH] Fix kanban column issue limits --- ui/src/components/IssuesList.test.tsx | 81 ++++++++++++++++++++++++++- ui/src/components/IssuesList.tsx | 47 +++++++++++++++- 2 files changed, 124 insertions(+), 4 deletions(-) diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index 52f6f42f571..ab2921df8e6 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -22,6 +22,8 @@ const mockIssuesApi = vi.hoisted(() => ({ listLabels: vi.fn(), })); +const mockKanbanBoard = vi.hoisted(() => vi.fn()); + const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn(), })); @@ -87,7 +89,16 @@ vi.mock("./IssueRow", () => ({ })); vi.mock("./KanbanBoard", () => ({ - KanbanBoard: () => null, + KanbanBoard: (props: { issues: Issue[] }) => { + mockKanbanBoard(props); + return ( +
+ {props.issues.map((issue) => ( + {issue.title} + ))} +
+ ); + }, })); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -189,6 +200,7 @@ describe("IssuesList", () => { container = document.createElement("div"); document.body.appendChild(container); dialogState.openNewIssue.mockReset(); + mockKanbanBoard.mockReset(); mockIssuesApi.list.mockReset(); mockIssuesApi.listLabels.mockReset(); mockAuthApi.getSession.mockReset(); @@ -404,6 +416,73 @@ describe("IssuesList", () => { }); }); + it("loads board issues with a separate result limit for each status column", async () => { + localStorage.setItem( + "paperclip:test-issues:company-1", + JSON.stringify({ viewMode: "board" }), + ); + + const parentIssue = createIssue({ + id: "issue-parent-total-limit", + title: "Parent total-limited issue", + status: "todo", + }); + const backlogIssue = createIssue({ + id: "issue-backlog", + title: "Backlog column issue", + status: "backlog", + }); + const doneIssue = createIssue({ + id: "issue-done", + title: "Done column issue", + status: "done", + }); + + mockIssuesApi.list.mockImplementation((_companyId, filters) => { + if (filters?.status === "backlog") return Promise.resolve([backlogIssue]); + if (filters?.status === "done") return Promise.resolve([doneIssue]); + return Promise.resolve([]); + }); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", expect.objectContaining({ + status: "backlog", + limit: 200, + includeRoutineExecutions: true, + })); + expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", expect.objectContaining({ + status: "done", + limit: 200, + includeRoutineExecutions: true, + })); + expect(mockKanbanBoard).toHaveBeenLastCalledWith(expect.objectContaining({ + issues: expect.arrayContaining([ + expect.objectContaining({ id: "issue-backlog" }), + expect.objectContaining({ id: "issue-done" }), + ]), + })); + expect(container.textContent).toContain("Backlog column issue"); + expect(container.textContent).toContain("Done column issue"); + expect(container.textContent).not.toContain("Parent total-limited issue"); + }); + + act(() => { + root.unmount(); + }); + }); + it("caps the first paint for large issue lists", async () => { const manyIssues = Array.from({ length: 220 }, (_, index) => createIssue({ diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index a9bc23823ab..a444a2dfd99 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -1,5 +1,5 @@ import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useQueries, useQuery } from "@tanstack/react-query"; import { accessApi } from "../api/access"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; @@ -60,9 +60,11 @@ import { statusBadge } from "../lib/status-colors"; import type { Issue, Project } from "@paperclipai/shared"; const ISSUE_SEARCH_DEBOUNCE_MS = 250; const ISSUE_SEARCH_RESULT_LIMIT = 200; +const ISSUE_BOARD_COLUMN_RESULT_LIMIT = 200; const INITIAL_ISSUE_ROW_RENDER_LIMIT = 150; const ISSUE_ROW_RENDER_BATCH_SIZE = 150; const ISSUE_ROW_RENDER_BATCH_DELAY_MS = 0; +const boardIssueStatuses = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"]; /* ── View state ── */ @@ -394,6 +396,31 @@ export function IssuesList({ enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0, placeholderData: (previousData) => previousData, }); + const boardIssueQueries = useQueries({ + queries: boardIssueStatuses.map((status) => ({ + queryKey: [ + ...queryKeys.issues.list(selectedCompanyId ?? "__no-company__"), + "board-column", + status, + normalizedIssueSearch, + projectId ?? "__all-projects__", + searchFilters ?? {}, + ISSUE_BOARD_COLUMN_RESULT_LIMIT, + enableRoutineVisibilityFilter ? "with-routine-executions" : "without-routine-executions", + ], + queryFn: () => + issuesApi.list(selectedCompanyId!, { + ...searchFilters, + ...(normalizedIssueSearch.length > 0 ? { q: normalizedIssueSearch } : {}), + projectId, + status, + limit: ISSUE_BOARD_COLUMN_RESULT_LIMIT, + ...(enableRoutineVisibilityFilter ? { includeRoutineExecutions: true } : {}), + }), + enabled: !!selectedCompanyId && viewState.viewMode === "board", + placeholderData: (previousData: Issue[] | undefined) => previousData, + })), + }); const { data: executionWorkspaces = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.executionWorkspaces.summaryList(selectedCompanyId) @@ -570,11 +597,25 @@ export function IssuesList({ return map; }, [issues]); + const boardIssues = useMemo(() => { + if (viewState.viewMode !== "board") return null; + const merged = new Map(); + let isPending = false; + for (const query of boardIssueQueries) { + isPending ||= query.isPending; + for (const issue of query.data ?? []) { + merged.set(issue.id, issue); + } + } + if (merged.size > 0) return [...merged.values()]; + return isPending ? issues : []; + }, [boardIssueQueries, issues, viewState.viewMode]); + const filtered = useMemo(() => { - const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues; + const sourceIssues = boardIssues ?? (normalizedIssueSearch.length > 0 ? searchedIssues : issues); const filteredByControls = applyIssueFilters(sourceIssues, viewState, currentUserId, enableRoutineVisibilityFilter); return sortIssues(filteredByControls, viewState); - }, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId, enableRoutineVisibilityFilter]); + }, [boardIssues, issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId, enableRoutineVisibilityFilter]); const { data: labels } = useQuery({ queryKey: queryKeys.issues.labels(selectedCompanyId!),