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 useEditController hook #3398

Merged
merged 8 commits into from
Jul 12, 2019
Merged
Show file tree
Hide file tree
Changes from 7 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
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
1 change: 1 addition & 0 deletions packages/ra-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"classnames": "~2.2.5",
"connected-react-router": "^6.4.0",
"date-fns": "^1.29.0",
"deepmerge": "^3.0.0",
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
"inflection": "~1.12.0",
"lodash": "~4.17.5",
"node-polyglot": "^2.2.2",
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