diff --git a/src/components/hooks/test/usePagination.test.tsx b/src/components/hooks/test/usePagination.test.tsx new file mode 100644 index 000000000..8997e75a3 --- /dev/null +++ b/src/components/hooks/test/usePagination.test.tsx @@ -0,0 +1,150 @@ +import { + fireEvent, + getByLabelText, + getByText, + render, + waitFor +} from '@testing-library/react'; +import { + PaginatedEntityResponse, + RequestConfig +} from 'models/AdminEntity/types'; +import * as React from 'react'; +import { PaginationConfig, usePagination } from '../usePagination'; + +const valueLabel = 'pagination-value'; +const fetchLabel = 'pagination-doFetch'; +const moreItemsAvailableLabel = 'pagination-moreItemsAvailable'; + +interface PaginationItem { + id: string; +} + +type FetchResponse = PaginatedEntityResponse; +interface PaginationTesterProps { + config: PaginationConfig<{}>; + doFetch: jest.Mock>; +} + +const PaginationTester = ({ config, doFetch }: PaginationTesterProps) => { + const fetchable = usePagination(config, doFetch); + const onClickFetch = () => fetchable.fetch(); + + return ( +
+
+
    + {fetchable.value.map(({ id }) => ( +
  • {`item-${id}`}
  • + ))} +
+
+
+ {fetchable.moreItemsAvailable ? 'true' : 'false'} +
+ +
+ ); +}; + +describe('usePagination', () => { + let entityCounter: number; + let config: PaginationConfig<{}>; + let doFetch: jest.Mock>; + + beforeEach(() => { + entityCounter = 0; + doFetch = jest + .fn() + .mockImplementation( + (fetchArg: any, { limit = 25 }: RequestConfig) => + Promise.resolve({ + entities: Array.from({ length: limit }, () => { + const id = `${entityCounter}`; + entityCounter += 1; + return { id }; + }), + token: `${entityCounter}` + }) + ); + config = { + cacheItems: false, + fetchArg: {}, + limit: 25 + }; + }); + + const renderTester = () => + render(); + const getElements = async (container: HTMLElement) => { + return waitFor(() => { + return { + fetchButton: getByLabelText(container, fetchLabel), + moreItemsAvailable: getByLabelText( + container, + moreItemsAvailableLabel + ) + }; + }); + }; + + const waitForLastItemRendered = async (container: HTMLElement) => { + return waitFor(() => getByText(container, `item-${entityCounter - 1}`)); + }; + + it('should pass returned token in subsequent calls', async () => { + const { container } = renderTester(); + const { fetchButton } = await getElements(container); + + expect(doFetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ token: '' }) + ); + + fireEvent.click(fetchButton); + await waitForLastItemRendered(container); + expect(doFetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ token: `${config.limit}` }) + ); + }); + + it('should reset token when config changes', async () => { + const { container } = renderTester(); + await getElements(container); + + expect(doFetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ token: '' }) + ); + + doFetch.mockClear(); + entityCounter = 0; + + // Change the config to trigger a rest of the pagination hook + config.limit = 10; + await getElements(renderTester().container); + + expect(doFetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ token: '' }) + ); + }); + + it('should set moreItemsAvailable if token is returned', async () => { + const { moreItemsAvailable } = await getElements( + renderTester().container + ); + expect(moreItemsAvailable.textContent).toBe('true'); + }); + + it('should not set moreItemsAvailable if no token is returned', async () => { + doFetch.mockResolvedValue({ entities: [{ id: '0' }] }); + const { moreItemsAvailable } = await getElements( + renderTester().container + ); + expect(moreItemsAvailable.textContent).toBe('false'); + }); +}); diff --git a/src/components/hooks/types.ts b/src/components/hooks/types.ts index a920c4abf..564ec30af 100644 --- a/src/components/hooks/types.ts +++ b/src/components/hooks/types.ts @@ -56,7 +56,7 @@ export interface FetchableData { debugName: string; fetch(): void; lastError: Error | null; - state: FetchableState; + state: FetchableState; value: T; } @@ -65,6 +65,10 @@ export interface FetchableExecution { terminateExecution(cause: string): Promise; } +export interface PaginationValue { + token?: string; + items: T[]; +} export interface PaginatedFetchableData extends FetchableData { /** Whether or not a fetch would yield more items. Useful for determining if * a "load more" button should be shown diff --git a/src/components/hooks/usePagination.ts b/src/components/hooks/usePagination.ts index 88b575a4b..1225d753d 100644 --- a/src/components/hooks/usePagination.ts +++ b/src/components/hooks/usePagination.ts @@ -1,9 +1,12 @@ -import { useContext, useEffect, useState } from 'react'; - -import { CacheContext, getCacheKey } from 'components/Cache'; +import { CacheContext } from 'components/Cache'; import { RequestConfig } from 'models'; - -import { FetchFn, PaginatedFetchableData, PaginatedFetchFn } from './types'; +import { useContext, useMemo } from 'react'; +import { + FetchFn, + PaginatedFetchableData, + PaginatedFetchFn, + PaginationValue +} from './types'; import { useFetchableData } from './useFetchableData'; export interface PaginationConfig extends RequestConfig { @@ -33,52 +36,46 @@ export function usePagination( doFetch: PaginatedFetchFn ): PaginatedFetchableData { const { cacheItems = false, debugName } = config; - const cacheKey = getCacheKey(config); - const [token, setToken] = useState(''); - const [moreItemsAvailable, setMoreItemsAvailable] = useState(false); const cache = useContext(CacheContext); - // Reset our state if the pagination config changes - useEffect(() => { - setToken(''); - setMoreItemsAvailable(false); - }, [cacheKey]); + const fetch: FetchFn< + PaginationValue, + PaginationConfig + > = useMemo( + () => async (params, currentValue) => { + const { token: previousToken = '', items: previousItems = [] } = + currentValue || {}; + const { fetchArg, ...requestConfig } = params; - const fetch: FetchFn> = async ( - params, - currentValue = [] - ) => { - const { fetchArg, ...requestConfig } = params; + // If our last fetch call returned a token, + // we have to pass that along in order to retrieve the next page + const finalConfig = { ...requestConfig, token: previousToken }; - // If our last fetch call returned a token, - // we have to pass that along in order to retrieve the next page - if (token) { - requestConfig.token = token; - } + const { entities, token } = await doFetch(fetchArg, finalConfig); + const result = cacheItems ? cache.mergeArray(entities) : entities; - const { entities, token: newToken } = await doFetch( - fetchArg, - requestConfig - ); - const values = cacheItems ? cache.mergeArray(entities) : entities; + const items = previousItems.concat(result); + return { + items, + token + }; + }, + [cache, cacheItems] + ); - if (newToken) { - setToken(newToken); - } - const newValue = currentValue.concat(values); - setMoreItemsAvailable(!!newToken); - return newValue; - }; + const fetchable = useFetchableData( + { + debugName, + defaultValue: { token: '', items: [] }, + doFetch: fetch + }, + config + ); + const { items: value, token } = fetchable.value; return { - ...useFetchableData( - { - debugName, - defaultValue: [], - doFetch: fetch - }, - config - ), - moreItemsAvailable + ...fetchable, + value, + moreItemsAvailable: !!token }; }