-
-
Notifications
You must be signed in to change notification settings - Fork 5.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #3398 from marmelab/useEditController
[RFR] Add useEditController hook
- Loading branch information
Showing
21 changed files
with
718 additions
and
460 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
103 changes: 103 additions & 0 deletions
103
packages/ra-core/src/controller/useEditController.spec.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
}); | ||
}); |
Oops, something went wrong.