From d56994b5f6070a133a3c7b1f41f516db5e8f661b Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 10 Sep 2019 18:41:09 +0200 Subject: [PATCH 01/10] [BC Break] Update TranslationProvider so it doesn't use Redux --- packages/ra-core/src/CoreAdmin.tsx | 7 +- .../ra-core/src/i18n/TranslationContext.ts | 6 +- .../ra-core/src/i18n/TranslationProvider.tsx | 145 +++++++++--------- packages/ra-core/src/i18n/index.ts | 8 +- packages/ra-core/src/i18n/useLocale.tsx | 28 ++++ .../ra-core/src/i18n/useSetLocale.spec.js | 66 ++++++++ packages/ra-core/src/i18n/useSetLocale.tsx | 38 +++++ .../ra-core/src/i18n/useTranslate.spec.tsx | 22 ++- packages/ra-core/src/i18n/useTranslate.ts | 19 +++ 9 files changed, 258 insertions(+), 81 deletions(-) create mode 100644 packages/ra-core/src/i18n/useLocale.tsx create mode 100644 packages/ra-core/src/i18n/useSetLocale.spec.js create mode 100644 packages/ra-core/src/i18n/useSetLocale.tsx diff --git a/packages/ra-core/src/CoreAdmin.tsx b/packages/ra-core/src/CoreAdmin.tsx index f1fdcd07883..89ccf9e113a 100644 --- a/packages/ra-core/src/CoreAdmin.tsx +++ b/packages/ra-core/src/CoreAdmin.tsx @@ -63,6 +63,7 @@ const CoreAdmin: FunctionComponent = ({ appLayout, authProvider, dataProvider, + i18nProvider, children, customRoutes = [], dashboard, @@ -76,7 +77,6 @@ const CoreAdmin: FunctionComponent = ({ history: customHistory, customReducers, customSagas, - i18nProvider, initialState, locale, }) => { @@ -98,7 +98,10 @@ const CoreAdmin: FunctionComponent = ({ return ( - + {loginPage !== false && loginPage !== true ? ( diff --git a/packages/ra-core/src/i18n/TranslationContext.ts b/packages/ra-core/src/i18n/TranslationContext.ts index 45585c69f5d..95d4b1bcd90 100644 --- a/packages/ra-core/src/i18n/TranslationContext.ts +++ b/packages/ra-core/src/i18n/TranslationContext.ts @@ -1,12 +1,16 @@ import { createContext } from 'react'; -import { Translate } from '../types'; +import { Translate, I18nProvider } from '../types'; export interface TranslationContextProps { + provider: I18nProvider; locale: string; translate: Translate; + setLocale: (locale: string) => void; } export const TranslationContext = createContext({ + provider: () => ({}), locale: 'en', translate: id => id, + setLocale: () => {}, }); diff --git a/packages/ra-core/src/i18n/TranslationProvider.tsx b/packages/ra-core/src/i18n/TranslationProvider.tsx index b0bb398c780..30f7c1d71fb 100644 --- a/packages/ra-core/src/i18n/TranslationProvider.tsx +++ b/packages/ra-core/src/i18n/TranslationProvider.tsx @@ -1,102 +1,107 @@ -import React, { Children, ReactElement, Component } from 'react'; +import React, { + useEffect, + useRef, + useCallback, + useMemo, + Children, + ReactElement, + FunctionComponent, +} from 'react'; import Polyglot from 'node-polyglot'; -import { connect, MapStateToProps } from 'react-redux'; import defaultMessages from 'ra-language-english'; import defaultsDeep from 'lodash/defaultsDeep'; -import { ReduxState } from '../types'; -import { - TranslationContextProps, - TranslationContext, -} from './TranslationContext'; -interface MappedProps { - locale: string; - messages: object; -} - -interface State { - contextValues: TranslationContextProps; -} +import { useSafeSetState } from '../util/hooks'; +import { I18nProvider } from '../types'; +import { TranslationContext } from './TranslationContext'; interface Props { + locale?: string; + i18nProvider: I18nProvider; children: ReactElement; } -interface ViewProps extends MappedProps, Props {} - /** * Creates a translation context, available to its children * - * Must be called within a Redux app. - * * @example * const MyApp = () => ( * - * + * * * * * ); */ -class TranslationProviderView extends Component { - constructor(props) { - super(props); - const { locale, messages } = props; - const polyglot = new Polyglot({ - locale, - phrases: defaultsDeep({ '': '' }, messages, defaultMessages), - }); - - this.state = { - contextValues: { - locale, - translate: polyglot.t.bind(polyglot), - }, - }; - } - - componentDidUpdate(prevProps) { - if ( - prevProps.locale !== this.props.locale || - prevProps.messages !== this.props.messages - ) { - const { locale, messages } = this.props; +const TranslationProvider: FunctionComponent = props => { + const { i18nProvider, children, locale = 'en' } = props; + const [state, setState] = useSafeSetState({ + provider: i18nProvider, + locale, + translate: (() => { + const messages = i18nProvider(locale); + if (messages instanceof Promise) { + throw new Error( + `The i18nProvider returned a Promise for the messages of the default locale (${locale}). Please update your i18nProvider to return the messages of the default locale in a synchronous way.` + ); + } const polyglot = new Polyglot({ locale, phrases: defaultsDeep({ '': '' }, messages, defaultMessages), }); + return polyglot.t.bind(polyglot); + })(), + }); - this.setState({ - contextValues: { - locale, + const setLocale = useCallback( + newLocale => + new Promise(resolve => + // so we systematically return a Promise for the messages + // i18nProvider may return a Promise for language changes, + resolve(i18nProvider(newLocale)) + ).then(messages => { + const polyglot = new Polyglot({ + locale: newLocale, + phrases: defaultsDeep( + { '': '' }, + messages, + defaultMessages + ), + }); + setState({ + provider: i18nProvider, + locale: newLocale, translate: polyglot.t.bind(polyglot), - }, - }); - } - } + }); + }), + [i18nProvider, setState] + ); - render() { - const { children } = this.props; - const { contextValues } = this.state; - - return ( - - {Children.only(children)} - - ); - } -} + const isInitialMount = useRef(true); + useEffect(() => { + if (isInitialMount.current) { + isInitialMount.current = false; + } else { + setLocale(locale); + } + }, [setLocale, locale]); -const mapStateToProps: MapStateToProps< - MappedProps, - any, - ReduxState -> = state => ({ - locale: state.i18n.locale, - messages: state.i18n.messages, -}); + // Allow locale modification by including setLocale in the context + // This can't be done in the initial state because setState doesn't exist yet + const value = useMemo( + () => ({ + ...state, + setLocale, + }), + [setLocale, state] + ); -const TranslationProvider = connect(mapStateToProps)(TranslationProviderView); + return ( + + {Children.only(children)} + + ); +}; export default TranslationProvider; diff --git a/packages/ra-core/src/i18n/index.ts b/packages/ra-core/src/i18n/index.ts index fc22ee10b32..e9c69bdb2b8 100644 --- a/packages/ra-core/src/i18n/index.ts +++ b/packages/ra-core/src/i18n/index.ts @@ -1,6 +1,8 @@ import defaultI18nProvider from './defaultI18nProvider'; import translate from './translate'; import TranslationProvider from './TranslationProvider'; +import useLocale from './useLocale'; +import useSetLocale from './useSetLocale'; import useTranslate from './useTranslate'; // Alias to translate to avoid shadowed variable names error with tslint @@ -8,8 +10,10 @@ const withTranslate = translate; export { defaultI18nProvider, - translate, - withTranslate, + translate, // deprecated + withTranslate, // deprecated + useLocale, + useSetLocale, useTranslate, TranslationProvider, }; diff --git a/packages/ra-core/src/i18n/useLocale.tsx b/packages/ra-core/src/i18n/useLocale.tsx new file mode 100644 index 00000000000..49fba1eb87a --- /dev/null +++ b/packages/ra-core/src/i18n/useLocale.tsx @@ -0,0 +1,28 @@ +import { useContext } from 'react'; + +import { TranslationContext } from './TranslationContext'; + +/** + * Get the current locale from the TranslationContext + * + * This hook rerenders when the locale changes. + * + * @example + * + * import { useLocale } from 'react-admin'; + * + * const availableLanguages = { + * en: 'English', + * fr: 'Français', + * } + * const CurrentLanguage = () => { + * const locale = useLocale(); + * return {availableLanguages[locale]}; + * } + */ +const useLocale = () => { + const { locale } = useContext(TranslationContext); + return locale; +}; + +export default useLocale; diff --git a/packages/ra-core/src/i18n/useSetLocale.spec.js b/packages/ra-core/src/i18n/useSetLocale.spec.js new file mode 100644 index 00000000000..06691a15817 --- /dev/null +++ b/packages/ra-core/src/i18n/useSetLocale.spec.js @@ -0,0 +1,66 @@ +import React from 'react'; +import expect from 'expect'; +import { render, fireEvent, cleanup, wait, act } from '@testing-library/react'; + +import useTranslate from './useTranslate'; +import useSetLocale from './useSetLocale'; +import TranslationProvider from './TranslationProvider'; +import { TranslationContext } from './TranslationContext'; + +describe('useTranslate', () => { + afterEach(cleanup); + + const Component = () => { + const translate = useTranslate(); + const setLocale = useSetLocale(); + return ( +
+ {translate('hello')} + +
+ ); + }; + + it('should not fail when used outside of a translation provider', () => { + const { queryAllByText } = render(); + expect(queryAllByText('hello')).toHaveLength(1); + }); + + it('should use the setLocale function set in the translation context', () => { + const setLocale = jest.fn(); + const { getByText } = render( + 'hallo', + provider: () => ({}), + setLocale, + }} + > + + + ); + fireEvent.click(getByText('Français')); + expect(setLocale).toHaveBeenCalledTimes(1); + }); + + it('should use the i18n provider when using TranslationProvider', async () => { + const i18nProvider = locale => { + if (locale === 'en') return { hello: 'hello' }; + if (locale === 'fr') return { hello: 'bonjour' }; + }; + const { getByText, queryAllByText } = render( + + + + ); + expect(queryAllByText('hello')).toHaveLength(1); + expect(queryAllByText('bonjour')).toHaveLength(0); + act(() => { + fireEvent.click(getByText('Français')); + }); + await wait(); + expect(queryAllByText('hello')).toHaveLength(0); + expect(queryAllByText('bonjour')).toHaveLength(1); + }); +}); diff --git a/packages/ra-core/src/i18n/useSetLocale.tsx b/packages/ra-core/src/i18n/useSetLocale.tsx new file mode 100644 index 00000000000..e7babf2d7d3 --- /dev/null +++ b/packages/ra-core/src/i18n/useSetLocale.tsx @@ -0,0 +1,38 @@ +import { useContext } from 'react'; + +import { TranslationContext } from './TranslationContext'; + +/** + * Set the current locale using the TranslationContext + * + * This hook rerenders when the locale changes. + * + * @param {string} locale + * + * @example + * + * import { useLocale } from 'react-admin'; + * + * const availableLanguages = { + * en: 'English', + * fr: 'Français', + * } + * const LanguageSwitcher = () => { + * const setLocale = useSetLocale(); + * return ( + *
    { + * Object.keys(availableLanguages).map(locale => { + *
  • setLocale(locale)}> + * {availableLanguages[locale]} + *
  • + * }) + * }
+ * ); + * } + */ +const useSetLocale = () => { + const { setLocale } = useContext(TranslationContext); + return setLocale; +}; + +export default useSetLocale; diff --git a/packages/ra-core/src/i18n/useTranslate.spec.tsx b/packages/ra-core/src/i18n/useTranslate.spec.tsx index 97eab5b1338..78b82c9bf8b 100644 --- a/packages/ra-core/src/i18n/useTranslate.spec.tsx +++ b/packages/ra-core/src/i18n/useTranslate.spec.tsx @@ -3,8 +3,8 @@ import expect from 'expect'; import { render, cleanup } from '@testing-library/react'; import useTranslate from './useTranslate'; +import TranslationProvider from './TranslationProvider'; import { TranslationContext } from './TranslationContext'; -import { renderWithRedux } from '../util'; describe('useTranslate', () => { afterEach(cleanup); @@ -22,7 +22,12 @@ describe('useTranslate', () => { it('should use the translate function set in the translation context', () => { const { queryAllByText } = render( 'hallo' }} + value={{ + locale: 'de', + translate: () => 'hallo', + provider: () => ({}), + setLocale: () => {}, + }} > @@ -31,10 +36,15 @@ describe('useTranslate', () => { expect(queryAllByText('hallo')).toHaveLength(1); }); - it('should use the messages set in the store', () => { - const { queryAllByText } = renderWithRedux(, { - i18n: { messages: { hello: 'bonjour' } }, - }); + it('should use the i18n provider when using TranslationProvider', () => { + const { queryAllByText } = render( + ({ hello: 'bonjour' })} + > + + + ); expect(queryAllByText('hello')).toHaveLength(0); expect(queryAllByText('bonjour')).toHaveLength(1); }); diff --git a/packages/ra-core/src/i18n/useTranslate.ts b/packages/ra-core/src/i18n/useTranslate.ts index 574e74a692a..35e237b393b 100644 --- a/packages/ra-core/src/i18n/useTranslate.ts +++ b/packages/ra-core/src/i18n/useTranslate.ts @@ -2,6 +2,25 @@ import { useContext } from 'react'; import { TranslationContext } from './TranslationContext'; +/** + * Translate a string using the current locale and the translations from the i18nProvider + * + * @see Polyglot.t() + * @link https://airbnb.io/polyglot.js/#polyglotprototypetkey-interpolationoptions + * + * @return {Function} A translation function, accepting two arguments + * - a string used as key in the translations + * - an interpolationOptions object + * + * @example + * + * import { useTranslate } from 'react-admin'; + * + * const SettingsMenu = () => { + * const translate = useTranslate(); + * return {translate('settings')}; + * } + */ const useTranslate = () => { const { translate } = useContext(TranslationContext); return translate; From 63c27e6c269a32778cf0fce3f25e17c370c420f2 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 10 Sep 2019 18:41:22 +0200 Subject: [PATCH 02/10] Add upgrade guide --- UPGRADE.md | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/UPGRADE.md b/UPGRADE.md index 0d8e0732e01..b2bca544d23 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1012,3 +1012,88 @@ If you have custom Login or Logout buttons that dispatch these actions, they wil If you had custom reducer or sagas based on these actions, they will no longer work. You will have to reimplement that custom logic using the new authentication hooks. **Tip**: If you need to clear the Redux state, you can dispatch the `CLEAR_STATE` action. + +## The translation layer no longer uses Redux + +React-admin translation (i18n) layer lets developers provide translations for UI and content, based on Airbnb's [Polyglot](https://airbnb.io/polyglot.js/) library. The previous implementation used Redux and redux-saga. In react-admin 3.0, the translation utilities are implemented using a React context and a set of hooks. + +If you didn't use translations, or if you passed your `i18nProvider` to the `` component and used only one language, you have nothing to change. Your app will continue to work just as before. We encourage you to migrate from the `withTranslate` HOC to the `useTranslate` hook, but that's not compulsory. + +```diff +-import { withTranslate } from 'react-admin'; ++import { useTranslate } from 'react-admin'; + +-const SettingsMenu = ({ translate }) => { ++const SettingsMenu = () => { ++ const translate = useTranslate(); + return {translate('settings')}; +} + +-export default withTranslate(SettingsMenu); ++export default SettingsMenu; +``` + +However, if your app allowed users to change locale at runtime, you need to update the menu or button that triggers the locale change. Instead of dispatching a `CHANGE_LOCALE` Redux action (which has no effect in react-admin 3.0), use the `useSetLocale` hook as follows: + +```diff +import React from 'react'; +-import { connect } from 'react-redux'; +import Button from '@material-ui/core/Button'; +-import { changeLocale } from 'react-admin'; ++import { useSetLocale } from 'react-admin'; + +-const localeSwitcher = ({ changeLocale }) => ++const LocaleSwitcher = () => { ++ const setLocale = usesetLocale(); +- const switchToFrench = () => changeLocale('fr'); ++ const switchToFrench = () => setLocale('fr'); +- const switchToEnglish = () => changeLocale('en'); ++ const switchToEnglish = () => setLocale('en'); + return ( +
+
Language
+ + +
+ ); +} + +-export default connect(null, { changeLocale })(LocaleSwitcher); ++export default LocaleSwitcher; +``` + +Also, if you connected a component to the redux store to get the current language, you now need to use the `useLocale()` hook instead. + +```diff +-import { connect } from 'react-redux'; ++import { useLocale } from 'react-admin'; + +const availableLanguages = { + en: 'English', + fr: 'Français', +} + +-const CurrentLanguage = ({ locale }) => { ++const CurrentLanguage = () => { ++ const locale = useLocale(); + return {availableLanguages[locale]}; +} + +- const mapStatetoProps = state => state.i18n.locale + +-export default connect(mapStateToProps)(CurrentLanguage); ++export default CurrentLanguage; +``` + +If you used a custom app, you must update the `` call as the signature has changed: + +```diff + return ( +- ++ + + // ... +``` From fe1b582acb769be549256dc24d99ce01116edc22 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 10 Sep 2019 18:41:48 +0200 Subject: [PATCH 03/10] Update UI and examples --- examples/demo/src/configuration/Configuration.js | 9 +++++---- examples/demo/src/layout/Menu.js | 1 - packages/ra-ui-materialui/src/layout/AppBar.js | 10 ++-------- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/examples/demo/src/configuration/Configuration.js b/examples/demo/src/configuration/Configuration.js index 0dabcc8ba8e..9c7ac06e2e4 100644 --- a/examples/demo/src/configuration/Configuration.js +++ b/examples/demo/src/configuration/Configuration.js @@ -3,7 +3,7 @@ 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 { useTranslate, useLocale, useSetLocale, Title } from 'react-admin'; import { makeStyles } from '@material-ui/core/styles'; import { changeTheme } from './actions'; @@ -14,9 +14,10 @@ const useStyles = makeStyles({ const Configuration = () => { const translate = useTranslate(); + const locale = useLocale(); + const setLocale = useSetLocale(); const classes = useStyles(); const theme = useSelector(state => state.theme); - const locale = useSelector(state => state.i18n.locale); const dispatch = useDispatch(); return ( @@ -48,7 +49,7 @@ const Configuration = () => { variant="contained" className={classes.button} color={locale === 'en' ? 'primary' : 'default'} - onClick={() => dispatch(changeLocale('en'))} + onClick={() => setLocale('en')} > en @@ -56,7 +57,7 @@ const Configuration = () => { variant="contained" className={classes.button} color={locale === 'fr' ? 'primary' : 'default'} - onClick={() => dispatch(changeLocale('fr'))} + onClick={() => setLocale('fr')} > fr diff --git a/examples/demo/src/layout/Menu.js b/examples/demo/src/layout/Menu.js index 52404cf552f..ce4c2102d7c 100644 --- a/examples/demo/src/layout/Menu.js +++ b/examples/demo/src/layout/Menu.js @@ -25,7 +25,6 @@ const Menu = ({ onMenuClick, logout }) => { 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] })); diff --git a/packages/ra-ui-materialui/src/layout/AppBar.js b/packages/ra-ui-materialui/src/layout/AppBar.js index ed284b4f042..9449a896912 100644 --- a/packages/ra-ui-materialui/src/layout/AppBar.js +++ b/packages/ra-ui-materialui/src/layout/AppBar.js @@ -1,6 +1,6 @@ import React, { Children, cloneElement } from 'react'; import PropTypes from 'prop-types'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch } from 'react-redux'; import classNames from 'classnames'; import MuiAppBar from '@material-ui/core/AppBar'; import Toolbar from '@material-ui/core/Toolbar'; @@ -58,17 +58,11 @@ const AppBar = ({ }) => { const classes = useStyles({ classes: classesOverride }); const dispatch = useDispatch(); - const locale = useSelector(state => state.i18n.locale); const isXSmall = useMediaQuery(theme => theme.breakpoints.down('xs')); return ( - + Date: Wed, 11 Sep 2019 04:50:51 +0200 Subject: [PATCH 04/10] [BC Break] Remove i18n reducer and i18n saga --- UPGRADE.md | 48 +++++++++++++++---- docs/CustomApp.md | 19 ++++---- packages/ra-core/src/CoreAdmin.tsx | 2 - packages/ra-core/src/createAdminStore.ts | 15 +----- packages/ra-core/src/index.ts | 3 -- packages/ra-core/src/reducer/i18n/index.ts | 13 ----- packages/ra-core/src/reducer/i18n/loading.ts | 34 ------------- .../ra-core/src/reducer/i18n/locale.spec.ts | 35 -------------- packages/ra-core/src/reducer/i18n/locale.ts | 22 --------- packages/ra-core/src/reducer/i18n/messages.ts | 20 -------- packages/ra-core/src/reducer/index.ts | 6 +-- packages/ra-core/src/sideEffect/admin.ts | 5 +- packages/ra-core/src/sideEffect/i18n.ts | 33 ------------- packages/ra-core/src/sideEffect/index.ts | 2 - packages/ra-core/src/types.ts | 4 -- packages/ra-core/src/util/TestContext.tsx | 7 ++- 16 files changed, 60 insertions(+), 208 deletions(-) delete mode 100644 packages/ra-core/src/reducer/i18n/index.ts delete mode 100644 packages/ra-core/src/reducer/i18n/loading.ts delete mode 100644 packages/ra-core/src/reducer/i18n/locale.spec.ts delete mode 100644 packages/ra-core/src/reducer/i18n/locale.ts delete mode 100644 packages/ra-core/src/reducer/i18n/messages.ts delete mode 100644 packages/ra-core/src/sideEffect/i18n.ts diff --git a/UPGRADE.md b/UPGRADE.md index b2bca544d23..5b8caa53894 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1085,15 +1085,47 @@ const availableLanguages = { +export default CurrentLanguage; ``` -If you used a custom app, you must update the `` call as the signature has changed: +If you used a custom Redux store, you must update the `createAdminStore` call to omit the i18n details: ```diff - return ( -- -+ - +const App = () => ( + + +``` + +Also, if you used a custom app without the `Admin` component, update the `` call as the signature has changed: + +```diff +const App = () => ( + + + +- ++ + + + + // ... ``` diff --git a/docs/CustomApp.md b/docs/CustomApp.md index f86a709b675..203d6aa4b4f 100644 --- a/docs/CustomApp.md +++ b/docs/CustomApp.md @@ -27,20 +27,16 @@ import { adminReducer, adminSaga, defaultI18nProvider, - i18nReducer, USER_LOGOUT, } from 'react-admin'; export default ({ authProvider, dataProvider, - i18nProvider = defaultI18nProvider, history, - locale = 'en', }) => { const reducer = combineReducers({ admin: adminReducer, - i18n: i18nReducer(locale, i18nProvider(locale)), router: connectRouter(history), { /* add your own reducers here */ }, }); @@ -50,7 +46,7 @@ export default ({ const saga = function* rootSaga() { yield all( [ - adminSaga(dataProvider, authProvider, i18nProvider), + adminSaga(dataProvider, authProvider), // add your own sagas here ].map(fork) ); @@ -115,7 +111,6 @@ const App = () => ( store={createAdminStore({ authProvider, dataProvider, - i18nProvider, history, })} > @@ -152,7 +147,7 @@ import { createHashHistory } from 'history'; +import { Switch, Route } from 'react-router-dom'; +import withContext from 'recompose/withContext'; -import { Admin, Resource } from 'react-admin'; -+import { TranslationProvider, Resource } from 'react-admin'; ++import { AuthContext, DataProviderContext, TranslationProvider, Resource } from 'react-admin'; import restProvider from 'ra-data-simple-rest'; import defaultMessages from 'ra-language-english'; +import { ThemeProvider } from '@material-ui/styles'; @@ -185,7 +180,6 @@ const App = () => ( store={createAdminStore({ authProvider, dataProvider, - i18nProvider, history, })} > @@ -197,7 +191,12 @@ const App = () => ( - - - -+ ++ ++ ++ + + + @@ -226,6 +225,8 @@ const App = () => ( + + + ++
++ - ); diff --git a/packages/ra-core/src/CoreAdmin.tsx b/packages/ra-core/src/CoreAdmin.tsx index 89ccf9e113a..b414dc93207 100644 --- a/packages/ra-core/src/CoreAdmin.tsx +++ b/packages/ra-core/src/CoreAdmin.tsx @@ -173,9 +173,7 @@ React-admin requires a valid dataProvider function to work.`); customReducers, customSagas, dataProvider, - i18nProvider, initialState, - locale, history: finalHistory, })} > diff --git a/packages/ra-core/src/createAdminStore.ts b/packages/ra-core/src/createAdminStore.ts index 35f7d6ab891..63058c32eda 100644 --- a/packages/ra-core/src/createAdminStore.ts +++ b/packages/ra-core/src/createAdminStore.ts @@ -33,17 +33,9 @@ export default ({ customReducers = {}, authProvider = null, customSagas = [], - i18nProvider = defaultI18nProvider, initialState, - locale = 'en', }: Params) => { - const messages = i18nProvider(locale); - const appReducer = createAppReducer( - customReducers, - locale, - messages, - history - ); + const appReducer = createAppReducer(customReducers, history); const resettableAppReducer = (state, action) => appReducer( @@ -64,10 +56,7 @@ export default ({ ); const saga = function* rootSaga() { yield all( - [ - adminSaga(dataProvider, authProvider, i18nProvider), - ...customSagas, - ].map(fork) + [adminSaga(dataProvider, authProvider), ...customSagas].map(fork) ); }; const sagaMiddleware = createSagaMiddleware(); diff --git a/packages/ra-core/src/index.ts b/packages/ra-core/src/index.ts index 96fdfb9c939..5405f14c417 100644 --- a/packages/ra-core/src/index.ts +++ b/packages/ra-core/src/index.ts @@ -1,6 +1,5 @@ import createAppReducer from './reducer'; import adminReducer from './reducer/admin'; -import i18nReducer from './reducer/i18n'; import queryReducer from './reducer/admin/resource/list/queryReducer'; import CoreAdmin from './CoreAdmin'; import CoreAdminRouter from './CoreAdminRouter'; @@ -11,7 +10,6 @@ import Resource from './Resource'; export { createAppReducer, adminReducer, - i18nReducer, queryReducer, CoreAdmin, CoreAdminRouter, @@ -31,7 +29,6 @@ export * from './form'; export { getResources, getReferenceResource, - getLocale, getNotification, getPossibleReferences, getPossibleReferenceValues, diff --git a/packages/ra-core/src/reducer/i18n/index.ts b/packages/ra-core/src/reducer/i18n/index.ts deleted file mode 100644 index a9937819890..00000000000 --- a/packages/ra-core/src/reducer/i18n/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { combineReducers } from 'redux'; -import localeReducer from './locale'; -import messagedReducer from './messages'; -import loading from './loading'; - -export default (initialLocale, defaultMessages) => - combineReducers({ - locale: localeReducer(initialLocale), - messages: messagedReducer(defaultMessages), - loading, - }); - -export const getLocale = state => state.locale; diff --git a/packages/ra-core/src/reducer/i18n/loading.ts b/packages/ra-core/src/reducer/i18n/loading.ts deleted file mode 100644 index a0c98aea0cd..00000000000 --- a/packages/ra-core/src/reducer/i18n/loading.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Reducer } from 'redux'; -import { - CHANGE_LOCALE, - ChangeLocaleAction, - CHANGE_LOCALE_SUCCESS, - ChangeLocaleSuccessAction, - CHANGE_LOCALE_FAILURE, - ChangeLocaleFailureAction, -} from '../../actions/localeActions'; - -type ActionTypes = - | ChangeLocaleAction - | ChangeLocaleSuccessAction - | ChangeLocaleFailureAction - | { type: 'OTHER_ACTION' }; - -type State = boolean; - -const loadingReducer: Reducer = ( - loading = false, - action: ActionTypes -) => { - switch (action.type) { - case CHANGE_LOCALE: - return true; - case CHANGE_LOCALE_SUCCESS: - case CHANGE_LOCALE_FAILURE: - return false; - default: - return loading; - } -}; - -export default loadingReducer; diff --git a/packages/ra-core/src/reducer/i18n/locale.spec.ts b/packages/ra-core/src/reducer/i18n/locale.spec.ts deleted file mode 100644 index 74a064e9f83..00000000000 --- a/packages/ra-core/src/reducer/i18n/locale.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import assert from 'assert'; - -import reducer from './locale'; -import { DEFAULT_LOCALE } from '../../i18n/index'; -import { - CHANGE_LOCALE_SUCCESS, - CHANGE_LOCALE_FAILURE, -} from '../../actions/localeActions'; - -describe('locale reducer', () => { - it('should return DEFAULT_LOCALE by default', () => { - assert.equal(DEFAULT_LOCALE, reducer()(undefined, { type: 'foo' })); - }); - it('should change with CHANGE_LOCALE_SUCCESS action', () => { - assert.equal( - 'fr', - reducer()('en', { - type: CHANGE_LOCALE_SUCCESS, - payload: { - locale: 'fr', - messages: {}, - }, - }) - ); - }); - it('should remain with CHANGE_LOCALE_FAILURE action', () => { - assert.equal( - 'en', - reducer()('en', { - type: CHANGE_LOCALE_FAILURE, - payload: 'fr', - }) - ); - }); -}); diff --git a/packages/ra-core/src/reducer/i18n/locale.ts b/packages/ra-core/src/reducer/i18n/locale.ts deleted file mode 100644 index 9a297f4a2c3..00000000000 --- a/packages/ra-core/src/reducer/i18n/locale.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Reducer } from 'redux'; -import { DEFAULT_LOCALE } from '../../i18n/index'; -import { - CHANGE_LOCALE_SUCCESS, - ChangeLocaleSuccessAction, -} from '../../actions/localeActions'; - -type ActionTypes = ChangeLocaleSuccessAction | { type: 'OTHER_ACTION' }; - -type State = string; - -export default (initialLocale: string = DEFAULT_LOCALE): Reducer => ( - previousLocale = initialLocale, - action: ActionTypes -) => { - switch (action.type) { - case CHANGE_LOCALE_SUCCESS: - return action.payload.locale; - default: - return previousLocale; - } -}; diff --git a/packages/ra-core/src/reducer/i18n/messages.ts b/packages/ra-core/src/reducer/i18n/messages.ts deleted file mode 100644 index e03ee829884..00000000000 --- a/packages/ra-core/src/reducer/i18n/messages.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Reducer } from 'redux'; -import { - CHANGE_LOCALE_SUCCESS, - ChangeLocaleSuccessAction, -} from '../../actions/index'; - -type ActionTypes = ChangeLocaleSuccessAction | { type: 'OTHER_ACTION' }; - -type State = any; - -export default (defaultMessages: string): Reducer => { - return (previousState = defaultMessages, action: ActionTypes) => { - switch (action.type) { - case CHANGE_LOCALE_SUCCESS: - return action.payload.messages; - default: - return previousState; - } - }; -}; diff --git a/packages/ra-core/src/reducer/index.ts b/packages/ra-core/src/reducer/index.ts index fc00d87c23f..1eab847880e 100644 --- a/packages/ra-core/src/reducer/index.ts +++ b/packages/ra-core/src/reducer/index.ts @@ -5,12 +5,10 @@ import admin, { getReferenceResource as adminGetReferenceResource, getPossibleReferenceValues as adminGetPossibleReferenceValues, } from './admin'; -import i18nReducer, { getLocale as adminGetLocale } from './i18n'; export { getNotification } from './admin/notifications'; -export default (customReducers, locale, messages, history) => +export default (customReducers, history) => combineReducers({ admin, - i18n: i18nReducer(locale, messages), router: connectRouter(history), ...customReducers, }); @@ -20,5 +18,5 @@ export const getPossibleReferenceValues = (state, props) => export const getResources = state => adminGetResources(state.admin); export const getReferenceResource = (state, props) => adminGetReferenceResource(state.admin, props); -export const getLocale = state => adminGetLocale(state.i18n); + export { getPossibleReferences } from './admin'; diff --git a/packages/ra-core/src/sideEffect/admin.ts b/packages/ra-core/src/sideEffect/admin.ts index bb91cdb6aba..59c8c9307b3 100644 --- a/packages/ra-core/src/sideEffect/admin.ts +++ b/packages/ra-core/src/sideEffect/admin.ts @@ -4,7 +4,6 @@ import auth from './auth'; import callback from './callback'; import fetch from './fetch'; import error from './error'; -import i18n from './i18n'; import notification from './notification'; import redirection from './redirection'; import accumulate from './accumulate'; @@ -17,12 +16,10 @@ import unload from './unload'; */ export default ( dataProvider: DataProvider, - authProvider: AuthProvider | null, - i18nProvider: I18nProvider + authProvider: AuthProvider | null ) => function* admin() { yield all([ - i18n(i18nProvider)(), auth(authProvider)(), undo(), fetch(dataProvider)(), diff --git a/packages/ra-core/src/sideEffect/i18n.ts b/packages/ra-core/src/sideEffect/i18n.ts deleted file mode 100644 index 3a4a39d2ff3..00000000000 --- a/packages/ra-core/src/sideEffect/i18n.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { all, call, put, takeLatest } from 'redux-saga/effects'; -import { I18nProvider } from '../types'; -import { - CHANGE_LOCALE, - changeLocaleSuccess, - changeLocaleFailure, - fetchStart, - fetchEnd, -} from '../actions'; - -/** - * The i18n side effect reacts to the CHANGE_LOCALE actions, calls - * the i18nProvider (which may be asynchronous) with the requested locale, - * and dispatches changeLocaleSuccess or changeLocaleFailure with the result. - */ -export default (i18nProvider: I18nProvider) => { - function* loadMessages(action) { - const locale = action.payload; - - yield put(fetchStart()); - - try { - const messages = yield call(i18nProvider, locale); - yield put(changeLocaleSuccess(locale, messages)); - } catch (err) { - yield put(changeLocaleFailure(action.payload.locale, err)); - } - yield put(fetchEnd()); - } - return function*() { - yield all([takeLatest(CHANGE_LOCALE, loadMessages)]); - }; -}; diff --git a/packages/ra-core/src/sideEffect/index.ts b/packages/ra-core/src/sideEffect/index.ts index c047a5905a5..cff56c919ce 100644 --- a/packages/ra-core/src/sideEffect/index.ts +++ b/packages/ra-core/src/sideEffect/index.ts @@ -7,7 +7,6 @@ import notificationSaga, { NotificationSideEffect } from './notification'; import redirectionSaga, { RedirectionSideEffect } from './redirection'; import accumulateSaga from './accumulate'; import refreshSaga, { RefreshSideEffect } from './refresh'; -import i18nSaga from './i18n'; import undoSaga from './undo'; import unloadSaga from './unload'; import useRedirect from './useRedirect'; @@ -29,7 +28,6 @@ export { accumulateSaga, refreshSaga, RefreshSideEffect, - i18nSaga, undoSaga, unloadSaga, useRedirect, diff --git a/packages/ra-core/src/types.ts b/packages/ra-core/src/types.ts index 6e2baf5131a..6221691d230 100644 --- a/packages/ra-core/src/types.ts +++ b/packages/ra-core/src/types.ts @@ -71,10 +71,6 @@ export interface ReduxState { [key: string]: any; }; }; - i18n: { - locale: string; - messages: object; - }; router: { location: Location; }; diff --git a/packages/ra-core/src/util/TestContext.tsx b/packages/ra-core/src/util/TestContext.tsx index c7411f27371..b81d6125f83 100644 --- a/packages/ra-core/src/util/TestContext.tsx +++ b/packages/ra-core/src/util/TestContext.tsx @@ -5,7 +5,9 @@ import TranslationProvider from '../i18n/TranslationProvider'; import merge from 'lodash/merge'; import { createMemoryHistory } from 'history'; +import defaultI18nProvider from '../i18n/defaultI18nProvider'; import createAdminStore from '../createAdminStore'; +import { I18nProvider } from '../types'; export const defaultStore = { admin: { @@ -13,11 +15,11 @@ export const defaultStore = { references: { possibleValues: {} }, ui: { viewVersion: 1 }, }, - i18n: { locale: 'en', messages: {} }, }; interface Props { initialState?: object; + i18nProvider?: I18nProvider; enableReducers?: boolean; } @@ -72,9 +74,10 @@ class TestContext extends Component { }; render() { + const { i18nProvider = defaultI18nProvider } = this.props; return ( - + {this.renderChildren()} From ece2285bf8fe11a13f726eb8396c49dbbb4f5328 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Wed, 11 Sep 2019 05:27:14 +0200 Subject: [PATCH 05/10] Fix unit tests --- packages/ra-core/src/CoreAdmin.tsx | 3 +- packages/ra-core/src/createAdminStore.ts | 1 - .../ra-core/src/form/ValidationError.spec.tsx | 32 ++++++++----------- packages/ra-core/src/util/FieldTitle.spec.tsx | 15 ++++----- .../ra-core/src/util/TestContext.spec.tsx | 5 --- packages/ra-core/src/util/renderWithRedux.tsx | 9 +++++- .../src/field/ArrayField.spec.js | 9 ++---- .../src/field/SelectField.spec.js | 6 ++-- .../src/input/CheckboxGroupInput.spec.tsx | 26 ++++++--------- 9 files changed, 46 insertions(+), 60 deletions(-) diff --git a/packages/ra-core/src/CoreAdmin.tsx b/packages/ra-core/src/CoreAdmin.tsx index b414dc93207..9eff5fdbddb 100644 --- a/packages/ra-core/src/CoreAdmin.tsx +++ b/packages/ra-core/src/CoreAdmin.tsx @@ -14,6 +14,7 @@ import AuthContext from './auth/AuthContext'; import DataProviderContext from './dataProvider/DataProviderContext'; import createAdminStore, { InitialState } from './createAdminStore'; import TranslationProvider from './i18n/TranslationProvider'; +import defaultI18nProvider from './i18n/defaultI18nProvider'; import CoreAdminRouter from './CoreAdminRouter'; import { AuthProvider, @@ -63,7 +64,7 @@ const CoreAdmin: FunctionComponent = ({ appLayout, authProvider, dataProvider, - i18nProvider, + i18nProvider = defaultI18nProvider, children, customRoutes = [], dashboard, diff --git a/packages/ra-core/src/createAdminStore.ts b/packages/ra-core/src/createAdminStore.ts index 63058c32eda..685f4b02b4d 100644 --- a/packages/ra-core/src/createAdminStore.ts +++ b/packages/ra-core/src/createAdminStore.ts @@ -7,7 +7,6 @@ import { History } from 'history'; import { AuthProvider, DataProvider, I18nProvider } from './types'; import createAppReducer from './reducer'; import { adminSaga } from './sideEffect'; -import { defaultI18nProvider } from './i18n'; import { CLEAR_STATE } from './actions/clearActions'; interface Window { diff --git a/packages/ra-core/src/form/ValidationError.spec.tsx b/packages/ra-core/src/form/ValidationError.spec.tsx index 2d955102188..5364e59730d 100644 --- a/packages/ra-core/src/form/ValidationError.spec.tsx +++ b/packages/ra-core/src/form/ValidationError.spec.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { render, cleanup } from '@testing-library/react'; import ValidationError from './ValidationError'; -import { TranslationProvider } from '../i18n'; import TestContext from '../util/TestContext'; const translate = jest.fn(key => key); @@ -10,27 +9,22 @@ const translate = jest.fn(key => key); const renderWithTranslations = content => render( ({ + ra: { + validation: { + required: 'Required', + minValue: 'Min Value %{value}', + oneOf: 'Must be one of %{list}', }, }, - }} + myapp: { + validation: { + match: 'Must match %{match}', + }, + }, + })} > - {content} + {content} ); diff --git a/packages/ra-core/src/util/FieldTitle.spec.tsx b/packages/ra-core/src/util/FieldTitle.spec.tsx index 9b8e6d9ac79..f3b6cc1ebd0 100644 --- a/packages/ra-core/src/util/FieldTitle.spec.tsx +++ b/packages/ra-core/src/util/FieldTitle.spec.tsx @@ -20,9 +20,11 @@ describe('FieldTitle', () => { }); it('should use the label as translate key when translation is available', () => { - const { container } = renderWithRedux(, { - i18n: { messages: { foo: 'bar' } }, - }); + const { container } = renderWithRedux( + , + {}, + () => ({ foo: 'bar' }) + ); expect(container.firstChild.textContent).toEqual('bar'); }); @@ -54,11 +56,8 @@ describe('FieldTitle', () => { it('should use the source and resource as translate key when translation is available', () => { const { container } = renderWithRedux( , - { - i18n: { - messages: { 'resources.posts.fields.title': 'titre' }, - }, - } + {}, + () => ({ 'resources.posts.fields.title': 'titre' }) ); expect(container.firstChild.textContent).toEqual('titre'); }); diff --git a/packages/ra-core/src/util/TestContext.spec.tsx b/packages/ra-core/src/util/TestContext.spec.tsx index 10bf696e3b7..866f11f8dc0 100644 --- a/packages/ra-core/src/util/TestContext.spec.tsx +++ b/packages/ra-core/src/util/TestContext.spec.tsx @@ -21,11 +21,6 @@ const primedStore = { }, customQueries: {}, }, - i18n: { - loading: false, - locale: 'en', - messages: {}, - }, router: { action: 'POP', location: { diff --git a/packages/ra-core/src/util/renderWithRedux.tsx b/packages/ra-core/src/util/renderWithRedux.tsx index 8c495f0a46c..e767ff452c2 100644 --- a/packages/ra-core/src/util/renderWithRedux.tsx +++ b/packages/ra-core/src/util/renderWithRedux.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { render, RenderResult } from '@testing-library/react'; import TestContext from './TestContext'; +import { I18nProvider } from '../types'; export interface RenderWithReduxResult extends RenderResult { dispatch: jest.Mock; @@ -18,6 +19,7 @@ export interface RenderWithReduxResult extends RenderResult { * * @param {ReactNode} component: The component you want to test in jsx * @param {Object} initialstate: Optional initial state of the redux store + * @param {Function} i18nProvider A function returning a dictionary for translations * @param {Object} options: Render options, e.g. to use a custom container element * @return {{ dispatch, reduxStore, ...rest }} helper function to test rendered component. * Same as @testing-library/react render method with added dispatch and reduxStore helper @@ -27,12 +29,17 @@ export interface RenderWithReduxResult extends RenderResult { export default ( component, initialState = {}, + i18nProvider?: I18nProvider, options = {} ): RenderWithReduxResult => { let dispatch; let reduxStore; const renderResult = render( - + {({ store }) => { dispatch = jest.spyOn(store, 'dispatch'); reduxStore = store; diff --git a/packages/ra-ui-materialui/src/field/ArrayField.spec.js b/packages/ra-ui-materialui/src/field/ArrayField.spec.js index 278bbf55cc9..8fdb3b18896 100644 --- a/packages/ra-ui-materialui/src/field/ArrayField.spec.js +++ b/packages/ra-ui-materialui/src/field/ArrayField.spec.js @@ -47,11 +47,6 @@ describe('', () => { }); it('should render the underlying iterator component', () => { - const store = createStore( - combineReducers({ - i18n: () => ({ locale: 'en', messages: {} }), - }) - ); const Dummy = () => ( ', () => { ); const wrapper = mount( - - + ({}))}> + ({})}> diff --git a/packages/ra-ui-materialui/src/field/SelectField.spec.js b/packages/ra-ui-materialui/src/field/SelectField.spec.js index b2d5c0f8d8f..2e7837ee325 100644 --- a/packages/ra-ui-materialui/src/field/SelectField.spec.js +++ b/packages/ra-ui-materialui/src/field/SelectField.spec.js @@ -113,7 +113,8 @@ describe('', () => { it('should translate the choice by default', () => { const { queryAllByText } = renderWithRedux( , - { i18n: { messages: { hello: 'bonjour' } } } + {}, + () => ({ hello: 'bonjour' }) ); expect(queryAllByText('hello')).toHaveLength(0); expect(queryAllByText('bonjour')).toHaveLength(1); @@ -126,7 +127,8 @@ describe('', () => { record={{ foo: 0 }} translateChoice={false} />, - { i18n: { messages: { hello: 'bonjour' } } } + {}, + () => ({ hello: 'bonjour' }) ); expect(queryAllByText('hello')).toHaveLength(1); expect(queryAllByText('bonjour')).toHaveLength(0); diff --git a/packages/ra-ui-materialui/src/input/CheckboxGroupInput.spec.tsx b/packages/ra-ui-materialui/src/input/CheckboxGroupInput.spec.tsx index 12d2a9dd6c2..004b69275bd 100644 --- a/packages/ra-ui-materialui/src/input/CheckboxGroupInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/CheckboxGroupInput.spec.tsx @@ -161,14 +161,11 @@ describe('', () => { onSubmit={jest.fn} render={() => } />, - { - i18n: { - messages: { - Angular: 'Angular **', - React: 'React **', - }, - }, - } + {}, + () => ({ + Angular: 'Angular **', + React: 'React **', + }) ); expect(queryByLabelText('Angular **')).not.toBeNull(); expect(queryByLabelText('React **')).not.toBeNull(); @@ -185,14 +182,11 @@ describe('', () => { /> )} />, - { - i18n: { - messages: { - Angular: 'Angular **', - React: 'React **', - }, - }, - } + {}, + () => ({ + Angular: 'Angular **', + React: 'React **', + }) ); expect(queryByLabelText('Angular **')).toBeNull(); expect(queryByLabelText('React **')).toBeNull(); From 4d154cc0573388e5b71adf61f978df6e8739499f Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Wed, 11 Sep 2019 05:51:17 +0200 Subject: [PATCH 06/10] Better fix unit tests --- .../ra-core/src/form/ValidationError.spec.tsx | 6 +- packages/ra-core/src/util/FieldTitle.spec.tsx | 40 ++++++++----- packages/ra-core/src/util/TestContext.tsx | 5 +- packages/ra-core/src/util/renderWithRedux.tsx | 9 +-- .../src/button/SaveButton.spec.js | 14 +++-- .../src/field/ArrayField.spec.js | 2 +- .../src/field/SelectField.spec.js | 26 ++++----- .../src/form/SimpleForm.spec.js | 11 ++-- .../src/input/CheckboxGroupInput.spec.tsx | 56 ++++++++++--------- .../ra-ui-materialui/src/list/List.spec.js | 2 +- 10 files changed, 91 insertions(+), 80 deletions(-) diff --git a/packages/ra-core/src/form/ValidationError.spec.tsx b/packages/ra-core/src/form/ValidationError.spec.tsx index 5364e59730d..3585addb747 100644 --- a/packages/ra-core/src/form/ValidationError.spec.tsx +++ b/packages/ra-core/src/form/ValidationError.spec.tsx @@ -2,13 +2,13 @@ import React from 'react'; import { render, cleanup } from '@testing-library/react'; import ValidationError from './ValidationError'; -import TestContext from '../util/TestContext'; +import TranslationProvider from '../i18n/TranslationProvider'; const translate = jest.fn(key => key); const renderWithTranslations = content => render( - ({ ra: { validation: { @@ -25,7 +25,7 @@ const renderWithTranslations = content => })} > {content} - + ); describe('ValidationError', () => { diff --git a/packages/ra-core/src/util/FieldTitle.spec.tsx b/packages/ra-core/src/util/FieldTitle.spec.tsx index f3b6cc1ebd0..891dd2f7c11 100644 --- a/packages/ra-core/src/util/FieldTitle.spec.tsx +++ b/packages/ra-core/src/util/FieldTitle.spec.tsx @@ -3,7 +3,7 @@ import { render, cleanup } from '@testing-library/react'; import React from 'react'; import { FieldTitle } from './FieldTitle'; -import renderWithRedux from './renderWithRedux'; +import TranslationProvider from '../i18n/TranslationProvider'; describe('FieldTitle', () => { afterEach(cleanup); @@ -20,24 +20,28 @@ describe('FieldTitle', () => { }); it('should use the label as translate key when translation is available', () => { - const { container } = renderWithRedux( - , - {}, - () => ({ foo: 'bar' }) + const { container } = render( + ({ foo: 'bar' })}> + + ); expect(container.firstChild.textContent).toEqual('bar'); }); it('should use the humanized source when given', () => { - const { container } = renderWithRedux( - + const { container } = render( + ({})}> + + ); expect(container.firstChild.textContent).toEqual('Title'); }); it('should use the humanized source when given with underscores', () => { - const { container } = renderWithRedux( - + const { container } = render( + ({})}> + + ); expect(container.firstChild.textContent).toEqual( 'Title with underscore' @@ -45,8 +49,10 @@ describe('FieldTitle', () => { }); it('should use the humanized source when given with camelCase', () => { - const { container } = renderWithRedux( - + const { container } = render( + ({})}> + + ); expect(container.firstChild.textContent).toEqual( 'Title with camel case' @@ -54,10 +60,14 @@ describe('FieldTitle', () => { }); it('should use the source and resource as translate key when translation is available', () => { - const { container } = renderWithRedux( - , - {}, - () => ({ 'resources.posts.fields.title': 'titre' }) + const { container } = render( + ({ + 'resources.posts.fields.title': 'titre', + })} + > + + ); expect(container.firstChild.textContent).toEqual('titre'); }); diff --git a/packages/ra-core/src/util/TestContext.tsx b/packages/ra-core/src/util/TestContext.tsx index b81d6125f83..893231dc7f1 100644 --- a/packages/ra-core/src/util/TestContext.tsx +++ b/packages/ra-core/src/util/TestContext.tsx @@ -74,12 +74,9 @@ class TestContext extends Component { }; render() { - const { i18nProvider = defaultI18nProvider } = this.props; return ( - - {this.renderChildren()} - + {this.renderChildren()} ); } diff --git a/packages/ra-core/src/util/renderWithRedux.tsx b/packages/ra-core/src/util/renderWithRedux.tsx index e767ff452c2..8c495f0a46c 100644 --- a/packages/ra-core/src/util/renderWithRedux.tsx +++ b/packages/ra-core/src/util/renderWithRedux.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { render, RenderResult } from '@testing-library/react'; import TestContext from './TestContext'; -import { I18nProvider } from '../types'; export interface RenderWithReduxResult extends RenderResult { dispatch: jest.Mock; @@ -19,7 +18,6 @@ export interface RenderWithReduxResult extends RenderResult { * * @param {ReactNode} component: The component you want to test in jsx * @param {Object} initialstate: Optional initial state of the redux store - * @param {Function} i18nProvider A function returning a dictionary for translations * @param {Object} options: Render options, e.g. to use a custom container element * @return {{ dispatch, reduxStore, ...rest }} helper function to test rendered component. * Same as @testing-library/react render method with added dispatch and reduxStore helper @@ -29,17 +27,12 @@ export interface RenderWithReduxResult extends RenderResult { export default ( component, initialState = {}, - i18nProvider?: I18nProvider, options = {} ): RenderWithReduxResult => { let dispatch; let reduxStore; const renderResult = render( - + {({ store }) => { dispatch = jest.spyOn(store, 'dispatch'); reduxStore = store; diff --git a/packages/ra-ui-materialui/src/button/SaveButton.spec.js b/packages/ra-ui-materialui/src/button/SaveButton.spec.js index 796d49fa153..23814aa9b11 100644 --- a/packages/ra-ui-materialui/src/button/SaveButton.spec.js +++ b/packages/ra-ui-materialui/src/button/SaveButton.spec.js @@ -13,7 +13,9 @@ describe('', () => { ); - expect(getByLabelText('Save').getAttribute('type')).toEqual('submit'); + expect(getByLabelText('ra.action.save').getAttribute('type')).toEqual( + 'submit' + ); }); it('should render as button type when submitOnEnter is false', () => { @@ -23,7 +25,9 @@ describe('', () => { ); - expect(getByLabelText('Save').getAttribute('type')).toEqual('button'); + expect(getByLabelText('ra.action.save').getAttribute('type')).toEqual( + 'button' + ); }); it('should trigger submit action when clicked if no saving is in progress', () => { @@ -37,7 +41,7 @@ describe('', () => { ); - fireEvent.mouseDown(getByLabelText('Save')); + fireEvent.mouseDown(getByLabelText('ra.action.save')); expect(onSubmit).toHaveBeenCalled(); }); @@ -50,7 +54,7 @@ describe('', () => { ); - fireEvent.mouseDown(getByLabelText('Save')); + fireEvent.mouseDown(getByLabelText('ra.action.save')); expect(onSubmit).not.toHaveBeenCalled(); }); @@ -72,7 +76,7 @@ describe('', () => { ); - fireEvent.mouseDown(getByLabelText('Save')); + fireEvent.mouseDown(getByLabelText('ra.action.save')); expect(dispatchSpy).toHaveBeenCalledWith({ payload: { message: 'ra.message.invalid_form', diff --git a/packages/ra-ui-materialui/src/field/ArrayField.spec.js b/packages/ra-ui-materialui/src/field/ArrayField.spec.js index 8fdb3b18896..d389cd7cd14 100644 --- a/packages/ra-ui-materialui/src/field/ArrayField.spec.js +++ b/packages/ra-ui-materialui/src/field/ArrayField.spec.js @@ -1,6 +1,6 @@ import React from 'react'; import { shallow, mount } from 'enzyme'; -import { createStore, combineReducers } from 'redux'; +import { createStore } from 'redux'; import { Provider } from 'react-redux'; import { TranslationProvider } from 'ra-core'; diff --git a/packages/ra-ui-materialui/src/field/SelectField.spec.js b/packages/ra-ui-materialui/src/field/SelectField.spec.js index 2e7837ee325..0bde7dd4730 100644 --- a/packages/ra-ui-materialui/src/field/SelectField.spec.js +++ b/packages/ra-ui-materialui/src/field/SelectField.spec.js @@ -2,7 +2,7 @@ import React from 'react'; import expect from 'expect'; import { render, cleanup } from '@testing-library/react'; -import { renderWithRedux } from 'ra-core'; +import { TranslationProvider } from 'ra-core'; import { SelectField } from './SelectField'; describe('', () => { @@ -111,24 +111,24 @@ describe('', () => { }); it('should translate the choice by default', () => { - const { queryAllByText } = renderWithRedux( - , - {}, - () => ({ hello: 'bonjour' }) + const { queryAllByText } = render( + ({ hello: 'bonjour' })}> + + ); expect(queryAllByText('hello')).toHaveLength(0); expect(queryAllByText('bonjour')).toHaveLength(1); }); it('should not translate the choice if translateChoice is false', () => { - const { queryAllByText } = renderWithRedux( - , - {}, - () => ({ hello: 'bonjour' }) + const { queryAllByText } = render( + ({ hello: 'bonjour' })}> + + ); expect(queryAllByText('hello')).toHaveLength(1); expect(queryAllByText('bonjour')).toHaveLength(0); diff --git a/packages/ra-ui-materialui/src/form/SimpleForm.spec.js b/packages/ra-ui-materialui/src/form/SimpleForm.spec.js index 54f2bf4d501..4ee92a77fb9 100644 --- a/packages/ra-ui-materialui/src/form/SimpleForm.spec.js +++ b/packages/ra-ui-materialui/src/form/SimpleForm.spec.js @@ -15,9 +15,12 @@ describe('', () => { ); - - expect(queryByLabelText('Name')).not.toBeNull(); - expect(queryByLabelText('City')).not.toBeNull(); + expect( + queryByLabelText('resources.undefined.fields.name') + ).not.toBeNull(); + expect( + queryByLabelText('resources.undefined.fields.city') + ).not.toBeNull(); }); it('should display ', () => { @@ -27,7 +30,7 @@ describe('', () => { ); - expect(queryByLabelText('Save')).not.toBeNull(); + expect(queryByLabelText('ra.action.save')).not.toBeNull(); }); it('should pass submitOnEnter to ', () => { diff --git a/packages/ra-ui-materialui/src/input/CheckboxGroupInput.spec.tsx b/packages/ra-ui-materialui/src/input/CheckboxGroupInput.spec.tsx index 004b69275bd..37bdbf0579a 100644 --- a/packages/ra-ui-materialui/src/input/CheckboxGroupInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/CheckboxGroupInput.spec.tsx @@ -3,7 +3,7 @@ import expect from 'expect'; import CheckboxGroupInput from './CheckboxGroupInput'; import { render, cleanup, fireEvent } from '@testing-library/react'; import { Form } from 'react-final-form'; -import { renderWithRedux } from 'ra-core'; +import { renderWithRedux, TranslationProvider } from 'ra-core'; describe('', () => { const defaultProps = { @@ -156,37 +156,41 @@ describe('', () => { }); it('should translate the choices by default', () => { - const { queryByLabelText } = renderWithRedux( -
} - />, - {}, - () => ({ - Angular: 'Angular **', - React: 'React **', - }) + const { queryByLabelText } = render( + ({ + Angular: 'Angular **', + React: 'React **', + })} + > + } + /> + ); expect(queryByLabelText('Angular **')).not.toBeNull(); expect(queryByLabelText('React **')).not.toBeNull(); }); it('should not translate the choices if translateChoice is false', () => { - const { queryByLabelText } = renderWithRedux( - ( - - )} - />, - {}, - () => ({ - Angular: 'Angular **', - React: 'React **', - }) + const { queryByLabelText } = render( + ({ + Angular: 'Angular **', + React: 'React **', + })} + > + ( + + )} + /> + ); expect(queryByLabelText('Angular **')).toBeNull(); expect(queryByLabelText('React **')).toBeNull(); diff --git a/packages/ra-ui-materialui/src/list/List.spec.js b/packages/ra-ui-materialui/src/list/List.spec.js index 531ac06881a..5e00f8e7eea 100644 --- a/packages/ra-ui-materialui/src/list/List.spec.js +++ b/packages/ra-ui-materialui/src/list/List.spec.js @@ -64,7 +64,7 @@ describe('', () => { ); expect(queryAllByText('filters')).toHaveLength(2); - expect(queryAllByLabelText('Export')).toHaveLength(1); + expect(queryAllByLabelText('ra.action.export')).toHaveLength(1); expect(queryAllByText('pagination')).toHaveLength(1); expect(queryAllByText('datagrid')).toHaveLength(1); }); From 17e4010fd8e31913f482f18bec0ce6c108cf6d0f Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Wed, 11 Sep 2019 06:46:49 +0200 Subject: [PATCH 07/10] Better handling of error and loading state for i18nProvider --- packages/ra-core/src/auth/AuthContext.tsx | 2 + .../src/dataProvider/DataProviderContext.ts | 2 + .../ra-core/src/form/ValidationError.spec.tsx | 5 +- .../ra-core/src/i18n/TranslationContext.ts | 6 ++- .../ra-core/src/i18n/TranslationProvider.tsx | 48 ++++++++++++------- .../ra-core/src/i18n/useSetLocale.spec.js | 9 ++-- .../ra-core/src/i18n/useTranslate.spec.tsx | 9 ++-- packages/ra-core/src/index.ts | 1 + packages/ra-core/src/loading/index.ts | 4 ++ packages/ra-core/src/loading/useLoading.ts | 19 ++++++++ .../ra-core/src/loading/useUpdateLoading.ts | 38 +++++++++++++++ packages/ra-core/src/util/FieldTitle.spec.tsx | 11 +++-- packages/ra-language-english/index.js | 2 + packages/ra-language-french/index.js | 2 + .../src/field/SelectField.spec.js | 6 +-- .../src/input/CheckboxGroupInput.spec.tsx | 4 +- 16 files changed, 129 insertions(+), 39 deletions(-) create mode 100644 packages/ra-core/src/loading/index.ts create mode 100644 packages/ra-core/src/loading/useLoading.ts create mode 100644 packages/ra-core/src/loading/useUpdateLoading.ts diff --git a/packages/ra-core/src/auth/AuthContext.tsx b/packages/ra-core/src/auth/AuthContext.tsx index 36612ae6aab..9d2e4a0f124 100644 --- a/packages/ra-core/src/auth/AuthContext.tsx +++ b/packages/ra-core/src/auth/AuthContext.tsx @@ -4,4 +4,6 @@ import { AuthProvider } from '../types'; const AuthContext = createContext(() => Promise.resolve()); +AuthContext.displayName = 'AuthContext'; + export default AuthContext; diff --git a/packages/ra-core/src/dataProvider/DataProviderContext.ts b/packages/ra-core/src/dataProvider/DataProviderContext.ts index d02a4c2f2e3..db440b5eb10 100644 --- a/packages/ra-core/src/dataProvider/DataProviderContext.ts +++ b/packages/ra-core/src/dataProvider/DataProviderContext.ts @@ -4,4 +4,6 @@ import { DataProvider } from '../types'; const DataProviderContext = createContext(null); +DataProviderContext.displayName = 'DataProviderContext'; + export default DataProviderContext; diff --git a/packages/ra-core/src/form/ValidationError.spec.tsx b/packages/ra-core/src/form/ValidationError.spec.tsx index 3585addb747..31b38eb0595 100644 --- a/packages/ra-core/src/form/ValidationError.spec.tsx +++ b/packages/ra-core/src/form/ValidationError.spec.tsx @@ -1,13 +1,14 @@ import React from 'react'; -import { render, cleanup } from '@testing-library/react'; +import { cleanup } from '@testing-library/react'; import ValidationError from './ValidationError'; import TranslationProvider from '../i18n/TranslationProvider'; +import { renderWithRedux } from '../util'; const translate = jest.fn(key => key); const renderWithTranslations = content => - render( + renderWithRedux( ({ ra: { diff --git a/packages/ra-core/src/i18n/TranslationContext.ts b/packages/ra-core/src/i18n/TranslationContext.ts index 95d4b1bcd90..f6d871d1557 100644 --- a/packages/ra-core/src/i18n/TranslationContext.ts +++ b/packages/ra-core/src/i18n/TranslationContext.ts @@ -8,9 +8,13 @@ export interface TranslationContextProps { setLocale: (locale: string) => void; } -export const TranslationContext = createContext({ +const TranslationContext = createContext({ provider: () => ({}), locale: 'en', translate: id => id, setLocale: () => {}, }); + +TranslationContext.displayName = 'TranslationContext'; + +export { TranslationContext }; diff --git a/packages/ra-core/src/i18n/TranslationProvider.tsx b/packages/ra-core/src/i18n/TranslationProvider.tsx index 30f7c1d71fb..b22ad2855d8 100644 --- a/packages/ra-core/src/i18n/TranslationProvider.tsx +++ b/packages/ra-core/src/i18n/TranslationProvider.tsx @@ -14,6 +14,8 @@ import defaultsDeep from 'lodash/defaultsDeep'; import { useSafeSetState } from '../util/hooks'; import { I18nProvider } from '../types'; import { TranslationContext } from './TranslationContext'; +import { useUpdateLoading } from '../loading'; +import { useNotify } from '../sideEffect'; interface Props { locale?: string; @@ -35,6 +37,8 @@ interface Props { */ const TranslationProvider: FunctionComponent = props => { const { i18nProvider, children, locale = 'en' } = props; + const { startLoading, stopLoading } = useUpdateLoading(); + const notify = useNotify(); const [state, setState] = useSafeSetState({ provider: i18nProvider, @@ -56,26 +60,34 @@ const TranslationProvider: FunctionComponent = props => { const setLocale = useCallback( newLocale => - new Promise(resolve => + new Promise(resolve => { + startLoading(); // so we systematically return a Promise for the messages // i18nProvider may return a Promise for language changes, - resolve(i18nProvider(newLocale)) - ).then(messages => { - const polyglot = new Polyglot({ - locale: newLocale, - phrases: defaultsDeep( - { '': '' }, - messages, - defaultMessages - ), - }); - setState({ - provider: i18nProvider, - locale: newLocale, - translate: polyglot.t.bind(polyglot), - }); - }), - [i18nProvider, setState] + resolve(i18nProvider(newLocale)); + }) + .then(messages => { + const polyglot = new Polyglot({ + locale: newLocale, + phrases: defaultsDeep( + { '': '' }, + messages, + defaultMessages + ), + }); + stopLoading(); + setState({ + provider: i18nProvider, + locale: newLocale, + translate: polyglot.t.bind(polyglot), + }); + }) + .catch(error => { + stopLoading(); + notify('ra.notification.i18n_error', 'warning'); + console.error(error); + }), + [i18nProvider, notify, setState, startLoading, stopLoading] ); const isInitialMount = useRef(true); diff --git a/packages/ra-core/src/i18n/useSetLocale.spec.js b/packages/ra-core/src/i18n/useSetLocale.spec.js index 06691a15817..c9695739a94 100644 --- a/packages/ra-core/src/i18n/useSetLocale.spec.js +++ b/packages/ra-core/src/i18n/useSetLocale.spec.js @@ -1,11 +1,12 @@ import React from 'react'; import expect from 'expect'; -import { render, fireEvent, cleanup, wait, act } from '@testing-library/react'; +import { fireEvent, cleanup, wait, act } from '@testing-library/react'; import useTranslate from './useTranslate'; import useSetLocale from './useSetLocale'; import TranslationProvider from './TranslationProvider'; import { TranslationContext } from './TranslationContext'; +import { renderWithRedux } from '../util'; describe('useTranslate', () => { afterEach(cleanup); @@ -22,13 +23,13 @@ describe('useTranslate', () => { }; it('should not fail when used outside of a translation provider', () => { - const { queryAllByText } = render(); + const { queryAllByText } = renderWithRedux(); expect(queryAllByText('hello')).toHaveLength(1); }); it('should use the setLocale function set in the translation context', () => { const setLocale = jest.fn(); - const { getByText } = render( + const { getByText } = renderWithRedux( { if (locale === 'en') return { hello: 'hello' }; if (locale === 'fr') return { hello: 'bonjour' }; }; - const { getByText, queryAllByText } = render( + const { getByText, queryAllByText } = renderWithRedux( diff --git a/packages/ra-core/src/i18n/useTranslate.spec.tsx b/packages/ra-core/src/i18n/useTranslate.spec.tsx index 78b82c9bf8b..1168533183c 100644 --- a/packages/ra-core/src/i18n/useTranslate.spec.tsx +++ b/packages/ra-core/src/i18n/useTranslate.spec.tsx @@ -1,10 +1,11 @@ import React from 'react'; import expect from 'expect'; -import { render, cleanup } from '@testing-library/react'; +import { cleanup } from '@testing-library/react'; import useTranslate from './useTranslate'; import TranslationProvider from './TranslationProvider'; import { TranslationContext } from './TranslationContext'; +import { renderWithRedux } from '../util'; describe('useTranslate', () => { afterEach(cleanup); @@ -15,12 +16,12 @@ describe('useTranslate', () => { }; it('should not fail when used outside of a translation provider', () => { - const { queryAllByText } = render(); + const { queryAllByText } = renderWithRedux(); expect(queryAllByText('hello')).toHaveLength(1); }); it('should use the translate function set in the translation context', () => { - const { queryAllByText } = render( + const { queryAllByText } = renderWithRedux( { }); it('should use the i18n provider when using TranslationProvider', () => { - const { queryAllByText } = render( + const { queryAllByText } = renderWithRedux( ({ hello: 'bonjour' })} diff --git a/packages/ra-core/src/index.ts b/packages/ra-core/src/index.ts index 5405f14c417..a339881940f 100644 --- a/packages/ra-core/src/index.ts +++ b/packages/ra-core/src/index.ts @@ -23,6 +23,7 @@ export * from './auth'; export * from './dataProvider'; export * from './i18n'; export * from './inference'; +export * from './loading'; export * from './util'; export * from './controller'; export * from './form'; diff --git a/packages/ra-core/src/loading/index.ts b/packages/ra-core/src/loading/index.ts new file mode 100644 index 00000000000..2e1c2c09a1b --- /dev/null +++ b/packages/ra-core/src/loading/index.ts @@ -0,0 +1,4 @@ +import useLoading from './useLoading'; +import useUpdateLoading from './useUpdateLoading'; + +export { useLoading, useUpdateLoading }; diff --git a/packages/ra-core/src/loading/useLoading.ts b/packages/ra-core/src/loading/useLoading.ts new file mode 100644 index 00000000000..80297224de9 --- /dev/null +++ b/packages/ra-core/src/loading/useLoading.ts @@ -0,0 +1,19 @@ +import { useSelector } from 'react-redux'; +import { ReduxState } from '../types'; + +/** + * Get the loading status, i.e. a boolean indicating if at least one request is pending + * + * @see useLoad + * + * @example + * + * import { useLoading } from 'react-admin'; + * + * const MyComponent = () => { + * const loading = useLoading(); + * return loading ? : ; + * } + */ +export default () => + useSelector((state: ReduxState) => state.admin.loading > 0); diff --git a/packages/ra-core/src/loading/useUpdateLoading.ts b/packages/ra-core/src/loading/useUpdateLoading.ts new file mode 100644 index 00000000000..a568249ca7e --- /dev/null +++ b/packages/ra-core/src/loading/useUpdateLoading.ts @@ -0,0 +1,38 @@ +import { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; + +import { fetchStart, fetchEnd } from '../actions/fetchActions'; + +/** + * Update the loading count, which starts or stops the loading indicator. + * + * To be used to show the loading indicator when you don't use the dataProvider. + * + * @return {Object} startLoading and stopLoading callbacks + * + * @example + * import { useUpdateLoading } from 'react-admin' + * + * const MyComponent = () => { + * const { startLoading, stopLoading } = useUpdateLoading(); + * useEffect(() => { + * startLoading(); + * fetch('http://my.domain.api/foo') + * .finally(() => stopLoading()); + * }, []); + * return Foo; + * } + */ +export default () => { + const dispatch = useDispatch(); + + const startLoading = useCallback(() => { + dispatch(fetchStart()); + }, [dispatch]); + + const stopLoading = useCallback(() => { + dispatch(fetchEnd()); + }, [dispatch]); + + return { startLoading, stopLoading }; +}; diff --git a/packages/ra-core/src/util/FieldTitle.spec.tsx b/packages/ra-core/src/util/FieldTitle.spec.tsx index 891dd2f7c11..36110d71c32 100644 --- a/packages/ra-core/src/util/FieldTitle.spec.tsx +++ b/packages/ra-core/src/util/FieldTitle.spec.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { FieldTitle } from './FieldTitle'; import TranslationProvider from '../i18n/TranslationProvider'; +import renderWithRedux from './renderWithRedux'; describe('FieldTitle', () => { afterEach(cleanup); @@ -20,7 +21,7 @@ describe('FieldTitle', () => { }); it('should use the label as translate key when translation is available', () => { - const { container } = render( + const { container } = renderWithRedux( ({ foo: 'bar' })}> @@ -29,7 +30,7 @@ describe('FieldTitle', () => { }); it('should use the humanized source when given', () => { - const { container } = render( + const { container } = renderWithRedux( ({})}> @@ -38,7 +39,7 @@ describe('FieldTitle', () => { }); it('should use the humanized source when given with underscores', () => { - const { container } = render( + const { container } = renderWithRedux( ({})}> @@ -49,7 +50,7 @@ describe('FieldTitle', () => { }); it('should use the humanized source when given with camelCase', () => { - const { container } = render( + const { container } = renderWithRedux( ({})}> @@ -60,7 +61,7 @@ describe('FieldTitle', () => { }); it('should use the source and resource as translate key when translation is available', () => { - const { container } = render( + const { container } = renderWithRedux( ({ 'resources.posts.fields.title': 'titre', diff --git a/packages/ra-language-english/index.js b/packages/ra-language-english/index.js index 49ae8cb524e..b9673c3228e 100644 --- a/packages/ra-language-english/index.js +++ b/packages/ra-language-english/index.js @@ -109,6 +109,8 @@ module.exports = { http_error: 'Server communication error', data_provider_error: 'dataProvider error. Check the console for details.', + i18n_error: + 'Cannot load the translations for the specified language', canceled: 'Action cancelled', logged_out: 'Your session has ended, please reconnect.', }, diff --git a/packages/ra-language-french/index.js b/packages/ra-language-french/index.js index fbe308fe45e..3c05bf4c610 100644 --- a/packages/ra-language-french/index.js +++ b/packages/ra-language-french/index.js @@ -114,6 +114,8 @@ module.exports = { http_error: 'Erreur de communication avec le serveur', data_provider_error: 'Erreur dans le dataProvider. Plus de détails dans la console.', + i18n_error: + 'Erreur de chargement des traductions pour la langue sélectionnée', canceled: 'Action annulée', logged_out: 'Votre session a pris fin, veuillez vous reconnecter.', }, diff --git a/packages/ra-ui-materialui/src/field/SelectField.spec.js b/packages/ra-ui-materialui/src/field/SelectField.spec.js index 0bde7dd4730..bd2f469c506 100644 --- a/packages/ra-ui-materialui/src/field/SelectField.spec.js +++ b/packages/ra-ui-materialui/src/field/SelectField.spec.js @@ -2,7 +2,7 @@ import React from 'react'; import expect from 'expect'; import { render, cleanup } from '@testing-library/react'; -import { TranslationProvider } from 'ra-core'; +import { TranslationProvider, renderWithRedux } from 'ra-core'; import { SelectField } from './SelectField'; describe('', () => { @@ -111,7 +111,7 @@ describe('', () => { }); it('should translate the choice by default', () => { - const { queryAllByText } = render( + const { queryAllByText } = renderWithRedux( ({ hello: 'bonjour' })}> @@ -121,7 +121,7 @@ describe('', () => { }); it('should not translate the choice if translateChoice is false', () => { - const { queryAllByText } = render( + const { queryAllByText } = renderWithRedux( ({ hello: 'bonjour' })}> ', () => { }); it('should translate the choices by default', () => { - const { queryByLabelText } = render( + const { queryByLabelText } = renderWithRedux( ({ Angular: 'Angular **', @@ -174,7 +174,7 @@ describe('', () => { }); it('should not translate the choices if translateChoice is false', () => { - const { queryByLabelText } = render( + const { queryByLabelText } = renderWithRedux( ({ Angular: 'Angular **', From 9abccac830b79b69f569a35e3b758d6139f8b74f Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Wed, 11 Sep 2019 14:18:17 +0200 Subject: [PATCH 08/10] use useTranslate in withTranslate --- packages/ra-core/src/i18n/translate.tsx | 42 +++++++------------------ 1 file changed, 11 insertions(+), 31 deletions(-) diff --git a/packages/ra-core/src/i18n/translate.tsx b/packages/ra-core/src/i18n/translate.tsx index 6ef3226b388..0682bba6eb0 100644 --- a/packages/ra-core/src/i18n/translate.tsx +++ b/packages/ra-core/src/i18n/translate.tsx @@ -1,13 +1,11 @@ import React, { ComponentType, Component, ComponentClass } from 'react'; import { default as wrapDisplayName } from 'recompose/wrapDisplayName'; import { default as warning } from '../util/warning'; -import { - TranslationContextProps, - TranslationContext, -} from './TranslationContext'; +import useTranslate from './useTranslate'; +import useLocale from './useLocale'; /** - * Higher-Order Component for getting access to the `translate` function in props. + * Higher-Order Component for getting access to the `locale` and the `translate` function in props. * * Requires that the app is decorated by the to inject * the translation dictionaries and function in the context. @@ -24,9 +22,7 @@ import { * * @param {*} BaseComponent The component to decorate */ -const withTranslate = ( - BaseComponent: ComponentType -): ComponentClass => { +const withTranslate = (BaseComponent: ComponentType): ComponentType => { warning( typeof BaseComponent === 'string', `The translate function is a Higher Order Component, and should not be called directly with a translation key. Use the translate function passed as prop to your component props instead: @@ -36,30 +32,14 @@ const MyHelloButton = ({ translate }) => ( );` ); - const { - translate: translateToDiscard, - ...defaultProps - } = (BaseComponent.defaultProps || {}) as any; + const TranslatedComponent = props => { + const translate = useTranslate(); + const locale = useLocale(); - class TranslatedComponent extends Component { - static defaultProps = defaultProps; - - static displayName = wrapDisplayName(BaseComponent, 'translate'); - - render() { - return ( - - {({ translate, locale }) => ( - - )} - - ); - } - } + return ( + + ); + }; return TranslatedComponent; }; From 9a64f327a1b02451edf739652813a88eb98c9b73 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Wed, 11 Sep 2019 14:18:51 +0200 Subject: [PATCH 09/10] Update translation documentation --- docs/Translation.md | 278 +++++++++++++++++++++++++------------ docs/_layouts/default.html | 16 ++- 2 files changed, 200 insertions(+), 94 deletions(-) diff --git a/docs/Translation.md b/docs/Translation.md index e8da960bd3c..12a8ee010bd 100644 --- a/docs/Translation.md +++ b/docs/Translation.md @@ -5,67 +5,104 @@ title: "Translation" # Translation -The react-admin interface uses English as the default language. But it also supports any other language, thanks to the [polyglot.js](http://airbnb.io/polyglot.js/) library. +The react-admin user interface uses English as the default language. But you can also display the UI and content in other languages, allow changing language at runtime, even lazy-loading optional languages to avoid increasing the bundle size with all translations. -## Changing Locale +The react-admin translation layer is based on [polyglot.js](http://airbnb.io/polyglot.js/), which uses JSON files for translations. You will use translation features mostly via the `i18nProvider`, and a set of hooks (`useTranslate`, `useLocale`, `useSetLocale`). -If you want to use another locale, you'll have to install a third-party package. For instance, to change the interface to French, you must install the `ra-language-french` npm package then instruct react-admin to use it. +**Tip**: We'll use a bit of custom vocabulary in this chapter: + +- "i18n" is a shorter way to write "internationalization" (an i followed by 18 letters followed by n) +- "locale" is a concept similar to languages, but it also includes the concept of country. For instance, there are several English locales (like `en_us` and `en_gb`) because US and UK citizens don't use exactly the same language. For react-admin, the "locale" is just a key for your i18nProvider, so it can have any value you want. -The `` component has an `i18nProvider` prop, which accepts a function with the following signature: +## Introducing the `i18nProvider` -```js +Just like for data fetching and authentication, react-admin relies on a simple function for translations. It's called the `i18nProvider`, and here is its signature: + +```jsx const i18nProvider = locale => messages; ``` -The `messages` should be a dictionary of interface and resource names (see the [Translation Messages section](#translation-messages) below for details about the dictionary format). +Given a locale, The `i18nProvider` function should return a dictionary of terms. For instance: + +```jsx +const i18nProvider = locale => { + if (locale === 'en') { + return { + ra: { + notification: { + http_error: 'Network error. Please retry', + }, + action: { + save: 'Save', + delete: 'Delete', + }, + }, + }; + } + if (locale === 'fr') { + return { + ra: { + notification: { + http_error: 'Erreur réseau, veuillez réessayer', + }, + action: { + save: 'Enregistrer', + delete: 'Supprimer', + }, + }, + }; + } +}; +``` + +If you want to add or update tranlations, you'll have to provide your own `i18nProvider`. -React-admin calls the `i18nProvider` when it starts, passing the `locale` specified on the `Admin` component as parameter. The provider must return the messages synchronously. React-admin also calls the `i18nProvider` whenever the locale changes, passing the new locale as parameter. So the simplest example for a multilingual interface reads as follow: +React-admin components use translation keys for their labels, and rely on the `i18nProvider` to translate them. For instance: ```jsx -import React from 'react'; -import { Admin, Resource } from 'react-admin'; -import frenchMessages from 'ra-language-french'; -import englishMessages from 'ra-language-english'; +const SaveButton = ({ doSave }) => { + const translate = useTranslate(); + return ( + ; + ); +}; +``` -const messages = { - fr: frenchMessages, - en: englishMessages, -} -const i18nProvider = locale => messages[locale]; +And just like for the `dataProvider` and the `authProvider`, you can *inject* the `i18nProvider` to your react-admin app using the `` component: -const App = () => ( - - ... - -); +```jsx +import i18nProvider from './i18n/i18nProvider'; -export default App; +const App = () => ( + + + // ... ``` -The `i18nProvider` may return a promise for locale change calls (except the initial call, when the app starts). This can be useful to only load the needed locale. For example: +## Changing The Default Locale -```js -import englishMessages from '../en.js'; +The default react-admin locale is `en`, for English. If you want to display the interface in another language by default, you'll have to install a third-party package. For instance, to change the interface to French, you must install the `ra-language-french` npm package, then use it in a custom `i18nProvider`, as follows: -const asyncMessages = { - fr: () => import('../i18n/fr.js').then(messages => messages.default), - it: () => import('../i18n/it.js').then(messages => messages.default), -}; +```jsx +import React from 'react'; +import { Admin, Resource } from 'react-admin'; +import frenchMessages from 'ra-language-french'; -const i18nProvider = locale => { - if (locale === 'en') { - // initial call, must return synchronously - return englishMessages; - } - // change of locale after initial call returns a promise - return asyncMessages[params.locale](); -} +const i18nProvider = () => frenchMessages; const App = () => ( - + ... ); + +export default App; ``` ## Available Locales @@ -113,9 +150,9 @@ These packages are not directly interoperable with react-admin, but the upgrade If you want to contribute a new translation, feel free to submit a pull request to update [this page](https://github.com/marmelab/react-admin/blob/master/docs/Translation.md) with a link to your package. -## Changing Locale At Runtime +## `useSetLocale`: Changing Locale At Runtime -If you want to offer the ability to change locale at runtime, you must provide the messages for all possible translations: +If you want to offer the ability to change locale at runtime, you must provide an `i18nProvider` that contains the messages for all possible locales: ```jsx import React from 'react'; @@ -138,23 +175,20 @@ const App = () => ( export default App; ``` -Then, dispatch the `CHANGE_LOCALE` action, by using the `changeLocale` action creator. For instance, the following component allows the user to switch the interface language between English and French: +Then, use the `useSetLocale` hook to change locale. For instance, the following component allows the user to switch the interface language between English and French: ```jsx import React, { Component } from 'react'; -import { useDispatch } from 'react-redux'; import Button from '@material-ui/core/Button'; -import { changeLocale } from 'react-admin'; +import { useSetLocale } from 'react-admin'; const LocaleSwitcher = () => { - const dispatch = useDispatch(); - const switchToFrench = () => dispatch(changeLocale('fr')); - const switchToEnglish = () => dispatch(changeLocale('en')); + const setLocale = useSetLocale(); return (
Language
- - + +
); } @@ -162,9 +196,67 @@ const LocaleSwitcher = () => { export default LocaleSwitcher; ``` +## `useLocale`: Getting The Current Locale + +Your language switcher component probably needs to know the current locale, in order to disable/transform the button for the current language. The `useLocale` hook returns the current locale: + +```jsx +import React, { Component } from 'react'; +import Button from '@material-ui/core/Button'; +import { useLocale, useSetLocale } from 'react-admin'; + +const LocaleSwitcher = () => { + const locale = useLocale(); + const setLocale = useSetLocale(); + return ( +
+
Language
+ + +
+ ); +} + +export default LocaleSwitcher; +``` + +## Lazy-Loading Locales + +Bundling all the possible locales in the `i18nProvider` is a great recipe to increase your bundle size, and slow down the initial application load. Fortunately, the `i18nProvider` may return a *promise* for locale change calls (except the initial call, when the app starts) to load secondary locales on demand. For example: + +```js +import englishMessages from '../en.js'; + +const i18nProvider = locale => { + if (locale === 'en') { + // initial call, must return synchronously + return englishMessages; + } + if (locale === 'fr') { + return import('../i18n/fr.js').then(messages => messages.default); + } +} + +const App = () => ( + + ... + +); +``` + ## Using The Browser Locale -React-admin provides a helper function named `resolveBrowserLocale()`, which helps you to introduce a dynamic locale attribution based on the locale configured in the user's browser. To use it, simply pass the function as `locale` prop. +React-admin provides a helper function named `resolveBrowserLocale()`, which detects the user's browser locale. To use it, simply pass the function as `locale` prop. ```jsx import React from 'react'; @@ -176,7 +268,7 @@ const messages = { fr: frenchMessages, en: englishMessages, }; -const i18nProvider = locale => messages[locale]; +const i18nProvider = locale => messages[locale] ? messages[locale] : messages.en; const App = () => ( @@ -187,6 +279,8 @@ const App = () => ( export default App; ``` +Beware that users from all around the world may use your application, so make sure the `i18nProvider` returns default messages even for unknown locales? + ## Translation Messages The `message` returned by the `i18nProvider` value should be a dictionary where the keys identify interface components, and values are the translated string. This dictionary is a simple JavaScript object looking like the following: @@ -218,8 +312,8 @@ By default, React-admin uses resource names ("post", "comment", etc) and field n However, before humanizing names, react-admin checks the `messages` dictionary for a possible translation, with the following keys: -- `${locale}.resources.${resourceName}.name` for resource names (used for the menu and page titles) -- `${locale}.resources.${resourceName}.fields.${fieldName}` for field names (used for datagrid header and form input labels) +- `resources.${resourceName}.name` for resource names (used for the menu and page titles) +- `resources.${resourceName}.fields.${fieldName}` for field names (used for datagrid header and form input labels) This lets you translate your own resource and field names by passing a `messages` object with a `resources` key: @@ -276,46 +370,6 @@ const App = () => ( ); ``` -## Translating Error Messages - -In Create and Edit views, forms can use custom validators. These validator functions should return translation keys rather than translated messages. React-admin automatically passes these identifiers to the translation function: - -```jsx -// in validators/required.js -const required = () => (value, allValues, props) => - value - ? undefined - : 'myroot.validation.required'; - -// in i18n/en.json -export default { - myroot: { - validation: { - required: 'Required field', - } - } -} -``` - -If the translation depends on a variable, the validator can return an object rather than a translation identifier: - -```jsx -// in validators/minLength.js -const minLength = (min) => (value, allValues, props) => - value.length >= min - ? undefined - : { message: 'myroot.validation.minLength', args: { min } }; - -// in i18n/en.js -export default { - myroot: { - validation: { - minLength: 'Must be %{min} characters at least', - } - } -} -``` - ## `useTranslate` Hook If you need to translate messages in your own components, React-admin provides a `useTranslate` hook, which returns the `translate` function: @@ -400,6 +454,46 @@ translate('not_yet_translated', { _: 'Default translation' }) To find more detailed examples, please refer to [http://airbnb.io/polyglot.js/](http://airbnb.io/polyglot.js/) +## Translating Error Messages + +In Create and Edit views, forms can use custom validators. These validator functions should return translation keys rather than translated messages. React-admin automatically passes these identifiers to the translation function: + +```jsx +// in validators/required.js +const required = () => (value, allValues, props) => + value + ? undefined + : 'myroot.validation.required'; + +// in i18n/en.json +export default { + myroot: { + validation: { + required: 'Required field', + } + } +} +``` + +If the translation depends on a variable, the validator can return an object rather than a translation identifier: + +```jsx +// in validators/minLength.js +const minLength = (min) => (value, allValues, props) => + value.length >= min + ? undefined + : { message: 'myroot.validation.minLength', args: { min } }; + +// in i18n/en.js +export default { + myroot: { + validation: { + minLength: 'Must be %{min} characters at least', + } + } +} +``` + ## Notifications With Variables It is possible to pass variables for polyglot interpolation with custom notifications. For example: diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index 59e8e94211e..010c1827207 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -765,13 +765,22 @@