Skip to content

Commit

Permalink
Merge pull request #3253 from marmelab/crud-hook
Browse files Browse the repository at this point in the history
[RFR] Add Crud hooks
  • Loading branch information
djhi authored May 22, 2019
2 parents bd594bb + a682d4d commit 078d026
Show file tree
Hide file tree
Showing 27 changed files with 683 additions and 274 deletions.
90 changes: 60 additions & 30 deletions docs/Actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ React-admin provides special hooks to emit read and write queries to the `dataPr

## `useQuery` Hook

Use the `useQuery` hook to emit a read query to the API when a component mounts. The parameters are the same as the ones expected by the [`dataProvider`](./DataProviders.md):
Use the `useQuery` hook to emit a read query to the API when a component mounts. Call it with an object having the same fields as the parameters expected by the [`dataProvider`](./DataProviders.md):

- `type`: The Query type, e.g `GET_LIST`
- `resource`: The Resource name, e.g. "posts"
- `params`: Query parameters. Depends on the query type.
- `payload`: Query parameters. Depends on the query type.

The return value of `useQuery` is an object, which updates according to the request state:

Expand All @@ -29,12 +29,12 @@ Here is an implementation of a user profile component using the `useQuery` hook:
import { useQuery, GET_ONE } from 'react-admin';

const UserProfile = ({ record }) => {
const { loading, error, data } = useQuery(
GET_ONE,
'users',
{ id: record.id }
);
if (loading) { return <Loading />; }
const { loaded, error, data } = useQuery({
type: GET_ONE,
resource: 'users',
payload: { id: record.id }
});
if (!loaded) { return <Loading />; }
if (error) { return <p>ERROR</p>; }
return <div>User {data.username}</div>;
};
Expand All @@ -50,14 +50,14 @@ Here is another example usage of `useQuery`, this time to display a list of user
import { useQuery, GET_LIST } from 'react-admin';

const UserList = () => {
const { loading, error, data, total } = useQuery(
GET_LIST,
'users',
{
const { loading, error, data, total } = useQuery({
type: GET_LIST,
resource: 'users',
payload: {
pagination: { page: 1, perPage: 10 },
sort: { field: 'username', order: 'ASC' },
}
);
});
if (loading) { return <Loading />; }
if (error) { return <p>ERROR</p>; }
return (
Expand All @@ -84,9 +84,32 @@ You can destructure the return value of the `useQuery` hook as `{ data, total, e

**Tip**: Your `dataProvider` should return the `total` value for list queries only, to express the total number of results (which may be higher than the number of returned results if the response is paginated).

## `useQueryWithStore` Hook

Internally, react-admin uses a more powerful version of `useQuery` called `useQueryWithStore`, which has an internal cache. In practice, `useQueryWithStore` persist the response from the dataProvider in the internal react-admin store, so that result remains available if the hook is called again in the future.

You can use this hook to avoid showing the loading indicator if the query was already fetched once.

```diff
-import { useQuery, GET_ONE } from 'react-admin';
+import { useQueryWithStore, GET_ONE } from 'react-admin';

const UserProfile = ({ record }) => {
- const { loaded, error, data } = useQuery({
+ const { loaded, error, data } = useQueryWithStore({
type: GET_ONE,
resource: 'users',
payload: { id: record.id }
});
if (!loaded) { return <Loading />; }
if (error) { return <p>ERROR</p>; }
return <div>User {data.username}</div>;
};
```

## `useMutation` Hook

`useQuery` emits the request to the `dataProvider` as soon as the component mounts. To emit the request based on a user action, use the `useMutation` hook instead. This hook returns a callback that emits the request when executed, and an object containing the request state:
`useQuery` emits the request to the `dataProvider` as soon as the component mounts. To emit the request based on a user action, use the `useMutation` hook instead. This hook takes the same arguments as `useQuery`, but returns a callback that emits the request when executed, and an object containing the request state:

- mount: { loading: false, loaded: false }
- mutate called: { loading: true, loaded: false }
Expand All @@ -100,11 +123,11 @@ Here is an implementation of an "Approve" button:
import { useMutation, UPDATE } from 'react-admin';

const ApproveButton = ({ record }) => {
const [approve, { loading }] = useMutation(
UPDATE,
'comments',
{ id: record.id, data: { isApproved: true } }
);
const [approve, { loading }] = useMutation({
type: UPDATE,
resource: 'comments',
payload: { id: record.id, data: { isApproved: true } }
});
return <FlatButton label="Approve" onClick={approve} disabled={loading} />;
};
```
Expand Down Expand Up @@ -143,7 +166,7 @@ export const CommentList = (props) =>

## Handling Side Effects

Fetching data is called a *side effect*, since it calls the outside world, and is asynchronous. Usual actions may have other side effects, like showing a notification, or redirecting the user to another page. Both `useQuery` and `useMutation` hooks accept a fourth parameter, which lets you describe the options of the query, including success and failure side effects.
Fetching data is called a *side effect*, since it calls the outside world, and is asynchronous. Usual actions may have other side effects, like showing a notification, or redirecting the user to another page. Both `useQuery` and `useMutation` hooks accept a second parameter in addition to the query, which lets you describe the options of the query, including success and failure side effects.

Here is how to add notifications and a redirection to the `ApproveButton` component using that fourth parameter:

Expand All @@ -153,17 +176,22 @@ import { useMutation, UPDATE } from 'react-admin';

const ApproveButton = ({ record }) => {
const [approve, { loading }] = useMutation(
UPDATE,
'comments',
{ id: record.id, data: { isApproved: true } },
{
type: UPDATE,
resource: 'comments',
payload: { id: record.id, data: { isApproved: true } },
},
+ {
+ onSuccess: {
+ notification: { body: 'Comment approved', level: 'info' },
+ redirectTo: '/comments',
+ },
+ onError: {
+ notification: { body: 'Error: comment not approved', level: 'warning' }
+ }
+ onFailure: {
+ notification: {
+ body: 'Error: comment not approved',
+ level: 'warning',
+ },
+ },
+ }
);
return <FlatButton label="Approve" onClick={approve} disabled={loading} />;
Expand Down Expand Up @@ -195,9 +223,11 @@ import { useMutation, UPDATE } from 'react-admin';

const ApproveButton = ({ record }) => {
const [approve, { loading }] = useMutation(
UPDATE,
'comments',
{ id: record.id, data: { isApproved: true } },
{
type: UPDATE,
resource: 'comments',
payload: { id: record.id, data: { isApproved: true } },
},
{
+ undoable: true,
onSuccess: {
Expand Down Expand Up @@ -269,7 +299,7 @@ const Dashboard = () => {
}
```

`useDataProvider` is more low-level than `useQuery` and `useMutation`, as it doesn't handle loading and error states (even though queries from `useDataProvider` trigger the global loading indicator). The `dataProvider` callback that it returns also accepts a fourth options parameter, just like the two other hoows.
`useDataProvider` is more low-level than `useQuery` and `useMutation`, as it doesn't handle loading and error states (even though queries from `useDataProvider` trigger the global loading indicator). The `dataProvider` callback that it returns also accepts a fourth options parameter.

## Legacy Components: `<Query>`, `<Mutation>`, and `withDataProvider`

Expand Down
2 changes: 1 addition & 1 deletion docs/DataProviders.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ The `dataProvider` parameter of the `<Admin>` component must be a function with
* @param {Object} payload Request parameters. Depends on the action type
* @returns {Promise} the Promise for a response
*/
const dataProvider = (type, resource, params) => new Promise();
const dataProvider = (type, resource, payload) => new Promise();
```

You can find a Data Provider example implementation in [`packages/ra-data-simple-rest/src/index.js`](https://github.com/marmelab/react-admin/blob/master/packages/ra-data-simple-rest/src/index.js);
Expand Down
3 changes: 3 additions & 0 deletions docs/_layouts/default.html
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,9 @@
<li class="chapter">
<a href="#usequery-hook"><code>useQuery</code></a>
</li>
<li class="chapter">
<a href="#usequerywithstore-hook"><code>useQueryWithStore</code></a>
</li>
<li class="chapter">
<a href="#usemutation-hook"><code>useMutation</code></a>
</li>
Expand Down
22 changes: 15 additions & 7 deletions examples/demo/src/dashboard/NewCustomers.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import CustomerIcon from '@material-ui/icons/PersonAdd';
import Divider from '@material-ui/core/Divider';
import { makeStyles } from '@material-ui/core/styles';
import { Link } from 'react-router-dom';
import { useTranslate, useQuery, GET_LIST } from 'react-admin';
import { useTranslate, useQueryWithStore, GET_LIST } from 'react-admin';

import CardIcon from './CardIcon';

Expand Down Expand Up @@ -40,16 +40,24 @@ const NewCustomers = () => {
const aMonthAgo = useMemo(() => {
const date = new Date();
date.setDate(date.getDate() - 30);
date.setHours(0);
date.setMinutes(0);
date.setSeconds(0);
date.setMilliseconds(0);
return date;
}, []);

const { loaded, data: visitors } = useQuery(GET_LIST, 'customers', {
filter: {
has_ordered: true,
first_seen_gte: aMonthAgo.toISOString(),
const { loaded, data: visitors } = useQueryWithStore({
type: GET_LIST,
resource: 'customers',
payload: {
filter: {
has_ordered: true,
first_seen_gte: aMonthAgo.toISOString(),
},
sort: { field: 'first_seen', order: 'DESC' },
pagination: { page: 1, perPage: 100 },
},
sort: { field: 'first_seen', order: 'DESC' },
pagination: { page: 1, perPage: 100 },
});

if (!loaded) return null;
Expand Down
12 changes: 7 additions & 5 deletions examples/demo/src/reviews/AcceptButton.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import Button from '@material-ui/core/Button';
import ThumbUp from '@material-ui/icons/ThumbUp';
import { useTranslate, useMutation } from 'react-admin';

const sideEffects = {
const options = {
undoable: true,
onSuccess: {
notification: {
Expand All @@ -29,10 +29,12 @@ const sideEffects = {
const AcceptButton = ({ record }) => {
const translate = useTranslate();
const [approve, { loading }] = useMutation(
'UPDATE',
'reviews',
{ id: record.id, data: { status: 'accepted' } },
sideEffects
{
type: 'UPDATE',
resource: 'reviews',
payload: { id: record.id, data: { status: 'accepted' } },
},
options
);
return record && record.status === 'pending' ? (
<Button
Expand Down
50 changes: 24 additions & 26 deletions packages/ra-core/src/controller/EditController.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { ReactNode, useEffect, useCallback } from 'react';
// @ts-ignore
import { useSelector, useDispatch } from 'react-redux';
import { useDispatch } from 'react-redux';
import { reset as resetForm } from 'redux-form';
import inflection from 'inflection';
import { crudGetOne, crudUpdate, startUndoable } from '../actions';
import { crudUpdate, startUndoable } from '../actions';
import { REDUX_FORM_NAME } from '../form';
import { useCheckMinimumRequiredProps } from './checkMinimumRequiredProps';
import { Translate, Record, Identifier, ReduxState } from '../types';
import { Translate, Record, Identifier } from '../types';
import { RedirectionSideEffect } from '../sideEffect';
import useGetOne from './../fetch/useGetOne';
import { useTranslate } from '../i18n';
import useVersion from './useVersion';

interface ChildrenFuncParams {
isLoading: boolean;
Expand Down Expand Up @@ -80,33 +82,29 @@ interface Props {
*/
const EditController = (props: Props) => {
useCheckMinimumRequiredProps('Edit', ['basePath', 'resource'], props);
const { basePath, children, id, resource, undoable } = props;
if (!children) {
return null;
}
const translate = useTranslate();
const dispatch = useDispatch();

const { basePath, children, id, resource, undoable } = props;

const record = useSelector((state: ReduxState) =>
state.admin.resources[props.resource]
? state.admin.resources[props.resource].data[props.id]
: null
);

const isLoading = useSelector(
(state: ReduxState) => state.admin.loading > 0
);

const version = useSelector(
(state: ReduxState) => state.admin.ui.viewVersion
);
const version = useVersion();
const { data: record, loading } = useGetOne(resource, id, {
basePath,
version, // used to force reload
onFailure: {
notification: {
body: 'ra.notification.item_doesnt_exist',
level: 'warning',
},
redirectTo: 'list',
refresh: true,
},
});

useEffect(() => {
dispatch(resetForm(REDUX_FORM_NAME));
dispatch(crudGetOne(resource, id, basePath));
}, [resource, id, basePath, version]);

if (!children) {
return null;
}
}, [resource, id, version]);

const resourceName = translate(`resources.${resource}.name`, {
smart_count: 1,
Expand Down Expand Up @@ -139,7 +137,7 @@ const EditController = (props: Props) => {
);

return children({
isLoading,
isLoading: loading,
defaultTitle,
save,
resource,
Expand Down
Loading

0 comments on commit 078d026

Please sign in to comment.