diff --git a/UPGRADE.md b/UPGRADE.md index e79e3049f7b..8368ebb2a6e 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -75,3 +75,36 @@ 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'; ++ import { useDispatch } from 'react-redux'; + +-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/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/examples/demo/src/reviews/AcceptButton.js b/examples/demo/src/reviews/AcceptButton.js index bdabf49b6e6..cc0f8f44e53 100644 --- a/examples/demo/src/reviews/AcceptButton.js +++ b/examples/demo/src/reviews/AcceptButton.js @@ -4,10 +4,11 @@ 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 = { + undoable: true, onSuccess: { notification: { body: 'resources.reviews.notification.approved_success', @@ -24,34 +25,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/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/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/fetch/Mutation.tsx b/packages/ra-core/src/fetch/Mutation.tsx new file mode 100644 index 00000000000..d1923e7dc59 --- /dev/null +++ b/packages/ra-core/src/fetch/Mutation.tsx @@ -0,0 +1,53 @@ +import { FunctionComponent, ReactElement } from 'react'; +import useMutation from './useMutation'; + +type DataProviderCallback = ( + type: string, + resource: string, + payload?: any, + options?: any +) => Promise; + +interface ChildrenFuncParams { + data?: any; + loading: boolean; + error?: any; +} + +interface Props { + children: ( + mutate: () => void, + params: ChildrenFuncParams + ) => ReactElement; + type: string; + resource: string; + payload?: any; + options?: any; +} + +/** + * Craft a callback to fetch the data provider and pass it to a child function + * + * @example + * + * const ApproveButton = ({ record }) => ( + * + * {(approve) => ( + * + * )} + * + * ); + */ +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/Query.spec.tsx b/packages/ra-core/src/fetch/Query.spec.tsx similarity index 76% rename from packages/ra-core/src/util/Query.spec.tsx rename to packages/ra-core/src/fetch/Query.spec.tsx index 27c3559d3d3..4316c066f44 100644 --- a/packages/ra-core/src/util/Query.spec.tsx +++ b/packages/ra-core/src/fetch/Query.spec.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { render, cleanup, - fireEvent, + act, // @ts-ignore waitForDomChange, } from 'react-testing-library'; @@ -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); @@ -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/packages/ra-core/src/fetch/Query.tsx b/packages/ra-core/src/fetch/Query.tsx new file mode 100644 index 00000000000..bf2a959f39b --- /dev/null +++ b/packages/ra-core/src/fetch/Query.tsx @@ -0,0 +1,66 @@ +import { FunctionComponent, ReactElement } from 'react'; +import useQuery from './useQuery'; + +interface ChildrenFuncParams { + data?: any; + total?: number; + loading: boolean; + loaded: boolean; + error?: any; +} + +interface Props { + children: (params: ChildrenFuncParams) => ReactElement; + type: string; + resource: string; + payload?: any; + options?: any; +} + +/** + * Fetch the data provider and pass the result to a child function + * + * @example + * + * const UserProfile = ({ record }) => ( + * + * {({ data, loading, error }) => { + * 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 = () => ( + * + * {({ data, total, loading, error }) => { + * if (loading) { return ; } + * if (error) { return

    ERROR

    ; } + * return ( + *
    + *

    Total users: {total}

    + *
      + * {data.map(user =>
    • {user.username}
    • )} + *
    + *
    + * ); + * }} + *
    + * ); + */ +const Query: FunctionComponent = ({ + children, + type, + resource, + payload, + options, +}) => children(useQuery(type, resource, payload, options)); + +export default Query; 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/fetch/hooks.ts b/packages/ra-core/src/fetch/hooks.ts new file mode 100644 index 00000000000..aac901f52fd --- /dev/null +++ b/packages/ra-core/src/fetch/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/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/fetch/useDataProvider.ts b/packages/ra-core/src/fetch/useDataProvider.ts new file mode 100644 index 00000000000..d516584ee6e --- /dev/null +++ b/packages/ra-core/src/fetch/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, 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, 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); + }), + [] + ); +}; + +export default useDataProvider; diff --git a/packages/ra-core/src/fetch/useMutation.ts b/packages/ra-core/src/fetch/useMutation.ts new file mode 100644 index 00000000000..a5b4aac9564 --- /dev/null +++ b/packages/ra-core/src/fetch/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 })]); // deep equality, see https://github.com/facebook/react/issues/14476#issuecomment-471199055 + + return [mutate, state]; +}; + +export default useMutation; diff --git a/packages/ra-core/src/fetch/useQuery.ts b/packages/ra-core/src/fetch/useQuery.ts new file mode 100644 index 00000000000..877b0996c04 --- /dev/null +++ b/packages/ra-core/src/fetch/useQuery.ts @@ -0,0 +1,96 @@ +import { useEffect } from 'react'; +import { useSafeSetState } from './hooks'; +import useDataProvider from './useDataProvider'; + +/** + * 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 ; } + * if (error) { return

    ERROR

    ; } + * return
    User {data.username}
    ; + * }; + * + * @example + * + * import { useQuery } from 'react-admin'; + * + * 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, + meta?: any +): { + data?: any; + total?: number; + error?: any; + loading: boolean; + loaded: boolean; +} => { + const [state, setState] = useSafeSetState({ + data: null, + error: null, + total: null, + loading: true, + loaded: false, + }); + const dataProvider = useDataProvider(); + useEffect(() => { + 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 })]); // deep equality, see https://github.com/facebook/react/issues/14476#issuecomment-471199055 + + return state; +}; + +export default useQuery; diff --git a/packages/ra-core/src/util/withDataProvider.ts b/packages/ra-core/src/fetch/withDataProvider.tsx similarity index 53% rename from packages/ra-core/src/util/withDataProvider.ts rename to packages/ra-core/src/fetch/withDataProvider.tsx index d1c42f5482f..a6383550e75 100644 --- a/packages/ra-core/src/util/withDataProvider.ts +++ b/packages/ra-core/src/fetch/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,10 @@ const mapDispatchToProps = (dispatch): DispatchProps => ({ * * export default withDataProvider(PostList); */ -const withDataProvider = (Component) => - connect<{}, DispatchProps, T>( - null, - mapDispatchToProps - )(Component as any); +const withDataProvider =

    ( + Component: React.ComponentType

    +): React.SFC

    => (props: P) => ( + +); export default withDataProvider; 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/Mutation.tsx b/packages/ra-core/src/util/Mutation.tsx deleted file mode 100644 index 6613dfb22a8..00000000000 --- a/packages/ra-core/src/util/Mutation.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { Component, ReactNode } from 'react'; -import withDataProvider from './withDataProvider'; - -type DataProviderCallback = ( - type: string, - resource: string, - payload?: any, - options?: any -) => Promise; - -interface ChildrenFuncParams { - data?: any; - loading: boolean; - error?: any; -} - -interface RawProps { - children: (mutate: () => void, params: ChildrenFuncParams) => ReactNode; - 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 - * - * @example - * - * const ApproveButton = ({ record }) => ( - * - * {(approve) => ( - * - * )} - * - * ); - */ -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); diff --git a/packages/ra-core/src/util/Query.tsx b/packages/ra-core/src/util/Query.tsx deleted file mode 100644 index a565961534c..00000000000 --- a/packages/ra-core/src/util/Query.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { Component, ReactNode } from 'react'; -import { shallowEqual } from 'recompose'; -import withDataProvider from './withDataProvider'; - -type DataProviderCallback = ( - type: string, - resource: string, - payload?: any, - options?: any -) => Promise; - -interface ChildrenFuncParams { - data?: any; - total?: number; - loading: boolean; - error?: any; -} - -interface RawProps { - children: (params: ChildrenFuncParams) => ReactNode; - 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 - * - * @example - * - * const UserProfile = ({ record }) => ( - * - * {({ data, loading, error }) => { - * 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 = () => ( - * - * {({ data, total, loading, error }) => { - * if (loading) { return ; } - * if (error) { return

    ERROR

    ; } - * return ( - *
    - *

    Total users: {total}

    - *
      - * {data.map(user =>
    • {user.username}
    • )} - *
    - *
    - * ); - * }} - *
    - * ); - */ -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); - } -} - -export default withDataProvider(Query); diff --git a/packages/ra-core/src/util/index.ts b/packages/ra-core/src/util/index.ts index 52f502471f7..03bc482572f 100644 --- a/packages/ra-core/src/util/index.ts +++ b/packages/ra-core/src/util/index.ts @@ -3,33 +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 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, warning, - withDataProvider, }; 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"