From 11f97bf96ff072a154e382cfc31efee448f51c2c Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 30 Apr 2019 17:52:56 +0200 Subject: [PATCH 01/12] Add useDataProvider Hook --- packages/ra-core/src/util/index.ts | 2 + packages/ra-core/src/util/useDataProvider.ts | 84 +++++++++++++++++++ ...thDataProvider.ts => withDataProvider.tsx} | 55 +++--------- 3 files changed, 97 insertions(+), 44 deletions(-) create mode 100644 packages/ra-core/src/util/useDataProvider.ts rename packages/ra-core/src/util/{withDataProvider.ts => withDataProvider.tsx} (53%) diff --git a/packages/ra-core/src/util/index.ts b/packages/ra-core/src/util/index.ts index 52f502471f7..1a47b001000 100644 --- a/packages/ra-core/src/util/index.ts +++ b/packages/ra-core/src/util/index.ts @@ -11,6 +11,7 @@ import removeEmpty from './removeEmpty'; import removeKey from './removeKey'; import resolveRedirectTo from './resolveRedirectTo'; import TestContext from './TestContext'; +import useDataProvider from './useDataProvider'; import warning from './warning'; import withDataProvider from './withDataProvider'; import * as fetchUtils from './fetch'; @@ -30,6 +31,7 @@ export { removeKey, resolveRedirectTo, TestContext, + useDataProvider, warning, withDataProvider, }; diff --git a/packages/ra-core/src/util/useDataProvider.ts b/packages/ra-core/src/util/useDataProvider.ts new file mode 100644 index 00000000000..fde51c5314d --- /dev/null +++ b/packages/ra-core/src/util/useDataProvider.ts @@ -0,0 +1,84 @@ +import { useMemo } from 'react'; +import { Dispatch } from 'redux'; +// @ts-ignore +import { useDispatch } from 'react-redux'; +import get from 'lodash/get'; + +import { startUndoable } from '../actions/undoActions'; + +/** + * Hook for getting an instance of the dataProvider as prop + * + * Injects a dataProvider function prop, which behaves just like + * the dataProvider function (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, of 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 = deps => { + const dispatch = useDispatch() as Dispatch; + + return useMemo( + () => (type, resource: string, payload: any, meta: any = {}) => + new Promise((resolve, reject) => { + const action = { + type: 'CUSTOM_FETCH', + payload, + meta: { + ...meta, + resource, + fetch: type, + onSuccess: { + ...get(meta, 'onSuccess', {}), + callback: ({ payload: response }) => + resolve(response), + }, + onFailure: { + ...get(meta, 'onFailure', {}), + callback: ({ error }) => + reject( + new Error( + error.message ? error.message : error + ) + ), + }, + }, + }; + + return meta.undoable + ? dispatch(startUndoable(action)) + : dispatch(action); + }), + deps + ); +}; + +export default useDataProvider; diff --git a/packages/ra-core/src/util/withDataProvider.ts b/packages/ra-core/src/util/withDataProvider.tsx similarity index 53% rename from packages/ra-core/src/util/withDataProvider.ts rename to packages/ra-core/src/util/withDataProvider.tsx index d1c42f5482f..1da1e158c2b 100644 --- a/packages/ra-core/src/util/withDataProvider.ts +++ b/packages/ra-core/src/util/withDataProvider.tsx @@ -1,46 +1,12 @@ -import { Dispatch, AnyAction } from 'redux'; -import { connect } from 'react-redux'; -import get from 'lodash/get'; - -import { startUndoable } from '../actions/undoActions'; +import React from 'react'; import { DataProvider } from '../types'; -interface DispatchProps { +import useDataProvider from './useDataProvider'; + +export interface DataProviderProps { dataProvider: DataProvider; - dispatch: Dispatch; } -const mapDispatchToProps = (dispatch): DispatchProps => ({ - dataProvider: (type, resource: string, payload: any, meta: any = {}) => - new Promise((resolve, reject) => { - const action = { - type: 'CUSTOM_FETCH', - payload, - meta: { - ...meta, - resource, - fetch: type, - onSuccess: { - ...get(meta, 'onSuccess', {}), - callback: ({ payload: response }) => resolve(response), - }, - onFailure: { - ...get(meta, 'onFailure', {}), - callback: ({ error }) => - reject( - new Error(error.message ? error.message : error) - ), - }, - }, - }; - - return meta.undoable - ? dispatch(startUndoable(action)) - : dispatch(action); - }), - dispatch, -}); - /** * Higher-order component for fetching the dataProvider * @@ -76,7 +42,7 @@ const mapDispatchToProps = (dispatch): DispatchProps => ({ * const { posts } = this.state; * return ( * - * {posts.map((post, index) => )} + * {posts.map((post, key) => )} * * ); * } @@ -88,10 +54,11 @@ const mapDispatchToProps = (dispatch): DispatchProps => ({ * * export default withDataProvider(PostList); */ -const withDataProvider = (Component) => - connect<{}, DispatchProps, T>( - null, - mapDispatchToProps - )(Component as any); +const withDataProvider =

( + Component: React.ComponentType

, + deps?: any[] +): React.SFC

