From 85a5439445c1a5ec39b05ce920cb23d7093d847e Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Tue, 6 Apr 2021 15:42:43 +0200 Subject: [PATCH] Implement refetch in useQuery and useQueryWithStore --- packages/ra-core/src/dataProvider/useQuery.ts | 23 +++- .../dataProvider/useQueryWithStore.spec.tsx | 130 ++++++++++++------ .../src/dataProvider/useQueryWithStore.ts | 32 +++-- 3 files changed, 127 insertions(+), 58 deletions(-) diff --git a/packages/ra-core/src/dataProvider/useQuery.ts b/packages/ra-core/src/dataProvider/useQuery.ts index dca734da960..b16a3e7e4cf 100644 --- a/packages/ra-core/src/dataProvider/useQuery.ts +++ b/packages/ra-core/src/dataProvider/useQuery.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useSafeSetState } from '../util/hooks'; import { OnSuccess, OnFailure } from '../types'; @@ -77,10 +77,19 @@ const useQuery = ( const { type, resource, payload } = query; const { withDeclarativeSideEffectsSupport, ...otherOptions } = options; const version = useVersion(); // used to allow force reload + // used to force a refetch without relying on version + // which might trigger other queries as well + const [innerVersion, setInnerVersion] = useState(0); + + const refetch = useCallback(() => { + setInnerVersion(prevInnerVersion => prevInnerVersion + 1); + }, []); + const requestSignature = JSON.stringify({ query, options: otherOptions, version, + innerVersion, }); const [state, setState] = useSafeSetState({ data: undefined, @@ -88,6 +97,7 @@ const useQuery = ( total: null, loading: true, loaded: false, + refetch, }); const dataProvider = useDataProvider(); const dataProviderWithDeclarativeSideEffects = useDataProviderWithDeclarativeSideEffects(); @@ -113,19 +123,21 @@ const useQuery = ( : [payload, otherOptions] ) .then(({ data, total }) => { - setState({ + setState(prev => ({ + ...prev, data, total, loading: false, loaded: true, - }); + })); }) .catch(error => { - setState({ + setState(prev => ({ + ...prev, error, loading: false, loaded: false, - }); + })); }); }, [ requestSignature, @@ -158,6 +170,7 @@ export type UseQueryValue = { error?: any; loading: boolean; loaded: boolean; + refetch: () => void; }; export default useQuery; diff --git a/packages/ra-core/src/dataProvider/useQueryWithStore.spec.tsx b/packages/ra-core/src/dataProvider/useQueryWithStore.spec.tsx index d6dcae99b9e..6a32130d305 100644 --- a/packages/ra-core/src/dataProvider/useQueryWithStore.spec.tsx +++ b/packages/ra-core/src/dataProvider/useQueryWithStore.spec.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { waitFor } from '@testing-library/react'; +import { fireEvent, waitFor } from '@testing-library/react'; import expect from 'expect'; import { renderWithRedux } from 'ra-test'; @@ -21,7 +21,12 @@ const UseQueryWithStore = ({ totalSelector ); if (callback) callback(hookValue); - return
hello
; + return ( + <> +
hello
+ + + ); }; describe('useQueryWithStore', () => { @@ -40,22 +45,23 @@ describe('useQueryWithStore', () => { , { admin: { resources: { posts: { data: {} } } } } ); - expect(callback).toBeCalledWith({ - data: undefined, - loading: true, - loaded: false, - error: null, - total: null, - }); + let callArgs = callback.mock.calls[0][0]; + expect(callArgs.data).toBeUndefined(); + expect(callArgs.loading).toEqual(true); + expect(callArgs.loaded).toEqual(false); + expect(callArgs.error).toBeNull(); + expect(callArgs.total).toBeNull(); callback.mockClear(); await new Promise(resolve => setImmediate(resolve)); // dataProvider Promise returns result on next tick - expect(callback).toBeCalledWith({ - data: { id: 1, title: 'titleFromDataProvider' }, - loading: false, - loaded: true, - error: null, - total: null, + callArgs = callback.mock.calls[1][0]; + expect(callArgs.data).toEqual({ + id: 1, + title: 'titleFromDataProvider', }); + expect(callArgs.loading).toEqual(false); + expect(callArgs.loaded).toEqual(true); + expect(callArgs.error).toBeNull(); + expect(callArgs.total).toBeNull(); }); it('should return data from the store first, then data from dataProvider', async () => { @@ -90,26 +96,27 @@ describe('useQueryWithStore', () => { }, } ); - expect(callback).toBeCalledWith({ - data: { id: 2, title: 'titleFromReduxStore' }, - loading: true, - loaded: true, - error: null, - total: null, - }); + let callArgs = callback.mock.calls[0][0]; + expect(callArgs.data).toEqual({ id: 2, title: 'titleFromReduxStore' }); + expect(callArgs.loading).toEqual(true); + expect(callArgs.loaded).toEqual(true); + expect(callArgs.error).toBeNull(); + expect(callArgs.total).toBeNull(); callback.mockClear(); await waitFor(() => { expect(dataProvider.getOne).toHaveBeenCalled(); }); // dataProvider Promise returns result on next tick await waitFor(() => { - expect(callback).toBeCalledWith({ - data: { id: 2, title: 'titleFromDataProvider' }, - loading: false, - loaded: true, - error: null, - total: null, + callArgs = callback.mock.calls[1][0]; + expect(callArgs.data).toEqual({ + id: 2, + title: 'titleFromDataProvider', }); + expect(callArgs.loading).toEqual(false); + expect(callArgs.loaded).toEqual(true); + expect(callArgs.error).toBeNull(); + expect(callArgs.total).toBeNull(); }); }); @@ -129,22 +136,22 @@ describe('useQueryWithStore', () => { , { admin: { resources: { posts: { data: {} } } } } ); - expect(callback).toBeCalledWith({ - data: undefined, - loading: true, - loaded: false, - error: null, - total: null, - }); + let callArgs = callback.mock.calls[0][0]; + expect(callArgs.data).toBeUndefined(); + expect(callArgs.loading).toEqual(true); + expect(callArgs.loaded).toEqual(false); + expect(callArgs.error).toBeNull(); + expect(callArgs.total).toBeNull(); callback.mockClear(); - await new Promise(resolve => setImmediate(resolve)); // dataProvider Promise returns result on next tick - expect(callback).toBeCalledWith({ - data: undefined, - loading: false, - loaded: false, - error: { message: 'error' }, - total: null, + await waitFor(() => { + expect(dataProvider.getOne).toHaveBeenCalled(); }); + callArgs = callback.mock.calls[0][0]; + expect(callArgs.data).toBeUndefined(); + expect(callArgs.loading).toEqual(false); + expect(callArgs.loaded).toEqual(false); + expect(callArgs.error).toEqual({ message: 'error' }); + expect(callArgs.total).toBeNull(); }); it('should refetch the dataProvider on refresh', async () => { @@ -186,6 +193,45 @@ describe('useQueryWithStore', () => { }); }); + it('should refetch the dataProvider when refetch is called', async () => { + const dataProvider = { + getOne: jest.fn(() => + Promise.resolve({ + data: { id: 3, title: 'titleFromDataProvider' }, + }) + ), + }; + const { getByText } = renderWithRedux( + + + , + { + admin: { + resources: { + posts: { + data: { + 3: { id: 3, title: 'titleFromReduxStore' }, + }, + }, + }, + }, + } + ); + await waitFor(() => { + expect(dataProvider.getOne).toBeCalledTimes(1); + }); + fireEvent.click(getByText('refetch')); + await waitFor(() => { + expect(dataProvider.getOne).toBeCalledTimes(2); + }); + }); + it('should call the dataProvider twice for different requests in the same tick', async () => { const dataProvider = { getOne: jest.fn(() => diff --git a/packages/ra-core/src/dataProvider/useQueryWithStore.ts b/packages/ra-core/src/dataProvider/useQueryWithStore.ts index f525c97973a..62921b2968e 100644 --- a/packages/ra-core/src/dataProvider/useQueryWithStore.ts +++ b/packages/ra-core/src/dataProvider/useQueryWithStore.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; import isEqual from 'lodash/isEqual'; @@ -20,6 +20,7 @@ export interface StateResult { error?: any; loading: boolean; loaded: boolean; + refetch: () => void; } export interface QueryOptions { @@ -114,19 +115,26 @@ const useQueryWithStore = ( dataSelector: (state: State) => any = defaultDataSelector(query), totalSelector: (state: State) => number = defaultTotalSelector(query), isDataLoaded: (data: any) => boolean = defaultIsDataLoaded -): { - data?: any; - total?: number; - error?: any; - loading: boolean; - loaded: boolean; -} => { +): StateResult => { const { type, resource, payload } = query; const version = useVersion(); // used to allow force reload - const requestSignature = JSON.stringify({ query, options, version }); + // used to force a refetch without relying on version + // which might trigger other queries as well + const [innerVersion, setInnerVersion] = useState(0); + const requestSignature = JSON.stringify({ + query, + options, + version, + innerVersion, + }); const requestSignatureRef = useRef(requestSignature); const data = useSelector(dataSelector); const total = useSelector(totalSelector); + + const refetch = useCallback(() => { + setInnerVersion(prevInnerVersion => prevInnerVersion + 1); + }, []); + const [state, setState]: [ StateResult, (StateResult) => void @@ -136,19 +144,21 @@ const useQueryWithStore = ( error: null, loading: true, loaded: isDataLoaded(data), + refetch, }); useEffect(() => { if (requestSignatureRef.current !== requestSignature) { // request has changed, reset the loading state requestSignatureRef.current = requestSignature; - setState({ + setState(prev => ({ + ...prev, data, total, error: null, loading: true, loaded: isDataLoaded(data), - }); + })); } else if (!isEqual(state.data, data) || state.total !== total) { // the dataProvider response arrived in the Redux store if (typeof total !== 'undefined' && isNaN(total)) {