From 8216a32a7cb1b145fbd0d9b3dac30d4cf42fc34f Mon Sep 17 00:00:00 2001 From: The Joel Date: Wed, 27 May 2026 20:18:15 +0100 Subject: [PATCH] feat: implement cursor-based pagination for courses and add related hooks and tests --- README.md | 25 ++++ src/__tests__/services/api/courseApi.test.ts | 63 ++++++++++ .../services/api/cursorPagination.test.ts | 77 ++++++++++++ src/hooks/index.ts | 4 +- src/hooks/useCoursePagination.ts | 103 ++++++++++++++++ src/services/api/courseApi.ts | 21 ++++ src/services/api/cursorPagination.ts | 111 ++++++++++++++++++ src/services/api/index.ts | 6 +- 8 files changed, 408 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/services/api/courseApi.test.ts create mode 100644 src/__tests__/services/api/cursorPagination.test.ts create mode 100644 src/hooks/useCoursePagination.ts create mode 100644 src/services/api/cursorPagination.ts diff --git a/README.md b/README.md index c3043f50..bfd120bf 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,31 @@ appLogger.infoSync('Quick log'); appLogger.errorSync('Error', error); ``` +### Cursor-based API pagination +The app now supports cursor-based list pagination for backend endpoints such as `/courses`. + +- `limit` — number of records per page +- `cursor` — opaque token returned by the previous page +- `orderBy` — stable sort field (`id`) +- `direction` — `asc` or `desc` + +Response shape: + +```json +{ + "items": [/* Course[] */], + "nextCursor": "string | null", + "hasMore": true +} +``` + +Cursor format: + +- URI-safe encoded JSON +- contains `{ lastId, orderBy, direction }` + +Use `courseApi.getCoursesPage({ limit, cursor, orderBy, direction })` to fetch each page. + ### Retrieve Logs (Development) ```typescript diff --git a/src/__tests__/services/api/courseApi.test.ts b/src/__tests__/services/api/courseApi.test.ts new file mode 100644 index 00000000..99f64ee9 --- /dev/null +++ b/src/__tests__/services/api/courseApi.test.ts @@ -0,0 +1,63 @@ +import apiClient from '@/services/api/axios.config'; +import { clearCache } from '@/services/api/cache'; +import { courseApi } from '@/services/api/courseApi'; +import { CursorPageResponse } from '@/services/api/cursorPagination'; + +jest.mock('@/services/api/axios.config', () => ({ + __esModule: true, + default: { + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + delete: jest.fn(), + }, +})); + +const mockedApiClient = apiClient as jest.Mocked; + +describe('courseApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + clearCache(); + }); + + it('calls the course page endpoint with cursor parameters', async () => { + const payload: CursorPageResponse = { + items: [{ id: 'course-123', title: 'Course 123', description: '', instructor: { id: 'inst-1', name: 'Instructor' }, sections: [], totalLessons: 0, totalDuration: 0, level: 'beginner', category: 'general' }], + nextCursor: 'abc', + hasMore: false, + }; + + mockedApiClient.get.mockResolvedValueOnce({ data: payload } as any); + + const result = await courseApi.getCoursesPage({ limit: 10, cursor: 'cursor-abc', orderBy: 'id', direction: 'desc' }); + + expect(mockedApiClient.get).toHaveBeenCalledTimes(1); + expect(mockedApiClient.get).toHaveBeenCalledWith('/courses', { + params: { + limit: 10, + cursor: 'cursor-abc', + orderBy: 'id', + direction: 'desc', + }, + }); + expect(result).toEqual(payload); + }); + + it('reuses cached page data for the same cursor request', async () => { + const payload: CursorPageResponse = { + items: [{ id: 'course-456', title: 'Course 456', description: '', instructor: { id: 'inst-2', name: 'Instructor 2' }, sections: [], totalLessons: 0, totalDuration: 0, level: 'intermediate', category: 'design' }], + nextCursor: null, + hasMore: false, + }; + + mockedApiClient.get.mockResolvedValueOnce({ data: payload } as any); + + const firstResult = await courseApi.getCoursesPage({ limit: 5, cursor: 'cursor-xyz', orderBy: 'id', direction: 'asc' }); + const secondResult = await courseApi.getCoursesPage({ limit: 5, cursor: 'cursor-xyz', orderBy: 'id', direction: 'asc' }); + + expect(mockedApiClient.get).toHaveBeenCalledTimes(1); + expect(firstResult).toEqual(payload); + expect(secondResult).toEqual(payload); + }); +}); diff --git a/src/__tests__/services/api/cursorPagination.test.ts b/src/__tests__/services/api/cursorPagination.test.ts new file mode 100644 index 00000000..f946fe79 --- /dev/null +++ b/src/__tests__/services/api/cursorPagination.test.ts @@ -0,0 +1,77 @@ +import { + buildCursor, + CursorPageRequest, + paginateWithCursor, + parseCursor, +} from '@/services/api/cursorPagination'; + +describe('cursorPagination', () => { + it('encodes and decodes cursor values cleanly', () => { + const cursor = buildCursor('course-123', 'id', 'asc'); + const payload = parseCursor(cursor); + + expect(typeof cursor).toBe('string'); + expect(payload).toEqual({ lastId: 'course-123', orderBy: 'id', direction: 'asc' }); + }); + + it('paginates a large dataset consistently and without duplicates', () => { + const items = Array.from({ length: 100 }, (_, index) => ({ + id: `course-${String(index + 1).padStart(3, '0')}`, + title: `Course ${index + 1}`, + })); + + let cursor: string | undefined; + const seen = new Set(); + const results: string[] = []; + + for (let page = 0; page < 10; page += 1) { + const request: CursorPageRequest = { + limit: 10, + cursor, + orderBy: 'id', + direction: 'asc', + }; + + const response = paginateWithCursor(items, request); + response.items.forEach((item) => { + expect(seen.has(item.id)).toBe(false); + seen.add(item.id); + results.push(item.id); + }); + + if (!response.hasMore) { + break; + } + + expect(response.nextCursor).not.toBeNull(); + cursor = response.nextCursor ?? undefined; + } + + expect(results).toHaveLength(100); + expect(results[0]).toBe('course-001'); + expect(results[results.length - 1]).toBe('course-100'); + }); + + it('supports descending ordering and stable cursor continuation', () => { + const items = Array.from({ length: 30 }, (_, index) => ({ + id: `course-${String(index + 1).padStart(3, '0')}`, + title: `Course ${index + 1}`, + })); + + const firstPage = paginateWithCursor(items, { limit: 5, direction: 'desc' }); + + expect(firstPage.items[0].id).toBe('course-030'); + expect(firstPage.hasMore).toBe(true); + expect(firstPage.nextCursor).toBeTruthy(); + + const secondPage = paginateWithCursor(items, { + limit: 5, + direction: 'desc', + cursor: firstPage.nextCursor ?? undefined, + }); + + expect(secondPage.items[0].id).toBe('course-025'); + expect(secondPage.items).toHaveLength(5); + expect(secondPage.hasMore).toBe(true); + }); +}); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index ef8dc5f4..616b13a0 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -2,7 +2,9 @@ export * from './useAnalytics'; export { AuthProvider, useAuth } from './useAuth'; export * from './useBiometricAuth'; export * from './useCamera'; +export * from './useCoursePagination'; export * from './useCourseProgress'; +export * from './useDebounce'; export * from './useDynamicFontSize'; export * from './useGestures'; export * from './useHapticFeedback'; @@ -21,4 +23,4 @@ export * from './useScreenReader'; export * from './useSwipe'; export * from './useVideoGestures'; export * from './useVoiceRecognition'; -export * from './useDebounce'; + diff --git a/src/hooks/useCoursePagination.ts b/src/hooks/useCoursePagination.ts new file mode 100644 index 00000000..b641962f --- /dev/null +++ b/src/hooks/useCoursePagination.ts @@ -0,0 +1,103 @@ +import { courseApi } from '@/services/api/courseApi'; +import { CursorPageRequest } from '@/services/api/cursorPagination'; +import { Course } from '@/types/course'; +import { useCallback, useEffect, useState } from 'react'; + +export interface UseCoursePaginationOptions { + initialLimit?: number; + orderBy?: string; + direction?: 'asc' | 'desc'; +} + +export interface UseCoursePaginationResult { + items: Course[]; + isLoading: boolean; + isRefreshing: boolean; + hasMore: boolean; + nextCursor: string | null; + loadNextPage: () => Promise; + refresh: () => Promise; +} + +export function useCoursePagination( + options: UseCoursePaginationOptions = {}, +): UseCoursePaginationResult { + const { + initialLimit = 20, + orderBy = 'id', + direction = 'asc', + } = options; + + const [items, setItems] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + const [hasMore, setHasMore] = useState(true); + const [isLoading, setIsLoading] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + + const fetchPage = useCallback(async (cursor?: string) => { + if (isLoading) { + return; + } + + setIsLoading(true); + + const request: CursorPageRequest = { + limit: initialLimit, + cursor, + orderBy, + direction, + }; + + try { + const response = await courseApi.getCoursesPage(request); + + setItems((previous) => { + if (!cursor) { + return response.items; + } + + const existingIds = new Set(previous.map((course) => course.id)); + return [...previous, ...response.items.filter((course) => !existingIds.has(course.id))]; + }); + setNextCursor(response.nextCursor); + setHasMore(response.hasMore); + } finally { + setIsLoading(false); + } + }, [direction, initialLimit, isLoading, orderBy]); + + const refresh = useCallback(async () => { + setIsRefreshing(true); + setNextCursor(null); + setHasMore(true); + setItems([]); + + try { + await fetchPage(); + } finally { + setIsRefreshing(false); + } + }, [fetchPage]); + + const loadNextPage = useCallback(async () => { + if (!hasMore || isLoading) { + return; + } + + await fetchPage(nextCursor ?? undefined); + }, [fetchPage, hasMore, isLoading, nextCursor]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + return { + items, + isLoading, + isRefreshing, + hasMore, + nextCursor, + loadNextPage, + refresh, + }; +} diff --git a/src/services/api/courseApi.ts b/src/services/api/courseApi.ts index 7b0a3fff..3a1d7ebf 100644 --- a/src/services/api/courseApi.ts +++ b/src/services/api/courseApi.ts @@ -1,6 +1,11 @@ import { Course } from "../../types/course"; import apiClient from "./axios.config"; import { fetchWithSWR, invalidateCache } from "./cache"; +import { + buildCursorCacheKey, + CursorPageRequest, + CursorPageResponse, +} from "./cursorPagination"; const COURSES_KEY = "courses:list"; const courseKey = (id: string) => `courses:${id}`; @@ -19,6 +24,22 @@ export const courseApi = { ); }, + getCoursesPage(request: CursorPageRequest = {}): Promise> { + const { limit = 20, cursor, orderBy = 'id', direction = 'asc' } = request; + const cacheKey = buildCursorCacheKey({ limit, cursor, orderBy, direction }); + + return fetchWithSWR( + cacheKey, + () => apiClient + .get>("/courses", { + params: { limit, cursor, orderBy, direction }, + }) + .then((r) => r.data), + TTL, + STALE_TTL, + ); + }, + getCourse(id: string): Promise { return fetchWithSWR( courseKey(id), diff --git a/src/services/api/cursorPagination.ts b/src/services/api/cursorPagination.ts new file mode 100644 index 00000000..13f91882 --- /dev/null +++ b/src/services/api/cursorPagination.ts @@ -0,0 +1,111 @@ +export type CursorDirection = 'asc' | 'desc'; + +export interface CursorPageRequest { + limit?: number; + cursor?: string; + orderBy?: string; + direction?: CursorDirection; +} + +export interface CursorPageResponse { + items: T[]; + nextCursor: string | null; + hasMore: boolean; +} + +interface CursorPayload { + lastId: string; + orderBy: string; + direction: CursorDirection; +} + +const DEFAULT_LIMIT = 20; +const DEFAULT_ORDER_BY = 'id'; +const DEFAULT_DIRECTION: CursorDirection = 'asc'; + +function encodeCursorPayload(payload: CursorPayload): string { + return encodeURIComponent(JSON.stringify(payload)); +} + +function decodeCursorPayload(cursor?: string): CursorPayload | null { + if (!cursor) { + return null; + } + + try { + return JSON.parse(decodeURIComponent(cursor)) as CursorPayload; + } catch { + return null; + } +} + +/** + * Build an opaque cursor for a paginated item boundary. + * The cursor is a URL-safe encoded JSON payload. + */ +export function buildCursor(lastId: string, orderBy = DEFAULT_ORDER_BY, direction: CursorDirection = DEFAULT_DIRECTION): string { + return encodeCursorPayload({ lastId, orderBy, direction }); +} + +/** + * Parse a cursor returned by the API. + * Returns null for invalid or missing values. + */ +export function parseCursor(cursor?: string): CursorPayload | null { + return decodeCursorPayload(cursor); +} + +/** + * Create a cache key for paginated list requests. + */ +export function buildCursorCacheKey(request: CursorPageRequest): string { + const { limit = DEFAULT_LIMIT, cursor = '', orderBy = DEFAULT_ORDER_BY, direction = DEFAULT_DIRECTION } = request; + return `cursor-page:${limit}:${cursor || 'start'}:${orderBy}:${direction}`; +} + +/** + * Pure helper for cursor-based pagination on an in-memory list. + * This is useful for tests, local fallbacks, and backend integration. + */ +export function paginateWithCursor( + items: T[], + request: CursorPageRequest = {}, +): CursorPageResponse { + const limit = Math.max(1, request.limit ?? DEFAULT_LIMIT); + const orderBy = request.orderBy ?? DEFAULT_ORDER_BY; + const direction = request.direction ?? DEFAULT_DIRECTION; + + const cursorPayload = decodeCursorPayload(request.cursor); + + const normalized = [...items].sort((left, right) => { + const leftValue = String((left as any)[orderBy] ?? left.id); + const rightValue = String((right as any)[orderBy] ?? right.id); + + if (leftValue === rightValue) { + return left.id.localeCompare(right.id); + } + + return leftValue.localeCompare(rightValue); + }); + + if (direction === 'desc') { + normalized.reverse(); + } + + const startIndex = cursorPayload && cursorPayload.orderBy === orderBy && cursorPayload.direction === direction + ? normalized.findIndex((item) => item.id === cursorPayload.lastId) + 1 + : 0; + + const paginated = normalized.slice(Math.max(0, startIndex), Math.max(0, startIndex) + limit); + const lastItem = paginated[paginated.length - 1]; + const hasMore = (Math.max(0, startIndex) + paginated.length) < normalized.length; + const nextCursor = hasMore && lastItem + ? buildCursor(lastItem.id, orderBy, direction) + : null; + + return { + items: paginated, + nextCursor, + hasMore, + }; +} diff --git a/src/services/api/index.ts b/src/services/api/index.ts index f024100a..bbcd8dfc 100644 --- a/src/services/api/index.ts +++ b/src/services/api/index.ts @@ -8,8 +8,12 @@ export const apiService = { delete: (url: string) => apiClient.delete(url), }; +export { clearCache, fetchWithSWR, invalidateCache } from "./cache"; export { courseApi } from "./courseApi"; +export { + buildCursor, buildCursorCacheKey, + paginateWithCursor, parseCursor +} from "./cursorPagination"; export { userApi } from "./userApi"; -export { fetchWithSWR, invalidateCache, clearCache } from "./cache"; export default apiService;