Fix kanban column issue limits

This commit is contained in:
Dotta committed 2026-04-24 00:21:07 -05:00
1 parent 80c5216dba
commit 1ce7c17ad0
2 files changed
+124 -4

No files matched your search

+80 -1
View File
@@ -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 (
<div data-testid="kanban-board">
{props.issues.map((issue) => (
<span key={issue.id}>{issue.title}</span>
))}
</div>
);
},
}));
// 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(
<IssuesList
issues={[parentIssue]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
enableRoutineVisibilityFilter
onUpdateIssue={() => 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({
+44 -3
View File
@@ -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<string, Issue>();
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!),