From ba9ba4d62e2601ea10af855774fc43fe8a525aa6 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Thu, 12 Sep 2019 18:44:06 +0200 Subject: [PATCH 01/13] [BC Break] Change i18n signature (again) --- .../ra-core/src/form/ValidationError.spec.tsx | 7 ++- .../src/i18n/TestTranslationProvider.tsx | 21 +++++++ .../ra-core/src/i18n/TranslationContext.ts | 12 ++-- .../ra-core/src/i18n/TranslationProvider.tsx | 38 +++--------- .../ra-core/src/i18n/defaultI18nProvider.ts | 5 +- packages/ra-core/src/i18n/index.ts | 6 +- .../ra-core/src/i18n/polyglotI18nProvider.ts | 59 +++++++++++++++++++ packages/ra-core/src/i18n/translate.spec.tsx | 1 + packages/ra-core/src/i18n/translate.tsx | 3 +- .../ra-core/src/i18n/useSetLocale.spec.js | 20 ++++--- .../ra-core/src/i18n/useTranslate.spec.tsx | 13 ++-- packages/ra-core/src/i18n/useTranslate.ts | 17 ++++-- packages/ra-core/src/types.ts | 9 ++- packages/ra-core/src/util/FieldTitle.spec.tsx | 26 ++++---- packages/ra-core/src/util/TestContext.tsx | 2 - .../src/field/ArrayField.spec.js | 6 +- .../src/field/SelectField.spec.js | 10 ++-- .../src/input/AutocompleteArrayInput.spec.tsx | 18 ++---- .../src/input/AutocompleteInput.spec.js | 18 ++---- .../src/input/CheckboxGroupInput.spec.tsx | 18 +++--- .../src/input/RadioButtonGroupInput.spec.tsx | 18 ++---- .../src/input/SelectArrayInput.spec.tsx | 10 +--- .../src/input/SelectInput.spec.tsx | 18 ++---- 23 files changed, 194 insertions(+), 161 deletions(-) create mode 100644 packages/ra-core/src/i18n/TestTranslationProvider.tsx create mode 100644 packages/ra-core/src/i18n/polyglotI18nProvider.ts diff --git a/packages/ra-core/src/form/ValidationError.spec.tsx b/packages/ra-core/src/form/ValidationError.spec.tsx index 31b38eb0595..e52b02d1c86 100644 --- a/packages/ra-core/src/form/ValidationError.spec.tsx +++ b/packages/ra-core/src/form/ValidationError.spec.tsx @@ -2,7 +2,8 @@ import React from 'react'; import { cleanup } from '@testing-library/react'; import ValidationError from './ValidationError'; -import TranslationProvider from '../i18n/TranslationProvider'; +import { TranslationProvider, polyglotI18nProvider } from '../i18n'; + import { renderWithRedux } from '../util'; const translate = jest.fn(key => key); @@ -10,7 +11,7 @@ const translate = jest.fn(key => key); const renderWithTranslations = content => renderWithRedux( ({ + i18nProvider={polyglotI18nProvider(() => ({ ra: { validation: { required: 'Required', @@ -23,7 +24,7 @@ const renderWithTranslations = content => match: 'Must match %{match}', }, }, - })} + }))} > {content} diff --git a/packages/ra-core/src/i18n/TestTranslationProvider.tsx b/packages/ra-core/src/i18n/TestTranslationProvider.tsx new file mode 100644 index 00000000000..80c036f456f --- /dev/null +++ b/packages/ra-core/src/i18n/TestTranslationProvider.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import lodashGet from 'lodash/get'; + +import { TranslationContext } from './TranslationContext'; + +export default ({ translate, messages, children }: any) => ( + Promise.resolve(), + i18nProvider: (_, options) => + messages + ? lodashGet(messages, options[0]) + ? lodashGet(messages, options[0]) + : options[1]._ + : translate.apply(null, options), + }} + > + {children} + +); diff --git a/packages/ra-core/src/i18n/TranslationContext.ts b/packages/ra-core/src/i18n/TranslationContext.ts index f6d871d1557..3e72342b0b6 100644 --- a/packages/ra-core/src/i18n/TranslationContext.ts +++ b/packages/ra-core/src/i18n/TranslationContext.ts @@ -1,18 +1,16 @@ import { createContext } from 'react'; -import { Translate, I18nProvider } from '../types'; +import { I18nProvider } from '../types'; export interface TranslationContextProps { - provider: I18nProvider; locale: string; - translate: Translate; - setLocale: (locale: string) => void; + setLocale: (locale: string) => Promise; + i18nProvider: I18nProvider; } const TranslationContext = createContext({ - provider: () => ({}), locale: 'en', - translate: id => id, - setLocale: () => {}, + setLocale: () => Promise.resolve(), + i18nProvider: (type, params) => (Array.isArray(params) ? params[0] : ''), }); TranslationContext.displayName = 'TranslationContext'; diff --git a/packages/ra-core/src/i18n/TranslationProvider.tsx b/packages/ra-core/src/i18n/TranslationProvider.tsx index b22ad2855d8..3e1196a5563 100644 --- a/packages/ra-core/src/i18n/TranslationProvider.tsx +++ b/packages/ra-core/src/i18n/TranslationProvider.tsx @@ -7,15 +7,12 @@ import React, { ReactElement, FunctionComponent, } from 'react'; -import Polyglot from 'node-polyglot'; -import defaultMessages from 'ra-language-english'; -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'; +import { I18N_CHANGE_LOCALE, I18nProvider } from '../types'; interface Props { locale?: string; @@ -41,45 +38,23 @@ const TranslationProvider: FunctionComponent = props => { const notify = useNotify(); 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); - })(), + i18nProvider, }); const setLocale = useCallback( - newLocale => + (newLocale: string) => new Promise(resolve => { startLoading(); // so we systematically return a Promise for the messages // i18nProvider may return a Promise for language changes, - resolve(i18nProvider(newLocale)); + resolve(i18nProvider(I18N_CHANGE_LOCALE, newLocale)); }) - .then(messages => { - const polyglot = new Polyglot({ - locale: newLocale, - phrases: defaultsDeep( - { '': '' }, - messages, - defaultMessages - ), - }); + .then(() => { stopLoading(); setState({ - provider: i18nProvider, locale: newLocale, - translate: polyglot.t.bind(polyglot), + i18nProvider, }); }) .catch(error => { @@ -90,6 +65,7 @@ const TranslationProvider: FunctionComponent = props => { [i18nProvider, notify, setState, startLoading, stopLoading] ); + // handle update with different locale const isInitialMount = useRef(true); useEffect(() => { if (isInitialMount.current) { diff --git a/packages/ra-core/src/i18n/defaultI18nProvider.ts b/packages/ra-core/src/i18n/defaultI18nProvider.ts index 63873bcc834..137a6c152db 100644 --- a/packages/ra-core/src/i18n/defaultI18nProvider.ts +++ b/packages/ra-core/src/i18n/defaultI18nProvider.ts @@ -1,6 +1,5 @@ import defaultMessages from 'ra-language-english'; -import { I18nProvider } from '../types'; -const defaultI18nProvider: I18nProvider = locale => defaultMessages; +import polyglotI18nProvider from './polyglotI18nProvider'; -export default defaultI18nProvider; +export default polyglotI18nProvider(() => defaultMessages); diff --git a/packages/ra-core/src/i18n/index.ts b/packages/ra-core/src/i18n/index.ts index e9c69bdb2b8..638ff1a5a26 100644 --- a/packages/ra-core/src/i18n/index.ts +++ b/packages/ra-core/src/i18n/index.ts @@ -1,5 +1,7 @@ import defaultI18nProvider from './defaultI18nProvider'; +import polyglotI18nProvider from './polyglotI18nProvider'; import translate from './translate'; +import { TranslationContext } from './TranslationContext'; import TranslationProvider from './TranslationProvider'; import useLocale from './useLocale'; import useSetLocale from './useSetLocale'; @@ -10,12 +12,14 @@ const withTranslate = translate; export { defaultI18nProvider, + polyglotI18nProvider, translate, // deprecated withTranslate, // deprecated + TranslationContext, + TranslationProvider, useLocale, useSetLocale, useTranslate, - TranslationProvider, }; export const DEFAULT_LOCALE = 'en'; diff --git a/packages/ra-core/src/i18n/polyglotI18nProvider.ts b/packages/ra-core/src/i18n/polyglotI18nProvider.ts new file mode 100644 index 00000000000..2147fd747af --- /dev/null +++ b/packages/ra-core/src/i18n/polyglotI18nProvider.ts @@ -0,0 +1,59 @@ +import Polyglot from 'node-polyglot'; + +import { I18N_TRANSLATE, I18N_CHANGE_LOCALE, I18nProvider } from '../types'; + +type GetMessages = (locale: string) => Object; + +/** + * Build a polyglot-based i18nProvider based on a function returning the messages for a locale + * + * @example + * + * import { Admin, Resource, polyglotI18nProvider } from 'react-admin'; + * import englishMessages from 'ra-language-english'; + * import frenchMessages from 'ra-language-french'; + * + * const messages = { + * fr: frenchMessages, + * en: englishMessages, + * }; + * const i18nProvider = polyglotI18nProvider(locale => messages[locale]) + */ +export default ( + getMessages: GetMessages, + initialLocale: string = 'en', + polyglotOptions: any = {} +): I18nProvider => { + const messages = getMessages(initialLocale); + if (messages instanceof Promise) { + throw new Error( + `The i18nProvider returned a Promise for the messages of the default locale (${initialLocale}). Please update your i18nProvider to return the messages of the default locale in a synchronous way.` + ); + } + const polyglot = new Polyglot({ + locale: initialLocale, + phrases: { '': '', ...messages }, + ...polyglotOptions, + }); + let translate = polyglot.t.bind(polyglot); + + return (type, params) => { + if (type === I18N_TRANSLATE) { + return translate.apply(null, params as [string, any?]); + } + if (type === I18N_CHANGE_LOCALE) { + return new Promise(resolve => { + // so we systematically return a Promise for the messages + // i18nProvider may return a Promise for language changes, + resolve(getMessages(params as string)); + }).then(messages => { + const newPolyglot = new Polyglot({ + locale: params, + phrases: { '': '', ...messages }, + ...polyglotOptions, + }); + translate = newPolyglot.t.bind(newPolyglot); + }); + } + }; +}; diff --git a/packages/ra-core/src/i18n/translate.spec.tsx b/packages/ra-core/src/i18n/translate.spec.tsx index ae193e21c77..4e83cb981ce 100644 --- a/packages/ra-core/src/i18n/translate.spec.tsx +++ b/packages/ra-core/src/i18n/translate.spec.tsx @@ -1,4 +1,5 @@ import React, { SFC } from 'react'; +import expect from 'expect'; import translate from './translate'; import { TranslationContextProps } from './TranslationContext'; diff --git a/packages/ra-core/src/i18n/translate.tsx b/packages/ra-core/src/i18n/translate.tsx index 9a434c97853..e31e2adfd79 100644 --- a/packages/ra-core/src/i18n/translate.tsx +++ b/packages/ra-core/src/i18n/translate.tsx @@ -1,5 +1,4 @@ -import React, { ComponentType, Component, ComponentClass } from 'react'; -import { default as wrapDisplayName } from 'recompose/wrapDisplayName'; +import React, { ComponentType } from 'react'; import { default as warning } from '../util/warning'; import useTranslate from './useTranslate'; import useLocale from './useLocale'; diff --git a/packages/ra-core/src/i18n/useSetLocale.spec.js b/packages/ra-core/src/i18n/useSetLocale.spec.js index c9695739a94..694eeaea3cc 100644 --- a/packages/ra-core/src/i18n/useSetLocale.spec.js +++ b/packages/ra-core/src/i18n/useSetLocale.spec.js @@ -1,11 +1,14 @@ import React from 'react'; import expect from 'expect'; -import { fireEvent, cleanup, wait, act } from '@testing-library/react'; +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'; +import { + TranslationContext, + TranslationProvider, + polyglotI18nProvider, +} from './'; import { renderWithRedux } from '../util'; describe('useTranslate', () => { @@ -23,18 +26,17 @@ describe('useTranslate', () => { }; it('should not fail when used outside of a translation provider', () => { - const { queryAllByText } = renderWithRedux(); + 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 } = renderWithRedux( + const { getByText } = render( '', locale: 'de', - translate: () => 'hallo', - provider: () => ({}), setLocale, }} > @@ -46,10 +48,10 @@ describe('useTranslate', () => { }); it('should use the i18n provider when using TranslationProvider', async () => { - const i18nProvider = locale => { + const i18nProvider = polyglotI18nProvider(locale => { if (locale === 'en') return { hello: 'hello' }; if (locale === 'fr') return { hello: 'bonjour' }; - }; + }); 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 1168533183c..e219365b032 100644 --- a/packages/ra-core/src/i18n/useTranslate.spec.tsx +++ b/packages/ra-core/src/i18n/useTranslate.spec.tsx @@ -20,14 +20,14 @@ describe('useTranslate', () => { expect(queryAllByText('hello')).toHaveLength(1); }); - it('should use the translate function set in the translation context', () => { + it('should use the i18nProvider I18N_TRANSLATE verb', () => { const { queryAllByText } = renderWithRedux( 'hallo', - provider: () => ({}), - setLocale: () => {}, + i18nProvider: type => + type === 'I18N_TRANSLATE' ? 'hallo' : '', + setLocale: () => Promise.resolve(), }} > @@ -39,10 +39,7 @@ describe('useTranslate', () => { it('should use the i18n provider when using TranslationProvider', () => { const { queryAllByText } = renderWithRedux( - ({ hello: 'bonjour' })} - > + 'bonjour'}> ); diff --git a/packages/ra-core/src/i18n/useTranslate.ts b/packages/ra-core/src/i18n/useTranslate.ts index 35e237b393b..ebb9dc88fb1 100644 --- a/packages/ra-core/src/i18n/useTranslate.ts +++ b/packages/ra-core/src/i18n/useTranslate.ts @@ -1,6 +1,7 @@ -import { useContext } from 'react'; +import { useContext, useCallback } from 'react'; import { TranslationContext } from './TranslationContext'; +import { I18N_TRANSLATE, Translate } from '../types'; /** * Translate a string using the current locale and the translations from the i18nProvider @@ -21,9 +22,17 @@ import { TranslationContext } from './TranslationContext'; * return {translate('settings')}; * } */ -const useTranslate = () => { - const { translate } = useContext(TranslationContext); - return translate; +const useTranslate = (): Translate => { + const { i18nProvider, locale } = useContext(TranslationContext); + const translate = useCallback( + (key: string, options?: any) => + i18nProvider(I18N_TRANSLATE, [key, options]) as string, + // update the hook each time the locale changes + [i18nProvider, locale] // eslint-disable-line react-hooks/exhaustive-deps + ); + return i18nProvider ? translate : identity; }; +const identity = key => key; + export default useTranslate; diff --git a/packages/ra-core/src/types.ts b/packages/ra-core/src/types.ts index 6221691d230..9e77875f064 100644 --- a/packages/ra-core/src/types.ts +++ b/packages/ra-core/src/types.ts @@ -25,7 +25,14 @@ export interface Pagination { perPage: number; } -export type I18nProvider = (locale: string) => object | Promise; +export const I18N_TRANSLATE = 'I18N_TRANSLATE'; +export const I18N_CHANGE_LOCALE = 'I18N_CHANGE_LOCALE'; + +export type I18nProvider = ( + type: typeof I18N_TRANSLATE | typeof I18N_CHANGE_LOCALE, + params: [string, any?] | string +) => string | Promise; + export type Translate = (id: string, options?: any) => string; export type AuthActionType = diff --git a/packages/ra-core/src/util/FieldTitle.spec.tsx b/packages/ra-core/src/util/FieldTitle.spec.tsx index 36110d71c32..e965f4a1c3f 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 TranslationProvider from '../i18n/TranslationProvider'; +import TestTranslationProvider from '../i18n/TestTranslationProvider'; import renderWithRedux from './renderWithRedux'; describe('FieldTitle', () => { @@ -22,27 +22,27 @@ describe('FieldTitle', () => { it('should use the label as translate key when translation is available', () => { const { container } = renderWithRedux( - ({ foo: 'bar' })}> + - + ); expect(container.firstChild.textContent).toEqual('bar'); }); it('should use the humanized source when given', () => { const { container } = renderWithRedux( - ({})}> + options._}> - + ); expect(container.firstChild.textContent).toEqual('Title'); }); it('should use the humanized source when given with underscores', () => { const { container } = renderWithRedux( - ({})}> + options._}> - + ); expect(container.firstChild.textContent).toEqual( 'Title with underscore' @@ -51,9 +51,9 @@ describe('FieldTitle', () => { it('should use the humanized source when given with camelCase', () => { const { container } = renderWithRedux( - ({})}> + options._}> - + ); expect(container.firstChild.textContent).toEqual( 'Title with camel case' @@ -62,13 +62,13 @@ describe('FieldTitle', () => { it('should use the source and resource as translate key when translation is available', () => { const { container } = renderWithRedux( - ({ + - + ); 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 893231dc7f1..4be15ee92d2 100644 --- a/packages/ra-core/src/util/TestContext.tsx +++ b/packages/ra-core/src/util/TestContext.tsx @@ -1,11 +1,9 @@ import React, { Component } from 'react'; import { createStore } from 'redux'; import { Provider } from 'react-redux'; -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'; diff --git a/packages/ra-ui-materialui/src/field/ArrayField.spec.js b/packages/ra-ui-materialui/src/field/ArrayField.spec.js index d389cd7cd14..217e868f141 100644 --- a/packages/ra-ui-materialui/src/field/ArrayField.spec.js +++ b/packages/ra-ui-materialui/src/field/ArrayField.spec.js @@ -2,7 +2,7 @@ import React from 'react'; import { shallow, mount } from 'enzyme'; import { createStore } from 'redux'; import { Provider } from 'react-redux'; -import { TranslationProvider } from 'ra-core'; +import { TestTranslationContext } from 'ra-core'; import { ArrayField } from './ArrayField'; import NumberField from './NumberField'; @@ -62,9 +62,7 @@ describe('', () => { ); const wrapper = mount( ({}))}> - ({})}> - - + ); expect( diff --git a/packages/ra-ui-materialui/src/field/SelectField.spec.js b/packages/ra-ui-materialui/src/field/SelectField.spec.js index bd2f469c506..1921ca3739f 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, renderWithRedux } from 'ra-core'; +import { TestTranslationProvider, renderWithRedux } from 'ra-core'; import { SelectField } from './SelectField'; describe('', () => { @@ -112,9 +112,9 @@ describe('', () => { it('should translate the choice by default', () => { const { queryAllByText } = renderWithRedux( - ({ hello: 'bonjour' })}> + - + ); expect(queryAllByText('hello')).toHaveLength(0); expect(queryAllByText('bonjour')).toHaveLength(1); @@ -122,13 +122,13 @@ describe('', () => { it('should not translate the choice if translateChoice is false', () => { const { queryAllByText } = renderWithRedux( - ({ hello: 'bonjour' })}> + - + ); expect(queryAllByText('hello')).toHaveLength(1); expect(queryAllByText('bonjour')).toHaveLength(0); diff --git a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx index 1ddfa9053c3..674a7689603 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx @@ -9,7 +9,7 @@ import { Form } from 'react-final-form'; import expect from 'expect'; import AutocompleteArrayInput from './AutocompleteArrayInput'; -import { TranslationContext } from 'ra-core'; +import { TestTranslationProvider } from 'ra-core'; describe('', () => { afterEach(cleanup); @@ -118,11 +118,7 @@ describe('', () => {
( - `**${x}**`, - }} - > + `**${x}**`}> ', () => { { id: 'p', name: 'Programming' }, ]} /> - + )} /> ); @@ -147,11 +143,7 @@ describe('', () => { ( - `**${x}**`, - }} - > + `**${x}**`}> ', () => { ]} translateChoice={false} /> - + )} /> ); diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.js b/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.js index b0e9f865a46..4ae5a00654f 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.js +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.js @@ -8,7 +8,7 @@ import { import AutocompleteInput from './AutocompleteInput'; import { Form } from 'react-final-form'; -import { TranslationContext } from 'ra-core'; +import { TestTranslationProvider } from 'ra-core'; describe('', () => { const defaultProps = { @@ -136,11 +136,7 @@ describe('', () => { it('should translate the value by default', () => { const { queryByDisplayValue } = render( - `**${x}**`, - }} - > + `**${x}**`}> ', () => { /> )} /> - + ); expect(queryByDisplayValue('**foo**')).not.toBeNull(); }); it('should not translate the value if translateChoice is false', () => { const { queryByDisplayValue } = render( - `**${x}**`, - }} - > + `**${x}**`}> ', () => { /> )} /> - + ); expect(queryByDisplayValue('foo')).not.toBeNull(); expect(queryByDisplayValue('**foo**')).toBeNull(); diff --git a/packages/ra-ui-materialui/src/input/CheckboxGroupInput.spec.tsx b/packages/ra-ui-materialui/src/input/CheckboxGroupInput.spec.tsx index 3bdc3918c31..8a3a321efa8 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, TranslationProvider } from 'ra-core'; +import { renderWithRedux, TestTranslationProvider } from 'ra-core'; describe('', () => { const defaultProps = { @@ -157,17 +157,17 @@ describe('', () => { it('should translate the choices by default', () => { const { queryByLabelText } = renderWithRedux( - ({ + } /> - + ); expect(queryByLabelText('Angular **')).not.toBeNull(); expect(queryByLabelText('React **')).not.toBeNull(); @@ -175,11 +175,11 @@ describe('', () => { it('should not translate the choices if translateChoice is false', () => { const { queryByLabelText } = renderWithRedux( - ({ + ', () => { /> )} /> - + ); expect(queryByLabelText('Angular **')).toBeNull(); expect(queryByLabelText('React **')).toBeNull(); diff --git a/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.spec.tsx b/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.spec.tsx index 2c74c2dbd78..91612470cf5 100644 --- a/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.spec.tsx @@ -2,7 +2,7 @@ import React from 'react'; import expect from 'expect'; import { render, cleanup, fireEvent } from '@testing-library/react'; import { Form } from 'react-final-form'; -import { TranslationContext } from 'ra-core'; +import { TestTranslationProvider } from 'ra-core'; import RadioButtonGroupInput from './RadioButtonGroupInput'; @@ -156,11 +156,7 @@ describe('', () => { it('should translate the choices by default', () => { const { queryByText } = render( - `**${x}**`, - }} - > + `**${x}**`}> ( @@ -170,18 +166,14 @@ describe('', () => { /> )} /> - + ); expect(queryByText('**Mastercard**')).not.toBeNull(); }); it('should not translate the choices if translateChoice is false', () => { const { queryByText } = render( - `**${x}**`, - }} - > + `**${x}**`}> ( @@ -192,7 +184,7 @@ describe('', () => { /> )} /> - + ); expect(queryByText('**Mastercard**')).toBeNull(); expect(queryByText('Mastercard')).not.toBeNull(); diff --git a/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx b/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx index a5656d1ba41..124c5934ead 100644 --- a/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx @@ -2,7 +2,7 @@ import React from 'react'; import expect from 'expect'; import { render, cleanup } from '@testing-library/react'; import { Form } from 'react-final-form'; -import { TranslationContext } from 'ra-core'; +import { TestTranslationProvider } from 'ra-core'; import SelectArrayInput from './SelectArrayInput'; @@ -182,16 +182,12 @@ describe('', () => { it('should translate the choices', () => { const { getByRole, queryByText } = render( - `**${x}**`, - }} - > + `**${x}**`}> } /> - + ); getByRole('button').click(); expect(queryByText('**Programming**')).not.toBeNull(); diff --git a/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx b/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx index 287247196fd..253db80d34c 100644 --- a/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { render, cleanup, fireEvent } from '@testing-library/react'; import { Form } from 'react-final-form'; -import { TranslationContext } from 'ra-core'; +import { TestTranslationProvider } from 'ra-core'; import SelectInput from './SelectInput'; import { required } from 'ra-core'; @@ -319,16 +319,12 @@ describe('', () => { it('should translate the choices by default', () => { const { getByRole, getByText, queryAllByRole } = render( - `**${x}**`, - }} - > + `**${x}**`}> } /> - + ); const select = getByRole('button'); fireEvent.click(select); @@ -344,11 +340,7 @@ describe('', () => { it('should not translate the choices if translateChoice is false', () => { const { getByRole, getByText, queryAllByRole } = render( - `**${x}**`, - }} - > + `**${x}**`}> ( @@ -358,7 +350,7 @@ describe('', () => { /> )} /> - + ); const select = getByRole('button'); fireEvent.click(select); From ebffb5a456607c7e2a8233a97213604964aa9e32 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Thu, 12 Sep 2019 18:44:36 +0200 Subject: [PATCH 02/13] Fix bug in Login screen --- packages/ra-ui-materialui/src/auth/Login.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ra-ui-materialui/src/auth/Login.tsx b/packages/ra-ui-materialui/src/auth/Login.tsx index 4d7aa2ade25..7a965b107a3 100644 --- a/packages/ra-ui-materialui/src/auth/Login.tsx +++ b/packages/ra-ui-materialui/src/auth/Login.tsx @@ -92,7 +92,7 @@ const Login: React.FunctionComponent< const checkAuth = useCheckAuth(); const dispatch = useDispatch(); useEffect(() => { - checkAuth() + checkAuth({}, false) .then(() => { // already authenticated, redirect to the home page dispatch(push('/')); From 7e50b3aad74255bc7fcfd36d6675cf995121ceeb Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Thu, 12 Sep 2019 18:44:52 +0200 Subject: [PATCH 03/13] Add ability to test language switch in simple demo --- examples/simple/src/Layout.js | 41 +++++++++++++++++++ examples/simple/src/i18nProvider.js | 5 ++- examples/simple/src/index.js | 2 + packages/ra-core/src/i18n/index.ts | 2 + packages/ra-ui-materialui/src/auth/Logout.tsx | 12 +++--- 5 files changed, 53 insertions(+), 9 deletions(-) create mode 100644 examples/simple/src/Layout.js diff --git a/examples/simple/src/Layout.js b/examples/simple/src/Layout.js new file mode 100644 index 00000000000..b838ceb74ae --- /dev/null +++ b/examples/simple/src/Layout.js @@ -0,0 +1,41 @@ +import React, { forwardRef } from 'react'; +import { Layout, AppBar, UserMenu, useLocale, useSetLocale } from 'react-admin'; +import { makeStyles, MenuItem, ListItemIcon } from '@material-ui/core'; +import { Language } from '@material-ui/icons'; + +const useStyles = makeStyles(theme => ({ + menuItem: { + color: theme.palette.text.secondary, + }, +})); + +const SwitchLanguage = forwardRef((props, ref) => { + const locale = useLocale(); + const setLocale = useSetLocale(); + const classes = useStyles(); + return ( + { + setLocale(locale === 'en' ? 'fr' : 'en'); + props.onClick(); + }} + > + + + + Switch Language + + ); +}); + +const MyUserMenu = props => ( + + + +); + +const MyAppBar = props => } />; + +export default props => ; diff --git a/examples/simple/src/i18nProvider.js b/examples/simple/src/i18nProvider.js index b25ad46244d..d12876121dd 100644 --- a/examples/simple/src/i18nProvider.js +++ b/examples/simple/src/i18nProvider.js @@ -1,14 +1,15 @@ +import { polyglotI18nProvider } from 'react-admin'; import englishMessages from './i18n/en'; const messages = { fr: () => import('./i18n/fr.js').then(messages => messages.default), }; -export default locale => { +export default polyglotI18nProvider(locale => { if (locale === 'fr') { return messages[locale](); } // Always fallback on english return englishMessages; -}; +}); diff --git a/examples/simple/src/index.js b/examples/simple/src/index.js index db301470d42..2ce6cc3ba52 100644 --- a/examples/simple/src/index.js +++ b/examples/simple/src/index.js @@ -11,6 +11,7 @@ import CustomRouteLayout from './customRouteLayout'; import CustomRouteNoLayout from './customRouteNoLayout'; import dataProvider from './dataProvider'; import i18nProvider from './i18nProvider'; +import Layout from './Layout'; import posts from './posts'; import users from './users'; import tags from './tags'; @@ -22,6 +23,7 @@ render( i18nProvider={i18nProvider} title="Example Admin" locale="en" + layout={Layout} customReducers={{ tree }} customRoutes={[ ({ menuItem: { color: theme.palette.text.secondary, }, - iconMenuPaddingStyle: { - paddingRight: '1.2em', - }, iconPaddingStyle: { paddingRight: theme.spacing(1), }, @@ -48,9 +46,9 @@ const LogoutWithRef: FunctionComponent< ref={ref} {...rest} > - + - + {translate('ra.auth.logout')} ); From d979869bef0a08f4fb77d7f275381491e99e8b7e Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Thu, 12 Sep 2019 20:33:34 +0200 Subject: [PATCH 04/13] Migrate demo example to new syntax --- examples/demo/src/App.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/demo/src/App.js b/examples/demo/src/App.js index 84e819a1b63..4579e401660 100644 --- a/examples/demo/src/App.js +++ b/examples/demo/src/App.js @@ -1,5 +1,5 @@ import React, { Component } from 'react'; -import { Admin, Resource } from 'react-admin'; +import { Admin, Resource, polyglotI18nProvider } from 'react-admin'; import './App.css'; @@ -20,14 +20,14 @@ import reviews from './reviews'; import dataProviderFactory from './dataProvider'; import fakeServerFactory from './fakeServer'; -const i18nProvider = locale => { +const i18nProvider = polyglotI18nProvider(locale => { if (locale === 'fr') { return import('./i18n/fr').then(messages => messages.default); } // Always fallback on english return englishMessages; -}; +}); class App extends Component { state = { dataProvider: null }; From 6bf6529348cd9d55cfcc7fdced624c93b911021b Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Thu, 12 Sep 2019 20:47:49 +0200 Subject: [PATCH 05/13] Add Upgrade guide --- UPGRADE.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/UPGRADE.md b/UPGRADE.md index b5c98d50ff2..bceeb3029f0 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1034,6 +1034,54 @@ If you had custom reducer or sagas based on these actions, they will no longer w **Tip**: If you need to clear the Redux state, you can dispatch the `CLEAR_STATE` action. +## i18nProvider Signature Changed + +The i18nProvider, that react-admin uses for translating UI and content, now has a signature similar to the other providers: it accepts a message type (either `I18N_TRANSLATE` or `I18N_CHANGE_LOCALE`) and a params argument. + +```jsx +// react-admin 2.x +const i18nProvider = (locale) => messages[locale]; + +// react-admin 3.x +const i18nProvider = (type, params) => { + const polyglot = new Polyglot({ locale: 'en', phrases: messages.en }); + let translate = polyglot.t.bind(polyglot); + if (type === 'I18N_TRANSLATE') { + return translate(params); + } + if type === 'I18N_CHANGE_LOCALE') { + return new Promise((resolve, reject) => { + // load new messages and update the translate function + }) + } +} +``` + +But don't worry: react-admin v3 exports a function called `polyglotI18nProvider`, that you can just wrap around your old `i18nProvider` to make it compatible with the new provider signature: + +```diff +import React from 'react'; +-import { Admin, Resource } from 'react-admin'; ++import { Admin, Resource, polyglotI18nProvider } from 'react-admin'; +import englishMessages from 'ra-language-english'; +import frenchMessages from 'ra-language-french'; + +const messages = { + fr: frenchMessages, + en: englishMessages, +}; +const i18nProvider = locale => messages[locale]; + +const App = () => ( +- ++ + ... + +); + +export default App; +``` + ## 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. From f444a9d955a5a1e05cdf608947567fcc3fb92b38 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Thu, 12 Sep 2019 21:09:53 +0200 Subject: [PATCH 06/13] Change signature for I18N_TRANSLATE call --- packages/ra-core/src/i18n/TestTranslationProvider.tsx | 8 ++++---- packages/ra-core/src/i18n/TranslationContext.ts | 3 ++- packages/ra-core/src/i18n/polyglotI18nProvider.ts | 6 +++++- packages/ra-core/src/i18n/useTranslate.ts | 2 +- packages/ra-core/src/types.ts | 2 +- 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/ra-core/src/i18n/TestTranslationProvider.tsx b/packages/ra-core/src/i18n/TestTranslationProvider.tsx index 80c036f456f..30da270586b 100644 --- a/packages/ra-core/src/i18n/TestTranslationProvider.tsx +++ b/packages/ra-core/src/i18n/TestTranslationProvider.tsx @@ -8,12 +8,12 @@ export default ({ translate, messages, children }: any) => ( value={{ locale: 'en', setLocale: () => Promise.resolve(), - i18nProvider: (_, options) => + i18nProvider: (_, options: { key: string; options?: Object }) => messages - ? lodashGet(messages, options[0]) - ? lodashGet(messages, options[0]) + ? lodashGet(messages, options.key) + ? lodashGet(messages, options.key) : options[1]._ - : translate.apply(null, options), + : translate.call(null, options.key, options.options), }} > {children} diff --git a/packages/ra-core/src/i18n/TranslationContext.ts b/packages/ra-core/src/i18n/TranslationContext.ts index 3e72342b0b6..98bd924d061 100644 --- a/packages/ra-core/src/i18n/TranslationContext.ts +++ b/packages/ra-core/src/i18n/TranslationContext.ts @@ -10,7 +10,8 @@ export interface TranslationContextProps { const TranslationContext = createContext({ locale: 'en', setLocale: () => Promise.resolve(), - i18nProvider: (type, params) => (Array.isArray(params) ? params[0] : ''), + i18nProvider: (type, params: { key: string; options?: Object }) => + params && params.key ? params.key : '', }); TranslationContext.displayName = 'TranslationContext'; diff --git a/packages/ra-core/src/i18n/polyglotI18nProvider.ts b/packages/ra-core/src/i18n/polyglotI18nProvider.ts index 2147fd747af..5c5cf915786 100644 --- a/packages/ra-core/src/i18n/polyglotI18nProvider.ts +++ b/packages/ra-core/src/i18n/polyglotI18nProvider.ts @@ -39,7 +39,11 @@ export default ( return (type, params) => { if (type === I18N_TRANSLATE) { - return translate.apply(null, params as [string, any?]); + const { key, options = {} } = params as { + key: string; + options?: Object; + }; + return translate.call(null, key, options); } if (type === I18N_CHANGE_LOCALE) { return new Promise(resolve => { diff --git a/packages/ra-core/src/i18n/useTranslate.ts b/packages/ra-core/src/i18n/useTranslate.ts index ebb9dc88fb1..a2fd6cc415a 100644 --- a/packages/ra-core/src/i18n/useTranslate.ts +++ b/packages/ra-core/src/i18n/useTranslate.ts @@ -26,7 +26,7 @@ const useTranslate = (): Translate => { const { i18nProvider, locale } = useContext(TranslationContext); const translate = useCallback( (key: string, options?: any) => - i18nProvider(I18N_TRANSLATE, [key, options]) as string, + i18nProvider(I18N_TRANSLATE, { key, options }) as string, // update the hook each time the locale changes [i18nProvider, locale] // eslint-disable-line react-hooks/exhaustive-deps ); diff --git a/packages/ra-core/src/types.ts b/packages/ra-core/src/types.ts index 9e77875f064..88d5fef4c42 100644 --- a/packages/ra-core/src/types.ts +++ b/packages/ra-core/src/types.ts @@ -30,7 +30,7 @@ export const I18N_CHANGE_LOCALE = 'I18N_CHANGE_LOCALE'; export type I18nProvider = ( type: typeof I18N_TRANSLATE | typeof I18N_CHANGE_LOCALE, - params: [string, any?] | string + params: { key: string; options?: Object } | string ) => string | Promise; export type Translate = (id: string, options?: any) => string; From c9224a193c758d6220bf6f53a0adc0cbe981abde Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Thu, 12 Sep 2019 21:30:35 +0200 Subject: [PATCH 07/13] Move the I18N_LOCALE_CHANGE logic inside the useSetLocale hook --- UPGRADE.md | 4 +- .../src/i18n/TestTranslationProvider.tsx | 4 +- .../ra-core/src/i18n/TranslationProvider.tsx | 42 +++---------------- .../ra-core/src/i18n/useSetLocale.spec.js | 11 ++--- packages/ra-core/src/i18n/useSetLocale.tsx | 33 +++++++++++++-- 5 files changed, 46 insertions(+), 48 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index bceeb3029f0..3a2fca848ec 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1047,9 +1047,11 @@ const i18nProvider = (type, params) => { const polyglot = new Polyglot({ locale: 'en', phrases: messages.en }); let translate = polyglot.t.bind(polyglot); if (type === 'I18N_TRANSLATE') { - return translate(params); + const { key, options } = params; + return translate(key, options); } if type === 'I18N_CHANGE_LOCALE') { + const newLocale = params; return new Promise((resolve, reject) => { // load new messages and update the translate function }) diff --git a/packages/ra-core/src/i18n/TestTranslationProvider.tsx b/packages/ra-core/src/i18n/TestTranslationProvider.tsx index 30da270586b..4aa31f2b935 100644 --- a/packages/ra-core/src/i18n/TestTranslationProvider.tsx +++ b/packages/ra-core/src/i18n/TestTranslationProvider.tsx @@ -8,11 +8,11 @@ export default ({ translate, messages, children }: any) => ( value={{ locale: 'en', setLocale: () => Promise.resolve(), - i18nProvider: (_, options: { key: string; options?: Object }) => + i18nProvider: (_, options: { key: string; options?: any }) => messages ? lodashGet(messages, options.key) ? lodashGet(messages, options.key) - : options[1]._ + : options.options._ : translate.call(null, options.key, options.options), }} > diff --git a/packages/ra-core/src/i18n/TranslationProvider.tsx b/packages/ra-core/src/i18n/TranslationProvider.tsx index 3e1196a5563..862f98ce7a3 100644 --- a/packages/ra-core/src/i18n/TranslationProvider.tsx +++ b/packages/ra-core/src/i18n/TranslationProvider.tsx @@ -1,6 +1,4 @@ import React, { - useEffect, - useRef, useCallback, useMemo, Children, @@ -10,9 +8,7 @@ import React, { import { useSafeSetState } from '../util/hooks'; import { TranslationContext } from './TranslationContext'; -import { useUpdateLoading } from '../loading'; -import { useNotify } from '../sideEffect'; -import { I18N_CHANGE_LOCALE, I18nProvider } from '../types'; +import { I18nProvider } from '../types'; interface Props { locale?: string; @@ -34,8 +30,6 @@ interface Props { */ const TranslationProvider: FunctionComponent = props => { const { i18nProvider, children, locale = 'en' } = props; - const { startLoading, stopLoading } = useUpdateLoading(); - const notify = useNotify(); const [state, setState] = useSafeSetState({ locale, @@ -44,37 +38,13 @@ const TranslationProvider: FunctionComponent = props => { const setLocale = useCallback( (newLocale: string) => - new Promise(resolve => { - startLoading(); - // so we systematically return a Promise for the messages - // i18nProvider may return a Promise for language changes, - resolve(i18nProvider(I18N_CHANGE_LOCALE, newLocale)); - }) - .then(() => { - stopLoading(); - setState({ - locale: newLocale, - i18nProvider, - }); - }) - .catch(error => { - stopLoading(); - notify('ra.notification.i18n_error', 'warning'); - console.error(error); - }), - [i18nProvider, notify, setState, startLoading, stopLoading] + setState({ + locale: newLocale, + i18nProvider, + }), + [i18nProvider, setState] ); - // handle update with different locale - const isInitialMount = useRef(true); - useEffect(() => { - if (isInitialMount.current) { - isInitialMount.current = false; - } else { - setLocale(locale); - } - }, [setLocale, locale]); - // 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( diff --git a/packages/ra-core/src/i18n/useSetLocale.spec.js b/packages/ra-core/src/i18n/useSetLocale.spec.js index 694eeaea3cc..a943e58bb8d 100644 --- a/packages/ra-core/src/i18n/useSetLocale.spec.js +++ b/packages/ra-core/src/i18n/useSetLocale.spec.js @@ -1,6 +1,6 @@ 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'; @@ -11,7 +11,7 @@ import { } from './'; import { renderWithRedux } from '../util'; -describe('useTranslate', () => { +describe('useSetLocale', () => { afterEach(cleanup); const Component = () => { @@ -26,13 +26,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', () => { + it('should use the setLocale function set in the translation context', async () => { const setLocale = jest.fn(); - const { getByText } = render( + const { getByText } = renderWithRedux( '', @@ -44,6 +44,7 @@ describe('useTranslate', () => { ); fireEvent.click(getByText('Français')); + await wait(); expect(setLocale).toHaveBeenCalledTimes(1); }); diff --git a/packages/ra-core/src/i18n/useSetLocale.tsx b/packages/ra-core/src/i18n/useSetLocale.tsx index e7babf2d7d3..83f9254b496 100644 --- a/packages/ra-core/src/i18n/useSetLocale.tsx +++ b/packages/ra-core/src/i18n/useSetLocale.tsx @@ -1,6 +1,9 @@ -import { useContext } from 'react'; +import { useContext, useCallback } from 'react'; import { TranslationContext } from './TranslationContext'; +import { I18N_CHANGE_LOCALE } from '../types'; +import { useUpdateLoading } from '../loading'; +import { useNotify } from '../sideEffect'; /** * Set the current locale using the TranslationContext @@ -30,9 +33,31 @@ import { TranslationContext } from './TranslationContext'; * ); * } */ -const useSetLocale = () => { - const { setLocale } = useContext(TranslationContext); - return setLocale; +const useSetLocale = (): SetLocale => { + const { setLocale, i18nProvider } = useContext(TranslationContext); + const { startLoading, stopLoading } = useUpdateLoading(); + const notify = useNotify(); + return useCallback( + (newLocale: string) => + new Promise(resolve => { + startLoading(); + // so we systematically return a Promise for the messages + // i18nProvider may return a Promise for language changes, + resolve(i18nProvider(I18N_CHANGE_LOCALE, newLocale)); + }) + .then(() => { + stopLoading(); + setLocale(newLocale); + }) + .catch(error => { + stopLoading(); + notify('ra.notification.i18n_error', 'warning'); + console.error(error); + }), + [i18nProvider, notify, setLocale, startLoading, stopLoading] + ); }; +type SetLocale = (locale: String) => Promise; + export default useSetLocale; From 42c8c9b9e578b7a86e822a697e33b5cd947c181c Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Thu, 12 Sep 2019 21:47:17 +0200 Subject: [PATCH 08/13] Update translation documentation --- docs/Translation.md | 132 ++++++++++++++++++++++++++++++-------------- 1 file changed, 92 insertions(+), 40 deletions(-) diff --git a/docs/Translation.md b/docs/Translation.md index 12a8ee010bd..74403e876fb 100644 --- a/docs/Translation.md +++ b/docs/Translation.md @@ -7,7 +7,7 @@ title: "Translation" 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. -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`). +You will use translation features mostly via the `i18nProvider`, and a set of hooks (`useTranslate`, `useLocale`, `useSetLocale`). **Tip**: We'll use a bit of custom vocabulary in this chapter: @@ -19,49 +19,94 @@ The react-admin translation layer is based on [polyglot.js](http://airbnb.io/pol 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; +const i18nProvider = (type, params) => string | Promise; ``` -Given a locale, The `i18nProvider` function should return a dictionary of terms. For instance: +The `i18nProvider` expects two possible `type` arguments: `I18N_TRANSLATE` and `I18N_CHANGE_LOCALE`. Here is the simplest possible implementation for a French and English provider: ```jsx -const i18nProvider = locale => { - if (locale === 'en') { - return { - ra: { - notification: { - http_error: 'Network error. Please retry', - }, - action: { - save: 'Save', - delete: 'Delete', - }, - }, - }; +import { I18N_TRANSLATE, I18N_CHANGE_LOCALE } from 'react-admin'; +import lodashGet from 'lodash/get'; + +const englishMessages = { + ra: { + notification: { + http_error: 'Network error. Please retry', + }, + action: { + save: 'Save', + delete: 'Delete', + }, + }, +}; +const frenchMessages = { + ra: { + notification: { + http_error: 'Erreur réseau, veuillez réessayer', + }, + action: { + save: 'Enregistrer', + delete: 'Supprimer', + }, + }, +}; + +const i18nProvider = (type, params) => { + let messages = englishMessages; + if (type === I18N_TRANSLATE) { + const { key } = params; + return lodashGet(messages, key) } - if (locale === 'fr') { - return { - ra: { - notification: { - http_error: 'Erreur réseau, veuillez réessayer', - }, - action: { - save: 'Enregistrer', - delete: 'Supprimer', - }, - }, - }; + if (type === I18N_CHANGE_LOCALE) { + const newLocale = params; + messages = (newLocale === 'fr') ? frenchMessages : englishMessages; + return Promise.resolve(); } }; ``` +But this is too naive: react-admin expects that i18nProviders support string interpolation for translation, and asynchronous message loading for locale change. That's why react-admin bundles an `i18nProvider` *factory* called `polyglotI18nProvider`. This factory relies on [polyglot.js](http://airbnb.io/polyglot.js/), which uses JSON files for translations. It only expects one argument: a function returning a list of messages based on a locale passed as argument. + +So the previous provider can be written as: + +```jsx +import { polyglotI18nProvider } from 'react-admin'; + +const englishMessages = { + ra: { + notification: { + http_error: 'Network error. Please retry', + }, + action: { + save: 'Save', + delete: 'Delete', + }, + }, +}; +const frenchMessages = { + ra: { + notification: { + http_error: 'Erreur réseau, veuillez réessayer', + }, + action: { + save: 'Enregistrer', + delete: 'Supprimer', + }, + }, +}; + +const i18nProvider = polyglotI18nProvider(locale => + locale === 'fr' ? frenchMessages : englishMessages +); +``` + If you want to add or update tranlations, you'll have to provide your own `i18nProvider`. React-admin components use translation keys for their labels, and rely on the `i18nProvider` to translate them. For instance: ```jsx const SaveButton = ({ doSave }) => { - const translate = useTranslate(); + const translate = useTranslate(); // calls the i18nProvider with the I18N_TRANSLATE type return (