Skip to content

Commit

Permalink
Merge pull request #3398 from marmelab/useEditController
Browse files Browse the repository at this point in the history
[RFR] Add useEditController hook
  • Loading branch information
djhi authored Jul 12, 2019
2 parents 8e400ee + 07ba620 commit 77376cf
Show file tree
Hide file tree
Showing 21 changed files with 718 additions and 460 deletions.
36 changes: 35 additions & 1 deletion docs/Actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,40 @@ export const CommentList = (props) =>
</List>;
```

**Tip**: For simple mutations, you can use a specialised hook like `useUpdate` instead of the more generic `useMutation`. The main benefit is that `useUpdate` will update the recod in Redux store first, allowing optimistic rendering of the UI:

```jsx
import { useUpdate } from 'react-admin';

const ApproveButton = ({ record }) => {
const [approve, { loading }] = useUpdate('comments', record.id, { isApproved: true }, record);
return <FlatButton label="Approve" onClick={approve} disabled={loading} />;
};
```

**Tip**: The mutation data can also be passed at call time, using the second parameter of the `mutate` callback:

```jsx
import { useMutation, UPDATE } from 'react-admin';

const MarkDateButton = ({ record }) => {
const [approve, { loading }] = useMutation({
type: UPDATE,
resource: 'posts',
payload: { id: record.id } // no data
});
// the mutation callback expects call time payload as second parameter
// and merges it with the initial payload when called
return <FlatButton
label="Mark Date"
onClick={() => approve(null, {
data: { updatedAt: new Date() } // data defined here
})}
disabled={loading}
/>;
};
```

## 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 second parameter in addition to the query, which lets you describe the options of the query, including success and failure side effects.
Expand Down Expand Up @@ -570,7 +604,7 @@ The side effects accepted in the `meta` field of the action are the same as in t

## Making An Action Undoable

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:
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
Expand Down
160 changes: 20 additions & 140 deletions packages/ra-core/src/controller/EditController.tsx
Original file line number Diff line number Diff line change
@@ -1,155 +1,35 @@
import { ReactNode, useEffect, useCallback } from 'react';
// @ts-ignore
import { useDispatch } from 'react-redux';
import { reset as resetForm } from 'redux-form';
import inflection from 'inflection';
import { crudUpdate, startUndoable } from '../actions';
import { REDUX_FORM_NAME } from '../form';
import { useCheckMinimumRequiredProps } from './checkMinimumRequiredProps';
import { Translate, Record, Identifier } from '../types';
import { RedirectionSideEffect } from '../sideEffect';
import useGetOne from './../fetch/useGetOne';
import { Translate } from '../types';
import { useTranslate } from '../i18n';
import useVersion from './useVersion';
import useEditController, {
EditProps,
EditControllerProps,
} from './useEditController';

interface ChildrenFuncParams {
isLoading: boolean;
defaultTitle: string;
save: (data: Record, redirect: RedirectionSideEffect) => void;
resource: string;
basePath: string;
record?: Record;
redirect: RedirectionSideEffect;
interface EditControllerComponentProps extends EditControllerProps {
translate: Translate;
version: number;
}

interface Props {
basePath: string;
children: (params: ChildrenFuncParams) => ReactNode;
hasCreate?: boolean;
hasEdit?: boolean;
hasShow?: boolean;
hasList?: boolean;
id: Identifier;
isLoading: boolean;
resource: string;
undoable?: boolean;
record?: Record;
interface Props extends EditProps {
children: (params: EditControllerComponentProps) => JSX.Element;
}

/**
* Page component for the Edit view
*
* The `<Edit>` component renders the page title and actions,
* fetches the record from the data provider.
* It is not responsible for rendering the actual form -
* that's the job of its child component (usually `<SimpleForm>`),
* to which it passes pass the `record` as prop.
*
* The `<Edit>` component accepts the following props:
*
* - title
* - actions
*
* Both expect an element for value.
* Render prop version of the useEditController hook
*
* @see useEditController
* @example
* // in src/posts.js
* import React from 'react';
* import { Edit, SimpleForm, TextInput } from 'react-admin';
*
* export const PostEdit = (props) => (
* <Edit {...props}>
* <SimpleForm>
* <TextInput source="title" />
* </SimpleForm>
* </Edit>
* );
*
* // in src/App.js
* import React from 'react';
* import { Admin, Resource } from 'react-admin';
*
* import { PostEdit } from './posts';
*
* const App = () => (
* <Admin dataProvider={...}>
* <Resource name="posts" edit={PostEdit} />
* </Admin>
* );
* export default App;
* const EditView = () => <div>...</div>
* const MyEdit = props => (
* <EditController {...props}>
* {controllerProps => <EditView {...controllerProps} {...props} />}
* </EditController>
* );
*/
const EditController = (props: Props) => {
useCheckMinimumRequiredProps(
'Edit',
['basePath', 'resource', 'children'],
props
);
const { basePath, children, id, resource, undoable } = props;
const translate = useTranslate();
const dispatch = useDispatch();
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));
}, [resource, id, version]); // eslint-disable-line react-hooks/exhaustive-deps

const resourceName = translate(`resources.${resource}.name`, {
smart_count: 1,
_: inflection.humanize(inflection.singularize(resource)),
});
const defaultTitle = translate('ra.page.edit', {
name: `${resourceName}`,
id,
record,
});

const save = useCallback(
(data: Partial<Record>, redirect: RedirectionSideEffect) => {
const updateAction = crudUpdate(
resource,
id,
data,
record,
basePath,
redirect
);

if (undoable) {
dispatch(startUndoable(updateAction));
} else {
dispatch(updateAction);
}
},
[resource, id, record, basePath, undoable] // eslint-disable-line react-hooks/exhaustive-deps
);

return children({
isLoading: loading,
defaultTitle,
save,
resource,
basePath,
record,
redirect: getDefaultRedirectRoute(),
translate,
version,
});
const EditController = ({ children, ...props }: Props) => {
const controllerProps = useEditController(props);
const translate = useTranslate(); // injected for backwards compatibility
return children({ translate, ...controllerProps });
};

const getDefaultRedirectRoute = () => 'list';

export default EditController;
4 changes: 1 addition & 3 deletions packages/ra-core/src/controller/ListController.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { ReactNode } from 'react';

import useListController, {
ListProps,
ListControllerProps,
Expand All @@ -12,7 +10,7 @@ interface ListControllerComponentProps extends ListControllerProps {
}

interface Props extends ListProps {
children: (params: ListControllerComponentProps) => ReactNode;
children: (params: ListControllerComponentProps) => JSX.Element;
}

/**
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 @@ -11,6 +11,7 @@ import useVersion from './useVersion';
import useSortState from './useSortState';
import usePaginationState from './usePaginationState';
import useListController from './useListController';
import useEditController from './useEditController';
import { useCheckMinimumRequiredProps } from './checkMinimumRequiredProps';
export {
getListControllerProps,
Expand All @@ -21,6 +22,7 @@ export {
ShowController,
useCheckMinimumRequiredProps,
useListController,
useEditController,
useRecordSelection,
useVersion,
useSortState,
Expand Down
103 changes: 103 additions & 0 deletions packages/ra-core/src/controller/useEditController.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import React from 'react';
import expect from 'expect';
import { act, cleanup } from 'react-testing-library';

import EditController from './EditController';
import renderWithRedux from '../util/renderWithRedux';

describe('useEditController', () => {
afterEach(cleanup);

const defaultProps = {
basePath: '',
hasCreate: true,
hasEdit: true,
hasList: true,
hasShow: true,
id: 12,
resource: 'posts',
debounce: 200,
};

it('should query the data provider for the record using a GET_ONE query', () => {
const { dispatch } = renderWithRedux(
<EditController {...defaultProps}>
{({ record }) => <div>{record && record.title}</div>}
</EditController>
);
const crudGetOneAction = dispatch.mock.calls[0][0];
expect(crudGetOneAction.type).toEqual('RA/CRUD_GET_ONE');
expect(crudGetOneAction.payload).toEqual({ id: 12 });
expect(crudGetOneAction.meta.resource).toEqual('posts');
});

it('should grab the record from the store based on the id', () => {
const { getByText } = renderWithRedux(
<EditController {...defaultProps}>
{({ record }) => <div>{record && record.title}</div>}
</EditController>,
{
admin: {
resources: {
posts: { data: { 12: { id: 12, title: 'hello' } } },
},
},
}
);
expect(getByText('hello')).toBeDefined();
});

it('should reset the redux form', () => {
const { dispatch } = renderWithRedux(
<EditController {...defaultProps}>
{({ record }) => <div>{record && record.title}</div>}
</EditController>
);
const formResetAction = dispatch.mock.calls[1][0];
expect(formResetAction.type).toEqual('@@redux-form/RESET');
expect(formResetAction.meta).toEqual({ form: 'record-form' });
});

it('should return an undoable save callback by default', () => {
let saveCallback;
const { dispatch } = renderWithRedux(
<EditController {...defaultProps}>
{({ save }) => {
saveCallback = save;
return null;
}}
</EditController>
);
act(() => saveCallback({ foo: 'bar' }));
const crudUpdateAction = dispatch.mock.calls[2][0];
expect(crudUpdateAction.type).toEqual('RA/UNDOABLE');
expect(crudUpdateAction.payload.action.type).toEqual('RA/CRUD_UPDATE');
expect(crudUpdateAction.payload.action.payload).toEqual({
id: 12,
data: { foo: 'bar' },
previousData: null,
});
expect(crudUpdateAction.payload.action.meta.resource).toEqual('posts');
});

it('should return a save callback when undoable is false', () => {
let saveCallback;
const { dispatch } = renderWithRedux(
<EditController {...defaultProps} undoable={false}>
{({ save }) => {
saveCallback = save;
return null;
}}
</EditController>
);
act(() => saveCallback({ foo: 'bar' }));
const crudUpdateAction = dispatch.mock.calls[2][0];
expect(crudUpdateAction.type).toEqual('RA/CRUD_UPDATE');
expect(crudUpdateAction.payload).toEqual({
id: 12,
data: { foo: 'bar' },
previousData: null,
});
expect(crudUpdateAction.meta.resource).toEqual('posts');
});
});
Loading

0 comments on commit 77376cf

Please sign in to comment.