diff --git a/docs/Actions.md b/docs/Actions.md index 267e4fb739e..3fcc38775bc 100644 --- a/docs/Actions.md +++ b/docs/Actions.md @@ -133,6 +133,7 @@ The return value of `useQuery` is an object representing the query state, using - `error`: `null` unless the `dataProvider` threw an error, in which case it contains that error. - `loading`: A boolean updating according to the request state - `loaded`: A boolean updating according to the request state +- `refetch`: A function you can call to trigger a refetch. It's different from the `refresh` function returned by `useRefresh` as it won't trigger a refresh of the view, only this specific query. This object updates according to the request state: @@ -175,6 +176,8 @@ const UserProfile = ({ record }) => { In practice, react-admin uses `useQueryWithStore` instead of `useQuery` everywhere, and you should probably do the same in your components. It really improves the User Experience, with only one little drawback: if the data changed on the backend side between two calls for the same query, the user may briefly see outdated data before the screen updates with the up-to-date data. +Just like `useQuery`, `useQueryWithStore` also returns a `refetch` function you can call to trigger a refetch. It's different from the `refresh` function returned by `useRefresh` as it won't trigger a refresh of the view, only this specific query. + ## `useMutation` Hook `useQuery` emits the request to the `dataProvider` as soon as the component mounts. To emit the request based on a user action, use the `useMutation` hook instead. This hook takes the same arguments as `useQuery`, but returns a callback that emits the request when executed. diff --git a/packages/ra-core/src/dataProvider/Query.tsx b/packages/ra-core/src/dataProvider/Query.tsx index ebb86fcc6ce..51716b8bb88 100644 --- a/packages/ra-core/src/dataProvider/Query.tsx +++ b/packages/ra-core/src/dataProvider/Query.tsx @@ -1,5 +1,5 @@ import { FunctionComponent } from 'react'; -import useQuery from './useQuery'; +import { useQuery } from './useQuery'; interface ChildrenFuncParams { data?: any; diff --git a/packages/ra-core/src/dataProvider/index.ts b/packages/ra-core/src/dataProvider/index.ts index 697e61c8f61..3517d73e81b 100644 --- a/packages/ra-core/src/dataProvider/index.ts +++ b/packages/ra-core/src/dataProvider/index.ts @@ -8,8 +8,6 @@ import cacheDataProviderProxy from './cacheDataProviderProxy'; import undoableEventEmitter from './undoableEventEmitter'; import useDataProvider from './useDataProvider'; import useMutation, { UseMutationValue } from './useMutation'; -import useQuery, { UseQueryValue } from './useQuery'; -import useQueryWithStore, { QueryOptions } from './useQueryWithStore'; import withDataProvider from './withDataProvider'; import useGetOne, { UseGetOneHookValue } from './useGetOne'; import useGetList from './useGetList'; @@ -25,12 +23,10 @@ import useDeleteMany from './useDeleteMany'; import useRefreshWhenVisible from './useRefreshWhenVisible'; import useIsAutomaticRefreshEnabled from './useIsAutomaticRefreshEnabled'; -export type { - QueryOptions, - UseMutationValue, - UseQueryValue, - UseGetOneHookValue, -}; +export * from './useQueryWithStore'; +export * from './useQuery'; + +export type { UseMutationValue, UseGetOneHookValue }; export { cacheDataProviderProxy, @@ -43,7 +39,6 @@ export { undoableEventEmitter, useDataProvider, useMutation, - useQuery, useGetOne, useGetList, useGetMainList, @@ -55,7 +50,6 @@ export { useCreate, useDelete, useDeleteMany, - useQueryWithStore, useRefreshWhenVisible, withDataProvider, useIsAutomaticRefreshEnabled, diff --git a/packages/ra-core/src/dataProvider/useGetList.ts b/packages/ra-core/src/dataProvider/useGetList.ts index fe114c2d7c6..eb086cbd5fd 100644 --- a/packages/ra-core/src/dataProvider/useGetList.ts +++ b/packages/ra-core/src/dataProvider/useGetList.ts @@ -10,7 +10,7 @@ import { RecordMap, UseDataProviderOptions, } from '../types'; -import useQueryWithStore from './useQueryWithStore'; +import { useQueryWithStore } from './useQueryWithStore'; const defaultPagination = { page: 1, perPage: 25 }; const defaultSort = { field: 'id', order: 'DESC' }; diff --git a/packages/ra-core/src/dataProvider/useGetMainList.tsx b/packages/ra-core/src/dataProvider/useGetMainList.tsx index 251094c8d15..de6d7ce27a1 100644 --- a/packages/ra-core/src/dataProvider/useGetMainList.tsx +++ b/packages/ra-core/src/dataProvider/useGetMainList.tsx @@ -9,7 +9,7 @@ import { Record, RecordMap, } from '../types'; -import useQueryWithStore from './useQueryWithStore'; +import { useQueryWithStore } from './useQueryWithStore'; const defaultIds = []; const defaultData = {}; diff --git a/packages/ra-core/src/dataProvider/useGetManyReference.ts b/packages/ra-core/src/dataProvider/useGetManyReference.ts index c80fd545203..b3ea948b560 100644 --- a/packages/ra-core/src/dataProvider/useGetManyReference.ts +++ b/packages/ra-core/src/dataProvider/useGetManyReference.ts @@ -10,7 +10,7 @@ import { Record, RecordMap, } from '../types'; -import useQueryWithStore from './useQueryWithStore'; +import { useQueryWithStore } from './useQueryWithStore'; import { getIds, getTotal, diff --git a/packages/ra-core/src/dataProvider/useGetMatching.ts b/packages/ra-core/src/dataProvider/useGetMatching.ts index a44ddc14143..1738e81a92a 100644 --- a/packages/ra-core/src/dataProvider/useGetMatching.ts +++ b/packages/ra-core/src/dataProvider/useGetMatching.ts @@ -9,7 +9,7 @@ import { Record, ReduxState, } from '../types'; -import useQueryWithStore from './useQueryWithStore'; +import { useQueryWithStore } from './useQueryWithStore'; import { getReferenceResource, getPossibleReferenceValues, diff --git a/packages/ra-core/src/dataProvider/useGetOne.ts b/packages/ra-core/src/dataProvider/useGetOne.ts index 952449ce90e..df83de560a6 100644 --- a/packages/ra-core/src/dataProvider/useGetOne.ts +++ b/packages/ra-core/src/dataProvider/useGetOne.ts @@ -6,7 +6,7 @@ import { ReduxState, UseDataProviderOptions, } from '../types'; -import useQueryWithStore from './useQueryWithStore'; +import { useQueryWithStore } from './useQueryWithStore'; /** * Call the dataProvider.getOne() method and return the resolved value diff --git a/packages/ra-core/src/dataProvider/useQuery.ts b/packages/ra-core/src/dataProvider/useQuery.ts index dca734da960..ca0b0184ec9 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'; @@ -6,6 +6,7 @@ import useDataProvider from './useDataProvider'; import useDataProviderWithDeclarativeSideEffects from './useDataProviderWithDeclarativeSideEffects'; import { DeclarativeSideEffect } from './useDeclarativeSideEffects'; import useVersion from '../controller/useVersion'; +import { DataProviderQuery } from './useQueryWithStore'; /** * Call the data provider on mount @@ -70,17 +71,26 @@ import useVersion from '../controller/useVersion'; * ); * }; */ -const useQuery = ( - query: Query, - options: QueryOptions = { onSuccess: undefined } +export const useQuery = ( + query: DataProviderQuery, + options: UseQueryOptions = { onSuccess: undefined } ): UseQueryValue => { 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 +98,7 @@ const useQuery = ( total: null, loading: true, loaded: false, + refetch, }); const dataProvider = useDataProvider(); const dataProviderWithDeclarativeSideEffects = useDataProviderWithDeclarativeSideEffects(); @@ -118,6 +129,7 @@ const useQuery = ( total, loading: false, loaded: true, + refetch, }); }) .catch(error => { @@ -125,6 +137,7 @@ const useQuery = ( error, loading: false, loaded: false, + refetch, }); }); }, [ @@ -138,13 +151,7 @@ const useQuery = ( return state; }; -export interface Query { - type: string; - resource?: string; - payload: object; -} - -export interface QueryOptions { +export interface UseQueryOptions { action?: string; enabled?: boolean; onSuccess?: OnSuccess | DeclarativeSideEffect; @@ -158,6 +165,5 @@ 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..e4db694ef4d 100644 --- a/packages/ra-core/src/dataProvider/useQueryWithStore.spec.tsx +++ b/packages/ra-core/src/dataProvider/useQueryWithStore.spec.tsx @@ -1,9 +1,9 @@ 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'; -import useQueryWithStore from './useQueryWithStore'; +import { useQueryWithStore } from './useQueryWithStore'; import { DataProviderContext } from '../dataProvider'; const UseQueryWithStore = ({ @@ -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..bd70b208be6 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'; @@ -8,18 +8,19 @@ import getFetchType from './getFetchType'; import { useSafeSetState } from '../util/hooks'; import { ReduxState, OnSuccess, OnFailure } from '../types'; -export interface Query { +export interface DataProviderQuery { type: string; resource: string; payload: object; } -export interface StateResult { +export interface UseQueryWithStoreValue { data?: any; total?: number; error?: any; loading: boolean; loaded: boolean; + refetch: () => void; } export interface QueryOptions { @@ -30,7 +31,7 @@ export interface QueryOptions { [key: string]: any; } -export type PartialQueryState = { +type PartialQueryState = { error?: any; loading: boolean; loaded: boolean; @@ -108,27 +109,34 @@ const defaultIsDataLoaded = (data: any): boolean => data !== undefined; * return
User {data.username}
; * }; */ -const useQueryWithStore = ( - query: Query, +export const useQueryWithStore = ( + query: DataProviderQuery, options: QueryOptions = { action: 'CUSTOM_QUERY' }, 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; -} => { +): UseQueryWithStoreValue => { 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, + UseQueryWithStoreValue, (StateResult) => void ] = useSafeSetState({ data, @@ -136,6 +144,7 @@ const useQueryWithStore = ( error: null, loading: true, loaded: isDataLoaded(data), + refetch, }); useEffect(() => { @@ -148,6 +157,7 @@ const useQueryWithStore = ( error: null, loading: true, loaded: isDataLoaded(data), + refetch, }); } else if (!isEqual(state.data, data) || state.total !== total) { // the dataProvider response arrived in the Redux store @@ -173,6 +183,7 @@ const useQueryWithStore = ( state.total, total, isDataLoaded, + refetch, ]); const dataProvider = useDataProvider(); @@ -234,5 +245,3 @@ const useQueryWithStore = ( return state; }; - -export default useQueryWithStore;