From 96893ab3086c10ad0b6278560137feacfcda7d14 Mon Sep 17 00:00:00 2001 From: Dorian Maliszewski Date: Tue, 30 Nov 2021 17:27:22 +0100 Subject: [PATCH] feat: add paginated dataloader --- .../__tests__/usePaginatedDataLoader.test.tsx | 212 ++++++++++++++++++ packages/use-dataloader/src/index.ts | 1 + packages/use-dataloader/src/types.ts | 40 ++++ .../src/usePaginatedDataLoader.ts | 108 +++++++++ 4 files changed, 361 insertions(+) create mode 100644 packages/use-dataloader/src/__tests__/usePaginatedDataLoader.test.tsx create mode 100644 packages/use-dataloader/src/usePaginatedDataLoader.ts diff --git a/packages/use-dataloader/src/__tests__/usePaginatedDataLoader.test.tsx b/packages/use-dataloader/src/__tests__/usePaginatedDataLoader.test.tsx new file mode 100644 index 000000000..7d37f432b --- /dev/null +++ b/packages/use-dataloader/src/__tests__/usePaginatedDataLoader.test.tsx @@ -0,0 +1,212 @@ +import { act } from '@testing-library/react' +import { renderHook } from '@testing-library/react-hooks' +import React from 'react' +import DataLoaderProvider from '../DataLoaderProvider' +import { KEY_IS_NOT_STRING_ERROR } from '../constants' +import { + UsePaginatedDataLoaderConfig, + UsePaginatedDataLoaderMethodParams, + UsePaginatedDataLoaderResult, +} from '../types' +import usePaginatedDataLoader from '../usePaginatedDataLoader' + +type UseDataLoaderHookProps = { + config: UsePaginatedDataLoaderConfig + key: string + method: (params: UsePaginatedDataLoaderMethodParams) => Promise +} + +const PROMISE_TIMEOUT = 5 + +const initialProps = { + config: { + enabled: true, + }, + key: 'test', + method: jest.fn( + ({ page, perPage }: UsePaginatedDataLoaderMethodParams) => + new Promise(resolve => { + setTimeout(() => resolve(`${page}-${perPage}`), PROMISE_TIMEOUT) + }), + ), +} +// eslint-disable-next-line react/prop-types +const wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} +) + +describe('useDataLoader', () => { + test('should render correctly without options', async () => { + const { result, waitForNextUpdate } = renderHook< + UseDataLoaderHookProps, + UsePaginatedDataLoaderResult + >(props => usePaginatedDataLoader(props.key, props.method), { + initialProps, + wrapper, + }) + expect(result.current.data).toStrictEqual({}) + expect(result.current.pageData).toBe(undefined) + expect(result.current.isLoading).toBe(true) + await waitForNextUpdate() + expect(initialProps.method).toBeCalledTimes(1) + expect(result.current.isLoading).toBe(false) + expect(result.current.isSuccess).toBe(true) + expect(result.current.data).toStrictEqual({ 1: '1-1' }) + expect(result.current.pageData).toBe('1-1') + }) + + test('should render correctly without request enabled then enable it', async () => { + const method = jest.fn( + () => + new Promise(resolve => { + setTimeout(() => resolve(true), PROMISE_TIMEOUT) + }), + ) + let enabled = false + const { rerender, result, waitForNextUpdate } = renderHook< + UseDataLoaderHookProps, + UsePaginatedDataLoaderResult + >( + props => + usePaginatedDataLoader(props.key, props.method, { + enabled, + }), + { + initialProps: { + ...initialProps, + key: 'test-not-enabled-then-reload', + method, + }, + wrapper, + }, + ) + expect(result.current.pageData).toBe(undefined) + expect(result.current.isLoading).toBe(false) + expect(method).toBeCalledTimes(0) + enabled = true + rerender() + expect(method).toBeCalledTimes(1) + expect(result.current.pageData).toBe(undefined) + expect(result.current.isLoading).toBe(true) + await waitForNextUpdate() + expect(result.current.isLoading).toBe(false) + expect(result.current.isSuccess).toBe(true) + expect(result.current.pageData).toBe(true) + }) + + test('should render correctly without valid key', () => { + const { result } = renderHook< + UseDataLoaderHookProps, + UsePaginatedDataLoaderResult + >(props => usePaginatedDataLoader(props.key, props.method), { + initialProps: { + ...initialProps, + // @ts-expect-error used because we test with bad key + key: 2, + }, + wrapper, + }) + expect(result.error?.message).toBe(KEY_IS_NOT_STRING_ERROR) + }) + + test('should render correctly with result null', async () => { + const { result, waitForNextUpdate } = renderHook< + UseDataLoaderHookProps, + UsePaginatedDataLoaderResult + >(props => usePaginatedDataLoader(props.key, props.method, props.config), { + initialProps: { + ...initialProps, + key: 'test-3', + method: () => + new Promise(resolve => { + setTimeout(() => resolve(null), PROMISE_TIMEOUT) + }), + }, + wrapper, + }) + expect(result.current.pageData).toBe(undefined) + expect(result.current.isLoading).toBe(true) + await waitForNextUpdate() + expect(result.current.pageData).toBe(undefined) + expect(result.current.isSuccess).toBe(true) + expect(result.current.isLoading).toBe(false) + }) + + test('should render correctly then change page', async () => { + const { result, waitForNextUpdate } = renderHook< + UseDataLoaderHookProps, + UsePaginatedDataLoaderResult + >(props => usePaginatedDataLoader(props.key, props.method), { + initialProps: { + ...initialProps, + key: 'test-4', + }, + wrapper, + }) + expect(result.current.data).toStrictEqual({}) + expect(result.current.pageData).toBe(undefined) + expect(result.current.isLoading).toBe(true) + await waitForNextUpdate() + expect(result.current.isLoading).toBe(false) + expect(result.current.isSuccess).toBe(true) + expect(result.current.data).toStrictEqual({ 1: '1-1' }) + expect(result.current.pageData).toBe('1-1') + + act(() => { + result.current.goToNextPage() + }) + expect(result.current.page).toBe(2) + expect(result.current.pageData).toBe(undefined) + expect(result.current.isLoading).toBe(true) + await waitForNextUpdate() + expect(result.current.isLoading).toBe(false) + expect(result.current.isSuccess).toBe(true) + expect(result.current.data).toStrictEqual({ 1: '1-1', 2: '2-1' }) + expect(result.current.pageData).toBe('2-1') + act(() => { + result.current.goToPreviousPage() + result.current.goToPreviousPage() + result.current.goToPreviousPage() + result.current.goToPreviousPage() + }) + expect(result.current.page).toBe(1) + expect(result.current.pageData).toBe('1-1') + act(() => { + result.current.goToPage(2) + result.current.goToPage(-21) + result.current.goToPage(0) + }) + expect(result.current.page).toBe(1) + expect(result.current.pageData).toBe('1-1') + }) + + test('should render correctly go to next page, change key and should be on page 1', async () => { + const hookProps = { + ...initialProps, + key: 'test-5', + } + const { rerender, result, waitForNextUpdate } = renderHook< + UseDataLoaderHookProps, + UsePaginatedDataLoaderResult + >(props => usePaginatedDataLoader(props.key, props.method), { + initialProps: hookProps, + wrapper, + }) + await waitForNextUpdate() + act(() => { + result.current.goToNextPage() + }) + await waitForNextUpdate() + expect(result.current.data).toStrictEqual({ 1: '1-1', 2: '2-1' }) + hookProps.key = 'test-5-bis' + rerender() + expect(result.current.isLoading).toBe(true) + expect(result.current.pageData).toBe(undefined) + expect(result.current.data).toStrictEqual({}) + await waitForNextUpdate() + expect(result.current.data).toStrictEqual({ 1: '1-1' }) + expect(result.current.pageData).toBe('1-1') + expect(result.current.isSuccess).toBe(true) + expect(result.current.isLoading).toBe(false) + }) +}) diff --git a/packages/use-dataloader/src/index.ts b/packages/use-dataloader/src/index.ts index a378a8be8..2ec38df04 100644 --- a/packages/use-dataloader/src/index.ts +++ b/packages/use-dataloader/src/index.ts @@ -3,3 +3,4 @@ export { useDataLoaderContext, } from './DataLoaderProvider' export { default as useDataLoader } from './useDataLoader' +export { default as usePaginatedDataLoader } from './usePaginatedDataLoader' diff --git a/packages/use-dataloader/src/types.ts b/packages/use-dataloader/src/types.ts index 3de0172cd..514b1aaa2 100644 --- a/packages/use-dataloader/src/types.ts +++ b/packages/use-dataloader/src/types.ts @@ -56,3 +56,43 @@ export interface UseDataLoaderResult { previousData?: T reload: () => Promise } + +/** + * Params send to the method + */ +export type UsePaginatedDataLoaderMethodParams = { + page: number + perPage: number +} + +export type UsePaginatedDataLoaderConfig = { + enabled?: boolean + initialData?: T + keepPreviousData?: boolean + onError?: OnErrorFn + onSuccess?: OnSuccessFn + pollingInterval?: number + /** + * Max time before data from previous success is considered as outdated (in millisecond) + */ + maxDataLifetime?: number + needPolling?: NeedPollingType + initialPage?: number + perPage?: number +} + +export type UsePaginatedDataLoaderResult = { + pageData?: T + data?: Record + error?: Error + isError: boolean + isIdle: boolean + isLoading: boolean + isPolling: boolean + isSuccess: boolean + reload: () => Promise + goToPage: (page: number) => void + goToNextPage: () => void + goToPreviousPage: () => void + page: number +} diff --git a/packages/use-dataloader/src/usePaginatedDataLoader.ts b/packages/use-dataloader/src/usePaginatedDataLoader.ts new file mode 100644 index 000000000..4370bcb56 --- /dev/null +++ b/packages/use-dataloader/src/usePaginatedDataLoader.ts @@ -0,0 +1,108 @@ +import { useCallback, useEffect, useState } from 'react' +import { KEY_IS_NOT_STRING_ERROR } from './constants' +import { + PromiseType, + UsePaginatedDataLoaderConfig, + UsePaginatedDataLoaderMethodParams, + UsePaginatedDataLoaderResult, +} from './types' +import useDataLoader from './useDataLoader' + +/** + * @param {string} baseFetchKey base key used to cache data. Hook append -page-X to that key for each page you load + * @param {() => PromiseType} method a method that return a promise + * @param {useDataLoaderConfig} config hook configuration + * @returns {useDataLoaderResult} hook result containing data, request state, and method to reload the data + */ +const usePaginatedDataLoader = ( + baseFetchKey: string, + method: (params: UsePaginatedDataLoaderMethodParams) => PromiseType, + { + enabled = true, + initialData, + keepPreviousData = true, + onError, + onSuccess, + pollingInterval, + maxDataLifetime, + needPolling, + initialPage, + perPage = 1, + }: UsePaginatedDataLoaderConfig = {}, +): UsePaginatedDataLoaderResult => { + if (typeof baseFetchKey !== 'string') { + throw new Error(KEY_IS_NOT_STRING_ERROR) + } + + const [data, setData] = useState>({}) + const [page, setPage] = useState(initialPage ?? 1) + + const pageMethod = useCallback( + () => method({ page, perPage }), + [method, page, perPage], + ) + const { + data: pageData, + isError, + isIdle, + isLoading, + isPolling, + isSuccess, + reload, + error, + } = useDataLoader(`${baseFetchKey}-page-${page}`, pageMethod, { + enabled, + initialData, + keepPreviousData, + maxDataLifetime, + needPolling, + onError, + onSuccess, + pollingInterval, + }) + + const goToNextPage = useCallback(() => { + setPage(current => current + 1) + }, []) + + const goToPreviousPage = useCallback(() => { + setPage(current => (current > 1 ? current - 1 : 1)) + }, []) + + const goToPage = useCallback((newPage: number) => { + setPage(newPage > 1 ? newPage : 1) + }, []) + + useEffect(() => { + setData(current => { + if (pageData !== current[page]) { + return { ...current, [page]: pageData } + } + + return current + }) + }, [pageData, page]) + + useEffect(() => { + setPage(1) + setData({}) + }, [baseFetchKey]) + + return { + data, + error, + goToNextPage, + goToPage, + goToPreviousPage, + isError, + isIdle, + isLoading, + isPolling, + isSuccess, + page, + pageData, + reload, + } +} + +export default usePaginatedDataLoader