=> (props: P) => ( + +); export default withDataProvider; From ed75b8c0d80654ff43d7f4dd6a5be788d1d11964 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 30 Apr 2019 18:01:54 +0200 Subject: [PATCH 02/12] Fix memoization --- packages/ra-core/src/util/useDataProvider.ts | 4 ++-- packages/ra-core/src/util/withDataProvider.tsx | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/ra-core/src/util/useDataProvider.ts b/packages/ra-core/src/util/useDataProvider.ts index fde51c5314d..085d70aec45 100644 --- a/packages/ra-core/src/util/useDataProvider.ts +++ b/packages/ra-core/src/util/useDataProvider.ts @@ -43,7 +43,7 @@ import { startUndoable } from '../actions/undoActions'; * } * } */ -const useDataProvider = deps => { +const useDataProvider = () => { const dispatch = useDispatch() as Dispatch; return useMemo( @@ -77,7 +77,7 @@ const useDataProvider = deps => { ? dispatch(startUndoable(action)) : dispatch(action); }), - deps + [] ); }; diff --git a/packages/ra-core/src/util/withDataProvider.tsx b/packages/ra-core/src/util/withDataProvider.tsx index 1da1e158c2b..a6383550e75 100644 --- a/packages/ra-core/src/util/withDataProvider.tsx +++ b/packages/ra-core/src/util/withDataProvider.tsx @@ -55,10 +55,9 @@ export interface DataProviderProps { * export default withDataProvider(PostList); */ const withDataProvider =

( - Component: React.ComponentType

, - deps?: any[] + Component: React.ComponentType

): React.SFC

=> (props: P) => ( - + ); export default withDataProvider; From 799d133d3394cece999238a44fcefd547f39a3a4 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 30 Apr 2019 18:38:04 +0200 Subject: [PATCH 03/12] First attempts at building a useQuery hook --- packages/ra-core/src/util/useQuery.ts | 95 +++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 packages/ra-core/src/util/useQuery.ts diff --git a/packages/ra-core/src/util/useQuery.ts b/packages/ra-core/src/util/useQuery.ts new file mode 100644 index 00000000000..82ac824b6fc --- /dev/null +++ b/packages/ra-core/src/util/useQuery.ts @@ -0,0 +1,95 @@ +import { useReducer, useRef, useEffect } from 'react'; +import isEqual from 'lodash/isEqual'; +import useDataProvider from './useDataProvider'; + +interface State { + data?: any; + total?: number; + loading: boolean; + loaded: boolean; + error?: any; +} + +/** + * Fetch the data provider and return the result. + * + * @example + * + * const UserProfile = ({ record }) => { + * const { data, loading, error } = useQuery('GET_ONE', 'users', { id: record.id }); + * if (loading) { return ; } + * if (error) { return

ERROR

; } + * return
User {data.username}
; + * }; + * + * @example + * + * const payload = { + * pagination: { page: 1, perPage: 10 }, + * sort: { field: 'username', order: 'ASC' }, + * }; + * const UserList = () => { + * const { data, total, loading, error } = useQuery('GET_LIST', 'users', payload); + * if (loading) { return ; } + * if (error) { return

ERROR

; } + * return ( + *
+ *

Total users: {total}

+ *
    + * {data.map(user =>
  • {user.username}
  • )} + *
+ *
+ * ); + * }; + */ +const useQuery = ( + type: string, + resource: string, + payload?: any, + options?: any +): State => { + const [state, setState] = useReducer( + (prevState, newState) => ({ ...prevState, ...newState }), + { + data: null, + error: null, + total: null, + loading: false, + loaded: false, + } + ); + const dataProvider = useDataProvider(); + useEffect(() => { + if ( + isEqual(previousInputs.current, [type, resource, payload, options]) + ) { + return; + } + setState({ loading: true }); + dataProvider(type, resource, payload, options) + .then(({ data: dataFromResponse, total: totalFromResponse }) => { + setState({ + data: dataFromResponse, + total: totalFromResponse, + loading: false, + loaded: true, + }); + }) + .catch(errorFromResponse => { + setState({ + error: errorFromResponse, + loading: false, + loaded: false, + }); + }); + }); + + const previousInputs = useRef(); + useEffect(() => { + previousInputs.current = [type, resource, payload, options]; + }); + + return state; +}; + +export default useQuery; From 9b5ca6d01ec86237dc16b842ce08a33fa0a4c0a5 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 30 Apr 2019 19:18:03 +0200 Subject: [PATCH 04/12] Add useQuery hook --- examples/demo/src/dashboard/Dashboard.js | 30 +------ examples/demo/src/dashboard/NewCustomers.js | 99 +++++++++++++-------- packages/ra-core/src/util/Query.tsx | 83 +++-------------- packages/ra-core/src/util/index.ts | 2 + packages/ra-core/src/util/useQuery.ts | 72 ++++++++++----- 5 files changed, 126 insertions(+), 160 deletions(-) diff --git a/examples/demo/src/dashboard/Dashboard.js b/examples/demo/src/dashboard/Dashboard.js index 042bee3042a..6fade6ca15c 100644 --- a/examples/demo/src/dashboard/Dashboard.js +++ b/examples/demo/src/dashboard/Dashboard.js @@ -35,7 +35,6 @@ class Dashboard extends Component { fetchData() { this.fetchOrders(); this.fetchReviews(); - this.fetchCustomers(); } async fetchOrders() { @@ -112,34 +111,10 @@ class Dashboard extends Component { }); } - async fetchCustomers() { - const { dataProvider } = this.props; - const aMonthAgo = new Date(); - aMonthAgo.setDate(aMonthAgo.getDate() - 30); - const { data: newCustomers } = await dataProvider( - GET_LIST, - 'customers', - { - filter: { - has_ordered: true, - first_seen_gte: aMonthAgo.toISOString(), - }, - sort: { field: 'first_seen', order: 'DESC' }, - pagination: { page: 1, perPage: 100 }, - } - ); - this.setState({ - newCustomers, - nbNewCustomers: newCustomers.reduce(nb => ++nb, 0), - }); - } - render() { const { - nbNewCustomers, nbNewOrders, nbPendingReviews, - newCustomers, pendingOrders, pendingOrdersCustomers, pendingReviews, @@ -208,10 +183,7 @@ class Dashboard extends Component { reviews={pendingReviews} customers={pendingReviewsCustomers} /> - + diff --git a/examples/demo/src/dashboard/NewCustomers.js b/examples/demo/src/dashboard/NewCustomers.js index 1460f552b9a..8474fe7b90e 100644 --- a/examples/demo/src/dashboard/NewCustomers.js +++ b/examples/demo/src/dashboard/NewCustomers.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import compose from 'recompose/compose'; import Card from '@material-ui/core/Card'; import List from '@material-ui/core/List'; @@ -10,7 +10,7 @@ import Typography from '@material-ui/core/Typography'; import CustomerIcon from '@material-ui/icons/PersonAdd'; import Divider from '@material-ui/core/Divider'; import { Link } from 'react-router-dom'; -import { translate } from 'react-admin'; +import { translate, useQuery, GET_LIST } from 'react-admin'; import CardIcon from './CardIcon'; @@ -40,43 +40,64 @@ const styles = theme => ({ }, }); -const NewCustomers = ({ visitors = [], nb, translate, classes }) => ( -
- - - - {translate('pos.dashboard.new_customers')} - - - {nb} - - - - {visitors.map(record => ( - - - - - ))} - - -
-); +const NewCustomers = ({ translate, classes }) => { + const aMonthAgo = useMemo(() => { + const date = new Date(); + date.setDate(date.getDate() - 30); + return date; + }, []); + + const { loaded, data: visitors } = useQuery(GET_LIST, 'customers', { + filter: { + has_ordered: true, + first_seen_gte: aMonthAgo.toISOString(), + }, + sort: { field: 'first_seen', order: 'DESC' }, + pagination: { page: 1, perPage: 100 }, + }); + + if (!loaded) return null; + const nb = visitors.reduce(nb => ++nb, 0); + return ( +
+ + + + {translate('pos.dashboard.new_customers')} + + + {nb} + + + + {visitors.map(record => ( + + + + + ))} + + +
+ ); +}; const enhance = compose( withStyles(styles), diff --git a/packages/ra-core/src/util/Query.tsx b/packages/ra-core/src/util/Query.tsx index a565961534c..bf2a959f39b 100644 --- a/packages/ra-core/src/util/Query.tsx +++ b/packages/ra-core/src/util/Query.tsx @@ -1,40 +1,22 @@ -import { Component, ReactNode } from 'react'; -import { shallowEqual } from 'recompose'; -import withDataProvider from './withDataProvider'; - -type DataProviderCallback = ( - type: string, - resource: string, - payload?: any, - options?: any -) => Promise; +import { FunctionComponent, ReactElement } from 'react'; +import useQuery from './useQuery'; interface ChildrenFuncParams { data?: any; total?: number; loading: boolean; + loaded: boolean; error?: any; } -interface RawProps { - children: (params: ChildrenFuncParams) => ReactNode; +interface Props { + children: (params: ChildrenFuncParams) => ReactElement; type: string; resource: string; payload?: any; options?: any; } -interface Props extends RawProps { - dataProvider: DataProviderCallback; -} - -interface State { - data?: any; - total?: number; - loading: boolean; - error?: any; -} - /** * Fetch the data provider and pass the result to a child function * @@ -73,51 +55,12 @@ interface State { * * ); */ -class Query extends Component { - state = { - data: null, - total: null, - loading: true, - error: null, - }; - - callDataProvider = () => { - const { dataProvider, type, resource, payload, options } = this.props; - dataProvider(type, resource, payload, options) - .then(({ data, total }) => { - this.setState({ - data, - total, - loading: false, - }); - }) - .catch(error => { - this.setState({ - error, - loading: false, - }); - }); - }; - - componentDidMount = () => { - this.callDataProvider(); - }; - - componentDidUpdate = prevProps => { - if ( - prevProps.type !== this.props.type || - prevProps.resource !== this.props.resource || - !shallowEqual(prevProps.payload, this.props.payload) || - !shallowEqual(prevProps.options, this.props.options) - ) { - this.callDataProvider(); - } - }; - - render() { - const { children } = this.props; - return children(this.state); - } -} +const Query: FunctionComponent = ({ + children, + type, + resource, + payload, + options, +}) => children(useQuery(type, resource, payload, options)); -export default withDataProvider(Query); +export default Query; diff --git a/packages/ra-core/src/util/index.ts b/packages/ra-core/src/util/index.ts index 1a47b001000..fb49903036d 100644 --- a/packages/ra-core/src/util/index.ts +++ b/packages/ra-core/src/util/index.ts @@ -12,6 +12,7 @@ import removeKey from './removeKey'; import resolveRedirectTo from './resolveRedirectTo'; import TestContext from './TestContext'; import useDataProvider from './useDataProvider'; +import useQuery from './useQuery'; import warning from './warning'; import withDataProvider from './withDataProvider'; import * as fetchUtils from './fetch'; @@ -32,6 +33,7 @@ export { resolveRedirectTo, TestContext, useDataProvider, + useQuery, warning, withDataProvider, }; diff --git a/packages/ra-core/src/util/useQuery.ts b/packages/ra-core/src/util/useQuery.ts index 82ac824b6fc..677381a5a81 100644 --- a/packages/ra-core/src/util/useQuery.ts +++ b/packages/ra-core/src/util/useQuery.ts @@ -10,6 +10,47 @@ interface State { error?: any; } +// thanks Kent C Dodds for the following helpers + +function useSetState(initialState) { + return useReducer( + (state, newState) => ({ ...state, ...newState }), + initialState + ); +} + +function useSafeSetState(initialState) { + const [state, setState] = useSetState(initialState); + + const mountedRef = useRef(false); + useEffect(() => { + mountedRef.current = true; + return () => (mountedRef.current = false); + }, []); + const safeSetState = args => mountedRef.current && setState(args); + + return [state, safeSetState]; +} + +function usePrevious(value) { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }); + return ref.current; +} + +function useDeepCompareEffect(callback, inputs) { + const cleanupRef = useRef(); + useEffect(() => { + if (!isEqual(previousInputs, inputs)) { + cleanupRef.current = callback(); + } + return cleanupRef.current; + }); + const previousInputs = usePrevious(inputs); +} + /** * Fetch the data provider and return the result. * @@ -48,23 +89,15 @@ const useQuery = ( payload?: any, options?: any ): State => { - const [state, setState] = useReducer( - (prevState, newState) => ({ ...prevState, ...newState }), - { - data: null, - error: null, - total: null, - loading: false, - loaded: false, - } - ); + const [state, setState] = useSafeSetState({ + data: null, + error: null, + total: null, + loading: false, + loaded: false, + }); const dataProvider = useDataProvider(); - useEffect(() => { - if ( - isEqual(previousInputs.current, [type, resource, payload, options]) - ) { - return; - } + useDeepCompareEffect(() => { setState({ loading: true }); dataProvider(type, resource, payload, options) .then(({ data: dataFromResponse, total: totalFromResponse }) => { @@ -82,12 +115,7 @@ const useQuery = ( loaded: false, }); }); - }); - - const previousInputs = useRef(); - useEffect(() => { - previousInputs.current = [type, resource, payload, options]; - }); + }, [type, resource, payload, options]); return state; }; From 338eccef36c3f3dd021a5215ef452c319d254c35 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 30 Apr 2019 19:55:21 +0200 Subject: [PATCH 05/12] Fix initial loading value in useQuery --- packages/ra-core/src/util/useQuery.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/ra-core/src/util/useQuery.ts b/packages/ra-core/src/util/useQuery.ts index 677381a5a81..c6a5e083b69 100644 --- a/packages/ra-core/src/util/useQuery.ts +++ b/packages/ra-core/src/util/useQuery.ts @@ -93,12 +93,11 @@ const useQuery = ( data: null, error: null, total: null, - loading: false, + loading: true, loaded: false, }); const dataProvider = useDataProvider(); useDeepCompareEffect(() => { - setState({ loading: true }); dataProvider(type, resource, payload, options) .then(({ data: dataFromResponse, total: totalFromResponse }) => { setState({ From 805ce8862aae3a1f6f8581cb98ab62199a4963a4 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Wed, 1 May 2019 00:36:15 +0200 Subject: [PATCH 06/12] Fix Query tests --- packages/ra-core/package.json | 2 +- packages/ra-core/src/util/Query.spec.tsx | 139 +++++++++++++---------- yarn.lock | 40 +++---- 3 files changed, 97 insertions(+), 84 deletions(-) diff --git a/packages/ra-core/package.json b/packages/ra-core/package.json index 305c5a873d4..a209a0fe66f 100644 --- a/packages/ra-core/package.json +++ b/packages/ra-core/package.json @@ -41,7 +41,7 @@ "react": "~16.8.0", "react-dom": "~16.8.0", "react-test-renderer": "~16.8.6", - "react-testing-library": "^5.2.3", + "react-testing-library": "^7.0.0", "rimraf": "^2.6.3" }, "peerDependencies": { diff --git a/packages/ra-core/src/util/Query.spec.tsx b/packages/ra-core/src/util/Query.spec.tsx index 27c3559d3d3..bb495df901c 100644 --- a/packages/ra-core/src/util/Query.spec.tsx +++ b/packages/ra-core/src/util/Query.spec.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { render, cleanup, - fireEvent, + act, // @ts-ignore waitForDomChange, } from 'react-testing-library'; @@ -31,22 +31,25 @@ describe('Query', () => { it('should dispatch a fetch action when mounting', () => { let dispatchSpy; const myPayload = {}; - render( - - {({ store }) => { - dispatchSpy = jest.spyOn(store, 'dispatch'); - return ( - - {() =>
Hello
} -
- ); - }} -
- ); + act(() => { + render( + + {({ store }) => { + dispatchSpy = jest.spyOn(store, 'dispatch'); + return ( + + {() =>
Hello
} +
+ ); + }} +
+ ); + }); + const action = dispatchSpy.mock.calls[0][0]; expect(action.type).toEqual('CUSTOM_FETCH'); expect(action.payload).toEqual(myPayload); @@ -93,14 +96,19 @@ describe('Query', () => { )} ); - const { getByTestId } = render( - - - - ); + let getByTestId; + act(() => { + const res = render( + + + + ); + getByTestId = res.getByTestId; + }); const testElement = getByTestId('test'); expect(testElement.textContent).toBe('no data'); expect(testElement.className).toEqual('loading'); + await waitForDomChange({ container: testElement }); expect(testElement.textContent).toEqual('bar'); expect(testElement.className).toEqual('idle'); @@ -124,13 +132,15 @@ describe('Query', () => { )} ); - - const { getByTestId } = render( - - - - ); - + let getByTestId; + act(() => { + const res = render( + + + + ); + getByTestId = res.getByTestId; + }); const testElement = getByTestId('test'); expect(testElement.className).toEqual('loading'); expect(testElement.textContent).toBe('no data'); @@ -157,14 +167,19 @@ describe('Query', () => { )} ); - const { getByTestId } = render( - - - - ); + let getByTestId; + act(() => { + const res = render( + + + + ); + getByTestId = res.getByTestId; + }); const testElement = getByTestId('test'); expect(testElement.textContent).toBe('no data'); expect(testElement.className).toEqual('loading'); + await waitForDomChange({ container: testElement }); expect(testElement.textContent).toEqual('provider error'); expect(testElement.className).toEqual('idle'); @@ -190,19 +205,21 @@ describe('Query', () => { ); const mySecondPayload = { foo: 1 }; - rerender( - - {() => ( - - {() =>
Hello
} -
- )} -
- ); + act(() => { + rerender( + + {() => ( + + {() =>
Hello
} +
+ )} +
+ ); + }); expect(dispatchSpy.mock.calls.length).toEqual(2); const action = dispatchSpy.mock.calls[1][0]; expect(action.type).toEqual('CUSTOM_FETCH'); @@ -230,19 +247,21 @@ describe('Query', () => { }} ); - rerender( - - {() => ( - - {() =>
Hello
} -
- )} -
- ); + act(() => { + rerender( + + {() => ( + + {() =>
Hello
} +
+ )} +
+ ); + }); expect(dispatchSpy.mock.calls.length).toEqual(1); }); }); diff --git a/yarn.lock b/yarn.lock index 1888ad18cd5..37bc6b5bb27 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6375,14 +6375,15 @@ dom-serializer@0, dom-serializer@~0.1.0: domelementtype "~1.1.1" entities "~1.1.1" -dom-testing-library@^3.12.0: - version "3.12.2" - resolved "https://registry.yarnpkg.com/dom-testing-library/-/dom-testing-library-3.12.2.tgz#9828a94f6c505780bb1e1e9e29afd319594e01a4" - integrity sha512-kMj5UFm5lSxnMDtD6XWkJuzK0CX+XdMd5NSnvtpneeYQFORTF5Q5C6mdweH6hhwFcKYzAsHCfnvldg9c8HuFOA== +dom-testing-library@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/dom-testing-library/-/dom-testing-library-4.0.1.tgz#f21ef42aea0bd635969b4227a487e4704dbea735" + integrity sha512-Yr0yWlpI2QdTDEgPEk0TEekwP4VyZlJpl9E7nKP2FCKni44cb1jzjsy9KX6hBDsNA7EVlPpq9DHzO2eoEaqDZg== dependencies: + "@babel/runtime" "^7.4.3" "@sheerun/mutationobserver-shim" "^0.3.2" - pretty-format "^23.6.0" - wait-for-expect "^1.0.0" + pretty-format "^24.7.0" + wait-for-expect "^1.1.1" dom-walk@^0.1.0: version "0.1.1" @@ -13352,14 +13353,6 @@ pretty-error@^2.0.2, pretty-error@^2.1.1: renderkid "^2.0.1" utila "~0.4" -pretty-format@^23.6.0: - version "23.6.0" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-23.6.0.tgz#5eaac8eeb6b33b987b7fe6097ea6a8a146ab5760" - integrity sha512-zf9NV1NSlDLDjycnwm6hpFATCGl/K1lt0R/GdkAK2O5LN/rwJoB+Mh93gGJjut4YbmecbfgLWVGSTCr0Ewvvbw== - dependencies: - ansi-regex "^3.0.0" - ansi-styles "^3.2.0" - pretty-format@^24.7.0: version "24.7.0" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-24.7.0.tgz#d23106bc2edcd776079c2daa5da02bcb12ed0c10" @@ -13972,12 +13965,13 @@ react-test-renderer@~16.8.6: react-is "^16.8.6" scheduler "^0.13.6" -react-testing-library@^5.2.3: - version "5.2.3" - resolved "https://registry.yarnpkg.com/react-testing-library/-/react-testing-library-5.2.3.tgz#c3be44bfa5eb1ba2acc1fb218785c40ebbdfe8ed" - integrity sha512-Bw52++7uORuIQnL55lK/WQfppqAc9+8yFG4lWUp/kmSOvYDnt8J9oI5fNCfAGSQi9iIhAv9aNsI2G5rtid0nrA== +react-testing-library@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/react-testing-library/-/react-testing-library-7.0.0.tgz#d3b535e44de94d7b0a83c56cd2e3cfed752dcec1" + integrity sha512-8SHqwG+uhN9VhAgNVkVa3f7VjTw/L5CIaoAxKmy+EZuDQ6O+VsfcpRAyUw3MDL1h8S/gGrEiazmHBVL/uXsftA== dependencies: - dom-testing-library "^3.12.0" + "@babel/runtime" "^7.4.3" + dom-testing-library "^4.0.0" react-themeable@^1.1.0: version "1.1.0" @@ -16705,10 +16699,10 @@ w3c-xmlserializer@^1.1.2: webidl-conversions "^4.0.2" xml-name-validator "^3.0.0" -wait-for-expect@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/wait-for-expect/-/wait-for-expect-1.0.1.tgz#73ab346ed56ed2ef66c380a59fd623755ceac0ce" - integrity sha512-TPZMSxGWUl2DWmqdspLDEy97/S1Mqq0pzbh2A7jTq0WbJurUb5GKli+bai6ayeYdeWTF0rQNWZmUvCVZ9gkrfA== +wait-for-expect@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/wait-for-expect/-/wait-for-expect-1.1.1.tgz#9cd10e07d52810af9e0aaf509872e38f3c3d81ae" + integrity sha512-vd9JOqqEcBbCDhARWhW85ecjaEcfBLuXgVBqatfS3iw6oU4kzAcs+sCNjF+TC9YHPImCW7ypsuQc+htscIAQCw== wait-on@^3.2.0: version "3.2.0" From 2b1a9d4903353e077ac3eaa3f0f2078d2c1d3850 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Thu, 2 May 2019 00:14:45 +0200 Subject: [PATCH 07/12] Add useMutation hook --- examples/demo/src/reviews/AcceptButton.js | 47 ++++++------- packages/ra-core/src/util/Mutation.tsx | 62 +++++------------ packages/ra-core/src/util/hooks.ts | 43 ++++++++++++ packages/ra-core/src/util/index.ts | 2 + packages/ra-core/src/util/useMutation.ts | 83 ++++++++++++++++++++++ packages/ra-core/src/util/useQuery.ts | 85 ++++++++--------------- 6 files changed, 196 insertions(+), 126 deletions(-) create mode 100644 packages/ra-core/src/util/hooks.ts create mode 100644 packages/ra-core/src/util/useMutation.ts diff --git a/examples/demo/src/reviews/AcceptButton.js b/examples/demo/src/reviews/AcceptButton.js index bdabf49b6e6..bcba6798e50 100644 --- a/examples/demo/src/reviews/AcceptButton.js +++ b/examples/demo/src/reviews/AcceptButton.js @@ -4,7 +4,7 @@ import { connect } from 'react-redux'; import { formValueSelector } from 'redux-form'; import Button from '@material-ui/core/Button'; import ThumbUp from '@material-ui/icons/ThumbUp'; -import { translate, Mutation } from 'react-admin'; +import { translate, useMutation } from 'react-admin'; import compose from 'recompose/compose'; const sideEffects = { @@ -24,34 +24,33 @@ const sideEffects = { }; /** - * This custom button demonstrate using to update data + * This custom button demonstrate using useMutation to update data */ -const AcceptButton = ({ record, translate }) => - record && record.status === 'pending' ? ( - { + const [approve, { loading }] = useMutation( + 'UPDATE', + 'reviews', + { id: record.id, data: { status: 'accepted' } }, + sideEffects + ); + return record && record.status === 'pending' ? ( + - )} - + + {translate('resources.reviews.action.accept')} + ) : ( ); +}; AcceptButton.propTypes = { record: PropTypes.object, diff --git a/packages/ra-core/src/util/Mutation.tsx b/packages/ra-core/src/util/Mutation.tsx index 6613dfb22a8..d1923e7dc59 100644 --- a/packages/ra-core/src/util/Mutation.tsx +++ b/packages/ra-core/src/util/Mutation.tsx @@ -1,5 +1,5 @@ -import { Component, ReactNode } from 'react'; -import withDataProvider from './withDataProvider'; +import { FunctionComponent, ReactElement } from 'react'; +import useMutation from './useMutation'; type DataProviderCallback = ( type: string, @@ -14,24 +14,17 @@ interface ChildrenFuncParams { error?: any; } -interface RawProps { - children: (mutate: () => void, params: ChildrenFuncParams) => ReactNode; +interface Props { + children: ( + mutate: () => void, + params: ChildrenFuncParams + ) => ReactElement; type: string; resource: string; payload?: any; options?: any; } -interface Props extends RawProps { - dataProvider: DataProviderCallback; -} - -interface State { - data?: any; - loading: boolean; - error?: any; -} - /** * Craft a callback to fetch the data provider and pass it to a child function * @@ -49,35 +42,12 @@ interface State { * * ); */ -class Mutation extends Component { - state = { - data: null, - loading: false, - error: null, - }; - - mutate = () => { - this.setState({ loading: true }); - const { dataProvider, type, resource, payload, options } = this.props; - dataProvider(type, resource, payload, options) - .then(({ data }) => { - this.setState({ - data, - loading: false, - }); - }) - .catch(error => { - this.setState({ - error, - loading: false, - }); - }); - }; - - render() { - const { children } = this.props; - return children(this.mutate, this.state); - } -} - -export default withDataProvider(Mutation); +const Mutation: FunctionComponent = ({ + children, + type, + resource, + payload, + options, +}) => children(...useMutation(type, resource, payload, options)); + +export default Mutation; diff --git a/packages/ra-core/src/util/hooks.ts b/packages/ra-core/src/util/hooks.ts new file mode 100644 index 00000000000..aac901f52fd --- /dev/null +++ b/packages/ra-core/src/util/hooks.ts @@ -0,0 +1,43 @@ +import { useReducer, useRef, useEffect } from 'react'; +import isEqual from 'lodash/isEqual'; + +// thanks Kent C Dodds for the following helpers + +export function useSetState(initialState) { + return useReducer( + (state, newState) => ({ ...state, ...newState }), + initialState + ); +} + +export function useSafeSetState(initialState) { + const [state, setState] = useSetState(initialState); + + const mountedRef = useRef(false); + useEffect(() => { + mountedRef.current = true; + return () => (mountedRef.current = false); + }, []); + const safeSetState = args => mountedRef.current && setState(args); + + return [state, safeSetState]; +} + +export function usePrevious(value) { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }); + return ref.current; +} + +export function useDeepCompareEffect(callback, inputs) { + const cleanupRef = useRef(); + useEffect(() => { + if (!isEqual(previousInputs, inputs)) { + cleanupRef.current = callback(); + } + return cleanupRef.current; + }); + const previousInputs = usePrevious(inputs); +} diff --git a/packages/ra-core/src/util/index.ts b/packages/ra-core/src/util/index.ts index fb49903036d..6dc973ee62f 100644 --- a/packages/ra-core/src/util/index.ts +++ b/packages/ra-core/src/util/index.ts @@ -12,6 +12,7 @@ import removeKey from './removeKey'; import resolveRedirectTo from './resolveRedirectTo'; import TestContext from './TestContext'; import useDataProvider from './useDataProvider'; +import useMutation from './useMutation'; import useQuery from './useQuery'; import warning from './warning'; import withDataProvider from './withDataProvider'; @@ -33,6 +34,7 @@ export { resolveRedirectTo, TestContext, useDataProvider, + useMutation, useQuery, warning, withDataProvider, diff --git a/packages/ra-core/src/util/useMutation.ts b/packages/ra-core/src/util/useMutation.ts new file mode 100644 index 00000000000..08136122f93 --- /dev/null +++ b/packages/ra-core/src/util/useMutation.ts @@ -0,0 +1,83 @@ +import { useCallback, useEffect } from 'react'; +import { useSafeSetState } from './hooks'; +import useDataProvider from './useDataProvider'; + +/** + * Returns a callback to fetch the data provider through Redux, usually for mutations + * + * The request starts when the callback is called. + * + * The return value updates according to the request state: + * + * - mount: { loading: false, loaded: false } + * - mutate called: { loading: true, loaded: false } + * - success: { data: [data from response], total: [total from response], loading: false, loaded: true } + * - error: { error: [error from response], loading: false, loaded: true } + * + * @param type The verb passed to th data provider, e.g. 'UPDATE' + * @param resource A resource name, e.g. 'posts', 'comments' + * @param payload The payload object, e.g; { id: 123, data: { isApproved: true } } + * @param meta Redux action metas, including side effects to be executed upon success of failure, e.g. { onSuccess: { refresh: true } } + * + * @returns A tuple with the mutation callback and the request state]. Destructure as [mutate, { data, total, error, loading, loaded }]. + * + * @example + * + * import { useMutation } from 'react-admin'; + * + * const ApproveButton = ({ record }) => { + * const [approve, { loading }] = useMutation( + * 'UPDATE', + * 'comments', + * { id: record.id, data: { isApproved: true } } + * ); + * return ; + * }; + */ +const useMutation = ( + type: string, + resource: string, + payload?: any, + meta?: any +): [ + () => void, + { + data?: any; + total?: number; + error?: any; + loading: boolean; + loaded: boolean; + } +] => { + const [state, setState] = useSafeSetState({ + data: null, + error: null, + total: null, + loading: false, + loaded: false, + }); + const dataProvider = useDataProvider(); + const mutate = useCallback(() => { + setState({ loading: true }); + dataProvider(type, resource, payload, meta) + .then(({ data: dataFromResponse, total: totalFromResponse }) => { + setState({ + data: dataFromResponse, + total: totalFromResponse, + loading: false, + loaded: true, + }); + }) + .catch(errorFromResponse => { + setState({ + error: errorFromResponse, + loading: false, + loaded: false, + }); + }); + }, [JSON.stringify({ type, resource, payload, meta })]); // see https://github.com/facebook/react/issues/14476#issuecomment-471199055 + + return [mutate, state]; +}; + +export default useMutation; diff --git a/packages/ra-core/src/util/useQuery.ts b/packages/ra-core/src/util/useQuery.ts index c6a5e083b69..fa0a626bdee 100644 --- a/packages/ra-core/src/util/useQuery.ts +++ b/packages/ra-core/src/util/useQuery.ts @@ -1,61 +1,26 @@ -import { useReducer, useRef, useEffect } from 'react'; -import isEqual from 'lodash/isEqual'; +import { useSafeSetState, useDeepCompareEffect } from './hooks'; import useDataProvider from './useDataProvider'; -interface State { - data?: any; - total?: number; - loading: boolean; - loaded: boolean; - error?: any; -} - -// thanks Kent C Dodds for the following helpers - -function useSetState(initialState) { - return useReducer( - (state, newState) => ({ ...state, ...newState }), - initialState - ); -} - -function useSafeSetState(initialState) { - const [state, setState] = useSetState(initialState); - - const mountedRef = useRef(false); - useEffect(() => { - mountedRef.current = true; - return () => (mountedRef.current = false); - }, []); - const safeSetState = args => mountedRef.current && setState(args); - - return [state, safeSetState]; -} - -function usePrevious(value) { - const ref = useRef(); - useEffect(() => { - ref.current = value; - }); - return ref.current; -} - -function useDeepCompareEffect(callback, inputs) { - const cleanupRef = useRef(); - useEffect(() => { - if (!isEqual(previousInputs, inputs)) { - cleanupRef.current = callback(); - } - return cleanupRef.current; - }); - const previousInputs = usePrevious(inputs); -} - /** - * Fetch the data provider and return the result. + * Fetch the data provider through Redux + * + * The return value updates according to the request state: + * + * - start: { loading: true, loaded: false } + * - success: { data: [data from response], total: [total from response], loading: false, loaded: true } + * - error: { error: [error from response], loading: false, loaded: true } + * + * @param type The verb passed to th data provider, e.g. 'GET_LIST', 'GET_ONE' + * @param resource A resource name, e.g. 'posts', 'comments' + * @param payload The payload object, e.g; { post_id: 12 } + * @param meta Redux action metas, including side effects to be executed upon success of failure, e.g. { onSuccess: { refresh: true } } + * + * @returns The current request state. Destructure as { data, total, error, loading, loaded }. * * @example * + * import { useQuery } from 'react-admin'; + * * const UserProfile = ({ record }) => { * const { data, loading, error } = useQuery('GET_ONE', 'users', { id: record.id }); * if (loading) { return ; } @@ -65,6 +30,8 @@ function useDeepCompareEffect(callback, inputs) { * * @example * + * import { useQuery } from 'react-admin'; + * * const payload = { * pagination: { page: 1, perPage: 10 }, * sort: { field: 'username', order: 'ASC' }, @@ -87,8 +54,14 @@ const useQuery = ( type: string, resource: string, payload?: any, - options?: any -): State => { + meta?: any +): { + data?: any; + total?: number; + error?: any; + loading: boolean; + loaded: boolean; +} => { const [state, setState] = useSafeSetState({ data: null, error: null, @@ -98,7 +71,7 @@ const useQuery = ( }); const dataProvider = useDataProvider(); useDeepCompareEffect(() => { - dataProvider(type, resource, payload, options) + dataProvider(type, resource, payload, meta) .then(({ data: dataFromResponse, total: totalFromResponse }) => { setState({ data: dataFromResponse, @@ -114,7 +87,7 @@ const useQuery = ( loaded: false, }); }); - }, [type, resource, payload, options]); + }, [type, resource, payload, meta]); return state; }; From 5b11127334bb4eec005ceb0934df801050cde2dc Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Thu, 2 May 2019 00:25:06 +0200 Subject: [PATCH 08/12] Move query and mutation to a new fetch directory --- .../ra-core/src/{util => fetch}/HttpError.ts | 0 .../src/{util => fetch}/Mutation.spec.tsx | 2 +- .../ra-core/src/{util => fetch}/Mutation.tsx | 0 .../src/{util => fetch}/Query.spec.tsx | 2 +- .../ra-core/src/{util => fetch}/Query.tsx | 0 .../ra-core/src/{util => fetch}/fetch.spec.ts | 0 packages/ra-core/src/{util => fetch}/fetch.ts | 0 packages/ra-core/src/{util => fetch}/hooks.ts | 0 packages/ra-core/src/fetch/index.ts | 19 +++++++++++++++++++ .../src/{util => fetch}/useDataProvider.ts | 0 .../src/{util => fetch}/useMutation.ts | 0 .../ra-core/src/{util => fetch}/useQuery.ts | 0 .../src/{util => fetch}/withDataProvider.tsx | 0 packages/ra-core/src/index.ts | 1 + packages/ra-core/src/util/index.ts | 16 ---------------- 15 files changed, 22 insertions(+), 18 deletions(-) rename packages/ra-core/src/{util => fetch}/HttpError.ts (100%) rename packages/ra-core/src/{util => fetch}/Mutation.spec.tsx (99%) rename packages/ra-core/src/{util => fetch}/Mutation.tsx (100%) rename packages/ra-core/src/{util => fetch}/Query.spec.tsx (99%) rename packages/ra-core/src/{util => fetch}/Query.tsx (100%) rename packages/ra-core/src/{util => fetch}/fetch.spec.ts (100%) rename packages/ra-core/src/{util => fetch}/fetch.ts (100%) rename packages/ra-core/src/{util => fetch}/hooks.ts (100%) create mode 100644 packages/ra-core/src/fetch/index.ts rename packages/ra-core/src/{util => fetch}/useDataProvider.ts (100%) rename packages/ra-core/src/{util => fetch}/useMutation.ts (100%) rename packages/ra-core/src/{util => fetch}/useQuery.ts (100%) rename packages/ra-core/src/{util => fetch}/withDataProvider.tsx (100%) diff --git a/packages/ra-core/src/util/HttpError.ts b/packages/ra-core/src/fetch/HttpError.ts similarity index 100% rename from packages/ra-core/src/util/HttpError.ts rename to packages/ra-core/src/fetch/HttpError.ts diff --git a/packages/ra-core/src/util/Mutation.spec.tsx b/packages/ra-core/src/fetch/Mutation.spec.tsx similarity index 99% rename from packages/ra-core/src/util/Mutation.spec.tsx rename to packages/ra-core/src/fetch/Mutation.spec.tsx index c83ba3c4601..e4334b49e83 100644 --- a/packages/ra-core/src/util/Mutation.spec.tsx +++ b/packages/ra-core/src/fetch/Mutation.spec.tsx @@ -10,7 +10,7 @@ import expect from 'expect'; import Mutation from './Mutation'; import CoreAdmin from '../CoreAdmin'; import Resource from '../Resource'; -import TestContext from './TestContext'; +import TestContext from '../util/TestContext'; describe('Mutation', () => { afterEach(cleanup); diff --git a/packages/ra-core/src/util/Mutation.tsx b/packages/ra-core/src/fetch/Mutation.tsx similarity index 100% rename from packages/ra-core/src/util/Mutation.tsx rename to packages/ra-core/src/fetch/Mutation.tsx diff --git a/packages/ra-core/src/util/Query.spec.tsx b/packages/ra-core/src/fetch/Query.spec.tsx similarity index 99% rename from packages/ra-core/src/util/Query.spec.tsx rename to packages/ra-core/src/fetch/Query.spec.tsx index bb495df901c..4316c066f44 100644 --- a/packages/ra-core/src/util/Query.spec.tsx +++ b/packages/ra-core/src/fetch/Query.spec.tsx @@ -10,7 +10,7 @@ import expect from 'expect'; import Query from './Query'; import CoreAdmin from '../CoreAdmin'; import Resource from '../Resource'; -import TestContext from './TestContext'; +import TestContext from '../util/TestContext'; describe('Query', () => { afterEach(cleanup); diff --git a/packages/ra-core/src/util/Query.tsx b/packages/ra-core/src/fetch/Query.tsx similarity index 100% rename from packages/ra-core/src/util/Query.tsx rename to packages/ra-core/src/fetch/Query.tsx diff --git a/packages/ra-core/src/util/fetch.spec.ts b/packages/ra-core/src/fetch/fetch.spec.ts similarity index 100% rename from packages/ra-core/src/util/fetch.spec.ts rename to packages/ra-core/src/fetch/fetch.spec.ts diff --git a/packages/ra-core/src/util/fetch.ts b/packages/ra-core/src/fetch/fetch.ts similarity index 100% rename from packages/ra-core/src/util/fetch.ts rename to packages/ra-core/src/fetch/fetch.ts diff --git a/packages/ra-core/src/util/hooks.ts b/packages/ra-core/src/fetch/hooks.ts similarity index 100% rename from packages/ra-core/src/util/hooks.ts rename to packages/ra-core/src/fetch/hooks.ts diff --git a/packages/ra-core/src/fetch/index.ts b/packages/ra-core/src/fetch/index.ts new file mode 100644 index 00000000000..b40038b4585 --- /dev/null +++ b/packages/ra-core/src/fetch/index.ts @@ -0,0 +1,19 @@ +import HttpError from './HttpError'; +import * as fetchUtils from './fetch'; +import Mutation from './Mutation'; +import Query from './Query'; +import useDataProvider from './useDataProvider'; +import useMutation from './useMutation'; +import useQuery from './useQuery'; +import withDataProvider from './withDataProvider'; + +export { + fetchUtils, + HttpError, + Mutation, + Query, + useDataProvider, + useMutation, + useQuery, + withDataProvider, +}; diff --git a/packages/ra-core/src/util/useDataProvider.ts b/packages/ra-core/src/fetch/useDataProvider.ts similarity index 100% rename from packages/ra-core/src/util/useDataProvider.ts rename to packages/ra-core/src/fetch/useDataProvider.ts diff --git a/packages/ra-core/src/util/useMutation.ts b/packages/ra-core/src/fetch/useMutation.ts similarity index 100% rename from packages/ra-core/src/util/useMutation.ts rename to packages/ra-core/src/fetch/useMutation.ts diff --git a/packages/ra-core/src/util/useQuery.ts b/packages/ra-core/src/fetch/useQuery.ts similarity index 100% rename from packages/ra-core/src/util/useQuery.ts rename to packages/ra-core/src/fetch/useQuery.ts diff --git a/packages/ra-core/src/util/withDataProvider.tsx b/packages/ra-core/src/fetch/withDataProvider.tsx similarity index 100% rename from packages/ra-core/src/util/withDataProvider.tsx rename to packages/ra-core/src/fetch/withDataProvider.tsx diff --git a/packages/ra-core/src/index.ts b/packages/ra-core/src/index.ts index 0422262ee4e..60a57683f06 100644 --- a/packages/ra-core/src/index.ts +++ b/packages/ra-core/src/index.ts @@ -22,6 +22,7 @@ export { export * from './dataFetchActions'; export * from './actions'; export * from './auth'; +export * from './fetch'; export * from './i18n'; export * from './inference'; export * from './util'; diff --git a/packages/ra-core/src/util/index.ts b/packages/ra-core/src/util/index.ts index 6dc973ee62f..03bc482572f 100644 --- a/packages/ra-core/src/util/index.ts +++ b/packages/ra-core/src/util/index.ts @@ -3,39 +3,23 @@ import FieldTitle from './FieldTitle'; import getFetchedAt from './getFetchedAt'; import getFieldLabelTranslationArgs from './getFieldLabelTranslationArgs'; import ComponentPropType from './ComponentPropType'; -import HttpError from './HttpError'; import linkToRecord from './linkToRecord'; -import Mutation from './Mutation'; -import Query from './Query'; import removeEmpty from './removeEmpty'; import removeKey from './removeKey'; import resolveRedirectTo from './resolveRedirectTo'; import TestContext from './TestContext'; -import useDataProvider from './useDataProvider'; -import useMutation from './useMutation'; -import useQuery from './useQuery'; import warning from './warning'; -import withDataProvider from './withDataProvider'; -import * as fetchUtils from './fetch'; export { - fetchUtils, downloadCSV, FieldTitle, getFetchedAt, getFieldLabelTranslationArgs, ComponentPropType, - HttpError, linkToRecord, - Mutation, - Query, removeEmpty, removeKey, resolveRedirectTo, TestContext, - useDataProvider, - useMutation, - useQuery, warning, - withDataProvider, }; From b131a342b58fb49bf3cdc333883ee46cc469d33e Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Sat, 4 May 2019 00:13:21 +0200 Subject: [PATCH 09/12] Document the new query API --- UPGRADE.md | 32 + docs/Actions.md | 716 ++++++++++------------ docs/_layouts/default.html | 15 +- examples/demo/src/reviews/AcceptButton.js | 1 + 4 files changed, 374 insertions(+), 390 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index e79e3049f7b..9765f577cb3 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -75,3 +75,35 @@ If you're using a Custom App, you had to render Resource components with the reg - + ``` + +## `withDataProvider` no longer injects `dispatch` + +The `withDataProvider` HOC used to inject two props: `dataProvider`, and redux' `dispatch`. This last prop is now easy to get via the `useDispatch` hook from Redux, so `withDataProvider` no longer injects it. + +```diff +import { + showNotification, + UPDATE, + withDataProvider, +} from 'react-admin'; + +-const ApproveButton = ({ dataProvider, dispatch, record }) => { ++const ApproveButton = ({ dataProvider, record }) => { ++ const dispatch = withDispatch(); + const handleClick = () => { + const updatedRecord = { ...record, is_approved: true }; + dataProvider(UPDATE, 'comments', { id: record.id, data: updatedRecord }) + .then(() => { + dispatch(showNotification('Comment approved')); + dispatch(push('/comments')); + }) + .catch((e) => { + dispatch(showNotification('Error: comment not approved', 'warning')) + }); + } + + return ; - } + return ; } -ApproveButton.propTypes = { - commentApprove: PropTypes.func.isRequired,, - record: PropTypes.object, -}; - -export default connect(null, { commentApprove })(ApproveButton); +export default ApproveButton; ``` It works fine: when a user presses the "Approve" button, the API receives the `UPDATE` call, and that approves the comment. Another added benefit of using custom actions with the `fetch` meta is that react-admin automatically handles the loading state, so you don't need to mess up with `fetchStart()` and `fetchEnd()` manually. @@ -473,7 +497,7 @@ But it's not possible to call `push` or `showNotification` in `handleClick` anym ## Adding Side Effects to Actions -Just like for the `withDataProvider`, you can associate side effects to a fetch action declaratively by setting the appropriate keys in the action `meta`. +Just like for the `useDataProvider` hook, you can associate side effects to a fetch action declaratively by setting the appropriate keys in the action `meta`. So the side effects will be declared in the action creator rather than in the component. For instance, to display a notification when the `COMMENT_APPROVE` action is successfully dispatched, add the `notification` meta: @@ -505,7 +529,7 @@ export const commentApprove = (id, data, basePath) => ({ }); ``` -The side effects accepted in the `meta` field of the action are the same as in the fourth parameter of the `dataProvider` function injected by `withDataProvider`: +The side effects accepted in the `meta` field of the action are the same as in the fourth parameter of the function returned by `useQuery`, `useMutation`, or `withDataProvider`: - `notification`: Display a notification. The property value should be an object describing the notification to display. The `body` can be a translation key. `level` can be either `info` or `warning`. - `redirectTo`: Redirect the user to another page. The property value should be the path to redirect the user to. @@ -516,40 +540,27 @@ The side effects accepted in the `meta` field of the action are the same as in t ## Making An Action Undoable -when using the `withDataProvider` function, you could trigger optimistic rendering and get an undo button for free. the same feature is possible using custom actions. You need to decorate the action with the `startUndoable` action creator: +when using the `useMutation` hook, you could trigger optimistic rendering and get an undo button for free. The same feature is possible using custom actions. You need to decorate the action with the `startUndoable` action creator: ```diff // in src/comments/ApproveButton.js -+import { startUndoable as startUndoableAction } from 'ra-core'; --import { commentApprove as commentApproveAction } from './commentActions'; -+import { commentApprove } from './commentActions'; - -class ApproveButton extends Component { - handleClick = () => { -- const { commentApprove, record } = this.props; -- commentApprove(record.id, record); -+ const { startUndoable, record } = this.props; -+ startUndoable(commentApprove(record.id, record)); - } +import { dispatch } from 'react-redux'; +import { commentApprove } from './commentActions'; ++import { startUndoable } from 'react-admin'; - render() { - return ; +const ApproveButton = ({ record }) => { + const dispatch = useDispatch(); + const handleClick = () => { +- dispatch(commentApprove(record.id, record)); ++ dispatch(startUndoable(commentApprove(record.id, record))); } + return ; } -ApproveButton.propTypes = { -- commentApprove: PropTypes.func, -+ startUndoable: PropTypes.func, - record: PropTypes.object, -}; - -export default connect(null, { -- commentApprove: commentApproveAction, -+ startUndoable: startUndoableAction, -})(ApproveButton); +export default ApproveButton; ``` -And that's all it takes to make a fetch action optimistic. Note that the `startUndoable` action creator is passed to Redux `connect` as `mapDispatchToProp`, to be decorated with `dispatch` - but `commentApprove` is not. Only the first action must be decorated with dispatch. +And that's all it takes to make a fetch action optimistic. ## Altering the Form Values before Submitting @@ -562,38 +573,28 @@ Knowing this, you can dispatch a custom action with a button and still benefit f ```jsx import React, { Component } from 'react'; -import { connect } from 'react-redux'; +import { dispatch } from 'react-redux'; import { crudCreate, SaveButton, Toolbar } from 'react-admin'; // A custom action creator which modifies the values before calling the default crudCreate action creator -const saveWithNote = (values, basePath, redirectTo) => +const addComment = (values, basePath, redirectTo) => crudCreate('posts', { ...values, average_note: 10 }, basePath, redirectTo); -class SaveWithNoteButtonView extends Component { - handleClick = () => { - const { basePath, handleSubmit, redirect, saveWithNote } = this.props; - +const SaveWithNoteButtonView = ({ handleSubmitWithRedirect, ...props }) => { + const handleClick = () => { + const { basePath, handleSubmit, redirect } = props; return handleSubmit(values => { - saveWithNote(values, basePath, redirect); + dispatch(addComment(values, basePath, redirect)); }); }; - render() { - const { handleSubmitWithRedirect, saveWithNote, ...props } = this.props; - - return ( - - ); - } + return ( + + ); } - -const SaveWithNoteButton = connect( - undefined, - { saveWithNote } -)(SaveWithNoteButtonView); ``` This button can be used in the `PostCreateToolbar` component: @@ -650,7 +651,7 @@ export const commentApprove = (id, data, basePath) => ({ }); ``` -Under the hood, `withDataProvider` uses the `callback` side effect to provide a Promise interface for dispatching fetch actions. As chaining custom side effects will quickly lead you to callback hell, we recommend that you use the `callback` side effect sparingly. +Under the hood, `useDataProvider` uses the `callback` side effect to provide a Promise interface for dispatching fetch actions. As chaining custom side effects will quickly lead you to callback hell, we recommend that you use the `callback` side effect sparingly. ## Custom Sagas @@ -717,35 +718,23 @@ This action can be triggered on mount by the following component: ```jsx // in src/BitCoinRate.js -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import { bitcoinRateReceived as bitcoinRateReceivedAction } from './bitcoinRateReceived'; +import React from 'react'; +import { dispatch } from 'react-redux'; +import { bitcoinRateReceived } from './bitcoinRateReceived'; -class BitCoinRate extends Component { - componentWillMount() { +const BitCoinRate = ({ rate }) => { + const dispatch = useDispatch(); + useEffect(() => { fetch('https://blockchain.info/fr/ticker') .then(response => response.json()) .then(rates => rates.USD['15m']) - .then(bitcoinRateReceived) // dispatch action when the response is received - } + .then(() => dispatch(bitcoinRateReceived(rate))) // dispatch action when the response is received + }, []); - render() { - const { rate } = this.props; - return
Current bitcoin value: {rate}$
- } + return
Current bitcoin value: {rate}$
} -BitCoinRate.propTypes = { - bitcoinRateReceived: PropTypes.func, - rate: PropTypes.number, -}; - -const mapStateToProps = state => ({ rate: state.bitcoinRate }); - -export default connect(mapStateToProps, { - bitcoinRateReceived: bitcoinRateReceivedAction, -})(BitCoinRate); +export default BitCoinRate; ``` In order to put the rate passed to `bitcoinRateReceived()` into the Redux store, you'll need a reducer: @@ -782,7 +771,7 @@ export default App; ``` {% endraw %} -**Tip**: You can avoid storing data in the Redux state by storing data in a component state instead. It's much less complicated to deal with, and more performant, too. Use the global state only when you really need to. +**Tip**: You can avoid storing data in the Redux state by storing data in a component state instead. It's much less complicated to deal with, and more performant, too. Use the global state only when you need to access data from several components which are far away in the application tree. ## List Bulk Actions @@ -792,44 +781,3 @@ Almost everything we saw before about custom actions is true for custom `List` b * They do not receive the current record in the `record` prop as there are many of them. You can find a complete example of a custom Bulk Action button in the `List` documentation, in the [Bulk Action Buttons](/List.html#bulk-action-buttons) section. - -## Conclusion - -Which style should you choose for your own action buttons? Here is a quick benchmark: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
SolutionAdvantagesDrawbacks
fetch
  • Nothing to learn
  • Requires duplication of authentication
  • Does not handle the loading state
  • Adds boilerplate
