diff --git a/packages/ra-core/src/dataProvider/Mutation.spec.tsx b/packages/ra-core/src/dataProvider/Mutation.spec.tsx index 2317b93af97..6397367aefe 100644 --- a/packages/ra-core/src/dataProvider/Mutation.spec.tsx +++ b/packages/ra-core/src/dataProvider/Mutation.spec.tsx @@ -1,8 +1,20 @@ import React from 'react'; -import { cleanup } from '@testing-library/react'; +import { + cleanup, + fireEvent, + waitForDomChange, + act, + render, +} from '@testing-library/react'; import expect from 'expect'; +import { push } from 'connected-react-router'; + import Mutation from './Mutation'; import renderWithRedux from '../util/renderWithRedux'; +import { showNotification, refreshView, setListSelectedIds } from '../actions'; +import DataProviderContext from './DataProviderContext'; +import TestContext from '../util/TestContext'; +import { useNotify } from '../sideEffect'; describe('Mutation', () => { afterEach(cleanup); @@ -37,4 +49,236 @@ describe('Mutation', () => { loading: false, }); }); + + it('supports declarative onSuccess side effects', async () => { + let dispatchSpy; + const dataProvider = jest.fn(); + dataProvider.mockImplementationOnce(() => + Promise.resolve({ data: { foo: 'bar' } }) + ); + + let getByTestId; + act(() => { + const res = render( + + + {({ store }) => { + dispatchSpy = jest.spyOn(store, 'dispatch'); + return ( + + {(mutate, { data }) => ( + + )} + + ); + }} + + + ); + getByTestId = res.getByTestId; + }); + + const testElement = getByTestId('test'); + fireEvent.click(testElement); + await waitForDomChange({ container: testElement }); + + expect(dispatchSpy).toHaveBeenCalledWith( + showNotification('Youhou!', 'info', { + messageArgs: {}, + undoable: false, + }) + ); + expect(dispatchSpy).toHaveBeenCalledWith(push('/a_path')); + expect(dispatchSpy).toHaveBeenCalledWith(refreshView()); + expect(dispatchSpy).toHaveBeenCalledWith(setListSelectedIds('foo', [])); + }); + + it('supports onSuccess side effects using hooks', async () => { + let dispatchSpy; + const dataProvider = jest.fn(); + dataProvider.mockImplementationOnce(() => + Promise.resolve({ data: { foo: 'bar' } }) + ); + + const Foo = () => { + const notify = useNotify(); + return ( + { + notify('Youhou!', 'info'); + }, + }} + > + {(mutate, { data }) => ( + + )} + + ); + }; + let getByTestId; + act(() => { + const res = render( + + + {({ store }) => { + dispatchSpy = jest.spyOn(store, 'dispatch'); + return ; + }} + + + ); + getByTestId = res.getByTestId; + }); + + const testElement = getByTestId('test'); + fireEvent.click(testElement); + await waitForDomChange({ container: testElement }); + + expect(dispatchSpy).toHaveBeenCalledWith( + showNotification('Youhou!', 'info', { + messageArgs: {}, + undoable: false, + }) + ); + }); + + it('supports declarative onFailure side effects', async () => { + let dispatchSpy; + const dataProvider = jest.fn(); + dataProvider.mockImplementationOnce(() => + Promise.reject({ message: 'provider error' }) + ); + + let getByTestId; + act(() => { + const res = render( + + + {({ store }) => { + dispatchSpy = jest.spyOn(store, 'dispatch'); + return ( + + {(mutate, { error }) => ( + + )} + + ); + }} + + + ); + getByTestId = res.getByTestId; + }); + + const testElement = getByTestId('test'); + fireEvent.click(testElement); + await waitForDomChange({ container: testElement }); + + expect(dispatchSpy).toHaveBeenCalledWith( + showNotification('Damn!', 'warning', { + messageArgs: {}, + undoable: false, + }) + ); + expect(dispatchSpy).toHaveBeenCalledWith(push('/a_path')); + expect(dispatchSpy).toHaveBeenCalledWith(refreshView()); + expect(dispatchSpy).toHaveBeenCalledWith(setListSelectedIds('foo', [])); + }); + + it('supports onFailure side effects using hooks', async () => { + let dispatchSpy; + const dataProvider = jest.fn(); + dataProvider.mockImplementationOnce(() => + Promise.reject({ message: 'provider error' }) + ); + + const Foo = () => { + const notify = useNotify(); + return ( + { + notify('Damn!', 'warning'); + }, + }} + > + {(mutate, { error }) => ( + + )} + + ); + }; + let getByTestId; + act(() => { + const res = render( + + + {({ store }) => { + dispatchSpy = jest.spyOn(store, 'dispatch'); + return ; + }} + + + ); + getByTestId = res.getByTestId; + }); + + const testElement = getByTestId('test'); + fireEvent.click(testElement); + await waitForDomChange({ container: testElement }); + + expect(dispatchSpy).toHaveBeenCalledWith( + showNotification('Damn!', 'warning', { + messageArgs: {}, + undoable: false, + }) + ); + }); }); diff --git a/packages/ra-core/src/dataProvider/Mutation.tsx b/packages/ra-core/src/dataProvider/Mutation.tsx index 3caa238f31c..d2d03bba269 100644 --- a/packages/ra-core/src/dataProvider/Mutation.tsx +++ b/packages/ra-core/src/dataProvider/Mutation.tsx @@ -47,6 +47,12 @@ const Mutation: FunctionComponent = ({ resource, payload, options, -}) => children(...useMutation({ type, resource, payload }, options)); +}) => + children( + ...useMutation( + { type, resource, payload }, + { ...options, withDeclarativeSideEffectsSupport: true } + ) + ); export default Mutation; diff --git a/packages/ra-core/src/dataProvider/Query.spec.tsx b/packages/ra-core/src/dataProvider/Query.spec.tsx index d8e1ef4e35d..bc1d1f73c4d 100644 --- a/packages/ra-core/src/dataProvider/Query.spec.tsx +++ b/packages/ra-core/src/dataProvider/Query.spec.tsx @@ -7,11 +7,16 @@ import { waitForDomChange, } from '@testing-library/react'; import expect from 'expect'; +import { push } from 'connected-react-router'; + import Query from './Query'; import CoreAdmin from '../CoreAdmin'; import Resource from '../Resource'; import renderWithRedux from '../util/renderWithRedux'; import TestContext from '../util/TestContext'; +import DataProviderContext from './DataProviderContext'; +import { showNotification, refreshView, setListSelectedIds } from '../actions'; +import { useNotify } from '../sideEffect'; describe('Query', () => { afterEach(cleanup); @@ -251,4 +256,243 @@ describe('Query', () => { }); expect(dispatchSpy.mock.calls.length).toEqual(3); }); + + it('supports declarative onSuccess side effects', async () => { + expect.assertions(4); + let dispatchSpy; + const dataProvider = jest.fn(); + dataProvider.mockImplementationOnce(() => + Promise.resolve({ data: [{ id: 1, foo: 'bar' }], total: 42 }) + ); + + let getByTestId; + act(() => { + const res = render( + + + {({ store }) => { + dispatchSpy = jest.spyOn(store, 'dispatch'); + return ( + + {({ loading, data, total }) => ( +
+ {loading ? 'no data' : total} +
+ )} +
+ ); + }} +
+
+ ); + getByTestId = res.getByTestId; + }); + + const testElement = getByTestId('test'); + await waitForDomChange({ container: testElement }); + + expect(dispatchSpy).toHaveBeenCalledWith( + showNotification('Youhou!', 'info', { + messageArgs: {}, + undoable: false, + }) + ); + expect(dispatchSpy).toHaveBeenCalledWith(push('/a_path')); + expect(dispatchSpy).toHaveBeenCalledWith(refreshView()); + expect(dispatchSpy).toHaveBeenCalledWith(setListSelectedIds('foo', [])); + }); + + it('supports onSuccess function for side effects', async () => { + let dispatchSpy; + const dataProvider = jest.fn(); + dataProvider.mockImplementationOnce(() => + Promise.resolve({ data: [{ id: 1, foo: 'bar' }], total: 42 }) + ); + + const Foo = () => { + const notify = useNotify(); + return ( + { + notify('Youhou!', 'info'); + }, + }} + > + {({ loading, data, total }) => ( +
+ {loading ? 'no data' : total} +
+ )} +
+ ); + }; + let getByTestId; + act(() => { + const res = render( + + + {({ store }) => { + dispatchSpy = jest.spyOn(store, 'dispatch'); + return ; + }} + + + ); + getByTestId = res.getByTestId; + }); + + const testElement = getByTestId('test'); + await waitForDomChange({ container: testElement }); + + expect(dispatchSpy).toHaveBeenCalledWith( + showNotification('Youhou!', 'info', { + messageArgs: {}, + undoable: false, + }) + ); + }); + + it('supports declarative onFailure side effects', async () => { + let dispatchSpy; + const dataProvider = jest.fn(); + dataProvider.mockImplementationOnce(() => + Promise.reject({ message: 'provider error' }) + ); + + let getByTestId; + act(() => { + const res = render( + + + {({ store }) => { + dispatchSpy = jest.spyOn(store, 'dispatch'); + return ( + + {({ loading, data, total }) => ( +
+ {loading ? 'no data' : total} +
+ )} +
+ ); + }} +
+
+ ); + getByTestId = res.getByTestId; + }); + + const testElement = getByTestId('test'); + await waitForDomChange({ container: testElement }); + + expect(dispatchSpy).toHaveBeenCalledWith( + showNotification('Damn!', 'warning', { + messageArgs: {}, + undoable: false, + }) + ); + expect(dispatchSpy).toHaveBeenCalledWith(push('/a_path')); + expect(dispatchSpy).toHaveBeenCalledWith(refreshView()); + expect(dispatchSpy).toHaveBeenCalledWith(setListSelectedIds('foo', [])); + }); + + it('supports onFailure function for side effects', async () => { + let dispatchSpy; + const dataProvider = jest.fn(); + dataProvider.mockImplementationOnce(() => + Promise.reject({ message: 'provider error' }) + ); + + const Foo = () => { + const notify = useNotify(); + return ( + { + notify('Damn!', 'warning'); + }, + }} + > + {({ loading, data, total }) => ( +
+ {loading ? 'no data' : total} +
+ )} +
+ ); + }; + let getByTestId; + act(() => { + const res = render( + + + {({ store }) => { + dispatchSpy = jest.spyOn(store, 'dispatch'); + return ; + }} + + + ); + getByTestId = res.getByTestId; + }); + + const testElement = getByTestId('test'); + await waitForDomChange({ container: testElement }); + + expect(dispatchSpy).toHaveBeenCalledWith( + showNotification('Damn!', 'warning', { + messageArgs: {}, + undoable: false, + }) + ); + }); }); diff --git a/packages/ra-core/src/dataProvider/Query.tsx b/packages/ra-core/src/dataProvider/Query.tsx index f7f62cc1576..480f57a359e 100644 --- a/packages/ra-core/src/dataProvider/Query.tsx +++ b/packages/ra-core/src/dataProvider/Query.tsx @@ -61,6 +61,12 @@ const Query: FunctionComponent = ({ resource, payload, options, -}) => children(useQuery({ type, resource, payload }, options)); +}) => + children( + useQuery( + { type, resource, payload }, + { ...options, withDeclarativeSideEffectsSupport: true } + ) + ); export default Query; diff --git a/packages/ra-core/src/dataProvider/useDataProviderWithDeclarativeSideEffects.ts b/packages/ra-core/src/dataProvider/useDataProviderWithDeclarativeSideEffects.ts new file mode 100644 index 00000000000..14d4f8d655d --- /dev/null +++ b/packages/ra-core/src/dataProvider/useDataProviderWithDeclarativeSideEffects.ts @@ -0,0 +1,76 @@ +import { + useNotify, + useRedirect, + useRefresh, + useUnselectAll, +} from '../sideEffect'; +import useDataProvider, { DataProviderHookFunction } from './useDataProvider'; +import { useCallback } from 'react'; + +/** + * This version of the useDataProvider hook ensure Query and Mutation components are still usable + * with side effects declared as objects. + * + * This is for backward compatibility only and will be removed in next major version. + */ +const useDataProviderWithDeclarativeSideEffects = (): DataProviderHookFunction => { + const dataProvider = useDataProvider(); + const notify = useNotify(); + const redirect = useRedirect(); + const refresh = useRefresh(); + const unselectAll = useUnselectAll(); + + return useCallback( + (type: string, resource: string, params: any, options: any = {}) => { + const convertToFunctionSideEffect = sideEffects => { + if (!sideEffects || typeof sideEffects === 'function') { + return sideEffects; + } + + if (Object.keys(sideEffects).length === 0) { + return undefined; + } + + const { + notification, + redirectTo, + refresh: needRefresh, + unselectAll: needUnselectAll, + } = sideEffects; + + return () => { + if (notification) { + notify( + notification.body, + notification.level, + notification.messageArgs + ); + } + + if (redirectTo) { + redirect(redirectTo); + } + + if (needRefresh) { + refresh(); + } + + if (needUnselectAll) { + unselectAll(resource); + } + }; + }; + + const onSuccess = convertToFunctionSideEffect(options.onSuccess); + const onFailure = convertToFunctionSideEffect(options.onFailure); + return dataProvider(type, resource, params, { + ...options, + onSuccess, + onFailure, + }); + }, + [dataProvider, notify, redirect, refresh, unselectAll] + ); +}; + +export default useDataProviderWithDeclarativeSideEffects; diff --git a/packages/ra-core/src/dataProvider/useMutation.ts b/packages/ra-core/src/dataProvider/useMutation.ts index da0691fe930..696c5cf15d6 100644 --- a/packages/ra-core/src/dataProvider/useMutation.ts +++ b/packages/ra-core/src/dataProvider/useMutation.ts @@ -3,17 +3,19 @@ import merge from 'lodash/merge'; import { useSafeSetState } from '../util/hooks'; import useDataProvider from './useDataProvider'; +import useDataProviderWithDeclarativeSideEffects from './useDataProviderWithDeclarativeSideEffects'; -export interface Query { +export interface Mutation { type: string; resource: string; payload: object; } -export interface QueryOptions { +export interface MutationOptions { meta?: any; action?: string; undoable?: boolean; + withDeclarativeSideEffectsSupport?: boolean; } /** @@ -74,8 +76,8 @@ export interface QueryOptions { * }; */ const useMutation = ( - query: Query, - options: QueryOptions = {} + query: Mutation, + options: MutationOptions = {} ): [ (event?: any, callTimePayload?: any, callTimeOptions?: any) => void, { @@ -95,10 +97,17 @@ const useMutation = ( loaded: false, }); const dataProvider = useDataProvider(); + const dataProviderWithDeclarativeSideEffects = useDataProviderWithDeclarativeSideEffects(); + const mutate = useCallback( (event, callTimePayload = {}, callTimeOptions = {}): void => { setState({ loading: true }); - dataProvider( + + const dataProviderWithSideEffects = options.withDeclarativeSideEffectsSupport + ? dataProviderWithDeclarativeSideEffects + : dataProvider; + + dataProviderWithSideEffects( type, resource, merge({}, payload, callTimePayload), diff --git a/packages/ra-core/src/dataProvider/useQuery.ts b/packages/ra-core/src/dataProvider/useQuery.ts index ec39c56ccb2..588b31411bc 100644 --- a/packages/ra-core/src/dataProvider/useQuery.ts +++ b/packages/ra-core/src/dataProvider/useQuery.ts @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { useSafeSetState } from '../util/hooks'; import useDataProvider from './useDataProvider'; +import useDataProviderWithDeclarativeSideEffects from './useDataProviderWithDeclarativeSideEffects'; export interface Query { type: string; @@ -13,6 +14,7 @@ export interface QueryOptions { meta?: any; action?: string; undoable?: false; + withDeclarativeSideEffectsSupport?: boolean; } /** @@ -94,8 +96,14 @@ const useQuery = ( loaded: false, }); const dataProvider = useDataProvider(); + const dataProviderWithDeclarativeSideEffects = useDataProviderWithDeclarativeSideEffects(); + useEffect(() => { - dataProvider(type, resource, payload, options) + const dataProviderWithSideEffects = options.withDeclarativeSideEffectsSupport + ? dataProviderWithDeclarativeSideEffects + : dataProvider; + + dataProviderWithSideEffects(type, resource, payload, options) .then(({ data, total }) => { setState({ data, diff --git a/packages/ra-core/src/dataProvider/withDataProvider.tsx b/packages/ra-core/src/dataProvider/withDataProvider.tsx index a6383550e75..ca0535ea6f4 100644 --- a/packages/ra-core/src/dataProvider/withDataProvider.tsx +++ b/packages/ra-core/src/dataProvider/withDataProvider.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { DataProvider } from '../types'; -import useDataProvider from './useDataProvider'; +import useDataProvider from './useDataProviderWithDeclarativeSideEffects'; export interface DataProviderProps { dataProvider: DataProvider; diff --git a/packages/ra-core/src/sideEffect/useUnselectAll.ts b/packages/ra-core/src/sideEffect/useUnselectAll.ts index ac8e7885a0c..dedfb7f2640 100644 --- a/packages/ra-core/src/sideEffect/useUnselectAll.ts +++ b/packages/ra-core/src/sideEffect/useUnselectAll.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react'; import { useDispatch } from 'react-redux'; import { setListSelectedIds } from '../actions'; +import { warning } from '../util'; /** * Hook for Unselect All Side Effect @@ -10,10 +11,17 @@ import { setListSelectedIds } from '../actions'; * const unselectAll = useUnselectAll('posts'); * unselectAll(); */ -const useUnselectAll = resource1 => { +const useUnselectAll = (resource1?: string) => { const dispatch = useDispatch(); return useCallback( - resource2 => dispatch(setListSelectedIds(resource2 || resource1, [])), + (resource2?: string) => { + warning( + !resource2 && !resource1, + "You didn't specify the resource at initialization (useUnselectAll('posts')) nor when using the callback (unselectAll('posts'))" + ); + + dispatch(setListSelectedIds(resource2 || resource1, [])); + }, [dispatch, resource1] ); };