Skip to content

Commit 53271b9

Browse files
feat(COMPT-41): implement usePaginatedQuery offset and cursor modes
- usePaginatedQuery(queryDef, params, options) supports mode: 'offset' | 'cursor' - Offset mode: page/pageSize (default 20)/nextPage/prevPage/totalPages - Cursor mode: fetchNextPage/hasNextPage/nextCursor via useInfiniteQuery - Both expose data as flat T[] array, isLoading, isFetching, isError, error - Offset uses useQuery with page in queryKey; cursor uses useInfiniteQuery - getCursor option required for cursor mode - Typed overloads: full inference, no TanStack internals exposed - 15 tests, 100% coverage on usePaginatedQuery.ts, 95.62% overall Closes COMPT-41
1 parent b253ea6 commit 53271b9

File tree

3 files changed

+478
-0
lines changed

3 files changed

+478
-0
lines changed

src/query/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './createQuery';
2+
export * from './usePaginatedQuery';
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { renderHook, waitFor, act } from '@testing-library/react';
3+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4+
import React from 'react';
5+
import { createQuery } from './createQuery';
6+
import { usePaginatedQuery } from './usePaginatedQuery';
7+
8+
function makeWrapper() {
9+
const queryClient = new QueryClient({
10+
defaultOptions: { queries: { retry: false } },
11+
});
12+
return ({ children }: { children: React.ReactNode }) => (
13+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
14+
);
15+
}
16+
17+
// ---------------------------------------------------------------------------
18+
// Fixtures
19+
// ---------------------------------------------------------------------------
20+
21+
interface Item {
22+
id: number;
23+
name: string;
24+
}
25+
26+
function makeItems(count: number, offset = 0): Item[] {
27+
return Array.from({ length: count }, (_, i) => ({
28+
id: offset + i + 1,
29+
name: `item-${offset + i + 1}`,
30+
}));
31+
}
32+
33+
const offsetFetcher = vi.fn(async (_params: { page: number; pageSize: number }) =>
34+
makeItems(_params.pageSize, (_params.page - 1) * _params.pageSize),
35+
);
36+
37+
const cursorFetcher = vi.fn(
38+
async (_params: { cursor?: string | number | null | undefined }): Promise<Item[]> => {
39+
const start = _params.cursor ? Number(_params.cursor) : 0;
40+
return makeItems(3, start);
41+
},
42+
);
43+
44+
const offsetQueryDef = createQuery(
45+
(p: { page: number; pageSize: number }) => ['items', 'offset', p.page, p.pageSize] as const,
46+
offsetFetcher,
47+
);
48+
49+
const cursorQueryDef = createQuery(
50+
(p: { cursor?: string | number | null | undefined }) => ['items', 'cursor', p.cursor] as const,
51+
cursorFetcher,
52+
);
53+
54+
// ---------------------------------------------------------------------------
55+
// Offset mode tests
56+
// ---------------------------------------------------------------------------
57+
58+
describe('usePaginatedQuery — offset mode', () => {
59+
beforeEach(() => vi.clearAllMocks());
60+
it('returns mode: offset', async () => {
61+
const { result } = renderHook(
62+
() =>
63+
usePaginatedQuery(
64+
offsetQueryDef,
65+
{ page: 1, pageSize: 3 },
66+
{ mode: 'offset', pageSize: 3 },
67+
),
68+
{ wrapper: makeWrapper() },
69+
);
70+
71+
await waitFor(() => expect(result.current.isLoading).toBe(false));
72+
expect(result.current.mode).toBe('offset');
73+
});
74+
75+
it('data is a flat array', async () => {
76+
const { result } = renderHook(
77+
() =>
78+
usePaginatedQuery(
79+
offsetQueryDef,
80+
{ page: 1, pageSize: 3 },
81+
{ mode: 'offset', pageSize: 3 },
82+
),
83+
{ wrapper: makeWrapper() },
84+
);
85+
86+
await waitFor(() => expect(result.current.isLoading).toBe(false));
87+
expect(Array.isArray(result.current.data)).toBe(true);
88+
expect(result.current.data).toHaveLength(3);
89+
});
90+
91+
it('starts at page 1 by default', async () => {
92+
const { result } = renderHook(
93+
() =>
94+
usePaginatedQuery(
95+
offsetQueryDef,
96+
{ page: 1, pageSize: 3 },
97+
{ mode: 'offset', pageSize: 3 },
98+
),
99+
{ wrapper: makeWrapper() },
100+
);
101+
102+
await waitFor(() => expect(result.current.isLoading).toBe(false));
103+
expect(result.current.page).toBe(1);
104+
});
105+
106+
it('respects initialPage option', async () => {
107+
const { result } = renderHook(
108+
() =>
109+
usePaginatedQuery(
110+
offsetQueryDef,
111+
{ page: 2, pageSize: 3 },
112+
{ mode: 'offset', pageSize: 3, initialPage: 2 },
113+
),
114+
{ wrapper: makeWrapper() },
115+
);
116+
117+
await waitFor(() => expect(result.current.isLoading).toBe(false));
118+
expect(result.current.page).toBe(2);
119+
});
120+
121+
it('pageSize defaults to 20', () => {
122+
const { result } = renderHook(
123+
() => usePaginatedQuery(offsetQueryDef, { page: 1, pageSize: 20 }, { mode: 'offset' }),
124+
{ wrapper: makeWrapper() },
125+
);
126+
expect(result.current.pageSize).toBe(20);
127+
});
128+
129+
it('nextPage increments page and triggers refetch', async () => {
130+
const { result } = renderHook(
131+
() =>
132+
usePaginatedQuery(
133+
offsetQueryDef,
134+
{ page: 1, pageSize: 3 },
135+
{ mode: 'offset', pageSize: 3 },
136+
),
137+
{ wrapper: makeWrapper() },
138+
);
139+
140+
await waitFor(() => expect(result.current.isLoading).toBe(false));
141+
act(() => result.current.nextPage());
142+
await waitFor(() => expect(result.current.page).toBe(2));
143+
await waitFor(() => expect(result.current.isLoading).toBe(false));
144+
// Page 2 items start at id 4
145+
expect(result.current.data[0].id).toBe(4);
146+
});
147+
148+
it('prevPage decrements page and does not go below 1', async () => {
149+
const { result } = renderHook(
150+
() =>
151+
usePaginatedQuery(
152+
offsetQueryDef,
153+
{ page: 2, pageSize: 3 },
154+
{ mode: 'offset', pageSize: 3, initialPage: 2 },
155+
),
156+
{ wrapper: makeWrapper() },
157+
);
158+
159+
await waitFor(() => expect(result.current.isLoading).toBe(false));
160+
act(() => result.current.prevPage());
161+
await waitFor(() => expect(result.current.page).toBe(1));
162+
163+
act(() => result.current.prevPage());
164+
await waitFor(() => expect(result.current.page).toBe(1)); // floor at 1
165+
});
166+
167+
it('exposes isLoading and isError and error', async () => {
168+
const { result } = renderHook(
169+
() =>
170+
usePaginatedQuery(
171+
offsetQueryDef,
172+
{ page: 1, pageSize: 3 },
173+
{ mode: 'offset', pageSize: 3 },
174+
),
175+
{ wrapper: makeWrapper() },
176+
);
177+
178+
await waitFor(() => expect(result.current.isLoading).toBe(false));
179+
expect(result.current.isError).toBe(false);
180+
expect(result.current.error).toBeNull();
181+
});
182+
});
183+
184+
// ---------------------------------------------------------------------------
185+
// Cursor mode tests
186+
// ---------------------------------------------------------------------------
187+
188+
describe('usePaginatedQuery — cursor mode', () => {
189+
beforeEach(() => vi.clearAllMocks());
190+
it('returns mode: cursor', async () => {
191+
const { result } = renderHook(
192+
() =>
193+
usePaginatedQuery(
194+
cursorQueryDef,
195+
{ cursor: undefined },
196+
{
197+
mode: 'cursor',
198+
getCursor: (page) => (page.length > 0 ? page[page.length - 1].id : undefined),
199+
},
200+
),
201+
{ wrapper: makeWrapper() },
202+
);
203+
204+
await waitFor(() => expect(result.current.isLoading).toBe(false));
205+
expect(result.current.mode).toBe('cursor');
206+
});
207+
208+
it('data is a flat array of first page', async () => {
209+
const { result } = renderHook(
210+
() =>
211+
usePaginatedQuery(
212+
cursorQueryDef,
213+
{ cursor: undefined },
214+
{
215+
mode: 'cursor',
216+
getCursor: (page) => (page.length > 0 ? page[page.length - 1].id : undefined),
217+
},
218+
),
219+
{ wrapper: makeWrapper() },
220+
);
221+
222+
await waitFor(() => expect(result.current.isLoading).toBe(false));
223+
expect(Array.isArray(result.current.data)).toBe(true);
224+
expect(result.current.data).toHaveLength(3);
225+
expect(result.current.data[0].id).toBe(1);
226+
});
227+
228+
it('fetchNextPage appends data correctly', async () => {
229+
const { result } = renderHook(
230+
() =>
231+
usePaginatedQuery(
232+
cursorQueryDef,
233+
{ cursor: undefined },
234+
{
235+
mode: 'cursor',
236+
getCursor: (page) => (page.length > 0 ? page[page.length - 1].id : undefined),
237+
},
238+
),
239+
{ wrapper: makeWrapper() },
240+
);
241+
242+
await waitFor(() => expect(result.current.isLoading).toBe(false));
243+
expect(result.current.data).toHaveLength(3);
244+
245+
await act(async () => {
246+
result.current.fetchNextPage();
247+
});
248+
await waitFor(() => expect(result.current.data).toHaveLength(6));
249+
250+
// Second page starts after id 3
251+
expect(result.current.data[3].id).toBe(4);
252+
});
253+
254+
it('hasNextPage is true when getCursor returns a value', async () => {
255+
const { result } = renderHook(
256+
() =>
257+
usePaginatedQuery(
258+
cursorQueryDef,
259+
{ cursor: undefined },
260+
{
261+
mode: 'cursor',
262+
getCursor: (page) => (page.length > 0 ? page[page.length - 1].id : undefined),
263+
},
264+
),
265+
{ wrapper: makeWrapper() },
266+
);
267+
268+
await waitFor(() => expect(result.current.isLoading).toBe(false));
269+
expect(result.current.hasNextPage).toBe(true);
270+
});
271+
272+
it('hasNextPage is false when getCursor returns undefined', async () => {
273+
const { result } = renderHook(
274+
() =>
275+
usePaginatedQuery(
276+
cursorQueryDef,
277+
{ cursor: undefined },
278+
{
279+
mode: 'cursor',
280+
getCursor: () => undefined,
281+
},
282+
),
283+
{ wrapper: makeWrapper() },
284+
);
285+
286+
await waitFor(() => expect(result.current.isLoading).toBe(false));
287+
expect(result.current.hasNextPage).toBe(false);
288+
});
289+
290+
it('nextCursor reflects the getCursor result', async () => {
291+
const { result } = renderHook(
292+
() =>
293+
usePaginatedQuery(
294+
cursorQueryDef,
295+
{ cursor: undefined },
296+
{
297+
mode: 'cursor',
298+
getCursor: (page) => (page.length > 0 ? page[page.length - 1].id : undefined),
299+
},
300+
),
301+
{ wrapper: makeWrapper() },
302+
);
303+
304+
await waitFor(() => expect(result.current.isLoading).toBe(false));
305+
expect(result.current.nextCursor).toBe(3); // last item on first page
306+
});
307+
308+
it('exposes isLoading and isError and error', async () => {
309+
const { result } = renderHook(
310+
() =>
311+
usePaginatedQuery(
312+
cursorQueryDef,
313+
{ cursor: undefined },
314+
{
315+
mode: 'cursor',
316+
getCursor: () => undefined,
317+
},
318+
),
319+
{ wrapper: makeWrapper() },
320+
);
321+
322+
await waitFor(() => expect(result.current.isLoading).toBe(false));
323+
expect(result.current.isError).toBe(false);
324+
expect(result.current.error).toBeNull();
325+
});
326+
});

0 commit comments

Comments
 (0)