dataProvider
  • Familiar API
  • Does not handle the loading state
  • Adds boilerplate
withDataProvider
  • Familiar API
  • Handles side effects
  • Adds boilerplate
  • Uses HOC
<Query> and <Mutation>
  • Declarative
  • Dense
  • Handles loading and error states
  • Handles side effects
  • Mix logic and presentation in markup
Custom action
  • Allows logic reuse
  • Handles side effects
  • Idiomatic to Redux
  • Hard to chain calls
diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index 56a5b78a58b..df26c6fb300 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -713,13 +713,10 @@
  • - The Basic Way: Using fetch + useQuery
  • - Using the dataProvider -
  • -
  • - Using withDataProvider + useMutation
  • Handling Side Effects @@ -728,8 +725,14 @@ Optimistic Rendering and Undo
  • - <Query> and <Mutation> + useDataProvider +
  • +
  • + <Query> and <Mutation>
  • +
  • + Querying The API With fetch +
  • Using a Custom Action Creator
  • diff --git a/examples/demo/src/reviews/AcceptButton.js b/examples/demo/src/reviews/AcceptButton.js index bcba6798e50..cc0f8f44e53 100644 --- a/examples/demo/src/reviews/AcceptButton.js +++ b/examples/demo/src/reviews/AcceptButton.js @@ -8,6 +8,7 @@ import { translate, useMutation } from 'react-admin'; import compose from 'recompose/compose'; const sideEffects = { + undoable: true, onSuccess: { notification: { body: 'resources.reviews.notification.approved_success', From 6eba7b25b6e6b887b75d20fc618bf6c4edd8336f Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Sat, 4 May 2019 00:18:35 +0200 Subject: [PATCH 10/12] Use same useEffect skip strategy for useMutation and useQuery --- packages/ra-core/src/fetch/useMutation.ts | 2 +- packages/ra-core/src/fetch/useQuery.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/ra-core/src/fetch/useMutation.ts b/packages/ra-core/src/fetch/useMutation.ts index 08136122f93..a5b4aac9564 100644 --- a/packages/ra-core/src/fetch/useMutation.ts +++ b/packages/ra-core/src/fetch/useMutation.ts @@ -75,7 +75,7 @@ const useMutation = ( loaded: false, }); }); - }, [JSON.stringify({ type, resource, payload, meta })]); // see https://github.com/facebook/react/issues/14476#issuecomment-471199055 + }, [JSON.stringify({ type, resource, payload, meta })]); // deep equality, see https://github.com/facebook/react/issues/14476#issuecomment-471199055 return [mutate, state]; }; diff --git a/packages/ra-core/src/fetch/useQuery.ts b/packages/ra-core/src/fetch/useQuery.ts index fa0a626bdee..877b0996c04 100644 --- a/packages/ra-core/src/fetch/useQuery.ts +++ b/packages/ra-core/src/fetch/useQuery.ts @@ -1,4 +1,5 @@ -import { useSafeSetState, useDeepCompareEffect } from './hooks'; +import { useEffect } from 'react'; +import { useSafeSetState } from './hooks'; import useDataProvider from './useDataProvider'; /** @@ -70,7 +71,7 @@ const useQuery = ( loaded: false, }); const dataProvider = useDataProvider(); - useDeepCompareEffect(() => { + useEffect(() => { dataProvider(type, resource, payload, meta) .then(({ data: dataFromResponse, total: totalFromResponse }) => { setState({ @@ -87,7 +88,7 @@ const useQuery = ( loaded: false, }); }); - }, [type, resource, payload, meta]); + }, [JSON.stringify({ type, resource, payload, meta })]); // deep equality, see https://github.com/facebook/react/issues/14476#issuecomment-471199055 return state; }; From e177747065041861a3214ced2b8b6ec160adcb83 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Sat, 4 May 2019 00:22:16 +0200 Subject: [PATCH 11/12] Fix Upgrade misses mention of react-redux --- UPGRADE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/UPGRADE.md b/UPGRADE.md index 9765f577cb3..8368ebb2a6e 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -86,6 +86,7 @@ import { UPDATE, withDataProvider, } from 'react-admin'; ++ import { useDispatch } from 'react-redux'; -const ApproveButton = ({ dataProvider, dispatch, record }) => { +const ApproveButton = ({ dataProvider, record }) => { From f0c74e6e7230ae4ff0fb0ac743ca3eaaf306f570 Mon Sep 17 00:00:00 2001 From: sedy-bot Date: Sat, 4 May 2019 15:58:44 +0000 Subject: [PATCH 12/12] Typo fix s/of/or/ As requested by djhi at https://github.com/marmelab/react-admin/pull/3181#discussion_r280986323 --- packages/ra-core/src/fetch/useDataProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ra-core/src/fetch/useDataProvider.ts b/packages/ra-core/src/fetch/useDataProvider.ts index 085d70aec45..d516584ee6e 100644 --- a/packages/ra-core/src/fetch/useDataProvider.ts +++ b/packages/ra-core/src/fetch/useDataProvider.ts @@ -17,7 +17,7 @@ import { startUndoable } from '../actions/undoActions'; * * 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, of make the action optimistic (with undoable: true). + * which may contain side effects, or make the action optimistic (with undoable: true). * * @example *