Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[RFR] Add Crud hooks #3253

Merged
merged 18 commits into from
May 22, 2019
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 36 additions & 29 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,11 +29,11 @@ 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 }
);
const { loading, error, data } = useQuery({
type: GET_ONE,
resource: 'users',
payload: { id: record.id }
});
if (loading) { 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 Down Expand Up @@ -86,7 +86,7 @@ You can destructure the return value of the `useQuery` hook as `{ data, total, e

## `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 +100,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 +143,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 +153,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 +200,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 +276,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
16 changes: 10 additions & 6 deletions examples/demo/src/dashboard/NewCustomers.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,17 @@ const NewCustomers = () => {
return date;
}, []);

const { loaded, data: visitors } = useQuery(GET_LIST, 'customers', {
filter: {
has_ordered: true,
first_seen_gte: aMonthAgo.toISOString(),
const { loaded, data: visitors } = useQuery({
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
41 changes: 20 additions & 21 deletions packages/ra-core/src/controller/EditController.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ import { ReactNode, useEffect, useCallback } from 'react';
import { useSelector, 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 { RedirectionSideEffect } from '../sideEffect';
import useGetOne from './useGetOne';
import { useTranslate } from '../i18n';

interface ChildrenFuncParams {
Expand Down Expand Up @@ -80,33 +81,31 @@ 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 { 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 +138,7 @@ const EditController = (props: Props) => {
);

return children({
isLoading,
isLoading: loading,
defaultTitle,
save,
resource,
Expand Down
44 changes: 19 additions & 25 deletions packages/ra-core/src/controller/ShowController.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { ReactNode, useEffect } from 'react';
import { ReactNode } from 'react';
// @ts-ignore
import { useDispatch, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import inflection from 'inflection';
import { crudGetOne } from '../actions';
import { useCheckMinimumRequiredProps } from './checkMinimumRequiredProps';
import { Translate, Record, Identifier, ReduxState } from '../types';
import useGetOne from './useGetOne';
import { useTranslate } from '../i18n';

interface ChildrenFuncParams {
Expand Down Expand Up @@ -74,31 +74,25 @@ interface Props {
const ShowController = (props: Props) => {
useCheckMinimumRequiredProps('Show', ['basePath', 'resource'], props);
const { basePath, children, id, resource } = props;
if (!children) {
return null;
}
const translate = useTranslate();
const dispatch = useDispatch();

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
);

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

if (!children) {
return null;
}

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,
},
});
const resourceName = translate(`resources.${resource}.name`, {
smart_count: 1,
_: inflection.humanize(inflection.singularize(resource)),
Expand All @@ -109,7 +103,7 @@ const ShowController = (props: Props) => {
record,
});
return children({
isLoading,
isLoading: loading,
defaultTitle,
resource,
basePath,
Expand Down
2 changes: 2 additions & 0 deletions packages/ra-core/src/controller/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import CreateController from './CreateController';
import EditController from './EditController';
import ListController from './ListController';
import ShowController from './ShowController';
import useGetOne from './useGetOne';
export {
getListControllerProps,
sanitizeListRestProps,
CreateController,
EditController,
ListController,
ShowController,
useGetOne,
};

export * from './field';
Expand Down
16 changes: 16 additions & 0 deletions packages/ra-core/src/controller/useGetOne.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { CRUD_GET_ONE } from '../actions/dataActions/crudGetOne';
import { GET_ONE } from '../dataFetchActions';
import { Identifier, ReduxState } from '../types';
import useQuery from '../fetch/useQuery';

const useGetOne = (resource: string, id: Identifier, options: any) =>
useQuery(
{ type: GET_ONE, resource, payload: { id } },
{ ...options, action: CRUD_GET_ONE },
(state: ReduxState) =>
state.admin.resources[resource]
? state.admin.resources[resource].data[id]
: null
);

export default useGetOne;
2 changes: 1 addition & 1 deletion packages/ra-core/src/fetch/Mutation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,6 @@ const Mutation: FunctionComponent<Props> = ({
resource,
payload,
options,
}) => children(...useMutation(type, resource, payload, options));
}) => children(...useMutation({ type, resource, payload }, options));

export default Mutation;
2 changes: 1 addition & 1 deletion packages/ra-core/src/fetch/Query.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,6 @@ const Query: FunctionComponent<Props> = ({
resource,
payload,
options,
}) => children(useQuery(type, resource, payload, options));
}) => children(useQuery({ type, resource, payload }, options));

export default Query;
Loading