mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
Make issues page load more on scroll
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
39ba2797fb
commit
09923b01b2
4 files changed
+230
-19
No files matched your search
@@ -222,6 +222,20 @@ async function waitForMicrotaskAssertion(assertion: () => void, attempts = 20) {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function setDocumentScrollMetrics({
|
||||
innerHeight,
|
||||
scrollY,
|
||||
scrollHeight,
|
||||
}: {
|
||||
innerHeight: number;
|
||||
scrollY: number;
|
||||
scrollHeight: number;
|
||||
}) {
|
||||
Object.defineProperty(window, "innerHeight", { configurable: true, value: innerHeight });
|
||||
Object.defineProperty(window, "scrollY", { configurable: true, value: scrollY });
|
||||
Object.defineProperty(document.documentElement, "scrollHeight", { configurable: true, value: scrollHeight });
|
||||
}
|
||||
|
||||
function renderWithQueryClient(node: ReactNode, container: HTMLDivElement) {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
@@ -269,6 +283,7 @@ describe("IssuesList", () => {
|
||||
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
|
||||
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]);
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
setDocumentScrollMetrics({ innerHeight: 600, scrollY: 0, scrollHeight: 2400 });
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
@@ -948,6 +963,86 @@ describe("IssuesList", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the next local issue batch after scrolling near the bottom", async () => {
|
||||
const manyIssues = Array.from({ length: 220 }, (_, index) =>
|
||||
createIssue({
|
||||
id: `issue-${index + 1}`,
|
||||
identifier: `PAP-${index + 1}`,
|
||||
title: `Issue ${index + 1}`,
|
||||
}),
|
||||
);
|
||||
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={manyIssues}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(100);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
setDocumentScrollMetrics({ innerHeight: 600, scrollY: 1500, scrollHeight: 2000 });
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(220);
|
||||
expect(container.textContent).not.toContain("Rendering 100 of 220 issues");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("requests more server issues after scrolling past the rendered rows", async () => {
|
||||
const visibleIssues = Array.from({ length: 100 }, (_, index) =>
|
||||
createIssue({
|
||||
id: `issue-${index + 1}`,
|
||||
identifier: `PAP-${index + 1}`,
|
||||
title: `Issue ${index + 1}`,
|
||||
}),
|
||||
);
|
||||
const onLoadMoreIssues = vi.fn();
|
||||
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={visibleIssues}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
hasMoreIssues
|
||||
onLoadMoreIssues={onLoadMoreIssues}
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(100);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
setDocumentScrollMetrics({ innerHeight: 600, scrollY: 1500, scrollHeight: 2000 });
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(onLoadMoreIssues).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("skips deferred row sizing for expanded parent rows with visible children", async () => {
|
||||
const parentIssue = createIssue({
|
||||
id: "issue-parent",
|
||||
|
||||
@@ -72,7 +72,7 @@ const ISSUE_SEARCH_RESULT_LIMIT = 200;
|
||||
const ISSUE_BOARD_COLUMN_RESULT_LIMIT = 200;
|
||||
const INITIAL_ISSUE_ROW_RENDER_LIMIT = 100;
|
||||
const ISSUE_ROW_RENDER_BATCH_SIZE = 150;
|
||||
const ISSUE_ROW_RENDER_BATCH_DELAY_MS = 0;
|
||||
const ISSUE_SCROLL_LOAD_THRESHOLD_PX = 320;
|
||||
const boardIssueStatuses = ISSUE_STATUSES;
|
||||
const issueStatusLabels: Record<IssueStatus, string> = {
|
||||
backlog: "Backlog",
|
||||
@@ -306,8 +306,11 @@ interface IssuesListProps {
|
||||
defaultSortField?: IssueSortField;
|
||||
showProgressSummary?: boolean;
|
||||
enableRoutineVisibilityFilter?: boolean;
|
||||
hasMoreIssues?: boolean;
|
||||
isLoadingMoreIssues?: boolean;
|
||||
mutedIssueIds?: Set<string>;
|
||||
issueBadgeById?: Map<string, string>;
|
||||
onLoadMoreIssues?: () => void;
|
||||
onSearchChange?: (search: string) => void;
|
||||
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
||||
}
|
||||
@@ -475,8 +478,11 @@ export function IssuesList({
|
||||
defaultSortField,
|
||||
showProgressSummary = false,
|
||||
enableRoutineVisibilityFilter = false,
|
||||
hasMoreIssues = false,
|
||||
isLoadingMoreIssues = false,
|
||||
mutedIssueIds,
|
||||
issueBadgeById,
|
||||
onLoadMoreIssues,
|
||||
onSearchChange,
|
||||
onUpdateIssue,
|
||||
}: IssuesListProps) {
|
||||
@@ -512,6 +518,7 @@ export function IssuesList({
|
||||
const [issueSearch, setIssueSearch] = useState(initialSearch ?? "");
|
||||
const [renderedIssueRowLimit, setRenderedIssueRowLimit] = useState(INITIAL_ISSUE_ROW_RENDER_LIMIT);
|
||||
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(() => loadIssueColumns(scopedKey));
|
||||
const renderedIssueIdsRef = useRef("");
|
||||
const deferredIssueSearch = useDeferredValue(issueSearch);
|
||||
const normalizedIssueSearch = deferredIssueSearch.trim().toLowerCase();
|
||||
|
||||
@@ -966,23 +973,74 @@ export function IssuesList({
|
||||
|
||||
useEffect(() => {
|
||||
if (viewState.viewMode !== "list") return;
|
||||
setRenderedIssueRowLimit(Math.min(filtered.length, INITIAL_ISSUE_ROW_RENDER_LIMIT));
|
||||
const nextIssueIds = filtered.map((issue) => issue.id).join("|");
|
||||
const previousIssueIds = renderedIssueIdsRef.current;
|
||||
renderedIssueIdsRef.current = nextIssueIds;
|
||||
|
||||
setRenderedIssueRowLimit((current) => {
|
||||
const nextInitialLimit = Math.min(filtered.length, INITIAL_ISSUE_ROW_RENDER_LIMIT);
|
||||
const listAppended = previousIssueIds.length > 0
|
||||
&& nextIssueIds.startsWith(previousIssueIds)
|
||||
&& filtered.length >= current;
|
||||
if (listAppended) return Math.min(filtered.length, Math.max(current, nextInitialLimit));
|
||||
return nextInitialLimit;
|
||||
});
|
||||
}, [filtered, viewState.viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const hasMoreRenderedRows = viewState.viewMode === "list" && renderedIssueRowLimit < filtered.length;
|
||||
const remainingIssueRowCount = Math.max(filtered.length - renderedIssueRowLimit, 0);
|
||||
const loadMoreIssueRows = useCallback(() => {
|
||||
if (viewState.viewMode !== "list") return;
|
||||
if (renderedIssueRowLimit >= filtered.length) return;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
if (hasMoreRenderedRows) {
|
||||
startTransition(() => {
|
||||
setRenderedIssueRowLimit((current) => Math.min(filtered.length, current + ISSUE_ROW_RENDER_BATCH_SIZE));
|
||||
});
|
||||
}, ISSUE_ROW_RENDER_BATCH_DELAY_MS);
|
||||
return;
|
||||
}
|
||||
if (hasMoreIssues && !isLoadingMoreIssues) {
|
||||
onLoadMoreIssues?.();
|
||||
}
|
||||
}, [
|
||||
filtered.length,
|
||||
hasMoreIssues,
|
||||
hasMoreRenderedRows,
|
||||
isLoadingMoreIssues,
|
||||
onLoadMoreIssues,
|
||||
viewState.viewMode,
|
||||
]);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [filtered.length, renderedIssueRowLimit, viewState.viewMode]);
|
||||
const canLoadMoreIssues = viewState.viewMode === "list"
|
||||
&& !isLoading
|
||||
&& (hasMoreRenderedRows || (hasMoreIssues && !isLoadingMoreIssues));
|
||||
|
||||
const remainingIssueRowCount = Math.max(filtered.length - renderedIssueRowLimit, 0);
|
||||
useEffect(() => {
|
||||
if (!canLoadMoreIssues) return;
|
||||
let animationFrameId: number | null = null;
|
||||
|
||||
const checkScrollPosition = () => {
|
||||
if (animationFrameId !== null) return;
|
||||
animationFrameId = window.requestAnimationFrame(() => {
|
||||
animationFrameId = null;
|
||||
const documentElement = document.documentElement;
|
||||
if (documentElement.scrollHeight === 0) return;
|
||||
const scrollBottom = window.scrollY + window.innerHeight;
|
||||
const threshold = documentElement.scrollHeight - ISSUE_SCROLL_LOAD_THRESHOLD_PX;
|
||||
if (scrollBottom >= threshold) {
|
||||
loadMoreIssueRows();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", checkScrollPosition, { passive: true });
|
||||
window.addEventListener("resize", checkScrollPosition);
|
||||
checkScrollPosition();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", checkScrollPosition);
|
||||
window.removeEventListener("resize", checkScrollPosition);
|
||||
if (animationFrameId !== null) window.cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [canLoadMoreIssues, loadMoreIssueRows]);
|
||||
|
||||
const newIssueDefaults = useCallback((groupKey?: string) => {
|
||||
const defaults: Record<string, unknown> = { ...(baseCreateIssueDefaults ?? {}) };
|
||||
@@ -1569,10 +1627,16 @@ export function IssuesList({
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
{remainingIssueRowCount > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Rendering {Math.min(renderedIssueRowLimit, filtered.length)} of {filtered.length} issues
|
||||
</p>
|
||||
{(remainingIssueRowCount > 0 || hasMoreIssues || isLoadingMoreIssues) && (
|
||||
<div className="py-2" data-testid="issues-load-more-sentinel">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isLoadingMoreIssues
|
||||
? "Loading more issues..."
|
||||
: remainingIssueRowCount > 0
|
||||
? `Rendering ${Math.min(renderedIssueRowLimit, filtered.length)} of ${filtered.length} issues`
|
||||
: "Scroll to load more issues"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildIssuesSearchUrl } from "./Issues";
|
||||
import { buildIssuesSearchUrl, getNextIssuesPageLimit, hasMoreIssuesToRequest } from "./Issues";
|
||||
|
||||
describe("buildIssuesSearchUrl", () => {
|
||||
it("preserves trailing spaces in the synced search param", () => {
|
||||
@@ -14,3 +14,17 @@ describe("buildIssuesSearchUrl", () => {
|
||||
expect(buildIssuesSearchUrl("http://localhost:3100/issues?q=bug+", "bug ")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
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("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);
|
||||
});
|
||||
});
|
||||
+42
-4
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useCallback } from "react";
|
||||
import { useEffect, useMemo, useCallback, useState } from "react";
|
||||
import { useLocation, useSearchParams } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { issuesApi } from "../api/issues";
|
||||
@@ -15,6 +15,17 @@ 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;
|
||||
|
||||
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 buildIssuesSearchUrl(currentHref: string, search: string): string | null {
|
||||
const url = new URL(currentHref);
|
||||
@@ -36,12 +47,15 @@ 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);
|
||||
const participantAgentId = searchParams.get("participantAgentId") ?? undefined;
|
||||
const initialWorkspaces = searchParams.getAll("workspace").filter((workspaceId) => workspaceId.length > 0);
|
||||
const workspaceIdFilter = initialWorkspaces.length === 1 ? initialWorkspaces[0] : undefined;
|
||||
const handleSearchChange = useCallback((search: string) => {
|
||||
setSyncedSearch(search);
|
||||
const nextUrl = buildIssuesSearchUrl(window.location.href, search);
|
||||
if (!nextUrl) return;
|
||||
window.history.replaceState(window.history.state, "", nextUrl);
|
||||
@@ -82,7 +96,17 @@ export function Issues() {
|
||||
setBreadcrumbs([{ label: "Issues" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const { data: issues, isLoading, error } = useQuery({
|
||||
const effectiveIssueListLimit = workspaceIdFilter ? WORKSPACE_FILTER_ISSUE_LIMIT : issueListLimit;
|
||||
|
||||
useEffect(() => {
|
||||
setSyncedSearch(initialSearch);
|
||||
}, [initialSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
setIssueListLimit(ISSUES_PAGE_INITIAL_LIMIT);
|
||||
}, [participantAgentId, workspaceIdFilter, selectedCompanyId, syncedSearch]);
|
||||
|
||||
const { data: issues, isLoading, isFetching, error } = useQuery({
|
||||
queryKey: [
|
||||
...queryKeys.issues.list(selectedCompanyId!),
|
||||
"participant-agent",
|
||||
@@ -90,16 +114,27 @@ export function Issues() {
|
||||
"workspace",
|
||||
workspaceIdFilter ?? "__all__",
|
||||
"with-routine-executions",
|
||||
"limit",
|
||||
effectiveIssueListLimit,
|
||||
],
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, {
|
||||
participantAgentId,
|
||||
workspaceId: workspaceIdFilter,
|
||||
includeRoutineExecutions: true,
|
||||
...(workspaceIdFilter ? { limit: WORKSPACE_FILTER_ISSUE_LIMIT } : {}),
|
||||
limit: effectiveIssueListLimit,
|
||||
}),
|
||||
enabled: !!selectedCompanyId,
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
|
||||
const hasMoreServerIssues = !workspaceIdFilter
|
||||
&& syncedSearch.trim().length === 0
|
||||
&& hasMoreIssuesToRequest(issues?.length ?? 0, issueListLimit);
|
||||
const loadMoreServerIssues = useCallback(() => {
|
||||
if (workspaceIdFilter) return;
|
||||
setIssueListLimit((current) => getNextIssuesPageLimit(current));
|
||||
}, [workspaceIdFilter]);
|
||||
|
||||
const updateIssue = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
issuesApi.update(id, data),
|
||||
@@ -116,6 +151,7 @@ export function Issues() {
|
||||
<IssuesList
|
||||
issues={issues ?? []}
|
||||
isLoading={isLoading}
|
||||
isLoadingMoreIssues={isFetching && !isLoading}
|
||||
error={error as Error | null}
|
||||
agents={agents}
|
||||
projects={projects}
|
||||
@@ -124,9 +160,11 @@ export function Issues() {
|
||||
issueLinkState={issueLinkState}
|
||||
initialAssignees={searchParams.get("assignee") ? [searchParams.get("assignee")!] : undefined}
|
||||
initialWorkspaces={initialWorkspaces.length > 0 ? initialWorkspaces : undefined}
|
||||
initialSearch={initialSearch}
|
||||
initialSearch={syncedSearch}
|
||||
onSearchChange={handleSearchChange}
|
||||
enableRoutineVisibilityFilter
|
||||
hasMoreIssues={hasMoreServerIssues}
|
||||
onLoadMoreIssues={loadMoreServerIssues}
|
||||
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
|
||||
searchFilters={participantAgentId || workspaceIdFilter ? { participantAgentId, workspaceId: workspaceIdFilter } : undefined}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user