diff --git a/docs/Fields.md b/docs/Fields.md index fb9045d74be..c249f36a816 100644 --- a/docs/Fields.md +++ b/docs/Fields.md @@ -1106,6 +1106,139 @@ export const PostShow = (props) => ( ); ``` +## Translatable Fields + +You may have fields which are translated in multiple languages and want users to verify each translation. To display them, you can use the `` component, which expects the translatable values to have the following structure: + +```js +{ + name: { + en: 'The english value', + fr: 'The french value', + tlh: 'The klingon value', + }, + description: { + en: 'The english value', + fr: 'The french value', + tlh: 'The klingon value', + } +} +``` + +This is how to use it: + +```jsx + + + + +``` + +React-admin uses the user locale as the default locale in this field. You can override this setting using the `defaultLocale` prop. + +```jsx + + + + +``` + +By default, `` will allow users to select the displayed locale using Material-ui tabs with the locale code as their labels. + +You may override the tabs labels using translation keys following this format: `ra.locales.[locale_code]`. For instance, `ra.locales.en` or `ra.locales.fr`. + +You may override the language selector using the `selector` prop, which accepts a React element: + +```jsx +const Selector = () => { + const { + locales, + selectLocale, + selectedLocale, + } = useTranslatableContext(); + + const handleChange = (event): void => { + selectLocale(event.target.value); + }; + + return ( + + ); +}; + +} +> + + + +``` + +If you have multiple `TranslatableFields` on the same page, you should specify a `groupKey` so that react-admin can create unique identifiers for accessibility. + +```jsx + + + + +``` + +### Using Translatable Fields In List or Show views + +The `TranslatableFields` component is not meant to be used inside a `List` as you probably don't want to have tabs inside multiple lines. The simple solution to display a translatable value would be to specify its source like this: `name.en`. However, you may want to display its translation for the current admin locale. + +In this case, you'll have to get the current locale through the `useLocale` hook and set the translatable field `source` dynamically. + +{% raw %} +```jsx +const PostList = (props) => { + const locale = useLocale(); + + return ( + + + + + + + + + + + ) +} +``` +{% endraw %} + +Note that you can't have an [optimized](https://marmelab.com/react-admin/List.html#performance) Datagrid when doing so, as changing the locale wouldn't trigger a render of its children. + +The same pattern applies to show views when you don't want to display all translations: get the locale from the `useLocale` hook and dynamically set the `source` prop of the translatable fields. + ## Recipes ### Styling Fields diff --git a/docs/Inputs.md b/docs/Inputs.md index a9c8daa38ef..d402964effa 100644 --- a/docs/Inputs.md +++ b/docs/Inputs.md @@ -1673,6 +1673,111 @@ export default ArtistEdit; Check [the `ra-relationships` documentation](https://marmelab.com/ra-enterprise/modules/ra-relationships#referencemanytomanyinput) for more details. +## Translatable Inputs + +You may have inputs which are translated in multiple languages and want users to edit translations for each language separately. To display them, you can use the `` component, which expects the translatable values to have the following structure: + +```js +{ + name: { + en: 'The english value', + fr: 'The french value', + tlh: 'The klingon value', + }, + description: { + en: 'The english value', + fr: 'The french value', + tlh: 'The klingon value', + } +} +``` + +This is how to use it: + +```jsx + + + + +``` + +React-admin uses the user locale as the default locale in this field. You can override this setting using the `defaultLocale` prop. + +```jsx + + + + +``` + +By default, `` will allow users to select the displayed locale using Material-ui tabs with the locale code as their labels. + +You may override the tabs labels using translation keys following this format: `ra.locales.[locale_code]`. For instance, `ra.locales.en` or `ra.locales.fr`. + +You may override the language selector using the `selector` prop, which accepts a React element: + +```jsx +const Selector = () => { + const { + locales, + selectLocale, + selectedLocale, + } = useTranslatableContext(); + + const handleChange = (event): void => { + selectLocale(event.target.value); + }; + + return ( + + ); +}; + +} +> + + + +``` + +If you have multiple `TranslatableInputs` on the same page, you should specify a `groupKey` so that react-admin can create unique identifiers for accessibility. + +```jsx + + + + +``` + +You can add validators to any of the inputs inside a `TranslatableInputs`. If an input has some validation error, the label of its parent tab will be highlighted as invalid: + +```jsx + + + + +``` + ## Recipes ### Transforming Input Value to/from Record diff --git a/docs/Translation.md b/docs/Translation.md index b2fa0d0901a..189e7154e0a 100644 --- a/docs/Translation.md +++ b/docs/Translation.md @@ -697,3 +697,27 @@ const i18nProvider = polyglotI18nProvider(locale => **Tip**: Check [the Polyglot documentation](https://airbnb.io/polyglot.js/#options-overview) for a list of options you can pass to Polyglot at startup. This solution is all-or-nothing: you can't silence only *some* missing translation warnings. An alternative solution consists of passing a default translation using the `_` translation option, as explained in the [Using Specific Polyglot Features section](#using-specific-polyglot-features) above. + +## Translating Record Fields + +Some of your records may contain fields that are translated in multiple languages. It's common, in such cases, to offer an interface allowing admin users to see and edit each translation. React-admin provides 2 components for that: + +- To display translatable fields, use the [``](/Fields.html#translatable-fields) component +- To edit translatable fields, use the [``](/Inputs.html#translatable-inputs) component + +They both expect the translatable values to have the following structure: + +```js +{ + name: { + en: 'The english value', + fr: 'The french value', + tlh: 'The klingon value', + }, + description: { + en: 'The english value', + fr: 'The french value', + tlh: 'The klingon value', + } +} +``` diff --git a/examples/simple/src/data.js b/examples/simple/src/data.js index a11ce43ffab..2ffb0813837 100644 --- a/examples/simple/src/data.js +++ b/examples/simple/src/data.js @@ -380,110 +380,110 @@ export default { tags: [ { id: 1, - name: 'Sport', + name: { en: 'Sport' }, published: 1, }, { id: 2, - name: 'Technology', + name: { en: 'Technology' }, published: false, }, { id: 3, - name: 'Code', + name: { en: 'Code' }, published: true, }, { id: 4, - name: 'Photo', + name: { en: 'Photo' }, published: false, }, { id: 5, - name: 'Music', + name: { en: 'Music' }, published: 1, }, { id: 6, - name: 'Parkour', + name: { en: 'Parkour' }, published: 1, parent_id: 1, }, { id: 7, - name: 'Crossfit', + name: { en: 'Crossfit' }, published: 1, parent_id: 1, }, { id: 8, - name: 'Computing', + name: { en: 'Computing' }, published: 1, parent_id: 2, }, { id: 9, - name: 'Nanoscience', + name: { en: 'Nanoscience' }, published: 1, parent_id: 2, }, { id: 10, - name: 'Blockchain', + name: { en: 'Blockchain' }, published: 1, parent_id: 2, }, { id: 11, - name: 'Node', + name: { en: 'Node' }, published: 1, parent_id: 3, }, { id: 12, - name: 'React', + name: { en: 'React' }, published: 1, parent_id: 3, }, { id: 13, - name: 'Nature', + name: { en: 'Nature' }, published: 1, parent_id: 4, }, { id: 14, - name: 'People', + name: { en: 'People' }, published: 1, parent_id: 4, }, { id: 15, - name: 'Animals', + name: { en: 'Animals' }, published: 1, parent_id: 13, }, { id: 16, - name: 'Moutains', + name: { en: 'Moutains' }, published: 1, parent_id: 13, }, { id: 17, - name: 'Rap', + name: { en: 'Rap' }, published: 1, parent_id: 5, }, { id: 18, - name: 'Rock', + name: { en: 'Rock' }, published: 1, parent_id: 5, }, { id: 19, - name: 'World', + name: { en: 'World' }, published: 1, parent_id: 5, }, diff --git a/examples/simple/src/posts/PostList.js b/examples/simple/src/posts/PostList.js index 26cff684828..1d387f60758 100644 --- a/examples/simple/src/posts/PostList.js +++ b/examples/simple/src/posts/PostList.js @@ -165,7 +165,7 @@ const PostList = props => { headerClassName={classes.hiddenOnSmallScreens} > - + @@ -178,6 +178,6 @@ const PostList = props => { ); }; -const tagSort = { field: 'name', order: 'ASC' }; +const tagSort = { field: 'name.en', order: 'ASC' }; export default PostList; diff --git a/examples/simple/src/posts/PostShow.js b/examples/simple/src/posts/PostShow.js index 7048942f388..756715bd76f 100644 --- a/examples/simple/src/posts/PostShow.js +++ b/examples/simple/src/posts/PostShow.js @@ -20,6 +20,7 @@ import { TextField, UrlField, useShowController, + useLocale, } from 'react-admin'; import { Link } from 'react-router-dom'; import Button from '@material-ui/core/Button'; @@ -39,6 +40,7 @@ const CreateRelatedComment = ({ record }) => ( const PostShow = props => { const controllerProps = useShowController(props); + const locale = useLocale(); return ( }> @@ -70,10 +72,10 @@ const PostShow = props => { - + diff --git a/examples/simple/src/posts/TagReferenceInput.js b/examples/simple/src/posts/TagReferenceInput.js index 6e787893033..7f473d0f28b 100644 --- a/examples/simple/src/posts/TagReferenceInput.js +++ b/examples/simple/src/posts/TagReferenceInput.js @@ -31,7 +31,7 @@ const TagReferenceInput = ({ ...props }) => { return (
- + + + )} + /> + ); + + fireEvent.change(queryByDisplayValue('english name'), { + target: { value: 'english name updated' }, + }); + fireEvent.click(getByText('ra.locales.fr')); + fireEvent.change(queryByDisplayValue('french nested field'), { + target: { value: 'french nested field updated' }, + }); + fireEvent.click(getByText('save')); + + expect(save).toHaveBeenCalledWith( + { + id: 123, + name: { + en: 'english name updated', + fr: 'french name', + }, + description: { + en: 'english description', + fr: 'french description', + }, + nested: { + field: { + en: 'english nested field', + fr: 'french nested field updated', + }, + }, + }, + undefined + ); + }); + + it('should allow to customize the locale selector', () => { + const Selector = () => { + const { + locales, + selectLocale, + selectedLocale, + } = useTranslatableContext(); + + const handleChange = (event): void => { + selectLocale(event.target.value); + }; + + return ( + + ); + }; + + const { getByLabelText, queryByDisplayValue } = renderWithRedux( + ( +
+ } + > + + + + + +
+ )} + /> + ); + + expect(getByLabelText('en').getAttribute('hidden')).toBeNull(); + expect(getByLabelText('fr').getAttribute('hidden')).not.toBeNull(); + + expect(queryByDisplayValue('english name')).not.toBeNull(); + expect(queryByDisplayValue('english description')).not.toBeNull(); + expect(queryByDisplayValue('english nested field')).not.toBeNull(); + + expect(queryByDisplayValue('french name')).not.toBeNull(); + expect(queryByDisplayValue('french description')).not.toBeNull(); + expect(queryByDisplayValue('french nested field')).not.toBeNull(); + + fireEvent.change(getByLabelText('select locale'), { + target: { value: 'fr' }, + }); + expect(getByLabelText('en').getAttribute('hidden')).not.toBeNull(); + expect(getByLabelText('fr').getAttribute('hidden')).toBeNull(); + }); +}); diff --git a/packages/ra-ui-materialui/src/input/TranslatableInputs.tsx b/packages/ra-ui-materialui/src/input/TranslatableInputs.tsx new file mode 100644 index 00000000000..53a25df3016 --- /dev/null +++ b/packages/ra-ui-materialui/src/input/TranslatableInputs.tsx @@ -0,0 +1,110 @@ +import * as React from 'react'; +import { ReactElement, ReactNode } from 'react'; +import { + TranslatableContextProvider, + useTranslatable, + UseTranslatableOptions, +} from 'ra-core'; +import { TranslatableInputsTabs } from './TranslatableInputsTabs'; +import { TranslatableInputsTabContent } from './TranslatableInputsTabContent'; +import { makeStyles } from '@material-ui/core/styles'; + +/** + * Provides a way to edit multiple languages for any input passed as children. + * It expects the translatable values to have the following structure: + * { + * name: { + * en: 'The english value', + * fr: 'The french value', + * tlh: 'The klingon value', + * }, + * description: { + * en: 'The english value', + * fr: 'The french value', + * tlh: 'The klingon value', + * } + * } + * + * @example Basic usage + * + * + * + * + * + * @example With a custom language selector + * } + * locales={['en', 'fr']} + * > + * + * + * + * const MyLanguageSelector = () => { + * const { + * locales, + * selectedLocale, + * selectLocale, + * } = useTranslatableContext(); + * + * return ( + * + * ); + * } + * + * * @param props The component props + * * @param {string} props.defaultLocale The locale selected by default. Default to 'en'. + * * @param {string[]} props.locales An array of the possible locales. For example: `['en', 'fr']. + * * @param {ReactElement} props.selector The element responsible for selecting a locale. Defaults to Material UI tabs. + */ +export const TranslatableInputs = (props: TranslatableProps): ReactElement => { + const { + defaultLocale, + locales, + groupKey = '', + selector = , + children, + } = props; + const context = useTranslatable({ defaultLocale, locales }); + const classes = useStyles(props); + + return ( +
+ + {selector} + {locales.map(locale => ( + + {children} + + ))} + +
+ ); +}; + +export interface TranslatableProps extends UseTranslatableOptions { + selector?: ReactElement; + children: ReactNode; + groupKey?: string; +} + +const useStyles = makeStyles( + theme => ({ + root: { + flexGrow: 1, + backgroundColor: theme.palette.background.default, + }, + }), + { + name: 'RaTranslatableInputs', + } +); diff --git a/packages/ra-ui-materialui/src/input/TranslatableInputsTab.tsx b/packages/ra-ui-materialui/src/input/TranslatableInputsTab.tsx new file mode 100644 index 00000000000..3cf297669b0 --- /dev/null +++ b/packages/ra-ui-materialui/src/input/TranslatableInputsTab.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import Tab, { TabProps } from '@material-ui/core/Tab'; +import { useFormGroup, useTranslate } from 'ra-core'; +import { makeStyles } from '@material-ui/core/styles'; +import { ClassesOverride } from '../types'; +import { capitalize } from 'inflection'; + +/** + * Single tab that selects a locale in a TranslatableInputs component. + * @see TranslatableInputs + */ +export const TranslatableInputsTab = ( + props: TranslatableInputsTabProps & TabProps +) => { + const { groupKey = '', locale, classes: classesOverride, ...rest } = props; + const { invalid } = useFormGroup(`${groupKey}${locale}`); + const classes = useStyles(props); + const translate = useTranslate(); + + return ( + + ); +}; + +const useStyles = makeStyles( + theme => ({ + error: { color: theme.palette.error.main }, + }), + { + name: 'RaTranslatableInputsTab', + } +); + +interface TranslatableInputsTabProps { + classes?: ClassesOverride; + groupKey?: string; + locale: string; +} diff --git a/packages/ra-ui-materialui/src/input/TranslatableInputsTabContent.tsx b/packages/ra-ui-materialui/src/input/TranslatableInputsTabContent.tsx new file mode 100644 index 00000000000..7c384a845a0 --- /dev/null +++ b/packages/ra-ui-materialui/src/input/TranslatableInputsTabContent.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; +import { + Children, + cloneElement, + isValidElement, + ReactElement, + ReactNode, +} from 'react'; +import { FormGroupContextProvider, useTranslatableContext } from 'ra-core'; +import { makeStyles } from '@material-ui/core/styles'; +import { ClassesOverride } from '../types'; + +/** + * Default container for a group of translatable inputs inside a TranslatableInputs component. + * @see TranslatableInputs + */ +export const TranslatableInputsTabContent = ( + props: TranslatableInputsTabContentProps +): ReactElement => { + const { children, groupKey = '', locale, ...other } = props; + const { selectedLocale, getLabel, getSource } = useTranslatableContext(); + const classes = useStyles(props); + + return ( + + + + ); +}; + +export type TranslatableInputsTabContentProps = { + children: ReactNode; + classes?: ClassesOverride; + groupKey?: string; + locale: string; +}; + +const useStyles = makeStyles( + theme => ({ + root: { + flexGrow: 1, + padding: theme.spacing(1), + backgroundColor: theme.palette.background.default, + }, + }), + { + name: 'RaTranslatableInputsTabContent', + } +); diff --git a/packages/ra-ui-materialui/src/input/TranslatableInputsTabs.tsx b/packages/ra-ui-materialui/src/input/TranslatableInputsTabs.tsx new file mode 100644 index 00000000000..64ac9658c5b --- /dev/null +++ b/packages/ra-ui-materialui/src/input/TranslatableInputsTabs.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { ReactElement } from 'react'; +import AppBar from '@material-ui/core/AppBar'; +import Tabs, { TabsProps } from '@material-ui/core/Tabs'; +import { useTranslatableContext } from 'ra-core'; +import { TranslatableInputsTab } from './TranslatableInputsTab'; +import { AppBarProps } from '../layout'; + +/** + * Default locale selector for the TranslatableInputs component. Generates a tab for each specified locale. + * @see TranslatableInputs + */ +export const TranslatableInputsTabs = ({ + groupKey, + TabsProps: tabsProps, +}: TranslatableInputsTabsProps & AppBarProps): ReactElement => { + const { locales, selectLocale, selectedLocale } = useTranslatableContext(); + + const handleChange = (event, newLocale): void => { + selectLocale(newLocale); + }; + + return ( + + + {locales.map(locale => ( + + ))} + + + ); +}; + +export interface TranslatableInputsTabsProps { + groupKey?: string; + TabsProps?: TabsProps; +} diff --git a/packages/ra-ui-materialui/src/input/index.ts b/packages/ra-ui-materialui/src/input/index.ts index e9bd5a6a53a..cfd161068b8 100644 --- a/packages/ra-ui-materialui/src/input/index.ts +++ b/packages/ra-ui-materialui/src/input/index.ts @@ -22,6 +22,10 @@ import SelectArrayInput from './SelectArrayInput'; import SelectInput from './SelectInput'; import TextInput from './TextInput'; import sanitizeInputRestProps from './sanitizeInputRestProps'; +export * from './TranslatableInputs'; +export * from './TranslatableInputsTabContent'; +export * from './TranslatableInputsTabs'; +export * from './TranslatableInputsTab'; export { ArrayInput,