diff --git a/docs/Actions.md b/docs/Actions.md index 28845b4aff3..85fd69d71f6 100644 --- a/docs/Actions.md +++ b/docs/Actions.md @@ -408,6 +408,26 @@ const BulkDeletePostsButton = ({ selectedIds }) => { }; ``` +## Synchronizing Dependant Queries +`useQuery` and all its corresponding specialized hooks support an `enabled` option. This is useful if you need to have a query executed only when a condition is met. For example, in the following example, we only fetch the categories if we have at least one post: +```jsx +// fetch posts +const { ids, data: posts, loading: isLoading } = useGetList( + 'posts', + { page: 1, perPage: 20 }, + { field: 'name', order: 'ASC' }, + {} +); + +// then fetch categories for these posts +const { data: categories, loading: isLoadingCategories } = useGetMany( + 'categories', + ids.map(id=> posts[id].category_id), + // run only if the first query returns non-empty result + { enabled: ids.length > 0 } +); +``` + ## Handling Side Effects In `useDataProvider` `useDataProvider` returns a `dataProvider` object. Each call to its method return a Promise, allowing adding business logic on success in `then()`, and on failure in `catch()`. diff --git a/packages/ra-core/src/dataProvider/getDataProviderCallArguments.ts b/packages/ra-core/src/dataProvider/getDataProviderCallArguments.ts index 68f35a1b94e..ff7c01a92ae 100644 --- a/packages/ra-core/src/dataProvider/getDataProviderCallArguments.ts +++ b/packages/ra-core/src/dataProvider/getDataProviderCallArguments.ts @@ -9,6 +9,7 @@ const OptionsProperties = [ 'onSuccess', 'undoable', 'mutationMode', + 'enabled', ]; const isDataProviderOptions = (value: any) => { diff --git a/packages/ra-core/src/dataProvider/useDataProvider.spec.js b/packages/ra-core/src/dataProvider/useDataProvider.spec.js index 906dc6b4e60..a25010dca66 100644 --- a/packages/ra-core/src/dataProvider/useDataProvider.spec.js +++ b/packages/ra-core/src/dataProvider/useDataProvider.spec.js @@ -319,6 +319,64 @@ describe('useDataProvider', () => { expect(onFailure.mock.calls[0][0]).toEqual(new Error('foo')); }); + it('should accept an enabled option to block the query until a condition is met', async () => { + const UseGetOneWithEnabled = () => { + const [data, setData] = useState(); + const [error, setError] = useState(); + const [isEnabled, setIsEnabled] = useState(false); + const dataProvider = useDataProvider(); + useEffect(() => { + dataProvider + .getOne('dummy', {}, { enabled: isEnabled }) + .then(res => setData(res.data)) + .catch(e => setError(e)); + }, [dataProvider, isEnabled]); + + let content =
loading
; + if (error) + content =
{error.message}
; + if (data) + content = ( +
{JSON.stringify(data)}
+ ); + return ( +
+ {content} + +
+ ); + }; + const getOne = jest + .fn() + .mockResolvedValue({ data: { id: 1, title: 'foo' } }); + const dataProvider = { getOne }; + const { queryByTestId, getByRole } = renderWithRedux( + + + + ); + expect(queryByTestId('loading')).not.toBeNull(); + await act(async () => { + await new Promise(resolve => setTimeout(resolve)); + }); + expect(getOne).not.toBeCalled(); + expect(queryByTestId('loading')).not.toBeNull(); + + // enable the query + fireEvent.click(getByRole('button', { name: 'toggle' })); + + await act(async () => { + await new Promise(resolve => setTimeout(resolve)); + }); + expect(getOne).toBeCalledTimes(1); + expect(queryByTestId('loading')).toBeNull(); + expect(queryByTestId('data').textContent).toBe( + '{"id":1,"title":"foo"}' + ); + }); + describe('mutationMode', () => { it('should wait for response to dispatch side effects in pessimistic mode', async () => { let resolveUpdate; diff --git a/packages/ra-core/src/dataProvider/useDataProvider.ts b/packages/ra-core/src/dataProvider/useDataProvider.ts index e8f17b3ef9c..e24722a40f5 100644 --- a/packages/ra-core/src/dataProvider/useDataProvider.ts +++ b/packages/ra-core/src/dataProvider/useDataProvider.ts @@ -134,6 +134,7 @@ const useDataProvider = (): DataProviderProxy => { onSuccess = undefined, onFailure = undefined, mutationMode = undoable ? 'undoable' : 'pessimistic', + enabled = true, ...rest } = options || {}; @@ -157,6 +158,13 @@ const useDataProvider = (): DataProviderProxy => { 'You must pass an onSuccess callback calling notify() to use the undoable mode' ); } + if (typeof enabled !== 'boolean') { + throw new Error('The enabled option must be a boolean'); + } + + if (enabled === false) { + return Promise.resolve({}); + } const params = { resource, diff --git a/packages/ra-core/src/dataProvider/useGetList.ts b/packages/ra-core/src/dataProvider/useGetList.ts index 6b467a245aa..59489f1fa6f 100644 --- a/packages/ra-core/src/dataProvider/useGetList.ts +++ b/packages/ra-core/src/dataProvider/useGetList.ts @@ -8,6 +8,7 @@ import { Identifier, Record, RecordMap, + UseDataProviderOptions, } from '../types'; import useQueryWithStore from './useQueryWithStore'; @@ -31,7 +32,10 @@ const defaultData = {}; * @param {Object} pagination The request pagination { page, perPage }, e.g. { page: 1, perPage: 10 } * @param {Object} sort The request sort { field, order }, e.g. { field: 'id', order: 'DESC' } * @param {Object} filter The request filters, e.g. { title: 'hello, world' } - * @param {Object} options Options object to pass to the dataProvider. May include side effects to be executed upon success or failure, e.g. { onSuccess: { refresh: true } } + * @param {Object} options Options object to pass to the dataProvider. + * @param {boolean} options.enabled Flag to conditionally run the query. If it's false, the query will not run + * @param {Function} options.onSuccess Side effect function to be executed upon success, e.g. { onSuccess: { refresh: true } } + * @param {Function} options.onFailure Side effect function to be executed upon failure, e.g. { onFailure: error => notify(error.message) } * * @returns The current request state. Destructure as { data, total, ids, error, loading, loaded }. * @@ -57,7 +61,7 @@ const useGetList = ( pagination: PaginationPayload, sort: SortPayload, filter: object, - options?: any + options?: UseDataProviderOptions ): { data?: RecordMap; ids?: Identifier[]; diff --git a/packages/ra-core/src/dataProvider/useGetMany.ts b/packages/ra-core/src/dataProvider/useGetMany.ts index 9c55b0c296a..4a2b2e0bc70 100644 --- a/packages/ra-core/src/dataProvider/useGetMany.ts +++ b/packages/ra-core/src/dataProvider/useGetMany.ts @@ -24,6 +24,11 @@ interface Query { interface QueriesToCall { [resource: string]: Query[]; } +interface UseGetManyOptions { + onSuccess?: Callback; + onFailure?: Callback; + enabled?: boolean; +} interface UseGetManyResult { data: Record[]; error?: any; @@ -59,7 +64,10 @@ const DataProviderOptions = { action: CRUD_GET_MANY }; * * @param resource The resource name, e.g. 'posts' * @param ids The resource identifiers, e.g. [123, 456, 789] - * @param options Options object to pass to the dataProvider. May include side effects to be executed upon success or failure, e.g. { onSuccess: { refresh: true } } + * @param {Object} options Options object to pass to the dataProvider. + * @param {boolean} options.enabled Flag to conditionally run the query. If it's false, the query will not run + * @param {Function} options.onSuccess Side effect function to be executed upon success, e.g. { onSuccess: { refresh: true } } + * @param {Function} options.onFailure Side effect function to be executed upon failure, e.g. { onFailure: error => notify(error.message) } * * @returns The current request state. Destructure as { data, error, loading, loaded }. * @@ -83,7 +91,7 @@ const DataProviderOptions = { action: CRUD_GET_MANY }; const useGetMany = ( resource: string, ids: Identifier[], - options: any = {} + options: UseGetManyOptions = {} ): UseGetManyResult => { // we can't use useQueryWithStore here because we're aggregating queries first // therefore part of the useQueryWithStore logic will have to be repeated below diff --git a/packages/ra-core/src/dataProvider/useGetManyReference.ts b/packages/ra-core/src/dataProvider/useGetManyReference.ts index 2eb8c6bfa03..320f0bbdb5b 100644 --- a/packages/ra-core/src/dataProvider/useGetManyReference.ts +++ b/packages/ra-core/src/dataProvider/useGetManyReference.ts @@ -18,6 +18,13 @@ import { const defaultIds = []; const defaultData = {}; +interface UseGetManyReferenceOptions { + onSuccess?: (args?: any) => void; + onFailure?: (error: any) => void; + enabled?: boolean; + [key: string]: any; +} + /** * Call the dataProvider.getManyReference() method and return the resolved result * as well as the loading state. @@ -38,7 +45,10 @@ const defaultData = {}; * @param {Object} sort The request sort { field, order }, e.g. { field: 'id', order: 'DESC' } * @param {Object} filter The request filters, e.g. { body: 'hello, world' } * @param {string} referencingResource The resource name, e.g. 'posts'. Used to generate a cache key - * @param {Object} options Options object to pass to the dataProvider. May include side effects to be executed upon success or failure, e.g. { onSuccess: { refresh: true } } + * @param {Object} options Options object to pass to the dataProvider. + * @param {boolean} options.enabled Flag to conditionally run the query. If it's false, the query will not run + * @param {Function} options.onSuccess Side effect function to be executed upon success, e.g. { onSuccess: { refresh: true } } + * @param {Function} options.onFailure Side effect function to be executed upon failure, e.g. { onFailure: error => notify(error.message) } * * @returns The current request state. Destructure as { data, total, ids, error, loading, loaded }. * @@ -71,7 +81,7 @@ const useGetManyReference = ( sort: SortPayload, filter: object, referencingResource: string, - options?: any + options?: UseGetManyReferenceOptions ) => { const relatedTo = useMemo( () => nameRelatedTo(resource, id, referencingResource, target, filter), diff --git a/packages/ra-core/src/dataProvider/useGetMatching.ts b/packages/ra-core/src/dataProvider/useGetMatching.ts index a1a3b016627..a44ddc14143 100644 --- a/packages/ra-core/src/dataProvider/useGetMatching.ts +++ b/packages/ra-core/src/dataProvider/useGetMatching.ts @@ -16,6 +16,13 @@ import { getPossibleReferences, } from '../reducer'; +interface UseGetMatchingOptions { + onSuccess?: (args?: any) => void; + onFailure?: (error: any) => void; + enabled?: boolean; + [key: string]: any; +} + const referenceSource = (resource, source) => `${resource}@${source}`; /** @@ -41,7 +48,10 @@ const referenceSource = (resource, source) => `${resource}@${source}`; * @param {Object} filter The request filters, e.g. { title: 'hello, world' } * @param {string} source The field in resource containing the ids of the referenced records, e.g. 'tag_ids' * @param {string} referencingResource The resource name, e.g. 'posts'. Used to build a cache key - * @param {Object} options Options object to pass to the dataProvider. May include side effects to be executed upon success or failure, e.g. { onSuccess: { refresh: true } } + * @param {Object} options Options object to pass to the dataProvider. + * @param {boolean} options.enabled Flag to conditionally run the query. If it's false, the query will not run + * @param {Function} options.onSuccess Side effect function to be executed upon success, e.g. { onSuccess: { refresh: true } } + * @param {Function} options.onFailure Side effect function to be executed upon failure, e.g. { onFailure: error => notify(error.message) } * * @returns The current request state. Destructure as { data, total, ids, error, loading, loaded }. * @@ -73,7 +83,7 @@ const useGetMatching = ( filter: object, source: string, referencingResource: string, - options?: any + options?: UseGetMatchingOptions ): UseGetMatchingResult => { const relatedTo = referenceSource(referencingResource, source); const payload = { pagination, sort, filter }; diff --git a/packages/ra-core/src/dataProvider/useGetOne.ts b/packages/ra-core/src/dataProvider/useGetOne.ts index 6bf60ea6d0c..952449ce90e 100644 --- a/packages/ra-core/src/dataProvider/useGetOne.ts +++ b/packages/ra-core/src/dataProvider/useGetOne.ts @@ -1,6 +1,11 @@ import get from 'lodash/get'; -import { Identifier, Record, ReduxState } from '../types'; +import { + Identifier, + Record, + ReduxState, + UseDataProviderOptions, +} from '../types'; import useQueryWithStore from './useQueryWithStore'; /** @@ -18,7 +23,10 @@ import useQueryWithStore from './useQueryWithStore'; * * @param resource The resource name, e.g. 'posts' * @param id The resource identifier, e.g. 123 - * @param options Options object to pass to the dataProvider. May include side effects to be executed upon success or failure, e.g. { onSuccess: { refresh: true } } + * @param {Object} options Options object to pass to the dataProvider. + * @param {boolean} options.enabled Flag to conditionally run the query. If it's false, the query will not run + * @param {Function} options.onSuccess Side effect function to be executed upon success, e.g. { onSuccess: { refresh: true } } + * @param {Function} options.onFailure Side effect function to be executed upon failure, e.g. { onFailure: error => notify(error.message) } * * @returns The current request state. Destructure as { data, error, loading, loaded }. * @@ -36,7 +44,7 @@ import useQueryWithStore from './useQueryWithStore'; const useGetOne = ( resource: string, id: Identifier, - options?: any + options?: UseDataProviderOptions ): UseGetOneHookValue => useQueryWithStore( { type: 'getOne', resource, payload: { id } }, diff --git a/packages/ra-core/src/dataProvider/useQuery.ts b/packages/ra-core/src/dataProvider/useQuery.ts index 7be2654e0e6..dca734da960 100644 --- a/packages/ra-core/src/dataProvider/useQuery.ts +++ b/packages/ra-core/src/dataProvider/useQuery.ts @@ -22,6 +22,7 @@ import useVersion from '../controller/useVersion'; * @param {Object} query.payload The payload object, e.g; { post_id: 12 } * @param {Object} options * @param {string} options.action Redux action type + * @param {boolean} options.enabled Flag to conditionally run the query. True by default. If it's false, the query will not run * @param {Function} options.onSuccess Side effect function to be executed upon success, e.g. () => refresh() * @param {Function} options.onFailure Side effect function to be executed upon failure, e.g. (error) => notify(error.message) * @param {boolean} options.withDeclarativeSideEffectsSupport Set to true to support legacy side effects e.g. { onSuccess: { refresh: true } } @@ -145,6 +146,7 @@ export interface Query { export interface QueryOptions { action?: string; + enabled?: boolean; onSuccess?: OnSuccess | DeclarativeSideEffect; onFailure?: OnFailure | DeclarativeSideEffect; withDeclarativeSideEffectsSupport?: boolean; diff --git a/packages/ra-core/src/dataProvider/useQueryWithStore.ts b/packages/ra-core/src/dataProvider/useQueryWithStore.ts index 33777c75f3d..f525c97973a 100644 --- a/packages/ra-core/src/dataProvider/useQueryWithStore.ts +++ b/packages/ra-core/src/dataProvider/useQueryWithStore.ts @@ -26,6 +26,7 @@ export interface QueryOptions { onSuccess?: OnSuccess; onFailure?: OnFailure; action?: string; + enabled?: boolean; [key: string]: any; } @@ -79,6 +80,7 @@ const defaultIsDataLoaded = (data: any): boolean => data !== undefined; * @param {Object} query.payload The payload object, e.g; { post_id: 12 } * @param {Object} options * @param {string} options.action Redux action type + * @param {boolean} options.enabled Flag to conditionally run the query. If it's false, the query will not run * @param {Function} options.onSuccess Side effect function to be executed upon success, e.g. () => refresh() * @param {Function} options.onFailure Side effect function to be executed upon failure, e.g. (error) => notify(error.message) * @param {Function} dataSelector Redux selector to get the result. Required. diff --git a/packages/ra-core/src/types.ts b/packages/ra-core/src/types.ts index 3486a27a6ca..b6b5edb9e5e 100644 --- a/packages/ra-core/src/types.ts +++ b/packages/ra-core/src/types.ts @@ -297,6 +297,7 @@ export interface UseDataProviderOptions { mutationMode?: MutationMode; onSuccess?: OnSuccess; onFailure?: OnFailure; + enabled?: boolean; } export type LegacyDataProvider = (