Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/query/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './createQuery';
export * from './usePaginatedQuery';
326 changes: 326 additions & 0 deletions src/query/usePaginatedQuery.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

// ---------------------------------------------------------------------------
// 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<Item[]> => {
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();
});
});
Loading
Loading