diff --git a/src/query/index.ts b/src/query/index.ts index a4ed052..2da88b4 100644 --- a/src/query/index.ts +++ b/src/query/index.ts @@ -1 +1,2 @@ export * from './createQuery'; +export * from './usePaginatedQuery'; diff --git a/src/query/usePaginatedQuery.test.tsx b/src/query/usePaginatedQuery.test.tsx new file mode 100644 index 0000000..6ad52d2 --- /dev/null +++ b/src/query/usePaginatedQuery.test.tsx @@ -0,0 +1,326 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; +import { createQuery } from './createQuery'; +import { usePaginatedQuery } from './usePaginatedQuery'; + +function makeWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +} + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +interface Item { + id: number; + name: string; +} + +function makeItems(count: number, offset = 0): Item[] { + return Array.from({ length: count }, (_, i) => ({ + id: offset + i + 1, + name: `item-${offset + i + 1}`, + })); +} + +const offsetFetcher = vi.fn(async (_params: { page: number; pageSize: number }) => + makeItems(_params.pageSize, (_params.page - 1) * _params.pageSize), +); + +const cursorFetcher = vi.fn( + async (_params: { cursor?: string | number | null | undefined }): Promise => { + const start = _params.cursor ? Number(_params.cursor) : 0; + return makeItems(3, start); + }, +); + +const offsetQueryDef = createQuery( + (p: { page: number; pageSize: number }) => ['items', 'offset', p.page, p.pageSize] as const, + offsetFetcher, +); + +const cursorQueryDef = createQuery( + (p: { cursor?: string | number | null | undefined }) => ['items', 'cursor', p.cursor] as const, + cursorFetcher, +); + +// --------------------------------------------------------------------------- +// Offset mode tests +// --------------------------------------------------------------------------- + +describe('usePaginatedQuery — offset mode', () => { + beforeEach(() => vi.clearAllMocks()); + it('returns mode: offset', async () => { + const { result } = renderHook( + () => + usePaginatedQuery( + offsetQueryDef, + { page: 1, pageSize: 3 }, + { mode: 'offset', pageSize: 3 }, + ), + { wrapper: makeWrapper() }, + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.mode).toBe('offset'); + }); + + it('data is a flat array', async () => { + const { result } = renderHook( + () => + usePaginatedQuery( + offsetQueryDef, + { page: 1, pageSize: 3 }, + { mode: 'offset', pageSize: 3 }, + ), + { wrapper: makeWrapper() }, + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(Array.isArray(result.current.data)).toBe(true); + expect(result.current.data).toHaveLength(3); + }); + + it('starts at page 1 by default', async () => { + const { result } = renderHook( + () => + usePaginatedQuery( + offsetQueryDef, + { page: 1, pageSize: 3 }, + { mode: 'offset', pageSize: 3 }, + ), + { wrapper: makeWrapper() }, + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.page).toBe(1); + }); + + it('respects initialPage option', async () => { + const { result } = renderHook( + () => + usePaginatedQuery( + offsetQueryDef, + { page: 2, pageSize: 3 }, + { mode: 'offset', pageSize: 3, initialPage: 2 }, + ), + { wrapper: makeWrapper() }, + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.page).toBe(2); + }); + + it('pageSize defaults to 20', () => { + const { result } = renderHook( + () => usePaginatedQuery(offsetQueryDef, { page: 1, pageSize: 20 }, { mode: 'offset' }), + { wrapper: makeWrapper() }, + ); + expect(result.current.pageSize).toBe(20); + }); + + it('nextPage increments page and triggers refetch', async () => { + const { result } = renderHook( + () => + usePaginatedQuery( + offsetQueryDef, + { page: 1, pageSize: 3 }, + { mode: 'offset', pageSize: 3 }, + ), + { wrapper: makeWrapper() }, + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + act(() => result.current.nextPage()); + await waitFor(() => expect(result.current.page).toBe(2)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + // Page 2 items start at id 4 + expect(result.current.data[0].id).toBe(4); + }); + + it('prevPage decrements page and does not go below 1', async () => { + const { result } = renderHook( + () => + usePaginatedQuery( + offsetQueryDef, + { page: 2, pageSize: 3 }, + { mode: 'offset', pageSize: 3, initialPage: 2 }, + ), + { wrapper: makeWrapper() }, + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + act(() => result.current.prevPage()); + await waitFor(() => expect(result.current.page).toBe(1)); + + act(() => result.current.prevPage()); + await waitFor(() => expect(result.current.page).toBe(1)); // floor at 1 + }); + + it('exposes isLoading and isError and error', async () => { + const { result } = renderHook( + () => + usePaginatedQuery( + offsetQueryDef, + { page: 1, pageSize: 3 }, + { mode: 'offset', pageSize: 3 }, + ), + { wrapper: makeWrapper() }, + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.isError).toBe(false); + expect(result.current.error).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Cursor mode tests +// --------------------------------------------------------------------------- + +describe('usePaginatedQuery — cursor mode', () => { + beforeEach(() => vi.clearAllMocks()); + it('returns mode: cursor', async () => { + const { result } = renderHook( + () => + usePaginatedQuery( + cursorQueryDef, + { cursor: undefined }, + { + mode: 'cursor', + getCursor: (page) => (page.length > 0 ? page[page.length - 1].id : undefined), + }, + ), + { wrapper: makeWrapper() }, + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.mode).toBe('cursor'); + }); + + it('data is a flat array of first page', async () => { + const { result } = renderHook( + () => + usePaginatedQuery( + cursorQueryDef, + { cursor: undefined }, + { + mode: 'cursor', + getCursor: (page) => (page.length > 0 ? page[page.length - 1].id : undefined), + }, + ), + { wrapper: makeWrapper() }, + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(Array.isArray(result.current.data)).toBe(true); + expect(result.current.data).toHaveLength(3); + expect(result.current.data[0].id).toBe(1); + }); + + it('fetchNextPage appends data correctly', async () => { + const { result } = renderHook( + () => + usePaginatedQuery( + cursorQueryDef, + { cursor: undefined }, + { + mode: 'cursor', + getCursor: (page) => (page.length > 0 ? page[page.length - 1].id : undefined), + }, + ), + { wrapper: makeWrapper() }, + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toHaveLength(3); + + await act(async () => { + result.current.fetchNextPage(); + }); + await waitFor(() => expect(result.current.data).toHaveLength(6)); + + // Second page starts after id 3 + expect(result.current.data[3].id).toBe(4); + }); + + it('hasNextPage is true when getCursor returns a value', async () => { + const { result } = renderHook( + () => + usePaginatedQuery( + cursorQueryDef, + { cursor: undefined }, + { + mode: 'cursor', + getCursor: (page) => (page.length > 0 ? page[page.length - 1].id : undefined), + }, + ), + { wrapper: makeWrapper() }, + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.hasNextPage).toBe(true); + }); + + it('hasNextPage is false when getCursor returns undefined', async () => { + const { result } = renderHook( + () => + usePaginatedQuery( + cursorQueryDef, + { cursor: undefined }, + { + mode: 'cursor', + getCursor: () => undefined, + }, + ), + { wrapper: makeWrapper() }, + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.hasNextPage).toBe(false); + }); + + it('nextCursor reflects the getCursor result', async () => { + const { result } = renderHook( + () => + usePaginatedQuery( + cursorQueryDef, + { cursor: undefined }, + { + mode: 'cursor', + getCursor: (page) => (page.length > 0 ? page[page.length - 1].id : undefined), + }, + ), + { wrapper: makeWrapper() }, + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.nextCursor).toBe(3); // last item on first page + }); + + it('exposes isLoading and isError and error', async () => { + const { result } = renderHook( + () => + usePaginatedQuery( + cursorQueryDef, + { cursor: undefined }, + { + mode: 'cursor', + getCursor: () => undefined, + }, + ), + { wrapper: makeWrapper() }, + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.isError).toBe(false); + expect(result.current.error).toBeNull(); + }); +}); diff --git a/src/query/usePaginatedQuery.ts b/src/query/usePaginatedQuery.ts new file mode 100644 index 0000000..6127958 --- /dev/null +++ b/src/query/usePaginatedQuery.ts @@ -0,0 +1,151 @@ +import React from 'react'; +import { + useQuery as useTanstackQuery, + useInfiniteQuery as useTanstackInfiniteQuery, +} from '@tanstack/react-query'; +import type { InfiniteData } from '@tanstack/react-query'; +import type { QueryDefinition } from './createQuery'; + +// --------------------------------------------------------------------------- +// Shared result fields +// --------------------------------------------------------------------------- + +interface PaginatedBase { + data: TData[]; + isLoading: boolean; + isFetching: boolean; + isError: boolean; + error: Error | null; +} + +// --------------------------------------------------------------------------- +// Offset mode +// --------------------------------------------------------------------------- + +export interface OffsetPaginationOptions { + mode: 'offset'; + pageSize?: number; + initialPage?: number; +} + +export interface OffsetPaginatedResult extends PaginatedBase { + mode: 'offset'; + page: number; + pageSize: number; + totalPages: number | undefined; + nextPage: () => void; + prevPage: () => void; +} + +// --------------------------------------------------------------------------- +// Cursor mode +// --------------------------------------------------------------------------- + +export interface CursorPaginationOptions { + mode: 'cursor'; + getCursor: (lastPage: TData[]) => string | number | null | undefined; +} + +export interface CursorPaginatedResult extends PaginatedBase { + mode: 'cursor'; + fetchNextPage: () => void; + hasNextPage: boolean; + nextCursor: string | number | null | undefined; +} + +// --------------------------------------------------------------------------- +// Overloads +// --------------------------------------------------------------------------- + +export function usePaginatedQuery( + queryDef: QueryDefinition, + params: TParams, + options: OffsetPaginationOptions, +): OffsetPaginatedResult; + +export function usePaginatedQuery( + queryDef: QueryDefinition, + params: TParams, + options: CursorPaginationOptions, +): CursorPaginatedResult; + +// --------------------------------------------------------------------------- +// Implementation +// --------------------------------------------------------------------------- + +export function usePaginatedQuery( + queryDef: QueryDefinition, + params: TParams, + options: OffsetPaginationOptions | CursorPaginationOptions, +): OffsetPaginatedResult | CursorPaginatedResult { + const isOffset = options.mode === 'offset'; + + // --- Offset state (only meaningful in offset mode) ----------------------- + const pageSize = isOffset ? ((options as OffsetPaginationOptions).pageSize ?? 20) : 20; + const [page, setPage] = React.useState( + isOffset ? ((options as OffsetPaginationOptions).initialPage ?? 1) : 1, + ); + + // --- Offset: useQuery ---------------------------------------------------- + const offsetQuery = useTanstackQuery({ + queryKey: [...queryDef.queryKey({ ...params, page, pageSize } as TParams), page, pageSize], + queryFn: () => queryDef.queryFn({ ...params, page, pageSize } as TParams), + enabled: isOffset, + placeholderData: (prev) => prev, + }); + + // --- Cursor: useInfiniteQuery -------------------------------------------- + const getCursor = !isOffset + ? (options as CursorPaginationOptions).getCursor + : () => undefined; + + const infiniteQuery = useTanstackInfiniteQuery< + TData[], + Error, + InfiniteData, + readonly unknown[], + string | number | null | undefined + >({ + queryKey: queryDef.queryKey(params), + queryFn: ({ pageParam }) => queryDef.queryFn({ ...params, cursor: pageParam } as TParams), + initialPageParam: undefined, + getNextPageParam: (lastPage) => getCursor(lastPage) ?? undefined, + enabled: !isOffset, + }); + + // --- Offset result ------------------------------------------------------- + if (isOffset) { + const rawData = offsetQuery.data ?? []; + return { + mode: 'offset', + data: rawData, + isLoading: offsetQuery.isLoading, + isFetching: offsetQuery.isFetching, + isError: offsetQuery.isError, + error: offsetQuery.error, + page, + pageSize, + totalPages: undefined, + nextPage: () => setPage((p) => p + 1), + prevPage: () => setPage((p) => Math.max(1, p - 1)), + }; + } + + // --- Cursor result ------------------------------------------------------- + const pages = infiniteQuery.data?.pages ?? []; + const flatData = pages.flat(); + const lastPage = pages[pages.length - 1] ?? []; + const nextCursor = getCursor(lastPage); + + return { + mode: 'cursor', + data: flatData, + isLoading: infiniteQuery.isLoading, + isFetching: infiniteQuery.isFetching, + isError: infiniteQuery.isError, + error: infiniteQuery.error, + fetchNextPage: () => infiniteQuery.fetchNextPage(), + hasNextPage: infiniteQuery.hasNextPage, + nextCursor, + }; +}