Skip to content

Commit

Permalink
Implement refetch in useQuery and useQueryWithStore
Browse files Browse the repository at this point in the history
  • Loading branch information
djhi committed Apr 6, 2021
1 parent 8676001 commit 85a5439
Show file tree
Hide file tree
Showing 3 changed files with 127 additions and 58 deletions.
23 changes: 18 additions & 5 deletions packages/ra-core/src/dataProvider/useQuery.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useCallback, useEffect, useState } from 'react';

import { useSafeSetState } from '../util/hooks';
import { OnSuccess, OnFailure } from '../types';
Expand Down Expand Up @@ -77,17 +77,27 @@ 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<UseQueryValue>({
data: undefined,
error: null,
total: null,
loading: true,
loaded: false,
refetch,
});
const dataProvider = useDataProvider();
const dataProviderWithDeclarativeSideEffects = useDataProviderWithDeclarativeSideEffects();
Expand All @@ -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,
Expand Down Expand Up @@ -158,6 +170,7 @@ export type UseQueryValue = {
error?: any;
loading: boolean;
loaded: boolean;
refetch: () => void;
};

export default useQuery;
130 changes: 88 additions & 42 deletions packages/ra-core/src/dataProvider/useQueryWithStore.spec.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -21,7 +21,12 @@ const UseQueryWithStore = ({
totalSelector
);
if (callback) callback(hookValue);
return <div>hello</div>;
return (
<>
<div>hello</div>
<button onClick={() => hookValue.refetch()}>refetch</button>
</>
);
};

describe('useQueryWithStore', () => {
Expand All @@ -40,22 +45,23 @@ describe('useQueryWithStore', () => {
</DataProviderContext.Provider>,
{ 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 () => {
Expand Down Expand Up @@ -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();
});
});

Expand All @@ -129,22 +136,22 @@ describe('useQueryWithStore', () => {
</DataProviderContext.Provider>,
{ 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 () => {
Expand Down Expand Up @@ -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(
<DataProviderContext.Provider value={dataProvider}>
<UseQueryWithStore
query={{
type: 'getOne',
resource: 'posts',
payload: { id: 3 },
}}
/>
</DataProviderContext.Provider>,
{
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(() =>
Expand Down
32 changes: 21 additions & 11 deletions packages/ra-core/src/dataProvider/useQueryWithStore.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -20,6 +20,7 @@ export interface StateResult {
error?: any;
loading: boolean;
loaded: boolean;
refetch: () => void;
}

export interface QueryOptions {
Expand Down Expand Up @@ -114,19 +115,26 @@ const useQueryWithStore = <State extends ReduxState = ReduxState>(
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
Expand All @@ -136,19 +144,21 @@ const useQueryWithStore = <State extends ReduxState = ReduxState>(
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)) {
Expand Down

0 comments on commit 85a5439

Please sign in to comment.