Page issues list with scroll offsets

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-28 12:52:09 -05:00
1 parent 19c786a20b
commit 889cf660ac
8 files changed
+74 -43

No files matched your search

+10
View File
@@ -924,6 +924,11 @@ export function issueRoutes(
? Number.parseInt(rawLimit, 10)
: null;
const limit = parsedLimit === null ? ISSUE_LIST_DEFAULT_LIMIT : clampIssueListLimit(parsedLimit);
const rawOffset = req.query.offset as string | undefined;
const parsedOffset = rawOffset !== undefined && /^\d+$/.test(rawOffset)
? Number.parseInt(rawOffset, 10)
: null;
const offset = parsedOffset ?? 0;
if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) {
res.status(403).json({ error: "assigneeUserId=me requires board authentication" });
@@ -945,6 +950,10 @@ export function issueRoutes(
res.status(400).json({ error: `limit must be a positive integer up to ${ISSUE_LIST_MAX_LIMIT}` });
return;
}
if (rawOffset !== undefined && (parsedOffset === null || !Number.isInteger(parsedOffset) || parsedOffset < 0)) {
res.status(400).json({ error: "offset must be a non-negative integer" });
return;
}
const result = await svc.list(companyId, {
status: req.query.status as string | undefined,
@@ -969,6 +978,7 @@ export function issueRoutes(
includeBlockedBy: req.query.includeBlockedBy === "true" || req.query.includeBlockedBy === "1",
q: req.query.q as string | undefined,
limit,
offset,
});
res.json(result);
});
+9 -1
View File
@@ -112,6 +112,7 @@ export interface IssueFilters {
includeBlockedBy?: boolean;
q?: string;
limit?: number;
offset?: number;
}
type IssueRow = typeof issues.$inferSelect;
@@ -2083,6 +2084,9 @@ export function issueService(db: Db) {
const limit = typeof filters?.limit === "number" && Number.isFinite(filters.limit)
? Math.max(1, Math.floor(filters.limit))
: undefined;
const offset = typeof filters?.offset === "number" && Number.isFinite(filters.offset)
? Math.max(0, Math.floor(filters.offset))
: 0;
const touchedByUserId = filters?.touchedByUserId?.trim() || undefined;
const inboxArchivedByUserId = filters?.inboxArchivedByUserId?.trim() || undefined;
const unreadForUserId = filters?.unreadForUserId?.trim() || undefined;
@@ -2205,8 +2209,12 @@ export function issueService(db: Db) {
asc(priorityOrder),
desc(canonicalLastActivityAt),
desc(issues.updatedAt),
desc(issues.id),
);
const rows = (limit === undefined ? await baseQuery : await baseQuery.limit(limit)).map((row) => ({
const pageQuery = offset > 0
? (limit === undefined ? baseQuery.offset(offset) : baseQuery.limit(limit).offset(offset))
: (limit === undefined ? baseQuery : baseQuery.limit(limit));
const rows = (await pageQuery).map((row) => ({
...row,
description: decodeDatabaseTextPreview(row.description, ISSUE_LIST_DESCRIPTION_MAX_CHARS),
}));
+8
View File
@@ -39,4 +39,12 @@ describe("issuesApi.list", () => {
"/companies/company-1/issues?workspaceId=workspace-1&limit=1000",
);
});
it("passes pagination offsets through to the company issues endpoint", async () => {
await issuesApi.list("company-1", { limit: 500, offset: 1500 });
expect(mockApi.get).toHaveBeenCalledWith(
"/companies/company-1/issues?limit=500&offset=1500",
);
});
});
+2
View File
@@ -48,6 +48,7 @@ export const issuesApi = {
includeBlockedBy?: boolean;
q?: string;
limit?: number;
offset?: number;
},
) => {
const params = new URLSearchParams();
@@ -70,6 +71,7 @@ export const issuesApi = {
if (filters?.includeBlockedBy) params.set("includeBlockedBy", "true");
if (filters?.q) params.set("q", filters.q);
if (filters?.limit) params.set("limit", String(filters.limit));
if (filters?.offset !== undefined) params.set("offset", String(filters.offset));
const qs = params.toString();
return api.get<Issue[]>(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`);
},
+4 -4
View File
@@ -963,8 +963,8 @@ describe("IssuesList", () => {
});
});
it("renders the next local issue batch after scrolling near the bottom", async () => {
const manyIssues = Array.from({ length: 220 }, (_, index) =>
it("keeps rendering local issue batches while the user stays near the bottom", async () => {
const manyIssues = Array.from({ length: 420 }, (_, index) =>
createIssue({
id: `issue-${index + 1}`,
identifier: `PAP-${index + 1}`,
@@ -993,8 +993,8 @@ describe("IssuesList", () => {
});
await waitForAssertion(() => {
expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(220);
expect(container.textContent).not.toContain("Rendering 100 of 220 issues");
expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(420);
expect(container.textContent).not.toContain("Rendering 250 of 420 issues");
});
act(() => {
+1 -1
View File
@@ -1040,7 +1040,7 @@ export function IssuesList({
window.removeEventListener("resize", checkScrollPosition);
if (animationFrameId !== null) window.cancelAnimationFrame(animationFrameId);
};
}, [canLoadMoreIssues, loadMoreIssueRows]);
}, [canLoadMoreIssues, loadMoreIssueRows, renderedIssueRowLimit]);
const newIssueDefaults = useCallback((groupKey?: string) => {
const defaults: Record<string, unknown> = { ...(baseCreateIssueDefaults ?? {}) };
+8 -9
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildIssuesSearchUrl, getNextIssuesPageLimit, hasMoreIssuesToRequest } from "./Issues";
import { buildIssuesSearchUrl, getNextIssuesPageOffset } from "./Issues";
describe("buildIssuesSearchUrl", () => {
it("preserves trailing spaces in the synced search param", () => {
@@ -16,15 +16,14 @@ describe("buildIssuesSearchUrl", () => {
});
describe("issues page pagination helpers", () => {
it("increments issue list limits up to the server cap", () => {
expect(getNextIssuesPageLimit(500)).toBe(750);
expect(getNextIssuesPageLimit(750)).toBe(1000);
expect(getNextIssuesPageLimit(1000)).toBe(1000);
it("advances to the next offset when the current page is full", () => {
expect(getNextIssuesPageOffset(500, 0)).toBe(500);
expect(getNextIssuesPageOffset(500, 500)).toBe(1000);
expect(getNextIssuesPageOffset(1000, 2000, 1000)).toBe(3000);
});
it("requests another issue page only when the current server limit is full", () => {
expect(hasMoreIssuesToRequest(499, 500)).toBe(false);
expect(hasMoreIssuesToRequest(500, 500)).toBe(true);
expect(hasMoreIssuesToRequest(1000, 1000)).toBe(false);
it("stops requesting issue pages when the current page is partial", () => {
expect(getNextIssuesPageOffset(499, 0)).toBeUndefined();
expect(getNextIssuesPageOffset(999, 2000, 1000)).toBeUndefined();
});
});
+32 -28
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useCallback, useState } from "react";
import { useLocation, useSearchParams } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useInfiniteQuery, useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
@@ -15,16 +15,14 @@ import { IssuesList } from "../components/IssuesList";
import { CircleDot } from "lucide-react";
const WORKSPACE_FILTER_ISSUE_LIMIT = 1000;
const ISSUES_PAGE_INITIAL_LIMIT = 500;
const ISSUES_PAGE_LIMIT_INCREMENT = 250;
const ISSUES_PAGE_MAX_LIMIT = 1000;
const ISSUES_PAGE_SIZE = 500;
export function getNextIssuesPageLimit(currentLimit: number): number {
return Math.min(ISSUES_PAGE_MAX_LIMIT, currentLimit + ISSUES_PAGE_LIMIT_INCREMENT);
}
export function hasMoreIssuesToRequest(loadedIssueCount: number, currentLimit: number): boolean {
return loadedIssueCount >= currentLimit && currentLimit < ISSUES_PAGE_MAX_LIMIT;
export function getNextIssuesPageOffset(
loadedPageSize: number,
currentOffset: number,
pageSize: number = ISSUES_PAGE_SIZE,
): number | undefined {
return loadedPageSize >= pageSize ? currentOffset + pageSize : undefined;
}
export function buildIssuesSearchUrl(currentHref: string, search: string): string | null {
@@ -47,7 +45,6 @@ export function Issues() {
const location = useLocation();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const [issueListLimit, setIssueListLimit] = useState(ISSUES_PAGE_INITIAL_LIMIT);
const initialSearch = searchParams.get("q") ?? "";
const [syncedSearch, setSyncedSearch] = useState(initialSearch);
@@ -96,17 +93,20 @@ export function Issues() {
setBreadcrumbs([{ label: "Issues" }]);
}, [setBreadcrumbs]);
const effectiveIssueListLimit = workspaceIdFilter ? WORKSPACE_FILTER_ISSUE_LIMIT : issueListLimit;
const issuePageSize = workspaceIdFilter ? WORKSPACE_FILTER_ISSUE_LIMIT : ISSUES_PAGE_SIZE;
useEffect(() => {
setSyncedSearch(initialSearch);
}, [initialSearch]);
useEffect(() => {
setIssueListLimit(ISSUES_PAGE_INITIAL_LIMIT);
}, [participantAgentId, workspaceIdFilter, selectedCompanyId, syncedSearch]);
const { data: issues, isLoading, isFetching, error } = useQuery({
const {
data: issuePages,
isLoading,
isFetchingNextPage,
error,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery({
queryKey: [
...queryKeys.issues.list(selectedCompanyId!),
"participant-agent",
@@ -114,26 +114,30 @@ export function Issues() {
"workspace",
workspaceIdFilter ?? "__all__",
"with-routine-executions",
"limit",
effectiveIssueListLimit,
"infinite",
issuePageSize,
],
queryFn: () => issuesApi.list(selectedCompanyId!, {
queryFn: ({ pageParam }) => issuesApi.list(selectedCompanyId!, {
participantAgentId,
workspaceId: workspaceIdFilter,
includeRoutineExecutions: true,
limit: effectiveIssueListLimit,
limit: issuePageSize,
offset: pageParam,
}),
initialPageParam: 0,
getNextPageParam: (lastPage, _allPages, lastPageParam) =>
getNextIssuesPageOffset(lastPage.length, lastPageParam, issuePageSize),
enabled: !!selectedCompanyId,
placeholderData: (previousData) => previousData,
});
const hasMoreServerIssues = !workspaceIdFilter
&& syncedSearch.trim().length === 0
&& hasMoreIssuesToRequest(issues?.length ?? 0, issueListLimit);
const issues = useMemo(() => issuePages?.pages.flat() ?? [], [issuePages]);
const hasMoreServerIssues = syncedSearch.trim().length === 0
&& hasNextPage === true;
const loadMoreServerIssues = useCallback(() => {
if (workspaceIdFilter) return;
setIssueListLimit((current) => getNextIssuesPageLimit(current));
}, [workspaceIdFilter]);
if (!hasNextPage || isFetchingNextPage) return;
void fetchNextPage();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
const updateIssue = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
@@ -151,7 +155,7 @@ export function Issues() {
<IssuesList
issues={issues ?? []}
isLoading={isLoading}
isLoadingMoreIssues={isFetching && !isLoading}
isLoadingMoreIssues={isFetchingNextPage}
error={error as Error | null}
agents={agents}
projects={projects}