From 84db55b39dc6d54bad44087bff5d12256dcde015 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 3 Sep 2019 17:11:11 +0200 Subject: [PATCH 1/2] Replace connect by hooks in easy components --- docs/Admin.md | 15 +-- docs/Authorization.md | 19 ++-- docs/Theming.md | 2 - docs/UnitTesting.md | 3 +- .../demo/src/configuration/Configuration.js | 33 ++---- examples/demo/src/layout/Layout.js | 25 +++-- examples/demo/src/layout/Menu.js | 24 ++--- .../src/dataProvider/withDataProvider.tsx | 3 - .../ra-core/src/form/withDefaultValue.tsx | 3 + .../src/button/RefreshButton.js | 78 +++++++------- .../src/button/RefreshIconButton.js | 101 ++++++++---------- .../ra-ui-materialui/src/button/SaveButton.js | 26 ++--- .../src/layout/LoadingIndicator.js | 15 +-- 13 files changed, 138 insertions(+), 209 deletions(-) diff --git a/docs/Admin.md b/docs/Admin.md index d7841c469a8..4a8e05ecbb3 100644 --- a/docs/Admin.md +++ b/docs/Admin.md @@ -161,14 +161,16 @@ If you want to add or remove menu items, for instance to link to non-resources p ```jsx // in src/Menu.js import React, { createElement } from 'react'; -import { connect } from 'react-redux'; +import { useSelector } from 'react-redux'; import { useMediaQuery } from '@material-ui/core'; import { MenuItemLink, getResources } from 'react-admin'; import { withRouter } from 'react-router-dom'; import LabelIcon from '@material-ui/icons/Label'; -const Menu = ({ resources, onMenuClick, open, logout }) => { +const Menu = ({ onMenuClick, logout }) => { const isXSmall = useMediaQuery(theme => theme.breakpoints.down('xs')); + const open = useSelector(state => state.admin.ui.sidebarOpen); + const resources = useSelector(getResources); return (
{resources.map(resource => ( @@ -193,20 +195,13 @@ const Menu = ({ resources, onMenuClick, open, logout }) => { ); } -const mapStateToProps = state => ({ - open: state.admin.ui.sidebarOpen, - resources: getResources(state), -}); - -export default withRouter(connect(mapStateToProps)(Menu)); +export default withRouter(Menu); ``` **Tip**: Note the `MenuItemLink` component. It must be used to avoid unwanted side effects in mobile views. It supports a custom text and icon (which must be a material-ui ``). **Tip**: Note that we include the `logout` item only on small devices. Indeed, the `logout` button is already displayed in the AppBar on larger devices. -**Tip**: Note that we use React Router [`withRouter`](https://reacttraining.com/react-router/web/api/withRouter) Higher Order Component and that it is used **before** Redux [`connect](https://github.com/reactjs/react-redux/blob/master/docs/api.html#connectmapstatetoprops-mapdispatchtoprops-mergeprops-options). This is required if you want the active menu item to be highlighted. - Then, pass it to the `` component as the `menu` prop: ```jsx diff --git a/docs/Authorization.md b/docs/Authorization.md index f0a36ff08fd..c9dd64b8c4b 100644 --- a/docs/Authorization.md +++ b/docs/Authorization.md @@ -244,20 +244,19 @@ What if you want to check the permissions inside a [custom menu](./Admin.md#menu ```jsx // in src/myMenu.js import React from 'react'; -import { connect } from 'react-redux'; import { MenuItemLink, usePermissions } from 'react-admin'; const Menu = ({ onMenuClick, logout }) => { const { permissions } = usePermissions(); return ( -
- - - { permissions === 'admin' && - - } - {logout} -
-); +
+ + + {permissions === 'admin' && + + } + {logout} +
+ ); } ``` diff --git a/docs/Theming.md b/docs/Theming.md index e002bc84ebf..5f98d87b83f 100644 --- a/docs/Theming.md +++ b/docs/Theming.md @@ -720,8 +720,6 @@ export default withRouter(Menu); **Tip**: Note that we include the `logout` item only on small devices. Indeed, the `logout` button is already displayed in the AppBar on larger devices. -**Tip**: Note that we use React Router [`withRouter`](https://reacttraining.com/react-router/web/api/withRouter) Higher Order Component and that it is used **before** Redux [`connect](https://github.com/reactjs/react-redux/blob/master/docs/api.html#connectmapstatetoprops-mapdispatchtoprops-mergeprops-options). This is required if you want the active menu item to be highlighted. - **Tip**: The `primaryText` prop accepts a React node. You can pass a custom element in it. For example: ```jsx diff --git a/docs/UnitTesting.md b/docs/UnitTesting.md index 4bd6c47b12c..4abc4e35c88 100644 --- a/docs/UnitTesting.md +++ b/docs/UnitTesting.md @@ -68,7 +68,7 @@ This means that reducers will work as they will within the app. ## Spying on the store 'dispatch' -If you are using `mapDispatch` within connected components, it is likely you will want to test that actions have been dispatched with the correct arguments. You can return the `store` being used within the tests using a `renderProp`. +If you are using `useDispatch` within your components, it is likely you will want to test that actions have been dispatched with the correct arguments. You can return the `store` being used within the tests using a `renderProp`. ```jsx let dispatchSpy; @@ -87,7 +87,6 @@ it('should send the user to another url', () => { }); ``` - ## Testing Permissions As explained on the [Authorization page](./Authorization.md), it's possible to manage permissions via the authentication provider in order to filter page and fields the users can see. diff --git a/examples/demo/src/configuration/Configuration.js b/examples/demo/src/configuration/Configuration.js index a8b5651bb3b..0dabcc8ba8e 100644 --- a/examples/demo/src/configuration/Configuration.js +++ b/examples/demo/src/configuration/Configuration.js @@ -1,11 +1,10 @@ import React from 'react'; -import { connect } from 'react-redux'; +import { useSelector, useDispatch } from 'react-redux'; import Card from '@material-ui/core/Card'; import CardContent from '@material-ui/core/CardContent'; import Button from '@material-ui/core/Button'; import { useTranslate, changeLocale, Title } from 'react-admin'; import { makeStyles } from '@material-ui/core/styles'; -import compose from 'recompose/compose'; import { changeTheme } from './actions'; const useStyles = makeStyles({ @@ -13,9 +12,12 @@ const useStyles = makeStyles({ button: { margin: '1em' }, }); -const Configuration = ({ theme, locale, changeTheme, changeLocale }) => { +const Configuration = () => { const translate = useTranslate(); const classes = useStyles(); + const theme = useSelector(state => state.theme); + const locale = useSelector(state => state.i18n.locale); + const dispatch = useDispatch(); return ( @@ -27,7 +29,7 @@ const Configuration = ({ theme, locale, changeTheme, changeLocale }) => { variant="contained" className={classes.button} color={theme === 'light' ? 'primary' : 'default'} - onClick={() => changeTheme('light')} + onClick={() => dispatch(changeTheme('light'))} > {translate('pos.theme.light')} </Button> @@ -35,7 +37,7 @@ const Configuration = ({ theme, locale, changeTheme, changeLocale }) => { variant="contained" className={classes.button} color={theme === 'dark' ? 'primary' : 'default'} - onClick={() => changeTheme('dark')} + onClick={() => dispatch(changeTheme('dark'))} > {translate('pos.theme.dark')} </Button> @@ -46,7 +48,7 @@ const Configuration = ({ theme, locale, changeTheme, changeLocale }) => { variant="contained" className={classes.button} color={locale === 'en' ? 'primary' : 'default'} - onClick={() => changeLocale('en')} + onClick={() => dispatch(changeLocale('en'))} > en </Button> @@ -54,7 +56,7 @@ const Configuration = ({ theme, locale, changeTheme, changeLocale }) => { variant="contained" className={classes.button} color={locale === 'fr' ? 'primary' : 'default'} - onClick={() => changeLocale('fr')} + onClick={() => dispatch(changeLocale('fr'))} > fr </Button> @@ -63,19 +65,4 @@ const Configuration = ({ theme, locale, changeTheme, changeLocale }) => { ); }; -const mapStateToProps = state => ({ - theme: state.theme, - locale: state.i18n.locale, -}); - -const enhance = compose( - connect( - mapStateToProps, - { - changeLocale, - changeTheme, - } - ) -); - -export default enhance(Configuration); +export default Configuration; diff --git a/examples/demo/src/layout/Layout.js b/examples/demo/src/layout/Layout.js index c23ad56e2c6..471327203ea 100644 --- a/examples/demo/src/layout/Layout.js +++ b/examples/demo/src/layout/Layout.js @@ -1,18 +1,23 @@ import React from 'react'; -import { connect } from 'react-redux'; +import { useSelector } from 'react-redux'; import { Layout, Sidebar } from 'react-admin'; import AppBar from './AppBar'; import Menu from './Menu'; import { darkTheme, lightTheme } from './themes'; const CustomSidebar = props => <Sidebar {...props} size={200} />; -const CustomLayout = props => ( - <Layout {...props} appBar={AppBar} sidebar={CustomSidebar} menu={Menu} /> -); -export default connect( - state => ({ - theme: state.theme === 'dark' ? darkTheme : lightTheme, - }), - {} -)(CustomLayout); +export default props => { + const theme = useSelector(state => + state.theme === 'dark' ? darkTheme : lightTheme + ); + return ( + <Layout + {...props} + appBar={AppBar} + sidebar={CustomSidebar} + menu={Menu} + theme={theme} + /> + ); +}; diff --git a/examples/demo/src/layout/Menu.js b/examples/demo/src/layout/Menu.js index 1242d22eeaf..52404cf552f 100644 --- a/examples/demo/src/layout/Menu.js +++ b/examples/demo/src/layout/Menu.js @@ -1,7 +1,6 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import compose from 'recompose/compose'; +import { useSelector } from 'react-redux'; import SettingsIcon from '@material-ui/icons/Settings'; import LabelIcon from '@material-ui/icons/Label'; import { useMediaQuery } from '@material-ui/core'; @@ -16,7 +15,7 @@ import categories from '../categories'; import reviews from '../reviews'; import SubMenu from './SubMenu'; -const Menu = ({ onMenuClick, open, logout }) => { +const Menu = ({ onMenuClick, logout }) => { const [state, setState] = useState({ menuCatalog: false, menuSales: false, @@ -24,6 +23,9 @@ const Menu = ({ onMenuClick, open, logout }) => { }); const translate = useTranslate(); const isXsmall = useMediaQuery(theme => theme.breakpoints.down('xs')); + const open = useSelector(state => state.admin.ui.sidebarOpen); + useSelector(state => state.theme); // force rerender on theme change + useSelector(state => state.i18n.locale); // force rerender on locale change const handleToggle = menu => { setState(state => ({ ...state, [menu]: !state[menu] })); @@ -141,18 +143,4 @@ Menu.propTypes = { logout: PropTypes.object, }; -const mapStateToProps = state => ({ - open: state.admin.ui.sidebarOpen, - theme: state.theme, - locale: state.i18n.locale, -}); - -const enhance = compose( - withRouter, - connect( - mapStateToProps, - {} - ) -); - -export default enhance(Menu); +export default withRouter(Menu); diff --git a/packages/ra-core/src/dataProvider/withDataProvider.tsx b/packages/ra-core/src/dataProvider/withDataProvider.tsx index ca0535ea6f4..1cc8f3b4c12 100644 --- a/packages/ra-core/src/dataProvider/withDataProvider.tsx +++ b/packages/ra-core/src/dataProvider/withDataProvider.tsx @@ -20,9 +20,6 @@ export interface DataProviderProps { * the injected dataProvider prop accepts a fourth parameter, an object literal * which may contain side effects, of make the action optimistic (with undoable: true). * - * As it uses connect() from react-redux, this HOC also injects the dispatch prop, - * allowing developers to dispatch additional actions upon completion. - * * @example * * import { withDataProvider, showNotification } from 'react-admin'; diff --git a/packages/ra-core/src/form/withDefaultValue.tsx b/packages/ra-core/src/form/withDefaultValue.tsx index e36a0e79974..38e157e115a 100644 --- a/packages/ra-core/src/form/withDefaultValue.tsx +++ b/packages/ra-core/src/form/withDefaultValue.tsx @@ -10,6 +10,9 @@ export interface DefaultValueProps extends InputProps { initializeForm: typeof initializeFormAction; } +/** + * @deprecated + */ export class DefaultValueView extends Component<any> { static propTypes = { decoratedComponent: PropTypes.oneOfType([ diff --git a/packages/ra-ui-materialui/src/button/RefreshButton.js b/packages/ra-ui-materialui/src/button/RefreshButton.js index a75ce9e39f8..90be0025fc3 100644 --- a/packages/ra-ui-materialui/src/button/RefreshButton.js +++ b/packages/ra-ui-materialui/src/button/RefreshButton.js @@ -1,47 +1,41 @@ -import React, { Component } from 'react'; +import React, { useCallback } from 'react'; import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; +import { useDispatch } from 'react-redux'; import NavigationRefresh from '@material-ui/icons/Refresh'; -import { refreshView as refreshViewAction } from 'ra-core'; +import { refreshView } from 'ra-core'; import Button from './Button'; -class RefreshButton extends Component { - static propTypes = { - label: PropTypes.string, - refreshView: PropTypes.func.isRequired, - icon: PropTypes.element, - }; - - static defaultProps = { - label: 'ra.action.refresh', - icon: <NavigationRefresh />, - }; - - handleClick = event => { - const { refreshView, onClick } = this.props; - event.preventDefault(); - refreshView(); - - if (typeof onClick === 'function') { - onClick(); - } - }; - - render() { - const { label, refreshView, icon, ...rest } = this.props; - - return ( - <Button label={label} onClick={this.handleClick} {...rest}> - {icon} - </Button> - ); - } -} - -const enhance = connect( - null, - { refreshView: refreshViewAction } -); - -export default enhance(RefreshButton); +const defaultIcon = <NavigationRefresh />; + +const RefreshButton = ({ + label = 'ra.action.refresh', + icon = defaultIcon, + onClick, + ...rest +}) => { + const dispatch = useDispatch(); + const handleClick = useCallback( + event => { + event.preventDefault(); + dispatch(refreshView()); + if (typeof onClick === 'function') { + onClick(); + } + }, + [dispatch, onClick] + ); + + return ( + <Button label={label} onClick={handleClick} {...rest}> + {icon} + </Button> + ); +}; + +RefreshButton.propTypes = { + label: PropTypes.string, + icon: PropTypes.element, +}; + +export default RefreshButton; diff --git a/packages/ra-ui-materialui/src/button/RefreshIconButton.js b/packages/ra-ui-materialui/src/button/RefreshIconButton.js index c936bed37e8..d090868fe93 100644 --- a/packages/ra-ui-materialui/src/button/RefreshIconButton.js +++ b/packages/ra-ui-materialui/src/button/RefreshIconButton.js @@ -1,67 +1,52 @@ -import React, { Component } from 'react'; +import React, { useCallback } from 'react'; import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import compose from 'recompose/compose'; +import { useDispatch } from 'react-redux'; import Tooltip from '@material-ui/core/Tooltip'; import IconButton from '@material-ui/core/IconButton'; import NavigationRefresh from '@material-ui/icons/Refresh'; -import { refreshView, translate } from 'ra-core'; +import { refreshView, useTranslate } from 'ra-core'; -class RefreshButton extends Component { - static propTypes = { - className: PropTypes.string, - label: PropTypes.string, - refreshView: PropTypes.func.isRequired, - translate: PropTypes.func.isRequired, - icon: PropTypes.element, - }; +const defaultIcon = <NavigationRefresh />; - static defaultProps = { - label: 'ra.action.refresh', - icon: <NavigationRefresh />, - }; +const RefreshIconButton = ({ + label = 'ra.action.refresh', + icon = defaultIcon, + onClick, + className, + ...rest +}) => { + const dispatch = useDispatch(); + const translate = useTranslate(); + const handleClick = useCallback( + event => { + event.preventDefault(); + dispatch(refreshView()); + if (typeof onClick === 'function') { + onClick(); + } + }, + [dispatch, onClick] + ); - handleClick = event => { - const { refreshView, onClick } = this.props; - event.preventDefault(); - refreshView(); + return ( + <Tooltip title={label && translate(label, { _: label })}> + <IconButton + aria-label={label && translate(label, { _: label })} + className={className} + color="inherit" + onClick={handleClick} + {...rest} + > + {icon} + </IconButton> + </Tooltip> + ); +}; - if (typeof onClick === 'function') { - onClick(); - } - }; +RefreshIconButton.propTypes = { + className: PropTypes.string, + label: PropTypes.string, + icon: PropTypes.element, +}; - render() { - const { - className, - label, - refreshView, - translate, - icon, - ...rest - } = this.props; - - return ( - <Tooltip title={label && translate(label, { _: label })}> - <IconButton - aria-label={label && translate(label, { _: label })} - className={className} - color="inherit" - onClick={this.handleClick} - {...rest} - > - {icon} - </IconButton> - </Tooltip> - ); - } -} - -const enhance = compose( - connect( - null, - { refreshView } - ), - translate -); -export default enhance(RefreshButton); +export default RefreshIconButton; diff --git a/packages/ra-ui-materialui/src/button/SaveButton.js b/packages/ra-ui-materialui/src/button/SaveButton.js index cfac5409699..19539fa1955 100644 --- a/packages/ra-ui-materialui/src/button/SaveButton.js +++ b/packages/ra-ui-materialui/src/button/SaveButton.js @@ -1,13 +1,11 @@ -import React, { cloneElement, useCallback } from 'react'; +import React, { cloneElement } from 'react'; import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import compose from 'recompose/compose'; import Button from '@material-ui/core/Button'; import CircularProgress from '@material-ui/core/CircularProgress'; import { makeStyles } from '@material-ui/core/styles'; import ContentSave from '@material-ui/icons/Save'; import classnames from 'classnames'; -import { showNotification, translate } from 'ra-core'; +import { useTranslate, useNotify } from 'ra-core'; const useStyles = makeStyles(theme => ({ button: { @@ -29,7 +27,6 @@ const sanitizeRestProps = ({ label, invalid, variant, - translate, handleSubmit, handleSubmitWithRedirect, submitOnEnter, @@ -37,7 +34,6 @@ const sanitizeRestProps = ({ redirect, resource, locale, - showNotification, undoable, ...rest }) => rest; @@ -51,15 +47,15 @@ export function SaveButton({ redirect, saving, submitOnEnter, - translate, variant = 'contained', icon, onClick, handleSubmitWithRedirect, - showNotification, ...rest }) { const classes = useStyles({ classes: classesOverride }); + const notify = useNotify(); + const translate = useTranslate(); // We handle the click event through mousedown because of an issue when // the button is not as the same place when mouseup occurs, preventing the click @@ -72,7 +68,7 @@ export function SaveButton({ event.preventDefault(); } else { if (invalid) { - showNotification('ra.message.invalid_form', 'warning'); + notify('ra.message.invalid_form', 'warning'); } // always submit form explicitly regardless of button type if (event) { @@ -135,9 +131,7 @@ SaveButton.propTypes = { PropTypes.func, ]), saving: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]), - showNotification: PropTypes.func, submitOnEnter: PropTypes.bool, - translate: PropTypes.func.isRequired, variant: PropTypes.oneOf(['text', 'outlined', 'contained']), icon: PropTypes.element, }; @@ -147,12 +141,4 @@ SaveButton.defaultProps = { icon: <ContentSave />, }; -const enhance = compose( - translate, - connect( - undefined, - { showNotification } - ) -); - -export default enhance(SaveButton); +export default SaveButton; diff --git a/packages/ra-ui-materialui/src/layout/LoadingIndicator.js b/packages/ra-ui-materialui/src/layout/LoadingIndicator.js index 464275760cb..812b1594215 100644 --- a/packages/ra-ui-materialui/src/layout/LoadingIndicator.js +++ b/packages/ra-ui-materialui/src/layout/LoadingIndicator.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import { connect } from 'react-redux'; +import { useSelector } from 'react-redux'; import { makeStyles } from '@material-ui/core/styles'; import CircularProgress from '@material-ui/core/CircularProgress'; @@ -16,11 +16,11 @@ const useStyles = makeStyles({ export const LoadingIndicator = ({ classes: classesOverride, className, - isLoading, ...rest }) => { + const loading = useSelector(state => state.admin.loading > 0); const classes = useStyles({ classes: classesOverride }); - return isLoading ? ( + return loading ? ( <CircularProgress className={classNames('app-loader', classes.loader, className)} color="inherit" @@ -40,11 +40,4 @@ LoadingIndicator.propTypes = { width: PropTypes.string, }; -const mapStateToProps = state => ({ - isLoading: state.admin.loading > 0, -}); - -export default connect( - mapStateToProps, - {} // Avoid connect passing dispatch in props -)(LoadingIndicator); +export default LoadingIndicator; From ce0ca787d90f0ad029b2d0ecf3f92397586b12f9 Mon Sep 17 00:00:00 2001 From: fzaninotto <fzaninotto@gmail.com> Date: Tue, 3 Sep 2019 18:39:40 +0200 Subject: [PATCH 2/2] Fix SaveButton tests --- .../ra-ui-materialui/src/button/SaveButton.js | 11 +-- .../src/button/SaveButton.spec.js | 76 +++++++++++-------- 2 files changed, 50 insertions(+), 37 deletions(-) diff --git a/packages/ra-ui-materialui/src/button/SaveButton.js b/packages/ra-ui-materialui/src/button/SaveButton.js index 19539fa1955..e6f33ba943f 100644 --- a/packages/ra-ui-materialui/src/button/SaveButton.js +++ b/packages/ra-ui-materialui/src/button/SaveButton.js @@ -38,7 +38,7 @@ const sanitizeRestProps = ({ ...rest }) => rest; -export function SaveButton({ +const SaveButton = ({ className, classes: classesOverride = {}, invalid, @@ -52,7 +52,7 @@ export function SaveButton({ onClick, handleSubmitWithRedirect, ...rest -}) { +}) => { const classes = useStyles({ classes: classesOverride }); const notify = useNotify(); const translate = useTranslate(); @@ -91,6 +91,7 @@ export function SaveButton({ }; const type = submitOnEnter ? 'submit' : 'button'; + const displayedLabel = label && translate(label, { _: label }); return ( <Button className={classnames(classes.button, className)} @@ -99,7 +100,7 @@ export function SaveButton({ onMouseDown={handleMouseDown} onClick={handleClick} color={saving ? 'default' : 'primary'} - aria-label={label && translate(label, { _: label })} + aria-label={displayedLabel} {...sanitizeRestProps(rest)} > {saving && saving.redirect === redirect ? ( @@ -113,10 +114,10 @@ export function SaveButton({ className: classnames(classes.leftIcon, classes.icon), }) )} - {label && translate(label, { _: label })} + {displayedLabel} </Button> ); -} +}; SaveButton.propTypes = { className: PropTypes.string, diff --git a/packages/ra-ui-materialui/src/button/SaveButton.spec.js b/packages/ra-ui-materialui/src/button/SaveButton.spec.js index 8f2bd6273bb..796d49fa153 100644 --- a/packages/ra-ui-materialui/src/button/SaveButton.spec.js +++ b/packages/ra-ui-materialui/src/button/SaveButton.spec.js @@ -1,43 +1,43 @@ import { render, cleanup, fireEvent } from '@testing-library/react'; import React from 'react'; +import { TestContext } from 'ra-core'; -import { SaveButton } from './SaveButton'; - -const translate = label => label; +import SaveButton from './SaveButton'; describe('<SaveButton />', () => { afterEach(cleanup); it('should render as submit type when submitOnEnter is true', () => { const { getByLabelText } = render( - <SaveButton submitOnEnter translate={translate} /> - ); - expect(getByLabelText('ra.action.save').getAttribute('type')).toEqual( - 'submit' + <TestContext> + <SaveButton submitOnEnter /> + </TestContext> ); + expect(getByLabelText('Save').getAttribute('type')).toEqual('submit'); }); it('should render as button type when submitOnEnter is false', () => { const { getByLabelText } = render( - <SaveButton submitOnEnter={false} translate={translate} /> + <TestContext> + <SaveButton submitOnEnter={false} /> + </TestContext> ); - expect(getByLabelText('ra.action.save').getAttribute('type')).toEqual( - 'button' - ); + expect(getByLabelText('Save').getAttribute('type')).toEqual('button'); }); it('should trigger submit action when clicked if no saving is in progress', () => { const onSubmit = jest.fn(); const { getByLabelText } = render( - <SaveButton - translate={translate} - handleSubmitWithRedirect={onSubmit} - saving={false} - /> + <TestContext> + <SaveButton + handleSubmitWithRedirect={onSubmit} + saving={false} + /> + </TestContext> ); - fireEvent.mouseDown(getByLabelText('ra.action.save')); + fireEvent.mouseDown(getByLabelText('Save')); expect(onSubmit).toHaveBeenCalled(); }); @@ -45,31 +45,43 @@ describe('<SaveButton />', () => { const onSubmit = jest.fn(); const { getByLabelText } = render( - <SaveButton - translate={translate} - handleSubmitWithRedirect={onSubmit} - saving - /> + <TestContext> + <SaveButton handleSubmitWithRedirect={onSubmit} saving /> + </TestContext> ); - fireEvent.mouseDown(getByLabelText('ra.action.save')); + fireEvent.mouseDown(getByLabelText('Save')); expect(onSubmit).not.toHaveBeenCalled(); }); + it('should show a notification if the form is not valid', () => { const onSubmit = jest.fn(); - const showNotification = jest.fn(); + let dispatchSpy; const { getByLabelText } = render( - <SaveButton - translate={translate} - handleSubmitWithRedirect={onSubmit} - invalid - showNotification={showNotification} - /> + <TestContext> + {({ store }) => { + dispatchSpy = jest.spyOn(store, 'dispatch'); + return ( + <SaveButton + handleSubmitWithRedirect={onSubmit} + invalid + /> + ); + }} + </TestContext> ); - fireEvent.mouseDown(getByLabelText('ra.action.save')); - expect(showNotification).toHaveBeenCalled(); + fireEvent.mouseDown(getByLabelText('Save')); + expect(dispatchSpy).toHaveBeenCalledWith({ + payload: { + message: 'ra.message.invalid_form', + messageArgs: {}, + type: 'warning', + undoable: false, + }, + type: 'RA/SHOW_NOTIFICATION', + }); expect(onSubmit).toHaveBeenCalled(); }); });