From d4094ed2817e6afa00b4b478f4f344e1a506baff Mon Sep 17 00:00:00 2001 From: Nazar Ilamanov Date: Tue, 21 Jan 2025 19:52:35 -0800 Subject: [PATCH] Add pagination to the balances hooks --- __tests__/hooks/useEvmTokenBalances.test.tsx | 101 +++++++++++++++++- __tests__/hooks/useSvmTokenBalances.test.tsx | 96 ++++++++++++++++- src/evm/types.ts | 1 + src/evm/useEvmTokenBalances.ts | 105 ++++++++++++++----- src/svm/types.ts | 1 + src/svm/useSvmTokenBalances.ts | 105 ++++++++++++++----- 6 files changed, 351 insertions(+), 58 deletions(-) diff --git a/__tests__/hooks/useEvmTokenBalances.test.tsx b/__tests__/hooks/useEvmTokenBalances.test.tsx index 161b975..99348f5 100644 --- a/__tests__/hooks/useEvmTokenBalances.test.tsx +++ b/__tests__/hooks/useEvmTokenBalances.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { renderHook, waitFor } from "@testing-library/react"; +import { renderHook, act, waitFor } from "@testing-library/react"; import { DuneProvider } from "../../src/provider"; import { useEvmTokenBalances } from "../../src/evm/useEvmTokenBalances"; import { fetchEvmBalances } from "../../src/evm/duneApi"; @@ -33,6 +33,11 @@ describe("useTokenBalances", () => { data: null, error: null, isLoading: false, + nextOffset: null, + offsets: [], + currentPage: 0, + nextPage: expect.any(Function), + previousPage: expect.any(Function), }); }); @@ -124,6 +129,11 @@ describe("useTokenBalances", () => { data: null, error: null, isLoading: false, + nextOffset: null, + offsets: [], + currentPage: 0, + nextPage: expect.any(Function), + previousPage: expect.any(Function), }); }); @@ -135,6 +145,95 @@ describe("useTokenBalances", () => { data: null, error: null, isLoading: false, + nextOffset: null, + offsets: [], + currentPage: 0, + nextPage: expect.any(Function), + previousPage: expect.any(Function), }); }); + + it("should handle pagination: next and previous pages", async () => { + const walletAddress = "0x1234567890abcdef1234567890abcdef12345678"; + + const page1Response = { + request_time: "2025-01-16T18:09:37.116ZZ", + response_time: "2025-01-16T18:09:37.156ZZ", + wallet_address: walletAddress, + next_offset: "offset1", + balances: [ + { + chain: "ethereum", + chain_id: 1, + address: "native", + amount: "121458493673814687", + symbol: "ETH", + decimals: 18, + price_usd: 3344.858473355283, + value_usd: 406.26147172582813, + }, + ], + }; + const page2Response = { + request_time: "2025-01-16T18:09:37.116ZZ", + response_time: "2025-01-16T18:09:37.156ZZ", + wallet_address: walletAddress, + next_offset: "offset2", + balances: [ + { + chain: "base", + chain_id: 8453, + address: "0x0000000000000000000000000000000000000000", + amount: "121458493673814687", + symbol: "ETH", + decimals: 18, + price_usd: 3344.858473355283, + value_usd: 406.26147172582813, + }, + ], + }; + + mockFetchEvmBalances + .mockResolvedValueOnce(page1Response) + .mockResolvedValueOnce(page2Response) + .mockResolvedValueOnce(page1Response); + + const { result } = renderHook(() => useEvmTokenBalances(walletAddress), { + wrapper, + }); + + // Wait for the first page + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.data).toEqual(page1Response); + expect(result.current.currentPage).toBe(0); + + // Fetch the next page + act(() => { + result.current.nextPage(); + }); + + // Wait for the second page + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.data).toEqual(page2Response); + expect(result.current.currentPage).toBe(1); + + // Fetch the previous page + act(() => { + result.current.previousPage(); + }); + + // Wait for the first page again + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.data).toEqual(page1Response); + expect(result.current.currentPage).toBe(0); + }); }); diff --git a/__tests__/hooks/useSvmTokenBalances.test.tsx b/__tests__/hooks/useSvmTokenBalances.test.tsx index 09ea00c..db71f69 100644 --- a/__tests__/hooks/useSvmTokenBalances.test.tsx +++ b/__tests__/hooks/useSvmTokenBalances.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { renderHook, waitFor } from "@testing-library/react"; +import { renderHook, act, waitFor } from "@testing-library/react"; import { DuneProvider } from "../../src/provider"; import { useSvmTokenBalances } from "../../src/svm/useSvmTokenBalances"; import { fetchSvmBalances } from "../../src/svm/duneApi"; @@ -123,6 +123,11 @@ describe("useTokenBalances", () => { data: null, error: null, isLoading: false, + nextOffset: null, + offsets: [], + currentPage: 0, + nextPage: expect.any(Function), + previousPage: expect.any(Function), }); }); @@ -136,6 +141,95 @@ describe("useTokenBalances", () => { data: null, error: null, isLoading: false, + nextOffset: null, + offsets: [], + currentPage: 0, + nextPage: expect.any(Function), + previousPage: expect.any(Function), }); }); + + it("should handle pagination: next and previous pages", async () => { + const walletAddress = "0x1234567890abcdef1234567890abcdef12345678"; + + const page1Response = { + request_time: "2025-01-16T18:09:37.116ZZ", + response_time: "2025-01-16T18:09:37.156ZZ", + wallet_address: walletAddress, + next_offset: "offset1", + balances: [ + { + chain: "ethereum", + chain_id: 1, + address: "native", + amount: "121458493673814687", + symbol: "ETH", + decimals: 18, + price_usd: 3344.858473355283, + value_usd: 406.26147172582813, + }, + ], + }; + const page2Response = { + request_time: "2025-01-16T18:09:37.116ZZ", + response_time: "2025-01-16T18:09:37.156ZZ", + wallet_address: walletAddress, + next_offset: "offset2", + balances: [ + { + chain: "base", + chain_id: 8453, + address: "0x0000000000000000000000000000000000000000", + amount: "121458493673814687", + symbol: "ETH", + decimals: 18, + price_usd: 3344.858473355283, + value_usd: 406.26147172582813, + }, + ], + }; + + mockFetchSvmBalances + .mockResolvedValueOnce(page1Response) + .mockResolvedValueOnce(page2Response) + .mockResolvedValueOnce(page1Response); + + const { result } = renderHook(() => useSvmTokenBalances(walletAddress), { + wrapper, + }); + + // Wait for the first page + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.data).toEqual(page1Response); + expect(result.current.currentPage).toBe(0); + + // Fetch the next page + act(() => { + result.current.nextPage(); + }); + + // Wait for the second page + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.data).toEqual(page2Response); + expect(result.current.currentPage).toBe(1); + + // Fetch the previous page + act(() => { + result.current.previousPage(); + }); + + // Wait for the first page again + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.data).toEqual(page1Response); + expect(result.current.currentPage).toBe(0); + }); }); diff --git a/src/evm/types.ts b/src/evm/types.ts index 6eeca0d..475215a 100644 --- a/src/evm/types.ts +++ b/src/evm/types.ts @@ -14,6 +14,7 @@ export type BalanceData = { request_time: string; response_time: string; wallet_address: string; + next_offset?: string | null; balances: TokenBalance[]; }; diff --git a/src/evm/useEvmTokenBalances.ts b/src/evm/useEvmTokenBalances.ts index 7d699ab..fda6708 100644 --- a/src/evm/useEvmTokenBalances.ts +++ b/src/evm/useEvmTokenBalances.ts @@ -14,46 +14,95 @@ export const useEvmTokenBalances = ( data: BalanceData | null; error: FetchError | null; isLoading: boolean; + nextOffset: string | null; // Track next_offset + offsets: string[]; // Store offsets for each page + currentPage: number; // Track the current page }>({ data: null, error: null, isLoading: false, + nextOffset: null, // Next offset from the API + offsets: [], // List of offsets corresponding to pages + currentPage: 0, // Start at the first page }); const memoizedParams = useDeepMemo(() => params, params); const apiKey = useGetApiKey(); + // Function to fetch data for a specific page + const fetchDataAsync = async (offset: string | null) => { + if (!apiKey || !walletAddress || !isAddress(walletAddress)) return; + + setState((prevState) => ({ ...prevState, isLoading: true })); + + try { + // Convert offset to number or undefined + const updatedParams = { + ...memoizedParams, + offset: offset ?? undefined, + }; + + const result = await fetchEvmBalances( + walletAddress, + updatedParams, + apiKey + ); + + setState((prevState) => ({ + ...prevState, + data: result, + error: null, + isLoading: false, + nextOffset: result.next_offset || null, + offsets: offset ? [...prevState.offsets, offset] : prevState.offsets, + })); + } catch (err) { + setState({ + data: null, + error: err as FetchError, + isLoading: false, + nextOffset: null, + offsets: [], + currentPage: 0, + }); + } + }; + + // Trigger fetch when walletAddress or params change useEffect(() => { - if (!apiKey) return; - const fetchDataAsync = async () => { - if (!walletAddress || !isAddress(walletAddress)) return; - - setState((prevState) => ({ ...prevState, isLoading: true })); - - try { - const result = await fetchEvmBalances( - walletAddress, - memoizedParams, - apiKey - ); - setState({ - data: result, - error: null, - isLoading: false, - }); - } catch (err) { - setState({ - data: null, - error: err as FetchError, - isLoading: false, - }); - } - }; - - fetchDataAsync(); + // Fetch the first page on initial load or when walletAddress changes + fetchDataAsync(null); }, [walletAddress, memoizedParams, apiKey]); - return state; + // Function to go to the next page + const nextPage = () => { + if (state.nextOffset) { + fetchDataAsync(state.nextOffset); // Fetch using the next offset + setState((prevState) => ({ + ...prevState, + currentPage: prevState.currentPage + 1, // Update page number + })); + } + }; + + // Function to go to the previous page + const previousPage = () => { + if (state.currentPage > 0) { + // Use the offset corresponding to the previous page + const previousOffset = state.offsets[state.currentPage - 1]; + fetchDataAsync(previousOffset); + setState((prevState) => ({ + ...prevState, + currentPage: prevState.currentPage - 1, + })); + } + }; + + return { + ...state, + nextPage, + previousPage, + }; }; /** @deprecated */ diff --git a/src/svm/types.ts b/src/svm/types.ts index 0261817..8d4ad46 100644 --- a/src/svm/types.ts +++ b/src/svm/types.ts @@ -15,6 +15,7 @@ export type BalanceData = { response_time: string; wallet_address: string; balances: TokenBalance[]; + next_offset?: string | null; }; export type FetchError = Error & { diff --git a/src/svm/useSvmTokenBalances.ts b/src/svm/useSvmTokenBalances.ts index 02a1766..a0bb6de 100644 --- a/src/svm/useSvmTokenBalances.ts +++ b/src/svm/useSvmTokenBalances.ts @@ -13,44 +13,93 @@ export const useSvmTokenBalances = ( data: BalanceData | null; error: FetchError | null; isLoading: boolean; + nextOffset: string | null; // Track next_offset + offsets: string[]; // Store offsets for each page + currentPage: number; // Track the current page }>({ data: null, error: null, isLoading: false, + nextOffset: null, // Next offset from the API + offsets: [], // List of offsets corresponding to pages + currentPage: 0, // Start at the first page }); const memoizedParams = useDeepMemo(() => params, params); const apiKey = useGetApiKey(); + // Function to fetch data for a specific page + const fetchDataAsync = async (offset: string | null) => { + if (!apiKey || !walletAddress) return; + + setState((prevState) => ({ ...prevState, isLoading: true })); + + try { + // Convert offset to number or undefined + const updatedParams = { + ...memoizedParams, + offset: offset ?? undefined, + }; + + const result = await fetchSvmBalances( + walletAddress, + updatedParams, + apiKey + ); + + setState((prevState) => ({ + ...prevState, + data: result, + error: null, + isLoading: false, + nextOffset: result.next_offset || null, + offsets: offset ? [...prevState.offsets, offset] : prevState.offsets, + })); + } catch (err) { + setState({ + data: null, + error: err as FetchError, + isLoading: false, + nextOffset: null, + offsets: [], + currentPage: 0, + }); + } + }; + + // Trigger fetch when walletAddress or params change useEffect(() => { - if (!apiKey) return; - const fetchDataAsync = async () => { - if (!walletAddress) return; - - setState((prevState) => ({ ...prevState, isLoading: true })); - - try { - const result = await fetchSvmBalances( - walletAddress, - memoizedParams, - apiKey - ); - setState({ - data: result, - error: null, - isLoading: false, - }); - } catch (err) { - setState({ - data: null, - error: err as FetchError, - isLoading: false, - }); - } - }; - - fetchDataAsync(); + // Fetch the first page on initial load or when walletAddress changes + fetchDataAsync(null); }, [walletAddress, memoizedParams, apiKey]); - return state; + // Function to go to the next page + const nextPage = () => { + if (state.nextOffset) { + fetchDataAsync(state.nextOffset); // Fetch using the next offset + setState((prevState) => ({ + ...prevState, + currentPage: prevState.currentPage + 1, // Update page number + })); + } + }; + + // Function to go to the previous page + const previousPage = () => { + if (state.currentPage > 0) { + // Use the offset corresponding to the previous page + const previousOffset = state.offsets[state.currentPage - 1]; + fetchDataAsync(previousOffset); + setState((prevState) => ({ + ...prevState, + currentPage: prevState.currentPage - 1, + })); + } + }; + + return { + ...state, + nextPage, + previousPage, + }; };