mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
feat(frontend): Add reusable infinite scroll component for consistent pagination across frontend (#10530)
We currently use infinite scroll pagination in multiple places, but our
strategies vary across these locations. This repetitive code writing is
not ideal, and our current methods are also complex. We’re not utilising
React Query’s useInfiniteQuery hooks effectively.
To address these issues, we’re introducing a new component called
`InfiniteScroll` that handles pagination independently.
### How to use it?
- Use React Query’s `useInfiniteHook` to return multiple data points.
For pagination, we only need `fetchNextPage`, `hasNextPage`, and
`isFetchingNextPage`.
```ts
const {
data: agents,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading: agentLoading,
} = useGetV2ListLibraryAgentsInfinite(
{
page: 1,
page_size: 8,
search_term: searchTerm || undefined,
sort_by: librarySort,
},
);
```
- Simply pass these three data points and the current data length to the
`InfiniteScroll` component. That's it
```tsx
<InfiniteScroll
dataLength={agents.length}
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
loader={<LoadingSpinner />}
>
...
```
### Changes
- Add the `InfiniteScroll.tsx` component for consistency and simplicity
in pagination across the frontend.
- Update the current library page to use the `InfiniteScroll` component.
### Checklist 📋
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] I’ve tested everything locally, and it’s working perfectly fine.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import LibraryActionSubHeader from "../LibraryActionSubHeader/LibraryActionSubHeader";
|
||||
import LibraryAgentCard from "../LibraryAgentCard/LibraryAgentCard";
|
||||
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
|
||||
import { useLibraryAgentList } from "./useLibraryAgentList";
|
||||
|
||||
export default function LibraryAgentList() {
|
||||
@@ -8,8 +9,9 @@ export default function LibraryAgentList() {
|
||||
agentLoading,
|
||||
agentCount,
|
||||
allAgents: agents,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isSearching,
|
||||
fetchNextPage,
|
||||
} = useLibraryAgentList();
|
||||
|
||||
const LoadingSpinner = () => (
|
||||
@@ -18,7 +20,6 @@ export default function LibraryAgentList() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* TODO: We need a new endpoint on backend that returns total number of agents */}
|
||||
<LibraryActionSubHeader agentCount={agentCount} />
|
||||
<div className="px-2">
|
||||
{agentLoading ? (
|
||||
@@ -26,18 +27,19 @@ export default function LibraryAgentList() {
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<InfiniteScroll
|
||||
dataLength={agents.length}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
loader={<LoadingSpinner />}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{agents.map((agent) => (
|
||||
<LibraryAgentCard key={agent.id} agent={agent} />
|
||||
))}
|
||||
</div>
|
||||
{(isFetchingNextPage || isSearching) && (
|
||||
<div className="flex items-center justify-center py-4 pt-8">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</InfiniteScroll>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useGetV2ListLibraryAgentsInfinite } from "@/app/api/__generated__/endpoints/library/library";
|
||||
import { LibraryAgentResponse } from "@/app/api/__generated__/models/libraryAgentResponse";
|
||||
import { useScrollThreshold } from "@/hooks/useScrollThreshold";
|
||||
import { useCallback } from "react";
|
||||
import { useLibraryPageContext } from "../state-provider";
|
||||
|
||||
export const useLibraryAgentList = () => {
|
||||
@@ -12,7 +10,6 @@ export const useLibraryAgentList = () => {
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isLoading: agentLoading,
|
||||
isFetching,
|
||||
} = useGetV2ListLibraryAgentsInfinite(
|
||||
{
|
||||
page: 1,
|
||||
@@ -34,38 +31,22 @@ export const useLibraryAgentList = () => {
|
||||
},
|
||||
);
|
||||
|
||||
const handleInfiniteScroll = useCallback(
|
||||
(scrollY: number) => {
|
||||
if (!hasNextPage || isFetchingNextPage) return;
|
||||
|
||||
const { scrollHeight, clientHeight } = document.documentElement;
|
||||
const SCROLL_THRESHOLD = 20;
|
||||
|
||||
if (scrollY + clientHeight >= scrollHeight - SCROLL_THRESHOLD) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
[hasNextPage, isFetchingNextPage, fetchNextPage],
|
||||
);
|
||||
|
||||
useScrollThreshold(handleInfiniteScroll, 50);
|
||||
|
||||
const allAgents =
|
||||
agents?.pages.flatMap((page) => {
|
||||
const data = page.data as LibraryAgentResponse;
|
||||
return data.agents;
|
||||
agents?.pages?.flatMap((page) => {
|
||||
const response = page.data as LibraryAgentResponse;
|
||||
return response.agents;
|
||||
}) ?? [];
|
||||
|
||||
const agentCount = agents?.pages[0]
|
||||
const agentCount = agents?.pages?.[0]
|
||||
? (agents.pages[0].data as LibraryAgentResponse).pagination.total_items
|
||||
: 0;
|
||||
|
||||
return {
|
||||
allAgents,
|
||||
agentLoading,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
agentCount,
|
||||
isSearching: isFetching && !isFetchingNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useInfiniteScroll } from "./useInfiniteScroll";
|
||||
|
||||
interface InfiniteScrollProps {
|
||||
children: React.ReactNode;
|
||||
dataLength: number;
|
||||
hasNextPage: boolean;
|
||||
loader?: React.ReactNode;
|
||||
endMessage?: React.ReactNode;
|
||||
scrollThreshold?: number;
|
||||
className?: string;
|
||||
scrollableTarget?: string;
|
||||
onLoadMore?: () => void;
|
||||
isFetchingNextPage: boolean;
|
||||
fetchNextPage: () => void;
|
||||
}
|
||||
|
||||
export const InfiniteScroll: React.FC<InfiniteScrollProps> = ({
|
||||
children,
|
||||
dataLength,
|
||||
hasNextPage,
|
||||
loader,
|
||||
endMessage,
|
||||
className,
|
||||
scrollThreshold = 20,
|
||||
scrollableTarget,
|
||||
onLoadMore,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
}) => {
|
||||
const { containerRef, bottomRef } = useInfiniteScroll({
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
scrollThreshold,
|
||||
scrollableTarget,
|
||||
onLoadMore,
|
||||
hasNextPage,
|
||||
});
|
||||
|
||||
const defaultLoader = (
|
||||
<div className="flex w-full items-center justify-center py-4">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-t-2 border-neutral-800" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn("w-full", className)}>
|
||||
{children}
|
||||
{hasNextPage ? (
|
||||
<div
|
||||
ref={bottomRef}
|
||||
className="flex w-full items-center justify-center py-8"
|
||||
>
|
||||
{loader || defaultLoader}
|
||||
</div>
|
||||
) : (
|
||||
dataLength > 0 && endMessage
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import { debounce } from "lodash";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface useInfiniteScrollProps {
|
||||
scrollThreshold: number;
|
||||
scrollableTarget?: string;
|
||||
onLoadMore?: () => void;
|
||||
isFetchingNextPage: boolean;
|
||||
fetchNextPage: () => void;
|
||||
hasNextPage: boolean;
|
||||
}
|
||||
|
||||
export const useInfiniteScroll = ({
|
||||
scrollableTarget,
|
||||
onLoadMore,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
scrollThreshold,
|
||||
}: useInfiniteScrollProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const [isInView, setIsInView] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (containerRef.current && typeof window !== "undefined") {
|
||||
const container = containerRef.current;
|
||||
const { bottom } = container.getBoundingClientRect();
|
||||
const { innerHeight } = window;
|
||||
const isVisible = bottom <= innerHeight + scrollThreshold;
|
||||
setIsInView(isVisible);
|
||||
}
|
||||
}, [scrollThreshold]);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (hasNextPage && !isLoading) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
handleLoadMore();
|
||||
onLoadMore?.();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [hasNextPage, isLoading, handleLoadMore, onLoadMore]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasNextPage) return;
|
||||
|
||||
const handleDebouncedScroll = debounce(handleScroll, 200);
|
||||
|
||||
const scrollElement = scrollableTarget
|
||||
? document.querySelector(scrollableTarget)
|
||||
: window;
|
||||
|
||||
if (!scrollElement) return;
|
||||
|
||||
scrollElement.addEventListener("scroll", handleDebouncedScroll);
|
||||
|
||||
handleScroll();
|
||||
|
||||
return () => {
|
||||
scrollElement.removeEventListener("scroll", handleDebouncedScroll);
|
||||
};
|
||||
}, [handleScroll, hasNextPage, scrollableTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInView && hasNextPage && !isLoading) {
|
||||
loadMore();
|
||||
}
|
||||
}, [isInView, hasNextPage, isLoading, loadMore]);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
bottomRef,
|
||||
};
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface ThresholdCallback<T> {
|
||||
(value: T): void;
|
||||
}
|
||||
|
||||
export const useScrollThreshold = <T>(
|
||||
callback: ThresholdCallback<T>,
|
||||
threshold: number,
|
||||
): boolean => {
|
||||
const [prevValue, setPrevValue] = useState<T | null>(null);
|
||||
const [isThresholdMet, setIsThresholdMet] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const { scrollY } = window;
|
||||
|
||||
if (scrollY >= threshold) {
|
||||
setIsThresholdMet(true);
|
||||
} else {
|
||||
setIsThresholdMet(false);
|
||||
}
|
||||
|
||||
if (scrollY >= threshold && (!prevValue || prevValue !== scrollY)) {
|
||||
callback(scrollY as T);
|
||||
setPrevValue(scrollY as T);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
handleScroll();
|
||||
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, [callback, threshold, prevValue]);
|
||||
|
||||
return isThresholdMet;
|
||||
};
|
||||
Reference in New Issue
Block a user