diff --git a/UPGRADE.md b/UPGRADE.md index e76a2689507..a2a027bef44 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -355,3 +355,167 @@ const PostList = props => ( // rest of the view ); ``` + +## New DataProviderContext Requires Custom App Modification + +The new dataProvider-related hooks (`useQuery`, `useMutation`, `useDataProvider`, etc.) grab the `dataProvider` instance from a new React context. If you use the `` component, your app will continue to work and there is nothing to do, as `` now provides that context. But if you use a Custom App, you'll need to set the value of that new `DataProvider` context: + +```diff +-import { TranslationProvider, Resource } from 'react-admin'; ++import { TranslationProvider, DataProviderContext, Resource } from 'react-admin'; + +const App = () => ( + + ++ + + + ... + + + + My admin + + + + + + + } /> + } /> + } /> + } /> + ... + + + ++ + + +); +``` + +Note that if you were unit testing controller components, you'll probably need to add a mock `dataProvider` via `` in your tests, too. + +## Custom Notification Must Emit UndoEvents + +The undo feature is partially implemented in the `Notification` component. If you've overridden that component, you'll have to add a call to `undoableEventEmitter` in case of confirmation and undo: + +```diff +// in src/MyNotification.js +import { + hideNotification, + getNotification, + translate, + undo, + complete, ++ undoableEventEmitter, +} from 'ra-core'; + +class Notification extends React.Component { + state = { + open: false, + }; + componentWillMount = () => { + this.setOpenState(this.props); + }; + componentWillReceiveProps = nextProps => { + this.setOpenState(nextProps); + }; + + setOpenState = ({ notification }) => { + this.setState({ + open: !!notification, + }); + }; + + handleRequestClose = () => { + this.setState({ + open: false, + }); + }; + + handleExited = () => { + const { notification, hideNotification, complete } = this.props; + if (notification && notification.undoable) { + complete(); ++ undoableEventEmitter.emit('end', { isUndo: false }); + } + hideNotification(); + }; + + handleUndo = () => { + const { undo } = this.props; + undo(); ++ undoableEventEmitter.emit('end', { isUndo: true }); + }; + + render() { + const { + undo, + complete, + classes, + className, + type, + translate, + notification, + autoHideDuration, + hideNotification, + ...rest + } = this.props; + const { + warning, + confirm, + undo: undoClass, // Rename classes.undo to undoClass in this scope to avoid name conflicts + ...snackbarClasses + } = classes; + return ( + + {translate('ra.action.undo')} + + ) : null + } + classes={snackbarClasses} + {...rest} + /> + ); + } +} +``` diff --git a/packages/ra-core/package.json b/packages/ra-core/package.json index 38d7f9d5684..f494004e1a9 100644 --- a/packages/ra-core/package.json +++ b/packages/ra-core/package.json @@ -53,6 +53,7 @@ "classnames": "~2.2.5", "connected-react-router": "^6.4.0", "date-fns": "^1.29.0", + "eventemitter3": "^3.0.0", "inflection": "~1.12.0", "lodash": "~4.17.5", "node-polyglot": "^2.2.2", diff --git a/packages/ra-core/src/CoreAdmin.tsx b/packages/ra-core/src/CoreAdmin.tsx index f74d69948de..af1de6273ae 100644 --- a/packages/ra-core/src/CoreAdmin.tsx +++ b/packages/ra-core/src/CoreAdmin.tsx @@ -5,9 +5,9 @@ import { History } from 'history'; import { createHashHistory } from 'history'; import { Switch, Route } from 'react-router-dom'; import { ConnectedRouter } from 'connected-react-router'; -import withContext from 'recompose/withContext'; import AuthContext from './auth/AuthContext'; +import DataProviderContext from './dataProvider/DataProviderContext'; import createAdminStore from './createAdminStore'; import TranslationProvider from './i18n/TranslationProvider'; import CoreAdminRouter from './CoreAdminRouter'; @@ -92,6 +92,7 @@ React-admin requires a valid dataProvider function to work.`); layout, appLayout, authProvider, + dataProvider, children, customRoutes = [], dashboard, @@ -118,44 +119,46 @@ React-admin requires a valid dataProvider function to work.`); } return ( - - - - {loginPage !== false && loginPage !== true ? ( + + + + + {loginPage !== false && loginPage !== true ? ( + + createElement(loginPage, { + ...props, + title, + theme, + }) + } + /> + ) : null} - createElement(loginPage, { - ...props, - title, - theme, - }) - } + path="/" + render={props => ( + + {children} + + )} /> - ) : null} - ( - - {children} - - )} - /> - - - + + + + ); } diff --git a/packages/ra-core/src/controller/useCreateController.ts b/packages/ra-core/src/controller/useCreateController.ts index 4c82bf30304..192c7411127 100644 --- a/packages/ra-core/src/controller/useCreateController.ts +++ b/packages/ra-core/src/controller/useCreateController.ts @@ -3,7 +3,7 @@ import { useCallback } from 'react'; import inflection from 'inflection'; import { parse } from 'query-string'; -import { useCreate } from '../fetch'; +import { useCreate } from '../dataProvider'; import { useCheckMinimumRequiredProps } from './checkMinimumRequiredProps'; import { Location } from 'history'; import { match as Match } from 'react-router'; diff --git a/packages/ra-core/src/controller/useEditController.spec.tsx b/packages/ra-core/src/controller/useEditController.spec.tsx index a36b7fdc803..71f9e1663d2 100644 --- a/packages/ra-core/src/controller/useEditController.spec.tsx +++ b/packages/ra-core/src/controller/useEditController.spec.tsx @@ -53,7 +53,7 @@ describe('useEditController', () => { {({ record }) =>
{record && record.title}
} ); - const formResetAction = dispatch.mock.calls[1][0]; + const formResetAction = dispatch.mock.calls[3][0]; expect(formResetAction.type).toEqual('@@redux-form/RESET'); expect(formResetAction.meta).toEqual({ form: 'record-form' }); }); @@ -69,15 +69,17 @@ describe('useEditController', () => { ); act(() => saveCallback({ foo: 'bar' })); - const crudUpdateAction = dispatch.mock.calls[2][0]; - expect(crudUpdateAction.type).toEqual('RA/UNDOABLE'); - expect(crudUpdateAction.payload.action.type).toEqual('RA/CRUD_UPDATE'); - expect(crudUpdateAction.payload.action.payload).toEqual({ + const call = dispatch.mock.calls.find( + params => params[0].type === 'RA/CRUD_UPDATE_OPTIMISTIC' + ); + expect(call).not.toBeUndefined(); + const crudUpdateAction = call[0]; + expect(crudUpdateAction.payload).toEqual({ id: 12, data: { foo: 'bar' }, previousData: null, }); - expect(crudUpdateAction.payload.action.meta.resource).toEqual('posts'); + expect(crudUpdateAction.meta.resource).toEqual('posts'); }); it('should return a save callback when undoable is false', () => { @@ -91,8 +93,15 @@ describe('useEditController', () => { ); act(() => saveCallback({ foo: 'bar' })); - const crudUpdateAction = dispatch.mock.calls[2][0]; - expect(crudUpdateAction.type).toEqual('RA/CRUD_UPDATE'); + const call = dispatch.mock.calls.find( + params => params[0].type === 'RA/CRUD_UPDATE_OPTIMISTIC' + ); + expect(call).toBeUndefined(); + const call2 = dispatch.mock.calls.find( + params => params[0].type === 'RA/CRUD_UPDATE' + ); + expect(call2).not.toBeUndefined(); + const crudUpdateAction = call2[0]; expect(crudUpdateAction.payload).toEqual({ id: 12, data: { foo: 'bar' }, diff --git a/packages/ra-core/src/controller/useEditController.ts b/packages/ra-core/src/controller/useEditController.ts index c2bd9649b04..7f3e039f09f 100644 --- a/packages/ra-core/src/controller/useEditController.ts +++ b/packages/ra-core/src/controller/useEditController.ts @@ -13,7 +13,7 @@ import { useRefresh, RedirectionSideEffect, } from '../sideEffect'; -import { useGetOne, useUpdate } from '../fetch'; +import { useGetOne, useUpdate } from '../dataProvider'; import { useTranslate } from '../i18n'; export interface EditProps { diff --git a/packages/ra-core/src/controller/useListController.ts b/packages/ra-core/src/controller/useListController.ts index cd9063dea22..a1ac625bb17 100644 --- a/packages/ra-core/src/controller/useListController.ts +++ b/packages/ra-core/src/controller/useListController.ts @@ -11,7 +11,7 @@ import { SORT_ASC } from '../reducer/admin/resource/list/queryReducer'; import { ListParams } from '../actions/listActions'; import { useNotify } from '../sideEffect'; import { Sort, RecordMap, Identifier } from '../types'; -import useGetList from './../fetch/useGetList'; +import useGetList from '../dataProvider/useGetList'; export interface ListProps { // the props you can change diff --git a/packages/ra-core/src/controller/useShowController.ts b/packages/ra-core/src/controller/useShowController.ts index c5bc8ee0874..e3b9a36652f 100644 --- a/packages/ra-core/src/controller/useShowController.ts +++ b/packages/ra-core/src/controller/useShowController.ts @@ -3,7 +3,7 @@ import inflection from 'inflection'; import useVersion from './useVersion'; import { useCheckMinimumRequiredProps } from './checkMinimumRequiredProps'; import { Record, Identifier } from '../types'; -import { useGetOne } from '../fetch'; +import { useGetOne } from '../dataProvider'; import { useTranslate } from '../i18n'; import { useNotify, useRedirect, useRefresh } from '../sideEffect'; diff --git a/packages/ra-core/src/dataProvider/DataProviderContext.ts b/packages/ra-core/src/dataProvider/DataProviderContext.ts new file mode 100644 index 00000000000..d02a4c2f2e3 --- /dev/null +++ b/packages/ra-core/src/dataProvider/DataProviderContext.ts @@ -0,0 +1,7 @@ +import { createContext } from 'react'; + +import { DataProvider } from '../types'; + +const DataProviderContext = createContext(null); + +export default DataProviderContext; diff --git a/packages/ra-core/src/fetch/HttpError.ts b/packages/ra-core/src/dataProvider/HttpError.ts similarity index 100% rename from packages/ra-core/src/fetch/HttpError.ts rename to packages/ra-core/src/dataProvider/HttpError.ts diff --git a/packages/ra-core/src/fetch/Mutation.spec.tsx b/packages/ra-core/src/dataProvider/Mutation.spec.tsx similarity index 100% rename from packages/ra-core/src/fetch/Mutation.spec.tsx rename to packages/ra-core/src/dataProvider/Mutation.spec.tsx diff --git a/packages/ra-core/src/fetch/Mutation.tsx b/packages/ra-core/src/dataProvider/Mutation.tsx similarity index 100% rename from packages/ra-core/src/fetch/Mutation.tsx rename to packages/ra-core/src/dataProvider/Mutation.tsx diff --git a/packages/ra-core/src/fetch/Query.spec.tsx b/packages/ra-core/src/dataProvider/Query.spec.tsx similarity index 96% rename from packages/ra-core/src/fetch/Query.spec.tsx rename to packages/ra-core/src/dataProvider/Query.spec.tsx index 2049f9ee19a..9771a64c40e 100644 --- a/packages/ra-core/src/fetch/Query.spec.tsx +++ b/packages/ra-core/src/dataProvider/Query.spec.tsx @@ -41,7 +41,6 @@ describe('Query', () => { const action = dispatchSpy.mock.calls[0][0]; expect(action.type).toEqual('CUSTOM_FETCH'); expect(action.payload).toEqual(myPayload); - expect(action.meta.fetch).toEqual('mytype'); expect(action.meta.resource).toEqual('myresource'); }); @@ -182,6 +181,7 @@ describe('Query', () => { }} ); + expect(dispatchSpy.mock.calls.length).toEqual(3); const mySecondPayload = { foo: 1 }; act(() => { rerender( @@ -198,11 +198,10 @@ describe('Query', () => { ); }); - expect(dispatchSpy.mock.calls.length).toEqual(2); - const action = dispatchSpy.mock.calls[1][0]; + expect(dispatchSpy.mock.calls.length).toEqual(6); + const action = dispatchSpy.mock.calls[3][0]; expect(action.type).toEqual('CUSTOM_FETCH'); expect(action.payload).toEqual(mySecondPayload); - expect(action.meta.fetch).toEqual('mytype'); expect(action.meta.resource).toEqual('myresource'); }); @@ -229,6 +228,7 @@ describe('Query', () => { }} ); + expect(dispatchSpy.mock.calls.length).toEqual(3); act(() => { const myPayload = { foo: { @@ -249,6 +249,6 @@ describe('Query', () => { ); }); - expect(dispatchSpy.mock.calls.length).toEqual(1); + expect(dispatchSpy.mock.calls.length).toEqual(3); }); }); diff --git a/packages/ra-core/src/fetch/Query.tsx b/packages/ra-core/src/dataProvider/Query.tsx similarity index 100% rename from packages/ra-core/src/fetch/Query.tsx rename to packages/ra-core/src/dataProvider/Query.tsx diff --git a/packages/ra-core/src/fetch/fetch.spec.ts b/packages/ra-core/src/dataProvider/fetch.spec.ts similarity index 100% rename from packages/ra-core/src/fetch/fetch.spec.ts rename to packages/ra-core/src/dataProvider/fetch.spec.ts diff --git a/packages/ra-core/src/fetch/fetch.ts b/packages/ra-core/src/dataProvider/fetch.ts similarity index 100% rename from packages/ra-core/src/fetch/fetch.ts rename to packages/ra-core/src/dataProvider/fetch.ts diff --git a/packages/ra-core/src/fetch/index.ts b/packages/ra-core/src/dataProvider/index.ts similarity index 84% rename from packages/ra-core/src/fetch/index.ts rename to packages/ra-core/src/dataProvider/index.ts index b09254dad77..b537022fb4a 100644 --- a/packages/ra-core/src/fetch/index.ts +++ b/packages/ra-core/src/dataProvider/index.ts @@ -1,7 +1,9 @@ +import DataProviderContext from './DataProviderContext'; import HttpError from './HttpError'; import * as fetchUtils from './fetch'; import Mutation from './Mutation'; import Query from './Query'; +import undoableEventEmitter from './undoableEventEmitter'; import useDataProvider from './useDataProvider'; import useMutation from './useMutation'; import useQuery from './useQuery'; @@ -16,10 +18,12 @@ import useDelete from './useDelete'; import useDeleteMany from './useDeleteMany'; export { - fetchUtils, + DataProviderContext, HttpError, + fetchUtils, Mutation, Query, + undoableEventEmitter, useDataProvider, useMutation, useQuery, diff --git a/packages/ra-core/src/dataProvider/undoableEventEmitter.ts b/packages/ra-core/src/dataProvider/undoableEventEmitter.ts new file mode 100644 index 00000000000..227709375d7 --- /dev/null +++ b/packages/ra-core/src/dataProvider/undoableEventEmitter.ts @@ -0,0 +1,3 @@ +import EventEmitter from 'eventemitter3'; + +export default new EventEmitter(); diff --git a/packages/ra-core/src/fetch/useCreate.ts b/packages/ra-core/src/dataProvider/useCreate.ts similarity index 100% rename from packages/ra-core/src/fetch/useCreate.ts rename to packages/ra-core/src/dataProvider/useCreate.ts diff --git a/packages/ra-core/src/dataProvider/useDataProvider.ts b/packages/ra-core/src/dataProvider/useDataProvider.ts new file mode 100644 index 00000000000..9f5b1d71577 --- /dev/null +++ b/packages/ra-core/src/dataProvider/useDataProvider.ts @@ -0,0 +1,294 @@ +import { useCallback, useContext } from 'react'; +import { Dispatch } from 'redux'; +import { useDispatch, useSelector } from 'react-redux'; + +import DataProviderContext from './DataProviderContext'; +import validateResponseFormat from './validateResponseFormat'; +import undoableEventEmitter from './undoableEventEmitter'; +import { + startOptimisticMode, + stopOptimisticMode, +} from '../actions/undoActions'; +import { FETCH_END, FETCH_ERROR, FETCH_START } from '../actions/fetchActions'; +import { showNotification } from '../actions/notificationActions'; +import { refreshView } from '../actions/uiActions'; +import { ReduxState, DataProvider } from '../types'; + +type DataProviderHookFunction = ( + type: string, + resource: string, + params: any, + options?: UseDataProviderOptions +) => Promise<{ data?: any; total?: any; error?: any }>; + +interface UseDataProviderOptions { + action?: string; + meta?: object; + undoable?: boolean; + onSuccess?: any; + onFailure?: any; +} + +const defaultDataProvider = () => Promise.resolve(); // avoids adding a context in tests + +/** + * Hook for getting an instance of the dataProvider as prop + * + * Gets a dataProvider function, which behaves just like the real dataProvider + * (same signature, returns a Promise), but dispatches Redux actions along the + * process. The benefit is that react-admin tracks the loading state when using + * this function, shows the loader animation while the dataProvider is waiting + * for a response, and executes side effects when the response arrives. + * + * In addition to the 3 parameters of the dataProvider function (verb, resource, + * payload), the returned function accepts a fourth parameter, an object + * literal which may contain side effects, or make the action optimistic (with + * undoable: true). + * + * @return dataProvider (type, resource, payload, options) => Promise + * + * @example + * + * import React, { useState } from 'react'; + * import { useDispatch } from 'react-redux'; + * import { useDataProvider, showNotification } from 'react-admin'; + * + * const PostList = () => { + * const [posts, setPosts] = useState([]) + * const dispatch = useDispatch(); + * const dataProvider = useDataProvider(); + * + * useEffect(() => { + * dataProvider('GET_LIST', 'posts', { filter: { status: 'pending' }}) + * .then(({ data }) => setPosts(data)) + * .catch(error => dispatch(showNotification(error.message, 'error'))); + * }, []) + * + * return ( + * + * {posts.map((post, key) => )} + * + * } + * } + */ +const useDataProvider = (): DataProviderHookFunction => { + const dispatch = useDispatch() as Dispatch; + const dataProvider = useContext(DataProviderContext) || defaultDataProvider; + const isOptimistic = useSelector( + (state: ReduxState) => state.admin.ui.optimistic + ); + + return useCallback( + ( + type: string, + resource: string, + payload: any, + options: UseDataProviderOptions = {} + ) => { + const { + action = 'CUSTOM_FETCH', + undoable = false, + onSuccess = {}, + onFailure = {}, + ...rest + } = options; + + if (isOptimistic) { + // in optimistic mode, all fetch actions are canceled, + // so the admin uses the store without synchronization + return Promise.resolve(); + } + + const params = { + type, + payload, + resource, + action, + rest, + successFunc: getSideEffectFunc(onSuccess), + failureFunc: getSideEffectFunc(onFailure), + dataProvider, + dispatch, + }; + return undoable + ? performUndoableQuery(params) + : performQuery(params); + }, + [dataProvider, dispatch, isOptimistic] + ); +}; + +const getSideEffectFunc = (effect): ((args: any) => void) => + effect instanceof Function ? effect : () => null; + +/** + * In undoable mode, the hook dispatches an optimistic action and executes + * the success side effects right away. Then it waits for a few seconds to + * actually call the dataProvider - unless the user dispatches an Undo action. + * + * We call that "optimistic" because the hook returns a resolved Promise + * immediately (although it has an empty value). That only works if the + * caller reads the result from the Redux store, not from the Promise. + */ +const performUndoableQuery = ({ + type, + payload, + resource, + action, + rest, + successFunc, + failureFunc, + dataProvider, + dispatch, +}: QueryFunctionParams) => { + dispatch(startOptimisticMode()); + dispatch({ + type: action, + payload, + meta: { resource, ...rest }, + }); + dispatch({ + type: `${action}_OPTIMISTIC`, + payload, + meta: { + resource, + fetch: type, + optimistic: true, + }, + }); + successFunc({}); + undoableEventEmitter.once('end', ({ isUndo }) => { + dispatch(stopOptimisticMode()); + if (isUndo) { + dispatch(showNotification('ra.notification.canceled')); + dispatch(refreshView()); + return; + } + dispatch({ + type: `${action}_LOADING`, + payload, + meta: { resource, ...rest }, + }); + dispatch({ type: FETCH_START }); + dataProvider(type, resource, payload) + .then(response => { + if (process.env.NODE_ENV !== 'production') { + validateResponseFormat(response, type); + } + dispatch({ + type: `${action}_SUCCESS`, + payload: response, + requestPayload: payload, + meta: { + ...rest, + resource, + fetchResponse: type, + fetchStatus: FETCH_END, + }, + }); + dispatch({ type: FETCH_END }); + }) + .catch(error => { + dispatch({ + type: `${action}_FAILURE`, + error: error.message ? error.message : error, + payload: error.body ? error.body : null, + requestPayload: payload, + meta: { + ...rest, + resource, + fetchResponse: type, + fetchStatus: FETCH_ERROR, + }, + }); + dispatch({ type: FETCH_ERROR, error }); + failureFunc(error); + throw new Error(error.message ? error.message : error); + }); + }); + return Promise.resolve({}); +}; + +/** + * In normal mode, the hook calls the dataProvider. When a successful response + * arrives, the hook dispatches a SUCCESS action, executes success side effects + * and returns the response. If the response is an error, the hook dispatches + * a FAILURE action, executes failure side effects, and throws an error. + */ +const performQuery = ({ + type, + payload, + resource, + action, + rest, + successFunc, + failureFunc, + dataProvider, + dispatch, +}: QueryFunctionParams) => { + dispatch({ + type: action, + payload, + meta: { resource, ...rest }, + }); + dispatch({ + type: `${action}_LOADING`, + payload, + meta: { resource, ...rest }, + }); + dispatch({ type: FETCH_START }); + + return dataProvider(type, resource, payload) + .then(response => { + if (process.env.NODE_ENV !== 'production') { + validateResponseFormat(response, type); + } + dispatch({ + type: `${action}_SUCCESS`, + payload: response, + requestPayload: payload, + meta: { + ...rest, + resource, + fetchResponse: type, + fetchStatus: FETCH_END, + }, + }); + dispatch({ type: FETCH_END }); + successFunc(response); + return response; + }) + .catch(error => { + dispatch({ + type: `${action}_FAILURE`, + error: error.message ? error.message : error, + payload: error.body ? error.body : null, + requestPayload: payload, + meta: { + ...rest, + resource, + fetchResponse: type, + fetchStatus: FETCH_ERROR, + }, + }); + dispatch({ type: FETCH_ERROR, error }); + failureFunc(error); + throw new Error(error.message ? error.message : error); + }); +}; + +interface QueryFunctionParams { + /** The fetch type, e.g. `UPDATE_MANY` */ + type: string; + payload: any; + resource: string; + /** The root action name, e.g. `CRUD_GET_MANY` */ + action: string; + rest: any; + successFunc: (args?: any) => void; + failureFunc: (error: any) => void; + dataProvider: DataProvider; + dispatch: Dispatch; +} + +export default useDataProvider; diff --git a/packages/ra-core/src/fetch/useDelete.ts b/packages/ra-core/src/dataProvider/useDelete.ts similarity index 100% rename from packages/ra-core/src/fetch/useDelete.ts rename to packages/ra-core/src/dataProvider/useDelete.ts diff --git a/packages/ra-core/src/fetch/useDeleteMany.ts b/packages/ra-core/src/dataProvider/useDeleteMany.ts similarity index 100% rename from packages/ra-core/src/fetch/useDeleteMany.ts rename to packages/ra-core/src/dataProvider/useDeleteMany.ts diff --git a/packages/ra-core/src/fetch/useGetList.ts b/packages/ra-core/src/dataProvider/useGetList.ts similarity index 97% rename from packages/ra-core/src/fetch/useGetList.ts rename to packages/ra-core/src/dataProvider/useGetList.ts index d9421bb5d29..a3dd9459a9a 100644 --- a/packages/ra-core/src/fetch/useGetList.ts +++ b/packages/ra-core/src/dataProvider/useGetList.ts @@ -2,7 +2,7 @@ import { useSelector, shallowEqual } from 'react-redux'; import { CRUD_GET_LIST } from '../actions/dataActions/crudGetList'; import { GET_LIST } from '../dataFetchActions'; import { Pagination, Sort, ReduxState } from '../types'; -import useQueryWithStore from '../fetch/useQueryWithStore'; +import useQueryWithStore from './useQueryWithStore'; /** * Call the dataProvider with a GET_LIST verb and return the result as well as the loading state. diff --git a/packages/ra-core/src/fetch/useGetOne.ts b/packages/ra-core/src/dataProvider/useGetOne.ts similarity index 96% rename from packages/ra-core/src/fetch/useGetOne.ts rename to packages/ra-core/src/dataProvider/useGetOne.ts index f7306c034af..9f716ce0dd6 100644 --- a/packages/ra-core/src/fetch/useGetOne.ts +++ b/packages/ra-core/src/dataProvider/useGetOne.ts @@ -1,7 +1,7 @@ import { CRUD_GET_ONE } from '../actions/dataActions/crudGetOne'; import { GET_ONE } from '../dataFetchActions'; import { Identifier, ReduxState } from '../types'; -import useQueryWithStore from '../fetch/useQueryWithStore'; +import useQueryWithStore from './useQueryWithStore'; /** * Call the dataProvider with a GET_ONE verb and return the result as well as the loading state. diff --git a/packages/ra-core/src/fetch/useMutation.spec.tsx b/packages/ra-core/src/dataProvider/useMutation.spec.tsx similarity index 98% rename from packages/ra-core/src/fetch/useMutation.spec.tsx rename to packages/ra-core/src/dataProvider/useMutation.spec.tsx index 534582e7bc3..0598da81886 100644 --- a/packages/ra-core/src/fetch/useMutation.spec.tsx +++ b/packages/ra-core/src/dataProvider/useMutation.spec.tsx @@ -38,7 +38,6 @@ describe('useMutation', () => { const action = dispatch.mock.calls[0][0]; expect(action.type).toEqual('CUSTOM_FETCH'); expect(action.payload).toEqual(myPayload); - expect(action.meta.fetch).toEqual('mytype'); expect(action.meta.resource).toEqual('myresource'); }); diff --git a/packages/ra-core/src/fetch/useMutation.ts b/packages/ra-core/src/dataProvider/useMutation.ts similarity index 100% rename from packages/ra-core/src/fetch/useMutation.ts rename to packages/ra-core/src/dataProvider/useMutation.ts diff --git a/packages/ra-core/src/fetch/useQuery.ts b/packages/ra-core/src/dataProvider/useQuery.ts similarity index 100% rename from packages/ra-core/src/fetch/useQuery.ts rename to packages/ra-core/src/dataProvider/useQuery.ts diff --git a/packages/ra-core/src/fetch/useQueryWithStore.ts b/packages/ra-core/src/dataProvider/useQueryWithStore.ts similarity index 91% rename from packages/ra-core/src/fetch/useQueryWithStore.ts rename to packages/ra-core/src/dataProvider/useQueryWithStore.ts index a20dddea227..4cd78dc95de 100644 --- a/packages/ra-core/src/fetch/useQueryWithStore.ts +++ b/packages/ra-core/src/dataProvider/useQueryWithStore.ts @@ -124,6 +124,12 @@ const useQueryWithStore = ( useEffect(() => { dataProvider(type, resource, payload, options) .then(() => { + // We don't care about the dataProvider response here, because + // it was already passed to SUCCESS reducers by the dataProvider + // hook, and the result is available from the Redux store + // through the data and total selectors. + // In addition, if the query is optimistic, the response + // will be empty, so it should not be used at all. setState(prevState => ({ ...prevState, loading: false, diff --git a/packages/ra-core/src/fetch/useUpdate.ts b/packages/ra-core/src/dataProvider/useUpdate.ts similarity index 100% rename from packages/ra-core/src/fetch/useUpdate.ts rename to packages/ra-core/src/dataProvider/useUpdate.ts diff --git a/packages/ra-core/src/fetch/useUpdateMany.ts b/packages/ra-core/src/dataProvider/useUpdateMany.ts similarity index 100% rename from packages/ra-core/src/fetch/useUpdateMany.ts rename to packages/ra-core/src/dataProvider/useUpdateMany.ts diff --git a/packages/ra-core/src/dataProvider/validateResponseFormat.ts b/packages/ra-core/src/dataProvider/validateResponseFormat.ts new file mode 100644 index 00000000000..64a7514e873 --- /dev/null +++ b/packages/ra-core/src/dataProvider/validateResponseFormat.ts @@ -0,0 +1,59 @@ +import { + fetchActionsWithRecordResponse, + fetchActionsWithArrayOfIdentifiedRecordsResponse, + fetchActionsWithArrayOfRecordsResponse, + fetchActionsWithTotalResponse, +} from '../dataFetchActions'; + +function validateResponseFormat( + response, + type, + logger = console.error // eslint-disable-line no-console +) { + if (!response.hasOwnProperty('data')) { + logger( + `The response to '${type}' must be like { data: ... }, but the received response does not have a 'data' key. The dataProvider is probably wrong for '${type}'.` + ); + throw new Error('ra.notification.data_provider_error'); + } + if ( + fetchActionsWithArrayOfRecordsResponse.includes(type) && + !Array.isArray(response.data) + ) { + logger( + `The response to '${type}' must be like { data : [...] }, but the received data is not an array. The dataProvider is probably wrong for '${type}'` + ); + throw new Error('ra.notification.data_provider_error'); + } + if ( + fetchActionsWithArrayOfIdentifiedRecordsResponse.includes(type) && + Array.isArray(response.data) && + response.data.length > 0 && + !response.data[0].hasOwnProperty('id') + ) { + logger( + `The response to '${type}' must be like { data : [{ id: 123, ...}, ...] }, but the received data items do not have an 'id' key. The dataProvider is probably wrong for '${type}'` + ); + throw new Error('ra.notification.data_provider_error'); + } + if ( + fetchActionsWithRecordResponse.includes(type) && + !response.data.hasOwnProperty('id') + ) { + logger( + `The response to '${type}' must be like { data: { id: 123, ... } }, but the received data does not have an 'id' key. The dataProvider is probably wrong for '${type}'` + ); + throw new Error('ra.notification.data_provider_error'); + } + if ( + fetchActionsWithTotalResponse.includes(type) && + !response.hasOwnProperty('total') + ) { + logger( + `The response to '${type}' must be like { data: [...], total: 123 }, but the received response does not have a 'total' key. The dataProvider is probably wrong for '${type}'` + ); + throw new Error('ra.notification.data_provider_error'); + } +} + +export default validateResponseFormat; diff --git a/packages/ra-core/src/fetch/withDataProvider.tsx b/packages/ra-core/src/dataProvider/withDataProvider.tsx similarity index 100% rename from packages/ra-core/src/fetch/withDataProvider.tsx rename to packages/ra-core/src/dataProvider/withDataProvider.tsx diff --git a/packages/ra-core/src/fetch/useDataProvider.ts b/packages/ra-core/src/fetch/useDataProvider.ts deleted file mode 100644 index 8ebeacb786d..00000000000 --- a/packages/ra-core/src/fetch/useDataProvider.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { useMemo } from 'react'; -import { Dispatch } from 'redux'; -import { useDispatch } from 'react-redux'; - -import { startUndoable } from '../actions/undoActions'; - -interface UseDataProviderOptions { - action?: string; - meta?: object; - undoable?: boolean; - onSuccess?: any; - onFailure?: any; -} - -const getSideEffects = (effects): [(args: any) => void, any] => { - let functionSideEffect = () => null; - let objectSideEffect = {}; - if (effects instanceof Function) { - functionSideEffect = effects; - } else { - objectSideEffect = effects; - } - return [functionSideEffect, objectSideEffect]; -}; - -/** - * Hook for getting an instance of the dataProvider as prop - * - * Gets a dataProvider function, which behaves just like - * the real dataProvider (same signature, returns a Promise), but - * uses Redux under the hood. The benefit is that react-admin tracks - * the loading state when using this function, and shows the loader animation - * while the dataProvider is waiting for a response. - * - * In addition to the 3 parameters of the dataProvider function (verb, resource, payload), - * the injected dataProvider prop accepts a fourth parameter, an object literal - * which may contain side effects, or make the action optimistic (with undoable: true). - * - * @example - * - * import React, { useState } from 'react'; - * import { useDispatch } from 'react-redux'; - * import { useDataProvider, showNotification } from 'react-admin'; - * - * const PostList = () => { - * const [posts, setPosts] = useState([]) - * const dispatch = useDispatch(); - * const dataProvider = useDataProvider(); - * - * useEffect(() => { - * dataProvider('GET_LIST', 'posts', { filter: { status: 'pending' }}) - * .then(({ data }) => setPosts(data)) - * .catch(error => dispatch(showNotification(error.message, 'error'))); - * }, []) - * - * return ( - * - * {posts.map((post, key) => )} - * - * } - * } - */ -const useDataProvider = () => { - const dispatch = useDispatch() as Dispatch; - - return useMemo( - () => ( - type: string, - resource: string, - payload: any, - options: UseDataProviderOptions = {} - ) => { - const { - action = 'CUSTOM_FETCH', - undoable = false, - onSuccess = {}, - onFailure = {}, - ...rest - } = options; - const [successFunc, successObj] = getSideEffects(onSuccess); - const [failureFunc, failureObj] = getSideEffects(onFailure); - return new Promise((resolve, reject) => { - const queryAction = { - type: action, - payload, - meta: { - ...rest, - resource, - fetch: type, - onSuccess: { - ...successObj, - callback: ({ payload: response }) => { - resolve(response); - if (successObj.callback) { - successObj.callback({ payload: response }); - } - successFunc(response); - return; - }, - }, - onFailure: { - ...failureObj, - callback: ({ error }) => { - const exception = new Error( - error.message ? error.message : error - ); - reject(exception); - if (failureObj.callback) { - failureObj.callback({ error }); - } - failureFunc(exception); - return; - }, - }, - }, - }; - - return undoable - ? dispatch(startUndoable(queryAction)) - : dispatch(queryAction); - }); - }, - [] // eslint-disable-line react-hooks/exhaustive-deps - ); -}; - -export default useDataProvider; diff --git a/packages/ra-core/src/index.ts b/packages/ra-core/src/index.ts index 60a57683f06..96fdfb9c939 100644 --- a/packages/ra-core/src/index.ts +++ b/packages/ra-core/src/index.ts @@ -22,7 +22,7 @@ export { export * from './dataFetchActions'; export * from './actions'; export * from './auth'; -export * from './fetch'; +export * from './dataProvider'; export * from './i18n'; export * from './inference'; export * from './util'; diff --git a/packages/ra-ui-materialui/src/layout/Notification.js b/packages/ra-ui-materialui/src/layout/Notification.js index e0a3edfcb99..43dd027e078 100644 --- a/packages/ra-ui-materialui/src/layout/Notification.js +++ b/packages/ra-ui-materialui/src/layout/Notification.js @@ -13,6 +13,7 @@ import { translate, undo, complete, + undoableEventEmitter, } from 'ra-core'; const styles = theme => @@ -55,10 +56,17 @@ class Notification extends React.Component { const { notification, hideNotification, complete } = this.props; if (notification && notification.undoable) { complete(); + undoableEventEmitter.emit('end', { isUndo: false }); } hideNotification(); }; + handleUndo = () => { + const { undo } = this.props; + undo(); + undoableEventEmitter.emit('end', { isUndo: true }); + }; + render() { const { undo, @@ -107,7 +115,7 @@ class Notification extends React.Component { color="primary" className={undoClass} size="small" - onClick={undo} + onClick={this.handleUndo} > {translate('ra.action.undo')}