mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
Page issues list with scroll offsets
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
19c786a20b
commit
889cf660ac
8 files changed
+74
-43
No files matched your search
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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}` : ""}`);
|
||||
},
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 ?? {}) };
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user