diff --git a/examples/simple/src/users/UserCreate.js b/examples/simple/src/users/UserCreate.js index a4d402acc55..c945177cb62 100644 --- a/examples/simple/src/users/UserCreate.js +++ b/examples/simple/src/users/UserCreate.js @@ -13,7 +13,14 @@ import { import Aside from './Aside'; -const UserEditToolbar = ({ permissions, ...props }) => ( +const UserEditToolbar = ({ + permissions, + hasList, + hasEdit, + hasShow, + hasCreate, + ...props +}) => ( , redirect: RedirectionSideEffect) => void; - resource: string; - basePath: string; - record?: Partial; - redirect: RedirectionSideEffect; +interface CreateControllerComponentProps extends CreateControllerProps { translate: Translate; } -interface Props { - basePath: string; - children: (params: ChildrenFuncParams) => ReactNode; - hasCreate?: boolean; - hasEdit?: boolean; - hasList?: boolean; - hasShow?: boolean; - location: Location; - match: Match; - record?: Partial; - resource: string; +interface Props extends CreateProps { + children: (params: CreateControllerComponentProps) => JSX.Element; } /** - * Page component for the Create view - * - * The `` component renders the page title and actions. - * It is not responsible for rendering the actual form - - * that's the job of its child component (usually ``), - * to which it passes pass the `record` as prop. - * - * The `` component accepts the following props: - * - * - title - * - actions - * - * Both expect an element for value. + * Render prop version of the useCreateController hook * + * @see useCreateController * @example - * // in src/posts.js - * import React from 'react'; - * import { Create, SimpleForm, TextInput } from 'react-admin'; - * - * export const PostCreate = (props) => ( - * - * - * - * - * - * ); - * - * // in src/App.js - * import React from 'react'; - * import { Admin, Resource } from 'react-admin'; * - * import { PostCreate } from './posts'; - * - * const App = () => ( - * - * - * - * ); - * export default App; + * const CreateView = () =>
...
+ * const MyCreate = props => ( + * + * {controllerProps => } + * + * ); */ -const CreateController = (props: Props) => { - useCheckMinimumRequiredProps( - 'Create', - ['basePath', 'location', 'resource', 'children'], - props - ); - const { - basePath, - children, - resource, - location, - record = {}, - hasShow, - hasEdit, - } = props; - - const translate = useTranslate(); - const dispatch = useDispatch(); - const recordToUse = getRecord(location, record); - const isLoading = useSelector( - (state: ReduxState) => state.admin.loading > 0 - ); - - const save = useCallback( - (data: Partial, redirect: RedirectionSideEffect) => { - dispatch(crudCreate(resource, data, basePath, redirect)); - }, - [resource, basePath] // 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.create', { - name: `${resourceName}`, - }); - return children({ - isLoading, - defaultTitle, - save, - resource, - basePath, - record: recordToUse, - redirect: getDefaultRedirectRoute(hasShow, hasEdit), - translate, - }); +const CreateController = ({ children, ...props }: Props) => { + const controllerProps = useCreateController(props); + const translate = useTranslate(); // injected for backwards compatibility + return children({ translate, ...controllerProps }); }; export default CreateController; - -export const getRecord = ({ state, search }, record: any = {}) => - state && state.record - ? state.record - : search - ? parse(search, { arrayFormat: 'bracket' }) - : record; - -const getDefaultRedirectRoute = (hasShow, hasEdit) => { - if (hasEdit) { - return 'edit'; - } - if (hasShow) { - return 'show'; - } - return 'list'; -}; diff --git a/packages/ra-core/src/controller/index.ts b/packages/ra-core/src/controller/index.ts index 4c0f990973c..1da352fc3b7 100644 --- a/packages/ra-core/src/controller/index.ts +++ b/packages/ra-core/src/controller/index.ts @@ -12,6 +12,7 @@ import useSortState from './useSortState'; import usePaginationState from './usePaginationState'; import useListController from './useListController'; import useEditController from './useEditController'; +import useCreateController from './useCreateController'; import useShowController from './useShowController'; import { useCheckMinimumRequiredProps } from './checkMinimumRequiredProps'; export { @@ -24,6 +25,7 @@ export { useCheckMinimumRequiredProps, useListController, useEditController, + useCreateController, useShowController, useRecordSelection, useVersion, diff --git a/packages/ra-core/src/controller/CreateController.spec.tsx b/packages/ra-core/src/controller/useCreateController.spec.tsx similarity index 94% rename from packages/ra-core/src/controller/CreateController.spec.tsx rename to packages/ra-core/src/controller/useCreateController.spec.tsx index 2f6c0649f60..6ff99119ae4 100644 --- a/packages/ra-core/src/controller/CreateController.spec.tsx +++ b/packages/ra-core/src/controller/useCreateController.spec.tsx @@ -1,6 +1,6 @@ -import { getRecord } from './CreateController'; +import { getRecord } from './useCreateController'; -describe('CreateController', () => { +describe('useCreateController', () => { describe('getRecord', () => { const location = { pathname: '/foo', diff --git a/packages/ra-core/src/controller/useCreateController.ts b/packages/ra-core/src/controller/useCreateController.ts new file mode 100644 index 00000000000..fdae4cd125b --- /dev/null +++ b/packages/ra-core/src/controller/useCreateController.ts @@ -0,0 +1,138 @@ +import { useCallback } from 'react'; +// @ts-ignore +import inflection from 'inflection'; +import { parse } from 'query-string'; + +import { useCreate } from '../fetch'; +import { useCheckMinimumRequiredProps } from './checkMinimumRequiredProps'; +import { Location } from 'history'; +import { match as Match } from 'react-router'; +import { Record } from '../types'; +import { RedirectionSideEffect } from '../sideEffect'; +import { useTranslate } from '../i18n'; + +export interface CreateControllerProps { + isLoading: boolean; + isSaving: boolean; + defaultTitle: string; + save: (record: Partial, redirect: RedirectionSideEffect) => void; + resource: string; + basePath: string; + record?: Partial; + redirect: RedirectionSideEffect; +} + +export interface CreateProps { + basePath: string; + hasCreate?: boolean; + hasEdit?: boolean; + hasList?: boolean; + hasShow?: boolean; + location: Location; + match: Match; + record?: Partial; + resource: string; +} + +/** + * Prepare data for the Create view + * + * @param {Object} props The props passed to the Create component. + * + * @return {Object} controllerProps Fetched data and callbacks for the Create view + * + * @example + * + * import { useCreateController } from 'react-admin'; + * import CreateView from './CreateView'; + * + * const MyCreate = props => { + * const controllerProps = useCreateController(props); + * return ; + * } + */ +const useCreateController = (props: CreateProps): CreateControllerProps => { + useCheckMinimumRequiredProps( + 'Create', + ['basePath', 'location', 'resource'], + props + ); + const { + basePath, + resource, + location, + record = {}, + hasShow, + hasEdit, + } = props; + + const translate = useTranslate(); + const recordToUse = getRecord(location, record); + + const [create, { loading: isSaving }] = useCreate( + resource, + {}, // set by the caller + { + onSuccess: { + notification: { + body: 'ra.notification.created', + level: 'info', + messageArgs: { + smart_count: 1, + }, + }, + basePath, + }, + onFailure: { + notification: { + body: 'ra.notification.http_error', + level: 'warning', + }, + }, + } + ); + + const save = useCallback( + (data: Partial, redirectTo = 'list') => + create(null, { data }, { onSuccess: { redirectTo } }), + [create] + ); + + const resourceName = translate(`resources.${resource}.name`, { + smart_count: 1, + _: inflection.humanize(inflection.singularize(resource)), + }); + const defaultTitle = translate('ra.page.create', { + name: `${resourceName}`, + }); + + return { + isLoading: false, + isSaving, + defaultTitle, + save, + resource, + basePath, + record: recordToUse, + redirect: getDefaultRedirectRoute(hasShow, hasEdit), + }; +}; + +export default useCreateController; + +export const getRecord = ({ state, search }, record: any = {}) => + state && state.record + ? state.record + : search + ? parse(search, { arrayFormat: 'bracket' }) + : record; + +const getDefaultRedirectRoute = (hasShow, hasEdit) => { + if (hasEdit) { + return 'edit'; + } + if (hasShow) { + return 'show'; + } + return 'list'; +}; diff --git a/packages/ra-core/src/fetch/index.ts b/packages/ra-core/src/fetch/index.ts index 9dc411230b1..dd56e8622c6 100644 --- a/packages/ra-core/src/fetch/index.ts +++ b/packages/ra-core/src/fetch/index.ts @@ -10,6 +10,7 @@ import withDataProvider from './withDataProvider'; import useGetOne from './useGetOne'; import useGetList from './useGetList'; import useUpdate from './useUpdate'; +import useCreate from './useCreate'; export { fetchUtils, @@ -22,6 +23,7 @@ export { useGetOne, useGetList, useUpdate, + useCreate, useQueryWithStore, withDataProvider, }; diff --git a/packages/ra-core/src/fetch/useCreate.ts b/packages/ra-core/src/fetch/useCreate.ts new file mode 100644 index 00000000000..6639c8a2778 --- /dev/null +++ b/packages/ra-core/src/fetch/useCreate.ts @@ -0,0 +1,37 @@ +import { CRUD_CREATE } from '../actions/dataActions/crudCreate'; +import { CREATE } from '../dataFetchActions'; +import useMutation from './useMutation'; + +/** + * Get a callback to call the dataProvider with a CREATE verb, the result and the loading state. + * + * The return value updates according to the request state: + * + * - start: [callback, { loading: true, loaded: false }] + * - success: [callback, { data: [data from response], loading: false, loaded: true }] + * - error: [callback, { error: [error from response], loading: false, loaded: true }] + * + * @param resource The resource name, e.g. 'posts' + * @param data The data to initialize the new record with, e.g. { title: 'hello, world" } + * @param options Options object to pass to the dataProvider. May include side effects to be executed upon success of failure, e.g. { onSuccess: { refresh: true } } + * + * @returns The current request state. Destructure as [create, { data, error, loading, loaded }]. + * + * @example + * + * import { useCreate } from 'react-admin'; + * + * const LikeButton = ({ record }) => { + * const like = { postId: record.id }; + * const [create, { loading, error }] = useCreate('likes', like); + * if (error) { return

ERROR

; } + * return