From 36cfdbab3462291376c945ecce645e3193db16e7 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Mon, 25 Sep 2023 18:17:38 +0200 Subject: [PATCH 01/18] Introduce ra-i18n-i18next --- examples/simple/package.json | 4 ++ examples/simple/src/i18nProvider-next.tsx | 13 ++++ examples/simple/src/index.tsx | 4 +- packages/ra-i18n-i18next/README.md | 13 ++++ packages/ra-i18n-i18next/package.json | 37 ++++++++++ packages/ra-i18n-i18next/src/index.ts | 88 +++++++++++++++++++++++ packages/ra-i18n-i18next/tsconfig.json | 10 +++ yarn.lock | 84 ++++++++++++++++++++++ 8 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 examples/simple/src/i18nProvider-next.tsx create mode 100644 packages/ra-i18n-i18next/README.md create mode 100644 packages/ra-i18n-i18next/package.json create mode 100644 packages/ra-i18n-i18next/src/index.ts create mode 100644 packages/ra-i18n-i18next/tsconfig.json diff --git a/examples/simple/package.json b/examples/simple/package.json index e14ea88172c..d95a873b3fc 100644 --- a/examples/simple/package.json +++ b/examples/simple/package.json @@ -13,11 +13,14 @@ "@emotion/styled": "^11.6.0", "@mui/icons-material": "^5.0.1", "@mui/material": "^5.0.2", + "i18next": "^23.5.1", + "i18next-resources-to-backend": "^1.1.4", "jsonexport": "^3.2.0", "lodash": "~4.17.5", "prop-types": "^15.7.2", "proxy-polyfill": "^0.3.0", "ra-data-fakerest": "^4.13.4", + "ra-i18n-i18next": "^4.13.4", "ra-i18n-polyglot": "^4.13.4", "ra-input-rich-text": "^4.13.4", "ra-language-english": "^4.13.4", @@ -26,6 +29,7 @@ "react-admin": "^4.13.4", "react-dom": "^17.0.0", "react-hook-form": "^7.43.9", + "react-i18next": "^13.2.2", "react-query": "^3.32.1", "react-router": "^6.1.0", "react-router-dom": "^6.1.0" diff --git a/examples/simple/src/i18nProvider-next.tsx b/examples/simple/src/i18nProvider-next.tsx new file mode 100644 index 00000000000..e3fb67ca894 --- /dev/null +++ b/examples/simple/src/i18nProvider-next.tsx @@ -0,0 +1,13 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import i18nextProvider, { convertRaMessagesToI18next } from 'ra-i18n-i18next'; +import englishMessages from './i18n/en'; + +const instance = i18n.use(initReactI18next); + +// TODO: remove +export default i18nextProvider(instance, { + resources: { + en: { translation: convertRaMessagesToI18next(englishMessages) }, + }, +}); diff --git a/examples/simple/src/index.tsx b/examples/simple/src/index.tsx index 800767145e2..7d758a710d0 100644 --- a/examples/simple/src/index.tsx +++ b/examples/simple/src/index.tsx @@ -9,7 +9,9 @@ import comments from './comments'; import CustomRouteLayout from './customRouteLayout'; import CustomRouteNoLayout from './customRouteNoLayout'; import dataProvider from './dataProvider'; -import i18nProvider from './i18nProvider'; +// TODO: revert +// import i18nProvider from './i18nProvider'; +import i18nProvider from './i18nProvider-next'; import Layout from './Layout'; import posts from './posts'; import users from './users'; diff --git a/packages/ra-i18n-i18next/README.md b/packages/ra-i18n-i18next/README.md new file mode 100644 index 00000000000..1b008eb2b47 --- /dev/null +++ b/packages/ra-i18n-i18next/README.md @@ -0,0 +1,13 @@ +# Polyglot i18n provider for react-admin + +Polyglot i18n provider for [react-admin](https://github.com/marmelab/react-admin), the frontend framework for building admin applications on top of REST/GraphQL services. It relies on [i18next](https://www.i18next.com/). + +## Installation + +```sh +npm install --save ra-i18n-i18next +``` + +## Usage + +TODO diff --git a/packages/ra-i18n-i18next/package.json b/packages/ra-i18n-i18next/package.json new file mode 100644 index 00000000000..5dd3870430e --- /dev/null +++ b/packages/ra-i18n-i18next/package.json @@ -0,0 +1,37 @@ +{ + "name": "ra-i18n-i18next", + "version": "4.13.4", + "description": "i18next i18n provider for react-admin", + "main": "dist/cjs/index.js", + "module": "dist/esm/index.js", + "types": "dist/cjs/index.d.ts", + "sideEffects": false, + "files": [ + "*.md", + "dist", + "src" + ], + "authors": [ + "François Zaninotto" + ], + "repository": "marmelab/react-admin", + "homepage": "https://github.com/marmelab/react-admin#readme", + "bugs": "https://github.com/marmelab/react-admin/issues", + "license": "MIT", + "scripts": { + "build": "yarn run build-cjs && yarn run build-esm", + "build-cjs": "rimraf ./dist/cjs && tsc --outDir dist/cjs", + "build-esm": "rimraf ./dist/esm && tsc --outDir dist/esm --module es2015", + "watch": "tsc --outDir dist/esm --module es2015 --watch" + }, + "dependencies": { + "i18next": "^23.5.1", + "ra-core": "^4.13.4" + }, + "devDependencies": { + "cross-env": "^5.2.0", + "rimraf": "^3.0.2", + "typescript": "^5.1.3" + }, + "gitHead": "e936ff2c3f887d2e98ef136cf3b3f3d254725fc4" +} diff --git a/packages/ra-i18n-i18next/src/index.ts b/packages/ra-i18n-i18next/src/index.ts new file mode 100644 index 00000000000..ded9937fd5e --- /dev/null +++ b/packages/ra-i18n-i18next/src/index.ts @@ -0,0 +1,88 @@ +import { InitOptions, i18n as I18n, TFunction } from 'i18next'; +import clone from 'lodash/clone'; +import { I18nProvider } from 'ra-core'; +/** + * Build a i18next-based i18nProvider. + * + * @example + * TODO + */ +export default (i18nInstance: I18n, options?: InitOptions): I18nProvider => { + let translate: TFunction; + + i18nInstance + .init({ + lng: 'en', + fallbackLng: 'en', + react: { useSuspense: false }, + ...options, + }) + .then(t => { + translate = t; + }); + return { + translate: (key: string, options: any = {}) => { + const { _: defaultValue, ...otherOptions } = options || {}; + return translate(key, { defaultValue, ...otherOptions }).toString(); + }, + changeLocale: async (newLocale: string) => { + await i18nInstance.changeLanguage(newLocale); + }, + getLocale: () => i18nInstance.language, + getLocales: () => { + return i18nInstance.languages + ? i18nInstance.languages.map(l => ({ locale: l, name: l })) + : undefined; + }, + }; +}; + +export const convertRaMessagesToI18next = ( + raMessages, + { prefix = '{{', suffix = '}}' } = {} +) => { + return Object.keys(raMessages).reduce((acc, key) => { + if (typeof acc[key] === 'object') { + acc[key] = convertRaMessagesToI18next(acc[key]); + return acc; + } + + const message = acc[key] as string; + + if (message.indexOf(' |||| ') > -1) { + const pluralVariants = message.split(' |||| '); + + if ( + pluralVariants.length > 2 && + process.env.NODE_ENV === 'development' + ) { + console.warn( + 'A message contains more than two plural forms so we can not convert it to i18next format automatically. You should provide your own translations for this language.' + ); + } + acc[`${key}_one`] = convertMessage(pluralVariants[0], { + prefix, + suffix, + }); + acc[`${key}_other`] = convertMessage(pluralVariants[1], { + prefix, + suffix, + }); + delete acc[key]; + } else { + acc[key] = convertMessage(message, { prefix, suffix }); + } + + return acc; + }, clone(raMessages)); +}; + +const convertMessage = ( + message: string, + { prefix = '{{', suffix = '}}' } = {} +) => { + return message.replace( + /%\{([a-zA-Z0-9-_]*)\}/g, + (match, p1) => `${prefix}${p1}${suffix}` + ); +}; diff --git a/packages/ra-i18n-i18next/tsconfig.json b/packages/ra-i18n-i18next/tsconfig.json new file mode 100644 index 00000000000..a18665f388c --- /dev/null +++ b/packages/ra-i18n-i18next/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src", + "allowJs": false + }, + "exclude": ["**/*.spec.ts", "**/*.spec.tsx", "**/*.spec.js"], + "include": ["src"] +} diff --git a/yarn.lock b/yarn.lock index 2fdc52a7b76..288c5e5b161 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1816,6 +1816,15 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.21.5": + version: 7.23.1 + resolution: "@babel/runtime@npm:7.23.1" + dependencies: + regenerator-runtime: ^0.14.0 + checksum: e57ab1436d4845efe67c3f76d578508bb584173690ecfeac105bc4e09d64b2aa6a53c1e03bca3c97cc238e5390a804e5a4ded211e6350243b735905ca45a4822 + languageName: node + linkType: hard + "@babel/template@npm:^7.16.7, @babel/template@npm:^7.22.5": version: 7.22.5 resolution: "@babel/template@npm:7.22.5" @@ -12832,6 +12841,15 @@ __metadata: languageName: node linkType: hard +"html-parse-stringify@npm:^3.0.1": + version: 3.0.1 + resolution: "html-parse-stringify@npm:3.0.1" + dependencies: + void-elements: 3.1.0 + checksum: 159292753d48b84d216d61121054ae5a33466b3db5b446e2ffc093ac077a411a99ce6cbe0d18e55b87cf25fa3c5a86c4d8b130b9719ec9b66623259000c72c15 + languageName: node + linkType: hard + "html-tags@npm:^3.1.0": version: 3.3.1 resolution: "html-tags@npm:3.3.1" @@ -13036,6 +13054,24 @@ __metadata: languageName: node linkType: hard +"i18next-resources-to-backend@npm:^1.1.4": + version: 1.1.4 + resolution: "i18next-resources-to-backend@npm:1.1.4" + dependencies: + "@babel/runtime": ^7.21.5 + checksum: 221a22d08eccdd946c12c11de1910c70d8bf61b9834f17b72ddad24ea304264a12ea50953a740e5fa1f56d32a2290dcef6f7eb699fd30984f7e8676944e41aed + languageName: node + linkType: hard + +"i18next@npm:^23.5.1": + version: 23.5.1 + resolution: "i18next@npm:23.5.1" + dependencies: + "@babel/runtime": ^7.22.5 + checksum: af49c399a90505ae26c1a022d06c4a11c4adcde6524b31c315dcaa43443c85892adef6de934b2af737abbdd2ffa66449d2854f135af8691223a8bb4ffaf6e1af + languageName: node + linkType: hard + "iconv-lite@npm:0.4.24, iconv-lite@npm:^0.4.24": version: 0.4.24 resolution: "iconv-lite@npm:0.4.24" @@ -18361,6 +18397,18 @@ __metadata: languageName: unknown linkType: soft +"ra-i18n-i18next@^4.13.4, ra-i18n-i18next@workspace:packages/ra-i18n-i18next": + version: 0.0.0-use.local + resolution: "ra-i18n-i18next@workspace:packages/ra-i18n-i18next" + dependencies: + cross-env: ^5.2.0 + i18next: ^23.5.1 + ra-core: ^4.13.4 + rimraf: ^3.0.2 + typescript: ^5.1.3 + languageName: unknown + linkType: soft + "ra-i18n-polyglot@^4.12.0, ra-i18n-polyglot@^4.13.4, ra-i18n-polyglot@workspace:packages/ra-i18n-polyglot": version: 0.0.0-use.local resolution: "ra-i18n-polyglot@workspace:packages/ra-i18n-polyglot" @@ -18852,6 +18900,24 @@ __metadata: languageName: node linkType: hard +"react-i18next@npm:^13.2.2": + version: 13.2.2 + resolution: "react-i18next@npm:13.2.2" + dependencies: + "@babel/runtime": ^7.22.5 + html-parse-stringify: ^3.0.1 + peerDependencies: + i18next: ">= 23.2.3" + react: ">= 16.8.0" + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + checksum: 846e73130414989304c395c247cec8371ec25b5a0284588c913542741ce0cc33b4681d573980c64320138601d08a2ddec5042dc6f841e48a575e3f11ab83a368 + languageName: node + linkType: hard + "react-inspector@npm:^6.0.0": version: 6.0.1 resolution: "react-inspector@npm:6.0.1" @@ -19426,6 +19492,13 @@ __metadata: languageName: node linkType: hard +"regenerator-runtime@npm:^0.14.0": + version: 0.14.0 + resolution: "regenerator-runtime@npm:0.14.0" + checksum: e25f062c1a183f81c99681691a342760e65c55e8d3a4d4fe347ebe72433b123754b942b70b622959894e11f8a9131dc549bd3c9a5234677db06a4af42add8d12 + languageName: node + linkType: hard + "regenerator-transform@npm:^0.15.1": version: 0.15.1 resolution: "regenerator-transform@npm:0.15.1" @@ -20295,11 +20368,14 @@ __metadata: "@mui/icons-material": ^5.0.1 "@mui/material": ^5.0.2 "@vitejs/plugin-react": ^2.2.0 + i18next: ^23.5.1 + i18next-resources-to-backend: ^1.1.4 jsonexport: ^3.2.0 lodash: ~4.17.5 prop-types: ^15.7.2 proxy-polyfill: ^0.3.0 ra-data-fakerest: ^4.13.4 + ra-i18n-i18next: ^4.13.4 ra-i18n-polyglot: ^4.13.4 ra-input-rich-text: ^4.13.4 ra-language-english: ^4.13.4 @@ -20309,6 +20385,7 @@ __metadata: react-app-polyfill: ^1.0.4 react-dom: ^17.0.0 react-hook-form: ^7.43.9 + react-i18next: ^13.2.2 react-query: ^3.32.1 react-router: ^6.1.0 react-router-dom: ^6.1.0 @@ -22109,6 +22186,13 @@ __metadata: languageName: node linkType: hard +"void-elements@npm:3.1.0": + version: 3.1.0 + resolution: "void-elements@npm:3.1.0" + checksum: 0b8686f9f9aa44012e9bd5eabf287ae0cde409b9a2854c5a2335cb83920c957668ac5876e3f0d158dd424744ac411a7270e64128556b451ed3bec875ef18534d + languageName: node + linkType: hard + "w3c-keyname@npm:^2.2.0": version: 2.2.4 resolution: "w3c-keyname@npm:2.2.4" From b60afbdba44b6f3279cce31aa6b1bd34732eb718 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 28 Sep 2023 11:51:52 +0200 Subject: [PATCH 02/18] Cleanup simple example --- examples/simple/src/i18nProvider-next.tsx | 13 ------------- examples/simple/src/index.tsx | 4 +--- 2 files changed, 1 insertion(+), 16 deletions(-) delete mode 100644 examples/simple/src/i18nProvider-next.tsx diff --git a/examples/simple/src/i18nProvider-next.tsx b/examples/simple/src/i18nProvider-next.tsx deleted file mode 100644 index e3fb67ca894..00000000000 --- a/examples/simple/src/i18nProvider-next.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import i18n from 'i18next'; -import { initReactI18next } from 'react-i18next'; -import i18nextProvider, { convertRaMessagesToI18next } from 'ra-i18n-i18next'; -import englishMessages from './i18n/en'; - -const instance = i18n.use(initReactI18next); - -// TODO: remove -export default i18nextProvider(instance, { - resources: { - en: { translation: convertRaMessagesToI18next(englishMessages) }, - }, -}); diff --git a/examples/simple/src/index.tsx b/examples/simple/src/index.tsx index 7d758a710d0..800767145e2 100644 --- a/examples/simple/src/index.tsx +++ b/examples/simple/src/index.tsx @@ -9,9 +9,7 @@ import comments from './comments'; import CustomRouteLayout from './customRouteLayout'; import CustomRouteNoLayout from './customRouteNoLayout'; import dataProvider from './dataProvider'; -// TODO: revert -// import i18nProvider from './i18nProvider'; -import i18nProvider from './i18nProvider-next'; +import i18nProvider from './i18nProvider'; import Layout from './Layout'; import posts from './posts'; import users from './users'; From a4e44c322250744190f2d3c4a18e3d2df0382bd3 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 28 Sep 2023 11:52:26 +0200 Subject: [PATCH 03/18] Fix pluralization & convertRaMessagesToI18next --- packages/ra-i18n-i18next/src/index.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/ra-i18n-i18next/src/index.ts b/packages/ra-i18n-i18next/src/index.ts index ded9937fd5e..71ed7c6e031 100644 --- a/packages/ra-i18n-i18next/src/index.ts +++ b/packages/ra-i18n-i18next/src/index.ts @@ -22,8 +22,13 @@ export default (i18nInstance: I18n, options?: InitOptions): I18nProvider => { }); return { translate: (key: string, options: any = {}) => { - const { _: defaultValue, ...otherOptions } = options || {}; - return translate(key, { defaultValue, ...otherOptions }).toString(); + const { _: defaultValue, smart_count: count, ...otherOptions } = + options || {}; + return translate(key, { + defaultValue, + count, + ...otherOptions, + }).toString(); }, changeLocale: async (newLocale: string) => { await i18nInstance.changeLanguage(newLocale); @@ -43,7 +48,7 @@ export const convertRaMessagesToI18next = ( ) => { return Object.keys(raMessages).reduce((acc, key) => { if (typeof acc[key] === 'object') { - acc[key] = convertRaMessagesToI18next(acc[key]); + acc[key] = convertRaMessagesToI18next(acc[key], { prefix, suffix }); return acc; } @@ -81,8 +86,10 @@ const convertMessage = ( message: string, { prefix = '{{', suffix = '}}' } = {} ) => { - return message.replace( + const result = message.replace( /%\{([a-zA-Z0-9-_]*)\}/g, (match, p1) => `${prefix}${p1}${suffix}` ); + + return result; }; From 182d29cba61d3bbbeb950fc1f1141da6d5ab3a34 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 28 Sep 2023 11:52:33 +0200 Subject: [PATCH 04/18] Add stories --- .../ra-i18n-i18next/src/index.stories.tsx | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 packages/ra-i18n-i18next/src/index.stories.tsx diff --git a/packages/ra-i18n-i18next/src/index.stories.tsx b/packages/ra-i18n-i18next/src/index.stories.tsx new file mode 100644 index 00000000000..1be44fb5ad5 --- /dev/null +++ b/packages/ra-i18n-i18next/src/index.stories.tsx @@ -0,0 +1,95 @@ +import * as React from 'react'; +import { Admin, EditGuesser, ListGuesser, Resource } from 'react-admin'; +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import englishMessages from 'ra-language-english'; +import fakeRestDataProvider from 'ra-data-fakerest'; +import i18nextProvider, { convertRaMessagesToI18next } from './index'; + +export default { + title: 'ra-i18n-18next', +}; + +export const Basic = () => { + const instance = i18n.use(initReactI18next); + const i18nProvider = i18nextProvider(instance, { + resources: { + en: { translation: convertRaMessagesToI18next(englishMessages) }, + }, + }); + return ( + + + + + ); +}; + +export const WithCustomTranslations = () => { + const instance = i18n.use(initReactI18next); + const i18nProvider = i18nextProvider(instance, { + resources: { + en: { + translation: { + ...convertRaMessagesToI18next(englishMessages), + resources: { + posts: { + name_one: 'Blog post', + name_other: 'Blog posts', + fields: { + title: 'Title', + }, + }, + }, + }, + }, + }, + }); + return ( + + + + + ); +}; +export const WithCustomOptions = () => { + const instance = i18n.use(initReactI18next); + const defaultMessages = convertRaMessagesToI18next(englishMessages, { + prefix: '#{', + suffix: '}#', + }); + + const i18nProvider = i18nextProvider(instance, { + interpolation: { + prefix: '#{', + suffix: '}#', + }, + resources: { + en: { + translation: { + ...defaultMessages, + resources: { + posts: { + name_one: 'Blog post', + name_other: 'Blog posts', + fields: { + title: 'Title', + }, + }, + }, + }, + }, + }, + }); + return ( + + + + + ); +}; + +const dataProvider = fakeRestDataProvider({ + posts: [{ id: 1, title: 'Lorem Ipsum' }], + comments: [{ id: 1, body: 'Sic dolor amet...' }], +}); From 922e891b95ca907c24022ce40deb685ad5d3a47a Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 28 Sep 2023 12:39:37 +0200 Subject: [PATCH 05/18] Refactor to allow lazy loading --- examples/simple/package.json | 4 - packages/ra-i18n-i18next/package.json | 2 + .../ra-i18n-i18next/src/index.stories.tsx | 102 ++++++++++++++++-- packages/ra-i18n-i18next/src/index.ts | 46 ++++++-- yarn.lock | 8 +- 5 files changed, 135 insertions(+), 27 deletions(-) diff --git a/examples/simple/package.json b/examples/simple/package.json index d95a873b3fc..e14ea88172c 100644 --- a/examples/simple/package.json +++ b/examples/simple/package.json @@ -13,14 +13,11 @@ "@emotion/styled": "^11.6.0", "@mui/icons-material": "^5.0.1", "@mui/material": "^5.0.2", - "i18next": "^23.5.1", - "i18next-resources-to-backend": "^1.1.4", "jsonexport": "^3.2.0", "lodash": "~4.17.5", "prop-types": "^15.7.2", "proxy-polyfill": "^0.3.0", "ra-data-fakerest": "^4.13.4", - "ra-i18n-i18next": "^4.13.4", "ra-i18n-polyglot": "^4.13.4", "ra-input-rich-text": "^4.13.4", "ra-language-english": "^4.13.4", @@ -29,7 +26,6 @@ "react-admin": "^4.13.4", "react-dom": "^17.0.0", "react-hook-form": "^7.43.9", - "react-i18next": "^13.2.2", "react-query": "^3.32.1", "react-router": "^6.1.0", "react-router-dom": "^6.1.0" diff --git a/packages/ra-i18n-i18next/package.json b/packages/ra-i18n-i18next/package.json index 5dd3870430e..07dd91b5a61 100644 --- a/packages/ra-i18n-i18next/package.json +++ b/packages/ra-i18n-i18next/package.json @@ -30,6 +30,8 @@ }, "devDependencies": { "cross-env": "^5.2.0", + "i18next-resources-to-backend": "^1.1.4", + "react-i18next": "^13.2.2", "rimraf": "^3.0.2", "typescript": "^5.1.3" }, diff --git a/packages/ra-i18n-i18next/src/index.stories.tsx b/packages/ra-i18n-i18next/src/index.stories.tsx index 1be44fb5ad5..0295f4401f5 100644 --- a/packages/ra-i18n-i18next/src/index.stories.tsx +++ b/packages/ra-i18n-i18next/src/index.stories.tsx @@ -2,9 +2,10 @@ import * as React from 'react'; import { Admin, EditGuesser, ListGuesser, Resource } from 'react-admin'; import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; +import resourcesToBackend from 'i18next-resources-to-backend'; import englishMessages from 'ra-language-english'; import fakeRestDataProvider from 'ra-data-fakerest'; -import i18nextProvider, { convertRaMessagesToI18next } from './index'; +import { useI18nextProvider, convertRaMessagesToI18next } from './index'; export default { title: 'ra-i18n-18next', @@ -12,22 +13,78 @@ export default { export const Basic = () => { const instance = i18n.use(initReactI18next); - const i18nProvider = i18nextProvider(instance, { + const i18nProvider = useI18nextProvider(instance, { resources: { - en: { translation: convertRaMessagesToI18next(englishMessages) }, + en: { + translation: convertRaMessagesToI18next(englishMessages), + }, }, }); + + if (!i18nProvider) return null; + + return ( + + } + edit={} + /> + } + edit={} + /> + + ); +}; + +export const WithLazyLoadedLanguages = () => { + const instance = i18n + .use( + resourcesToBackend(language => { + if (language === 'fr') { + return import( + `ra-language-french` + ).then(({ default: messages }) => + convertRaMessagesToI18next(messages) + ); + } + return import( + `ra-language-english` + ).then(({ default: messages }) => + convertRaMessagesToI18next(messages) + ); + }) + ) + .use(initReactI18next); + + const i18nProvider = useI18nextProvider(instance, {}, [ + { locale: 'en', name: 'English' }, + { locale: 'fr', name: 'French' }, + ]); + + if (!i18nProvider) return null; + return ( - - + } + edit={} + /> + } + edit={} + /> ); }; export const WithCustomTranslations = () => { const instance = i18n.use(initReactI18next); - const i18nProvider = i18nextProvider(instance, { + const i18nProvider = useI18nextProvider(instance, { resources: { en: { translation: { @@ -45,13 +102,25 @@ export const WithCustomTranslations = () => { }, }, }); + + if (!i18nProvider) return null; + return ( - - + } + edit={} + /> + } + edit={} + /> ); }; + export const WithCustomOptions = () => { const instance = i18n.use(initReactI18next); const defaultMessages = convertRaMessagesToI18next(englishMessages, { @@ -59,7 +128,7 @@ export const WithCustomOptions = () => { suffix: '}#', }); - const i18nProvider = i18nextProvider(instance, { + const i18nProvider = useI18nextProvider(instance, { interpolation: { prefix: '#{', suffix: '}#', @@ -81,10 +150,21 @@ export const WithCustomOptions = () => { }, }, }); + + if (!i18nProvider) return null; + return ( - - + } + edit={} + /> + } + edit={} + /> ); }; diff --git a/packages/ra-i18n-i18next/src/index.ts b/packages/ra-i18n-i18next/src/index.ts index 71ed7c6e031..55e9ed13018 100644 --- a/packages/ra-i18n-i18next/src/index.ts +++ b/packages/ra-i18n-i18next/src/index.ts @@ -1,16 +1,47 @@ import { InitOptions, i18n as I18n, TFunction } from 'i18next'; import clone from 'lodash/clone'; -import { I18nProvider } from 'ra-core'; +import { I18nProvider, Locale } from 'ra-core'; +import { useEffect, useRef, useState } from 'react'; /** * Build a i18next-based i18nProvider. * * @example * TODO */ -export default (i18nInstance: I18n, options?: InitOptions): I18nProvider => { +export const useI18nextProvider = ( + i18nInstance: I18n, + options?: InitOptions, + availableLocales: Locale[] = [{ locale: 'en', name: 'English' }] +) => { + const [i18nProvider, setI18nProvider] = useState(null); + const initializationPromise = useRef>(null); + + useEffect(() => { + if (initializationPromise.current) { + return; + } + + initializationPromise.current = getI18nProvider( + i18nInstance, + options, + availableLocales + ).then(provider => { + setI18nProvider(provider); + return provider; + }); + }, []); + + return i18nProvider; +}; + +export const getI18nProvider = async ( + i18nInstance: I18n, + options?: InitOptions, + availableLocales: Locale[] = [{ locale: 'en', name: 'English' }] +): Promise => { let translate: TFunction; - i18nInstance + await i18nInstance .init({ lng: 'en', fallbackLng: 'en', @@ -20,6 +51,7 @@ export default (i18nInstance: I18n, options?: InitOptions): I18nProvider => { .then(t => { translate = t; }); + return { translate: (key: string, options: any = {}) => { const { _: defaultValue, smart_count: count, ...otherOptions } = @@ -31,13 +63,13 @@ export default (i18nInstance: I18n, options?: InitOptions): I18nProvider => { }).toString(); }, changeLocale: async (newLocale: string) => { - await i18nInstance.changeLanguage(newLocale); + await i18nInstance.loadLanguages(newLocale); + const t = await i18nInstance.changeLanguage(newLocale); + translate = t; }, getLocale: () => i18nInstance.language, getLocales: () => { - return i18nInstance.languages - ? i18nInstance.languages.map(l => ({ locale: l, name: l })) - : undefined; + return availableLocales; }, }; }; diff --git a/yarn.lock b/yarn.lock index 288c5e5b161..14ea3a9bff6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18397,13 +18397,15 @@ __metadata: languageName: unknown linkType: soft -"ra-i18n-i18next@^4.13.4, ra-i18n-i18next@workspace:packages/ra-i18n-i18next": +"ra-i18n-i18next@workspace:packages/ra-i18n-i18next": version: 0.0.0-use.local resolution: "ra-i18n-i18next@workspace:packages/ra-i18n-i18next" dependencies: cross-env: ^5.2.0 i18next: ^23.5.1 + i18next-resources-to-backend: ^1.1.4 ra-core: ^4.13.4 + react-i18next: ^13.2.2 rimraf: ^3.0.2 typescript: ^5.1.3 languageName: unknown @@ -20368,14 +20370,11 @@ __metadata: "@mui/icons-material": ^5.0.1 "@mui/material": ^5.0.2 "@vitejs/plugin-react": ^2.2.0 - i18next: ^23.5.1 - i18next-resources-to-backend: ^1.1.4 jsonexport: ^3.2.0 lodash: ~4.17.5 prop-types: ^15.7.2 proxy-polyfill: ^0.3.0 ra-data-fakerest: ^4.13.4 - ra-i18n-i18next: ^4.13.4 ra-i18n-polyglot: ^4.13.4 ra-input-rich-text: ^4.13.4 ra-language-english: ^4.13.4 @@ -20385,7 +20384,6 @@ __metadata: react-app-polyfill: ^1.0.4 react-dom: ^17.0.0 react-hook-form: ^7.43.9 - react-i18next: ^13.2.2 react-query: ^3.32.1 react-router: ^6.1.0 react-router-dom: ^6.1.0 From 2991d481f576c96b1645588ed023cf441faca07a Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 28 Sep 2023 14:20:09 +0200 Subject: [PATCH 06/18] Simplify API --- .../ra-i18n-i18next/src/index.stories.tsx | 105 +++++++++--------- packages/ra-i18n-i18next/src/index.ts | 23 ++-- 2 files changed, 68 insertions(+), 60 deletions(-) diff --git a/packages/ra-i18n-i18next/src/index.stories.tsx b/packages/ra-i18n-i18next/src/index.stories.tsx index 0295f4401f5..8ee2704136d 100644 --- a/packages/ra-i18n-i18next/src/index.stories.tsx +++ b/packages/ra-i18n-i18next/src/index.stories.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { Admin, EditGuesser, ListGuesser, Resource } from 'react-admin'; import i18n from 'i18next'; -import { initReactI18next } from 'react-i18next'; import resourcesToBackend from 'i18next-resources-to-backend'; import englishMessages from 'ra-language-english'; import fakeRestDataProvider from 'ra-data-fakerest'; @@ -12,11 +11,12 @@ export default { }; export const Basic = () => { - const instance = i18n.use(initReactI18next); - const i18nProvider = useI18nextProvider(instance, { - resources: { - en: { - translation: convertRaMessagesToI18next(englishMessages), + const i18nProvider = useI18nextProvider({ + options: { + resources: { + en: { + translation: convertRaMessagesToI18next(englishMessages), + }, }, }, }); @@ -40,29 +40,28 @@ export const Basic = () => { }; export const WithLazyLoadedLanguages = () => { - const instance = i18n - .use( - resourcesToBackend(language => { - if (language === 'fr') { - return import( - `ra-language-french` - ).then(({ default: messages }) => - convertRaMessagesToI18next(messages) - ); - } + const i18nInstance = i18n.use( + resourcesToBackend(language => { + if (language === 'fr') { return import( - `ra-language-english` + `ra-language-french` ).then(({ default: messages }) => convertRaMessagesToI18next(messages) ); - }) - ) - .use(initReactI18next); + } + return import(`ra-language-english`).then(({ default: messages }) => + convertRaMessagesToI18next(messages) + ); + }) + ); - const i18nProvider = useI18nextProvider(instance, {}, [ - { locale: 'en', name: 'English' }, - { locale: 'fr', name: 'French' }, - ]); + const i18nProvider = useI18nextProvider({ + i18nInstance, + availableLocales: [ + { locale: 'en', name: 'English' }, + { locale: 'fr', name: 'French' }, + ], + }); if (!i18nProvider) return null; @@ -83,18 +82,19 @@ export const WithLazyLoadedLanguages = () => { }; export const WithCustomTranslations = () => { - const instance = i18n.use(initReactI18next); - const i18nProvider = useI18nextProvider(instance, { - resources: { - en: { - translation: { - ...convertRaMessagesToI18next(englishMessages), - resources: { - posts: { - name_one: 'Blog post', - name_other: 'Blog posts', - fields: { - title: 'Title', + const i18nProvider = useI18nextProvider({ + options: { + resources: { + en: { + translation: { + ...convertRaMessagesToI18next(englishMessages), + resources: { + posts: { + name_one: 'Blog post', + name_other: 'Blog posts', + fields: { + title: 'Title', + }, }, }, }, @@ -122,27 +122,28 @@ export const WithCustomTranslations = () => { }; export const WithCustomOptions = () => { - const instance = i18n.use(initReactI18next); const defaultMessages = convertRaMessagesToI18next(englishMessages, { prefix: '#{', suffix: '}#', }); - const i18nProvider = useI18nextProvider(instance, { - interpolation: { - prefix: '#{', - suffix: '}#', - }, - resources: { - en: { - translation: { - ...defaultMessages, - resources: { - posts: { - name_one: 'Blog post', - name_other: 'Blog posts', - fields: { - title: 'Title', + const i18nProvider = useI18nextProvider({ + options: { + interpolation: { + prefix: '#{', + suffix: '}#', + }, + resources: { + en: { + translation: { + ...defaultMessages, + resources: { + posts: { + name_one: 'Blog post', + name_other: 'Blog posts', + fields: { + title: 'Title', + }, }, }, }, diff --git a/packages/ra-i18n-i18next/src/index.ts b/packages/ra-i18n-i18next/src/index.ts index 55e9ed13018..f80ed780d64 100644 --- a/packages/ra-i18n-i18next/src/index.ts +++ b/packages/ra-i18n-i18next/src/index.ts @@ -1,18 +1,24 @@ -import { InitOptions, i18n as I18n, TFunction } from 'i18next'; +import { useEffect, useRef, useState } from 'react'; +import { createInstance, InitOptions, i18n as I18n, TFunction } from 'i18next'; +import { initReactI18next } from 'react-i18next'; import clone from 'lodash/clone'; import { I18nProvider, Locale } from 'ra-core'; -import { useEffect, useRef, useState } from 'react'; + /** * Build a i18next-based i18nProvider. * * @example * TODO */ -export const useI18nextProvider = ( - i18nInstance: I18n, - options?: InitOptions, - availableLocales: Locale[] = [{ locale: 'en', name: 'English' }] -) => { +export const useI18nextProvider = ({ + i18nInstance = createInstance(), + options = {}, + availableLocales = [{ locale: 'en', name: 'English' }], +}: { + i18nInstance?: I18n; + options?: InitOptions; + availableLocales?: Locale[]; +} = {}) => { const [i18nProvider, setI18nProvider] = useState(null); const initializationPromise = useRef>(null); @@ -29,7 +35,7 @@ export const useI18nextProvider = ( setI18nProvider(provider); return provider; }); - }, []); + }, [availableLocales, i18nInstance, options]); return i18nProvider; }; @@ -42,6 +48,7 @@ export const getI18nProvider = async ( let translate: TFunction; await i18nInstance + .use(initReactI18next) .init({ lng: 'en', fallbackLng: 'en', From 7f7bf12afbcb7b796e3eb19e22b15589320e84f4 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 28 Sep 2023 14:42:47 +0200 Subject: [PATCH 07/18] Refactor --- .../src/convertRaMessagesToI18next.ts | 53 +++++++ .../ra-i18n-i18next/src/index.stories.tsx | 121 ++++++++-------- packages/ra-i18n-i18next/src/index.ts | 136 +----------------- .../ra-i18n-i18next/src/useI18nextProvider.ts | 81 +++++++++++ 4 files changed, 196 insertions(+), 195 deletions(-) create mode 100644 packages/ra-i18n-i18next/src/convertRaMessagesToI18next.ts create mode 100644 packages/ra-i18n-i18next/src/useI18nextProvider.ts diff --git a/packages/ra-i18n-i18next/src/convertRaMessagesToI18next.ts b/packages/ra-i18n-i18next/src/convertRaMessagesToI18next.ts new file mode 100644 index 00000000000..0b61fb74257 --- /dev/null +++ b/packages/ra-i18n-i18next/src/convertRaMessagesToI18next.ts @@ -0,0 +1,53 @@ +import clone from 'lodash/clone'; + +export const convertRaMessagesToI18next = ( + raMessages, + { prefix = '{{', suffix = '}}' } = {} +) => { + return Object.keys(raMessages).reduce((acc, key) => { + if (typeof acc[key] === 'object') { + acc[key] = convertRaMessagesToI18next(acc[key], { prefix, suffix }); + return acc; + } + + const message = acc[key] as string; + + if (message.indexOf(' |||| ') > -1) { + const pluralVariants = message.split(' |||| '); + + if ( + pluralVariants.length > 2 && + process.env.NODE_ENV === 'development' + ) { + console.warn( + 'A message contains more than two plural forms so we can not convert it to i18next format automatically. You should provide your own translations for this language.' + ); + } + acc[`${key}_one`] = convertMessage(pluralVariants[0], { + prefix, + suffix, + }); + acc[`${key}_other`] = convertMessage(pluralVariants[1], { + prefix, + suffix, + }); + delete acc[key]; + } else { + acc[key] = convertMessage(message, { prefix, suffix }); + } + + return acc; + }, clone(raMessages)); +}; + +export const convertMessage = ( + message: string, + { prefix = '{{', suffix = '}}' } = {} +) => { + const result = message.replace( + /%\{([a-zA-Z0-9-_]*)\}/g, + (match, p1) => `${prefix}${p1}${suffix}` + ); + + return result; +}; diff --git a/packages/ra-i18n-i18next/src/index.stories.tsx b/packages/ra-i18n-i18next/src/index.stories.tsx index 8ee2704136d..c8209a1c38b 100644 --- a/packages/ra-i18n-i18next/src/index.stories.tsx +++ b/packages/ra-i18n-i18next/src/index.stories.tsx @@ -4,7 +4,9 @@ import i18n from 'i18next'; import resourcesToBackend from 'i18next-resources-to-backend'; import englishMessages from 'ra-language-english'; import fakeRestDataProvider from 'ra-data-fakerest'; -import { useI18nextProvider, convertRaMessagesToI18next } from './index'; +import { MemoryRouter } from 'react-router-dom'; +import { useI18nextProvider } from './index'; +import { convertRaMessagesToI18next } from './convertRaMessagesToI18next'; export default { title: 'ra-i18n-18next', @@ -24,18 +26,20 @@ export const Basic = () => { if (!i18nProvider) return null; return ( - - } - edit={} - /> - } - edit={} - /> - + + + } + edit={} + /> + } + edit={} + /> + + ); }; @@ -66,18 +70,20 @@ export const WithLazyLoadedLanguages = () => { if (!i18nProvider) return null; return ( - - } - edit={} - /> - } - edit={} - /> - + + + } + edit={} + /> + } + edit={} + /> + + ); }; @@ -106,18 +112,20 @@ export const WithCustomTranslations = () => { if (!i18nProvider) return null; return ( - - } - edit={} - /> - } - edit={} - /> - + + + } + edit={} + /> + } + edit={} + /> + + ); }; @@ -135,18 +143,7 @@ export const WithCustomOptions = () => { }, resources: { en: { - translation: { - ...defaultMessages, - resources: { - posts: { - name_one: 'Blog post', - name_other: 'Blog posts', - fields: { - title: 'Title', - }, - }, - }, - }, + translation: defaultMessages, }, }, }, @@ -155,18 +152,20 @@ export const WithCustomOptions = () => { if (!i18nProvider) return null; return ( - - } - edit={} - /> - } - edit={} - /> - + + + } + edit={} + /> + } + edit={} + /> + + ); }; diff --git a/packages/ra-i18n-i18next/src/index.ts b/packages/ra-i18n-i18next/src/index.ts index f80ed780d64..a91c2bc04db 100644 --- a/packages/ra-i18n-i18next/src/index.ts +++ b/packages/ra-i18n-i18next/src/index.ts @@ -1,134 +1,2 @@ -import { useEffect, useRef, useState } from 'react'; -import { createInstance, InitOptions, i18n as I18n, TFunction } from 'i18next'; -import { initReactI18next } from 'react-i18next'; -import clone from 'lodash/clone'; -import { I18nProvider, Locale } from 'ra-core'; - -/** - * Build a i18next-based i18nProvider. - * - * @example - * TODO - */ -export const useI18nextProvider = ({ - i18nInstance = createInstance(), - options = {}, - availableLocales = [{ locale: 'en', name: 'English' }], -}: { - i18nInstance?: I18n; - options?: InitOptions; - availableLocales?: Locale[]; -} = {}) => { - const [i18nProvider, setI18nProvider] = useState(null); - const initializationPromise = useRef>(null); - - useEffect(() => { - if (initializationPromise.current) { - return; - } - - initializationPromise.current = getI18nProvider( - i18nInstance, - options, - availableLocales - ).then(provider => { - setI18nProvider(provider); - return provider; - }); - }, [availableLocales, i18nInstance, options]); - - return i18nProvider; -}; - -export const getI18nProvider = async ( - i18nInstance: I18n, - options?: InitOptions, - availableLocales: Locale[] = [{ locale: 'en', name: 'English' }] -): Promise => { - let translate: TFunction; - - await i18nInstance - .use(initReactI18next) - .init({ - lng: 'en', - fallbackLng: 'en', - react: { useSuspense: false }, - ...options, - }) - .then(t => { - translate = t; - }); - - return { - translate: (key: string, options: any = {}) => { - const { _: defaultValue, smart_count: count, ...otherOptions } = - options || {}; - return translate(key, { - defaultValue, - count, - ...otherOptions, - }).toString(); - }, - changeLocale: async (newLocale: string) => { - await i18nInstance.loadLanguages(newLocale); - const t = await i18nInstance.changeLanguage(newLocale); - translate = t; - }, - getLocale: () => i18nInstance.language, - getLocales: () => { - return availableLocales; - }, - }; -}; - -export const convertRaMessagesToI18next = ( - raMessages, - { prefix = '{{', suffix = '}}' } = {} -) => { - return Object.keys(raMessages).reduce((acc, key) => { - if (typeof acc[key] === 'object') { - acc[key] = convertRaMessagesToI18next(acc[key], { prefix, suffix }); - return acc; - } - - const message = acc[key] as string; - - if (message.indexOf(' |||| ') > -1) { - const pluralVariants = message.split(' |||| '); - - if ( - pluralVariants.length > 2 && - process.env.NODE_ENV === 'development' - ) { - console.warn( - 'A message contains more than two plural forms so we can not convert it to i18next format automatically. You should provide your own translations for this language.' - ); - } - acc[`${key}_one`] = convertMessage(pluralVariants[0], { - prefix, - suffix, - }); - acc[`${key}_other`] = convertMessage(pluralVariants[1], { - prefix, - suffix, - }); - delete acc[key]; - } else { - acc[key] = convertMessage(message, { prefix, suffix }); - } - - return acc; - }, clone(raMessages)); -}; - -const convertMessage = ( - message: string, - { prefix = '{{', suffix = '}}' } = {} -) => { - const result = message.replace( - /%\{([a-zA-Z0-9-_]*)\}/g, - (match, p1) => `${prefix}${p1}${suffix}` - ); - - return result; -}; +export * from './useI18nextProvider'; +export * from './convertRaMessagesToI18next'; diff --git a/packages/ra-i18n-i18next/src/useI18nextProvider.ts b/packages/ra-i18n-i18next/src/useI18nextProvider.ts new file mode 100644 index 00000000000..c1006d87c49 --- /dev/null +++ b/packages/ra-i18n-i18next/src/useI18nextProvider.ts @@ -0,0 +1,81 @@ +import { useEffect, useRef, useState } from 'react'; +import { createInstance, InitOptions, i18n as I18n, TFunction } from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import { I18nProvider, Locale } from 'ra-core'; + +/** + * Build a i18next-based i18nProvider. + * + * @example + * TODO + */ +export const useI18nextProvider = ({ + i18nInstance = createInstance(), + options = {}, + availableLocales = [{ locale: 'en', name: 'English' }], +}: { + i18nInstance?: I18n; + options?: InitOptions; + availableLocales?: Locale[]; +} = {}) => { + const [i18nProvider, setI18nProvider] = useState(null); + const initializationPromise = useRef>(null); + + useEffect(() => { + if (initializationPromise.current) { + return; + } + + initializationPromise.current = getI18nProvider( + i18nInstance, + options, + availableLocales + ).then(provider => { + setI18nProvider(provider); + return provider; + }); + }, [availableLocales, i18nInstance, options]); + + return i18nProvider; +}; + +export const getI18nProvider = async ( + i18nInstance: I18n, + options?: InitOptions, + availableLocales: Locale[] = [{ locale: 'en', name: 'English' }] +): Promise => { + let translate: TFunction; + + await i18nInstance + .use(initReactI18next) + .init({ + lng: 'en', + fallbackLng: 'en', + react: { useSuspense: false }, + ...options, + }) + .then(t => { + translate = t; + }); + + return { + translate: (key: string, options: any = {}) => { + const { _: defaultValue, smart_count: count, ...otherOptions } = + options || {}; + return translate(key, { + defaultValue, + count, + ...otherOptions, + }).toString(); + }, + changeLocale: async (newLocale: string) => { + await i18nInstance.loadLanguages(newLocale); + const t = await i18nInstance.changeLanguage(newLocale); + translate = t; + }, + getLocale: () => i18nInstance.language, + getLocales: () => { + return availableLocales; + }, + }; +}; From 6e837867d79d3ca382e3837037f094c38caff9b1 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 28 Sep 2023 14:42:53 +0200 Subject: [PATCH 08/18] Add tests --- .../src/convertRaMessagesToI18next.spec.tsx | 74 +++++++++++++++++++ packages/ra-i18n-i18next/src/index.spec.tsx | 66 +++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 packages/ra-i18n-i18next/src/convertRaMessagesToI18next.spec.tsx create mode 100644 packages/ra-i18n-i18next/src/index.spec.tsx diff --git a/packages/ra-i18n-i18next/src/convertRaMessagesToI18next.spec.tsx b/packages/ra-i18n-i18next/src/convertRaMessagesToI18next.spec.tsx new file mode 100644 index 00000000000..3d3bc84706b --- /dev/null +++ b/packages/ra-i18n-i18next/src/convertRaMessagesToI18next.spec.tsx @@ -0,0 +1,74 @@ +import { convertRaMessagesToI18next } from './convertRaMessagesToI18next'; + +describe('i18next i18nProvider', () => { + describe('convertRaMessagesToI18next', () => { + test('should convert react-admin default messages to i18next format', () => { + expect( + convertRaMessagesToI18next( + { + simple: 'simple', + interpolation: 'interpolation %{variable}', + pluralization: 'singular |||| plural', + nested: { + deep: { + simple: 'simple', + interpolation: 'interpolation %{variable}', + pluralization: 'singular |||| plural', + }, + }, + }, + {} + ) + ).toEqual({ + simple: 'simple', + interpolation: 'interpolation {{variable}}', + pluralization_one: 'singular', + pluralization_other: 'plural', + nested: { + deep: { + simple: 'simple', + interpolation: 'interpolation {{variable}}', + pluralization_one: 'singular', + pluralization_other: 'plural', + }, + }, + }); + }); + + test('should convert react-admin default messages to i18next format with custom prefix/suffix', () => { + expect( + convertRaMessagesToI18next( + { + simple: 'simple', + interpolation: 'interpolation %{variable}', + pluralization: 'singular |||| plural', + nested: { + deep: { + simple: 'simple', + interpolation: 'interpolation %{variable}', + pluralization: 'singular |||| plural', + }, + }, + }, + { + prefix: '#{', + suffix: '}#', + } + ) + ).toEqual({ + simple: 'simple', + interpolation: 'interpolation #{variable}#', + pluralization_one: 'singular', + pluralization_other: 'plural', + nested: { + deep: { + simple: 'simple', + interpolation: 'interpolation #{variable}#', + pluralization_one: 'singular', + pluralization_other: 'plural', + }, + }, + }); + }); + }); +}); diff --git a/packages/ra-i18n-i18next/src/index.spec.tsx b/packages/ra-i18n-i18next/src/index.spec.tsx new file mode 100644 index 00000000000..7e7a6a6f02e --- /dev/null +++ b/packages/ra-i18n-i18next/src/index.spec.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { + Basic, + WithCustomTranslations, + WithCustomOptions, + WithLazyLoadedLanguages, +} from './index.stories'; + +describe('i18next i18nProvider', () => { + test('should work with no configuration except the messages', async () => { + render(); + + await screen.findByText('Comments'); + await screen.findByText('Export'); + expect(await screen.findAllByText('Posts')).toHaveLength(2); + + // Check interpolation + await screen.findByText('1-1 of 1'); + fireEvent.click(await screen.findByText('Lorem Ipsum')); + // Check singularization + await screen.findByText('Post #1'); + }); + + test('should work with custom interpolation options', async () => { + render(); + + await screen.findByText('Export'); + + // Check interpolation + await screen.findByText('1-1 of 1'); + fireEvent.click( + await screen.findByText('English', { selector: 'button' }) + ); + fireEvent.click(await screen.findByText('French')); + await screen.findByText('Exporter'); + }); + + test('should work with custom translations', async () => { + render(); + + await screen.findByText('Comments'); + await screen.findByText('Export'); + expect(await screen.findAllByText('Blog posts')).toHaveLength(2); + + // Check interpolation + await screen.findByText('1-1 of 1'); + fireEvent.click(await screen.findByText('Lorem Ipsum')); + // Check singularization + await screen.findByText('Blog post #1'); + }); + + test('should work with multiple languages', async () => { + render(); + + await screen.findByText('Comments'); + await screen.findByText('Export'); + expect(await screen.findAllByText('Blog posts')).toHaveLength(2); + + // Check interpolation + await screen.findByText('1-1 of 1'); + fireEvent.click(await screen.findByText('Lorem Ipsum')); + // Check singularization + await screen.findByText('Blog post #1'); + }); +}); From d2a5e65080762b7a708872b8f9f34d7cc1524bf2 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 28 Sep 2023 14:44:33 +0200 Subject: [PATCH 09/18] Fix tests --- packages/ra-i18n-i18next/src/index.spec.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/ra-i18n-i18next/src/index.spec.tsx b/packages/ra-i18n-i18next/src/index.spec.tsx index 7e7a6a6f02e..8b95e40faf6 100644 --- a/packages/ra-i18n-i18next/src/index.spec.tsx +++ b/packages/ra-i18n-i18next/src/index.spec.tsx @@ -22,7 +22,7 @@ describe('i18next i18nProvider', () => { await screen.findByText('Post #1'); }); - test('should work with custom interpolation options', async () => { + test('should work with multiple languages', async () => { render(); await screen.findByText('Export'); @@ -50,17 +50,17 @@ describe('i18next i18nProvider', () => { await screen.findByText('Blog post #1'); }); - test('should work with multiple languages', async () => { - render(); + test('should work with custom interpolation options', async () => { + render(); await screen.findByText('Comments'); await screen.findByText('Export'); - expect(await screen.findAllByText('Blog posts')).toHaveLength(2); + expect(await screen.findAllByText('Posts')).toHaveLength(2); // Check interpolation await screen.findByText('1-1 of 1'); fireEvent.click(await screen.findByText('Lorem Ipsum')); // Check singularization - await screen.findByText('Blog post #1'); + await screen.findByText('Post #1'); }); }); From d216907f8faa586b1dd7cf50c0e5efb5a3d341cc Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 28 Sep 2023 14:54:32 +0200 Subject: [PATCH 10/18] Rename and document convertRaMessagesToI18next --- .../src/convertRaMessagesToI18next.spec.tsx | 6 +- .../src/convertRaMessagesToI18next.ts | 92 ++++++++++++++++--- .../ra-i18n-i18next/src/index.stories.tsx | 14 +-- 3 files changed, 89 insertions(+), 23 deletions(-) diff --git a/packages/ra-i18n-i18next/src/convertRaMessagesToI18next.spec.tsx b/packages/ra-i18n-i18next/src/convertRaMessagesToI18next.spec.tsx index 3d3bc84706b..822b0f64ec8 100644 --- a/packages/ra-i18n-i18next/src/convertRaMessagesToI18next.spec.tsx +++ b/packages/ra-i18n-i18next/src/convertRaMessagesToI18next.spec.tsx @@ -1,10 +1,10 @@ -import { convertRaMessagesToI18next } from './convertRaMessagesToI18next'; +import { convertRaTranslationsToI18next } from './convertRaMessagesToI18next'; describe('i18next i18nProvider', () => { describe('convertRaMessagesToI18next', () => { test('should convert react-admin default messages to i18next format', () => { expect( - convertRaMessagesToI18next( + convertRaTranslationsToI18next( { simple: 'simple', interpolation: 'interpolation %{variable}', @@ -37,7 +37,7 @@ describe('i18next i18nProvider', () => { test('should convert react-admin default messages to i18next format with custom prefix/suffix', () => { expect( - convertRaMessagesToI18next( + convertRaTranslationsToI18next( { simple: 'simple', interpolation: 'interpolation %{variable}', diff --git a/packages/ra-i18n-i18next/src/convertRaMessagesToI18next.ts b/packages/ra-i18n-i18next/src/convertRaMessagesToI18next.ts index 0b61fb74257..06fc648523b 100644 --- a/packages/ra-i18n-i18next/src/convertRaMessagesToI18next.ts +++ b/packages/ra-i18n-i18next/src/convertRaMessagesToI18next.ts @@ -1,12 +1,41 @@ import clone from 'lodash/clone'; -export const convertRaMessagesToI18next = ( - raMessages, +/** + * A function that takes translations from a standard react-admin language package and converts them to i18next format. + * It transforms the following: + * - interpolations wrappers from `%{foo}` to `{{foo}}` unless a prefix and/or a suffix are provided + * - pluralization messages from a single key containing text like `"key": "foo |||| bar"` to multiple keys `"foo_one": "foo"` and `"foo_other": "bar"` + * @param raMessages The translations to convert. This is an object as found in packages such as ra-language-english. + * @param options Options to customize the conversion. + * @param options.prefix The prefix to use for interpolation variables. Defaults to `{{`. + * @param options.suffix The suffix to use for interpolation variables. Defaults to `}}`. + * @returns The converted translations as an object. + * + * @example Convert the english translations from ra-language-english to i18next format + * import englishMessages from 'ra-language-english'; + * import { convertRaMessagesToI18next } from 'ra-i18n-18next'; + * + * const messages = convertRaMessagesToI18next(englishMessages); + * + * @example Convert the english translations from ra-language-english to i18next format with custom interpolation wrappers + * import englishMessages from 'ra-language-english'; + * import { convertRaMessagesToI18next } from 'ra-i18n-18next'; + * + * const messages = convertRaMessagesToI18next(englishMessages, { + * prefix: '#{', + * suffix: '}#', + * }); + */ +export const convertRaTranslationsToI18next = ( + raMessages: object, { prefix = '{{', suffix = '}}' } = {} ) => { return Object.keys(raMessages).reduce((acc, key) => { if (typeof acc[key] === 'object') { - acc[key] = convertRaMessagesToI18next(acc[key], { prefix, suffix }); + acc[key] = convertRaTranslationsToI18next(acc[key], { + prefix, + suffix, + }); return acc; } @@ -23,28 +52,63 @@ export const convertRaMessagesToI18next = ( 'A message contains more than two plural forms so we can not convert it to i18next format automatically. You should provide your own translations for this language.' ); } - acc[`${key}_one`] = convertMessage(pluralVariants[0], { - prefix, - suffix, - }); - acc[`${key}_other`] = convertMessage(pluralVariants[1], { + acc[`${key}_one`] = convertRaTranslationToI18next( + pluralVariants[0], + { + prefix, + suffix, + } + ); + acc[`${key}_other`] = convertRaTranslationToI18next( + pluralVariants[1], + { + prefix, + suffix, + } + ); + delete acc[key]; + } else { + acc[key] = convertRaTranslationToI18next(message, { prefix, suffix, }); - delete acc[key]; - } else { - acc[key] = convertMessage(message, { prefix, suffix }); } return acc; }, clone(raMessages)); }; -export const convertMessage = ( - message: string, +/** + * A function that takes a single translation text from a standard react-admin language package and converts it to i18next format. + * It transforms the interpolations wrappers from `%{foo}` to `{{foo}}` unless a prefix and/or a suffix are provided + * + * @param translationText The translation text to convert. + * @param options Options to customize the conversion. + * @param options.prefix The prefix to use for interpolation variables. Defaults to `{{`. + * @param options.suffix The suffix to use for interpolation variables. Defaults to `}}`. + * @returns The converted translation text. + * + * @example Convert a single message to i18next format + * import { convertRaTranslationToI18next } from 'ra-i18n-18next'; + * + * const messages = convertRaTranslationToI18next("Hello %{name}!"); + * // "Hello {{name}}!" + * + * @example Convert the english translations from ra-language-english to i18next format with custom interpolation wrappers + * import englishMessages from 'ra-language-english'; + * import { convertRaTranslationToI18next } from 'ra-i18n-18next'; + * + * const messages = convertRaTranslationToI18next("Hello %{name}!", { + * prefix: '#{', + * suffix: '}#', + * }); + * // "Hello #{name}#!" + */ +export const convertRaTranslationToI18next = ( + translationText: string, { prefix = '{{', suffix = '}}' } = {} ) => { - const result = message.replace( + const result = translationText.replace( /%\{([a-zA-Z0-9-_]*)\}/g, (match, p1) => `${prefix}${p1}${suffix}` ); diff --git a/packages/ra-i18n-i18next/src/index.stories.tsx b/packages/ra-i18n-i18next/src/index.stories.tsx index c8209a1c38b..abe21a7e94d 100644 --- a/packages/ra-i18n-i18next/src/index.stories.tsx +++ b/packages/ra-i18n-i18next/src/index.stories.tsx @@ -6,7 +6,7 @@ import englishMessages from 'ra-language-english'; import fakeRestDataProvider from 'ra-data-fakerest'; import { MemoryRouter } from 'react-router-dom'; import { useI18nextProvider } from './index'; -import { convertRaMessagesToI18next } from './convertRaMessagesToI18next'; +import { convertRaTranslationsToI18next } from './convertRaMessagesToI18next'; export default { title: 'ra-i18n-18next', @@ -17,7 +17,9 @@ export const Basic = () => { options: { resources: { en: { - translation: convertRaMessagesToI18next(englishMessages), + translation: convertRaTranslationsToI18next( + englishMessages + ), }, }, }, @@ -50,11 +52,11 @@ export const WithLazyLoadedLanguages = () => { return import( `ra-language-french` ).then(({ default: messages }) => - convertRaMessagesToI18next(messages) + convertRaTranslationsToI18next(messages) ); } return import(`ra-language-english`).then(({ default: messages }) => - convertRaMessagesToI18next(messages) + convertRaTranslationsToI18next(messages) ); }) ); @@ -93,7 +95,7 @@ export const WithCustomTranslations = () => { resources: { en: { translation: { - ...convertRaMessagesToI18next(englishMessages), + ...convertRaTranslationsToI18next(englishMessages), resources: { posts: { name_one: 'Blog post', @@ -130,7 +132,7 @@ export const WithCustomTranslations = () => { }; export const WithCustomOptions = () => { - const defaultMessages = convertRaMessagesToI18next(englishMessages, { + const defaultMessages = convertRaTranslationsToI18next(englishMessages, { prefix: '#{', suffix: '}#', }); From 12b03dfddad019c7f66adcdfb539fa09ae7bc224 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 28 Sep 2023 15:23:26 +0200 Subject: [PATCH 11/18] Add more documentation --- packages/ra-i18n-i18next/README.md | 222 +++++++++++++++++- .../ra-i18n-i18next/src/useI18nextProvider.ts | 52 +++- 2 files changed, 268 insertions(+), 6 deletions(-) diff --git a/packages/ra-i18n-i18next/README.md b/packages/ra-i18n-i18next/README.md index 1b008eb2b47..c138c557e10 100644 --- a/packages/ra-i18n-i18next/README.md +++ b/packages/ra-i18n-i18next/README.md @@ -1,6 +1,6 @@ -# Polyglot i18n provider for react-admin +# i18next i18n provider for react-admin -Polyglot i18n provider for [react-admin](https://github.com/marmelab/react-admin), the frontend framework for building admin applications on top of REST/GraphQL services. It relies on [i18next](https://www.i18next.com/). +i18next i18n provider for [react-admin](https://github.com/marmelab/react-admin), the frontend framework for building admin applications on top of REST/GraphQL services. It relies on [i18next](https://www.i18next.com/). ## Installation @@ -10,4 +10,220 @@ npm install --save ra-i18n-i18next ## Usage -TODO +```tsx +import { Admin } from 'react-admin'; +import { useI18nextProvider, convertRaMessagesToI18next } from 'ra-i18n-i18next'; +import englishMessages from 'ra-language-english'; + +const App = () => { + const i18nProvider = useI18nextProvider({ + options: { + resources: { + translations: convertRaMessagesToI18next(englishMessages) + } + } + }); + if (!i18nProvider) return (
Loading...
); + + return ( + + ... + + ); +}; +``` + +## API + +### `useI18nextProvider` hook + +A hook that returns an i18nProvider for react-admin applications, based on i18next. + +You can provide your own i18next instance but don't initialize it, the hook will do it for you with the options you may provide. Besides, this hook already adds the `initReactI18next` plugin to i18next. + +#### Usage + +```tsx +import { Admin } from 'react-admin'; +import { useI18nextProvider, convertRaMessagesToI18next } from 'ra-i18n-i18next'; +import englishMessages from 'ra-language-english'; + +const App = () => { + const i18nProvider = useI18nextProvider({ + options: { + resources: { + translations: convertRaMessagesToI18next(englishMessages) + } + } + }); + if (!i18nProvider) return (
Loading...
); + + return ( + + ... + + ); +}; +``` + +#### Parameters + +| Parameter | Required | Type | Default | Description | +| -------------------- | -------- | ----------- | ------- | ---------------------------------------------------------------- | +| `i18nextInstance` | Optional | I18n | | Your own i18next instance. If not provided, one will be created. | +| `options` | Optional | InitOptions | | The options passed to the i18next init function | +| `availableLocales` | Optional | Locale[] | | An array describing the available locales. Used to automatically include the locale selector menu in the default react-admin AppBar | + +##### `i18nextInstance` + +This parameter let you pass your own instance of i18next, allowing you to customize its plugins such as the backends. + +```tsx +import { Admin } from 'react-admin'; +import { useI18nextProvider } from 'ra-i18n-i18next'; +import i18n from 'i18next'; +import Backend from 'i18next-http-backend'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +const App = () => { + const i18nextInstance = i18n + .use(Backend) + .use(LanguageDetector); + + const i18nProvider = useI18nextProvider({ + i18nInstance: i18nextInstance, + options: { + fallbackLng: 'en', + debug: true, + interpolation: { + escapeValue: false, // not needed for react!! + }, + } + }); + + if (!i18nProvider) return (
Loading...
); + + return ( + + ... + + ); +}; +``` + +##### `options` + +This parameter let you pass your own options for the i18n `init` function. + +```tsx +import { Admin } from 'react-admin'; +import { useI18nextProvider } from 'ra-i18n-i18next'; +import i18n from 'i18next'; + +const App = () => { + const i18nProvider = useI18nextProvider({ + options: { + fallbackLng: 'en', + debug: true, + } + }); + + if (!i18nProvider) return (
Loading...
); + + return ( + + ... + + ); +}; +``` + +#### `availableLocales` + +This parameter let you provide the list of available locales for your application. This is used by the default react-admin AppBar to detect whether to display a locale selector. + +```tsx +import { Admin } from 'react-admin'; +import { useI18nextProvider } from 'ra-i18n-i18next'; +import i18n from 'i18next'; +import resourcesToBackend from 'i18next-resources-to-backend'; + +const App = () => { + const i18nInstance = i18n.use( + resourcesToBackend(language => { + if (language === 'fr') { + return import( + `ra-language-french` + ).then(({ default: messages }) => + convertRaTranslationsToI18next(messages) + ); + } + return import(`ra-language-english`).then(({ default: messages }) => + convertRaTranslationsToI18next(messages) + ); + }) + ); + + const i18nProvider = useI18nextProvider({ + i18nInstance, + availableLocales: [ + { locale: 'en', name: 'English' }, + { locale: 'fr', name: 'French' }, + ], + }); + + if (!i18nProvider) return (
Loading...
); + + return ( + + ... + + ); +}; +``` + +### `convertRaMessagesToI18next` function + +A function that takes translations from a standard react-admin language package and converts them to i18next format. +It transforms the following: +- interpolations wrappers from `%{foo}` to `{{foo}}` unless a prefix and/or a suffix are provided +- pluralization messages from a single key containing text like `"key": "foo |||| bar"` to multiple keys `"foo_one": "foo"` and `"foo_other": "bar"` + + +@param raMessages The translations to convert. This is an object as found in packages such as ra-language-english. +@param options Options to customize the conversion. +@param options.prefix The prefix to use for interpolation variables. Defaults to `{{`. +@param options.suffix The suffix to use for interpolation variables. Defaults to `}}`. +@returns The converted translations as an object. + +#### Usage + +```ts +import englishMessages from 'ra-language-english'; +import { convertRaMessagesToI18next } from 'ra-i18n-18next'; + +const messages = convertRaMessagesToI18next(englishMessages); +``` + +#### Parameters + +| Parameter | Required | Type | Default | Description | +| -------------------- | -------- | ----------- | ------- | ---------------------------------------------------------------- | +| `raMessages` | Required | object | | An object containing standard react-admin translations such as provided by ra-language-english | +| `options` | Optional | object | | An object providing custom interpolation suffix and/or suffix | + +@example Convert the english translations from ra-language-english to i18next format with custom interpolation wrappers + +##### `options` + +If you provided interpolation options to your i18next instance, you should provide them when calling this function: + +```ts +import englishMessages from 'ra-language-english'; +import { convertRaMessagesToI18next } from 'ra-i18n-18next'; + +const messages = convertRaMessagesToI18next(englishMessages, { + prefix: '#{', + suffix: '}#', +}); +``` diff --git a/packages/ra-i18n-i18next/src/useI18nextProvider.ts b/packages/ra-i18n-i18next/src/useI18nextProvider.ts index c1006d87c49..adc59001837 100644 --- a/packages/ra-i18n-i18next/src/useI18nextProvider.ts +++ b/packages/ra-i18n-i18next/src/useI18nextProvider.ts @@ -4,10 +4,56 @@ import { initReactI18next } from 'react-i18next'; import { I18nProvider, Locale } from 'ra-core'; /** - * Build a i18next-based i18nProvider. + * A hook that returns an i18nProvider for react-admin applications, based on i18next. + * You can provide your own i18next instance but don't initialize it, the hook will do it for you with the options you may provide. + * Besides, this hook already adds the `initReactI18next` plugin to i18next. * - * @example - * TODO + * @example Basic usage + * import { Admin } from 'react-admin'; + * import { useI18nextProvider } from 'ra-i18n-i18next'; + * + * const App = () => { + * const i18nProvider = useI18nextProvider(); + * if (!i18nProvider) return (
Loading...
); + * + * return ( + * + * ... + * + * ); + * }; + * + * @example With a custom i18next instance and options + * import { Admin } from 'react-admin'; + * import { useI18nextProvider } from 'ra-i18n-i18next'; + * import i18n from 'i18next'; + * import Backend from 'i18next-http-backend'; + * import LanguageDetector from 'i18next-browser-languagedetector'; + * + * const App = () => { + * const i18nextInstance = i18n + * .use(Backend) + * .use(LanguageDetector); + * + * const i18nProvider = useI18nextProvider({ + * i18nInstance: i18nextInstance, + * options: { + * fallbackLng: 'en', + * debug: true, + * interpolation: { + * escapeValue: false, // not needed for react!! + * }, + * } + * }); + * + * if (!i18nProvider) return (
Loading...
); + * + * return ( + * + * ... + * + * ); + * }; */ export const useI18nextProvider = ({ i18nInstance = createInstance(), From 9b6a41fcdf1a7f5c5562079d66e7dcbf3db0efa2 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 28 Sep 2023 15:45:37 +0200 Subject: [PATCH 12/18] Add documentation --- docs/Reference.md | 1 + docs/Translation.md | 54 +++++++++++ docs/convertRaMessagesToI18next.md | 43 ++++++++ docs/navigation.html | 2 + docs/useI18nextProvider.md | 151 +++++++++++++++++++++++++++++ packages/ra-i18n-i18next/README.md | 7 -- 6 files changed, 251 insertions(+), 7 deletions(-) create mode 100644 docs/convertRaMessagesToI18next.md create mode 100644 docs/useI18nextProvider.md diff --git a/docs/Reference.md b/docs/Reference.md index 857fb7763ca..fe345224473 100644 --- a/docs/Reference.md +++ b/docs/Reference.md @@ -250,6 +250,7 @@ title: "Index" **- I -** * [`useInfiniteGetList`](./useInfiniteGetList.md) * [`useInput`](./useInput.md) +* [`useI18nextProvider`](./useI18nextProvider.md) **- L -** * [`useList`](./useList.md) diff --git a/docs/Translation.md b/docs/Translation.md index d832d571990..4699a418cae 100644 --- a/docs/Translation.md +++ b/docs/Translation.md @@ -130,6 +130,60 @@ const App = () => ( Check [the translation setup documentation](./TranslationSetup.md) for details about `ra-i18n-polyglot` and how to configure it. +## `ra-i18n-i18next` + +React-admin also provides a package called `ra-i18n-i18next` that leverages [the i18next library](https://www.i18next.com/) to build an `i18nProvider` based on a dictionary of translations. + +```tsx +// in src/i18nProvider.js +import { useI18nextProvider, convertRaTranslationsToI18next } from 'ra-i18n-i18next'; +import en from 'ra-language-english'; +import fr from 'ra-language-french'; + +const i18nInstance = i18n.use( + resourcesToBackend(language => { + if (language === 'fr') { + return import( + `ra-language-french` + ).then(({ default: messages }) => + convertRaTranslationsToI18next(messages) + ); + } + return import(`ra-language-english`).then(({ default: messages }) => + convertRaTranslationsToI18next(messages) + ); + }) +); + +export const useMyI18nProvider = useI18nextProvider({ + i18nInstance, + availableLocales: [ + { locale: 'en', name: 'English' }, + { locale: 'fr', name: 'French' }, + ], +}); + +// in src/App.js +import { Admin } from 'react-admin'; +import { useMyI18nProvider } from './i18nProvider'; + +const App = () => { + const i18nProvider = useMyI18nProvider(); + if (!i18nProvider) return null; + + return ( + + ... + + ); +}; +``` + +Check [the useI18nextProvider documentation](./useI18nextProvider.md) for details about `ra-i18n-i18next` and how to configure it. + ## Translation Files `ra-i18n-polyglot` relies on JSON objects for translations. This means that the only thing required to add support for a new language is a JSON file. diff --git a/docs/convertRaMessagesToI18next.md b/docs/convertRaMessagesToI18next.md new file mode 100644 index 00000000000..6945be0f929 --- /dev/null +++ b/docs/convertRaMessagesToI18next.md @@ -0,0 +1,43 @@ +--- +layout: default +title: "convertRaTranslationsToI18next" +--- + +# `convertRaTranslationsToI18next` + +A function that takes translations from a standard react-admin language package and converts them to i18next format. +It transforms the following: +- interpolations wrappers from `%{foo}` to `{{foo}}` unless a prefix and/or a suffix are provided +- pluralization messages from a single key containing text like `"key": "foo |||| bar"` to multiple keys `"foo_one": "foo"` and `"foo_other": "bar"` + +## Usage + +```ts +import englishMessages from 'ra-language-english'; +import { convertRaMessagesToI18next } from 'ra-i18n-18next'; + +const messages = convertRaMessagesToI18next(englishMessages); +``` + +## Parameters + +| Parameter | Required | Type | Default | Description | +| -------------------- | -------- | ----------- | ------- | ---------------------------------------------------------------- | +| `raMessages` | Required | object | | An object containing standard react-admin translations such as provided by ra-language-english | +| `options` | Optional | object | | An object providing custom interpolation suffix and/or suffix | + +@example Convert the english translations from ra-language-english to i18next format with custom interpolation wrappers + +### `options` + +If you provided interpolation options to your i18next instance, you should provide them when calling this function: + +```ts +import englishMessages from 'ra-language-english'; +import { convertRaMessagesToI18next } from 'ra-i18n-18next'; + +const messages = convertRaMessagesToI18next(englishMessages, { + prefix: '#{', + suffix: '}#', +}); +``` diff --git a/docs/navigation.html b/docs/navigation.html index 91e7c3bf93a..fc337143d06 100644 --- a/docs/navigation.html +++ b/docs/navigation.html @@ -226,6 +226,8 @@
  • useTranslate
  • useLocaleState
  • <LocalesMenuButton>
  • +
  • useI18nextProvider
  • +
  • convertRaTranslationsToI18next
    • Other UI components
      diff --git a/docs/useI18nextProvider.md b/docs/useI18nextProvider.md new file mode 100644 index 00000000000..91dfc5551b8 --- /dev/null +++ b/docs/useI18nextProvider.md @@ -0,0 +1,151 @@ +--- +layout: default +title: "useI18nextProvider" +--- + +# `useI18nextProvider` + +A hook that returns an i18nProvider for react-admin applications, based on i18next. + +You can provide your own i18next instance but don't initialize it, the hook will do it for you with the options you may provide. Besides, this hook already adds the `initReactI18next` plugin to i18next. + +## Usage + +```tsx +import { Admin } from 'react-admin'; +import { useI18nextProvider, convertRaMessagesToI18next } from 'ra-i18n-i18next'; +import englishMessages from 'ra-language-english'; + +const App = () => { + const i18nProvider = useI18nextProvider({ + options: { + resources: { + translations: convertRaMessagesToI18next(englishMessages) + } + } + }); + if (!i18nProvider) return (
      Loading...
      ); + + return ( + + ... + + ); +}; +``` + +## Parameters + +| Parameter | Required | Type | Default | Description | +| -------------------- | -------- | ----------- | ------- | ---------------------------------------------------------------- | +| `i18nextInstance` | Optional | I18n | | Your own i18next instance. If not provided, one will be created. | +| `options` | Optional | InitOptions | | The options passed to the i18next init function | +| `availableLocales` | Optional | Locale[] | | An array describing the available locales. Used to automatically include the locale selector menu in the default react-admin AppBar | + +### `i18nextInstance` + +This parameter let you pass your own instance of i18next, allowing you to customize its plugins such as the backends. + +```tsx +import { Admin } from 'react-admin'; +import { useI18nextProvider } from 'ra-i18n-i18next'; +import i18n from 'i18next'; +import Backend from 'i18next-http-backend'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +const App = () => { + const i18nextInstance = i18n + .use(Backend) + .use(LanguageDetector); + + const i18nProvider = useI18nextProvider({ + i18nInstance: i18nextInstance, + options: { + fallbackLng: 'en', + debug: true, + interpolation: { + escapeValue: false, // not needed for react!! + }, + } + }); + + if (!i18nProvider) return (
      Loading...
      ); + + return ( + + ... + + ); +}; +``` + +### `options` + +This parameter let you pass your own options for the i18n `init` function. + +```tsx +import { Admin } from 'react-admin'; +import { useI18nextProvider } from 'ra-i18n-i18next'; +import i18n from 'i18next'; + +const App = () => { + const i18nProvider = useI18nextProvider({ + options: { + fallbackLng: 'en', + debug: true, + } + }); + + if (!i18nProvider) return (
      Loading...
      ); + + return ( + + ... + + ); +}; +``` + +## `availableLocales` + +This parameter let you provide the list of available locales for your application. This is used by the default react-admin AppBar to detect whether to display a locale selector. + +```tsx +import { Admin } from 'react-admin'; +import { useI18nextProvider } from 'ra-i18n-i18next'; +import i18n from 'i18next'; +import resourcesToBackend from 'i18next-resources-to-backend'; + +const App = () => { + const i18nInstance = i18n.use( + resourcesToBackend(language => { + if (language === 'fr') { + return import( + `ra-language-french` + ).then(({ default: messages }) => + convertRaTranslationsToI18next(messages) + ); + } + return import(`ra-language-english`).then(({ default: messages }) => + convertRaTranslationsToI18next(messages) + ); + }) + ); + + const i18nProvider = useI18nextProvider({ + i18nInstance, + availableLocales: [ + { locale: 'en', name: 'English' }, + { locale: 'fr', name: 'French' }, + ], + }); + + if (!i18nProvider) return (
      Loading...
      ); + + return ( + + ... + + ); +}; +``` \ No newline at end of file diff --git a/packages/ra-i18n-i18next/README.md b/packages/ra-i18n-i18next/README.md index c138c557e10..47d5ea4679e 100644 --- a/packages/ra-i18n-i18next/README.md +++ b/packages/ra-i18n-i18next/README.md @@ -189,13 +189,6 @@ It transforms the following: - interpolations wrappers from `%{foo}` to `{{foo}}` unless a prefix and/or a suffix are provided - pluralization messages from a single key containing text like `"key": "foo |||| bar"` to multiple keys `"foo_one": "foo"` and `"foo_other": "bar"` - -@param raMessages The translations to convert. This is an object as found in packages such as ra-language-english. -@param options Options to customize the conversion. -@param options.prefix The prefix to use for interpolation variables. Defaults to `{{`. -@param options.suffix The suffix to use for interpolation variables. Defaults to `}}`. -@returns The converted translations as an object. - #### Usage ```ts From d86b6b1c643794412531575b8c9a6d48e68eab18 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Mon, 2 Oct 2023 12:17:31 +0200 Subject: [PATCH 13/18] Apply review suggestions --- docs/Translation.md | 11 +- docs/convertRaMessagesToI18next.md | 43 ----- docs/useI18nextProvider.md | 151 ------------------ packages/ra-i18n-i18next/README.md | 27 ++-- .../ra-i18n-i18next/src/index.stories.tsx | 4 +- .../ra-i18n-i18next/src/useI18nextProvider.ts | 18 +-- 6 files changed, 34 insertions(+), 220 deletions(-) delete mode 100644 docs/convertRaMessagesToI18next.md delete mode 100644 docs/useI18nextProvider.md diff --git a/docs/Translation.md b/docs/Translation.md index 4699a418cae..446bed5055b 100644 --- a/docs/Translation.md +++ b/docs/Translation.md @@ -134,6 +134,15 @@ Check [the translation setup documentation](./TranslationSetup.md) for details a React-admin also provides a package called `ra-i18n-i18next` that leverages [the i18next library](https://www.i18next.com/) to build an `i18nProvider` based on a dictionary of translations. +You might prefer this package over `ra-i18n-polyglot` when: +- you already use i18next services such as [locize](https://locize.com/) +- you want more control on how you organize translations, leveraging [multiple files and namespaces](https://www.i18next.com/principles/namespaces) +- you want more control on how you [load translations](https://www.i18next.com/how-to/add-or-load-translations) +- you want to use features not available in Polyglot such as: + - [advanced formatting](https://www.i18next.com/translation-function/formatting); + - [nested translations](https://www.i18next.com/translation-function/nesting) + - [context](https://www.i18next.com/translation-function/context) + ```tsx // in src/i18nProvider.js import { useI18nextProvider, convertRaTranslationsToI18next } from 'ra-i18n-i18next'; @@ -182,7 +191,7 @@ const App = () => { }; ``` -Check [the useI18nextProvider documentation](./useI18nextProvider.md) for details about `ra-i18n-i18next` and how to configure it. +Check [the ra-i18n-18next documentation](https://github.com/marmelab/react-admin/tree/master/packages/ra-i18n-18next) for details. ## Translation Files diff --git a/docs/convertRaMessagesToI18next.md b/docs/convertRaMessagesToI18next.md deleted file mode 100644 index 6945be0f929..00000000000 --- a/docs/convertRaMessagesToI18next.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -layout: default -title: "convertRaTranslationsToI18next" ---- - -# `convertRaTranslationsToI18next` - -A function that takes translations from a standard react-admin language package and converts them to i18next format. -It transforms the following: -- interpolations wrappers from `%{foo}` to `{{foo}}` unless a prefix and/or a suffix are provided -- pluralization messages from a single key containing text like `"key": "foo |||| bar"` to multiple keys `"foo_one": "foo"` and `"foo_other": "bar"` - -## Usage - -```ts -import englishMessages from 'ra-language-english'; -import { convertRaMessagesToI18next } from 'ra-i18n-18next'; - -const messages = convertRaMessagesToI18next(englishMessages); -``` - -## Parameters - -| Parameter | Required | Type | Default | Description | -| -------------------- | -------- | ----------- | ------- | ---------------------------------------------------------------- | -| `raMessages` | Required | object | | An object containing standard react-admin translations such as provided by ra-language-english | -| `options` | Optional | object | | An object providing custom interpolation suffix and/or suffix | - -@example Convert the english translations from ra-language-english to i18next format with custom interpolation wrappers - -### `options` - -If you provided interpolation options to your i18next instance, you should provide them when calling this function: - -```ts -import englishMessages from 'ra-language-english'; -import { convertRaMessagesToI18next } from 'ra-i18n-18next'; - -const messages = convertRaMessagesToI18next(englishMessages, { - prefix: '#{', - suffix: '}#', -}); -``` diff --git a/docs/useI18nextProvider.md b/docs/useI18nextProvider.md deleted file mode 100644 index 91dfc5551b8..00000000000 --- a/docs/useI18nextProvider.md +++ /dev/null @@ -1,151 +0,0 @@ ---- -layout: default -title: "useI18nextProvider" ---- - -# `useI18nextProvider` - -A hook that returns an i18nProvider for react-admin applications, based on i18next. - -You can provide your own i18next instance but don't initialize it, the hook will do it for you with the options you may provide. Besides, this hook already adds the `initReactI18next` plugin to i18next. - -## Usage - -```tsx -import { Admin } from 'react-admin'; -import { useI18nextProvider, convertRaMessagesToI18next } from 'ra-i18n-i18next'; -import englishMessages from 'ra-language-english'; - -const App = () => { - const i18nProvider = useI18nextProvider({ - options: { - resources: { - translations: convertRaMessagesToI18next(englishMessages) - } - } - }); - if (!i18nProvider) return (
      Loading...
      ); - - return ( - - ... - - ); -}; -``` - -## Parameters - -| Parameter | Required | Type | Default | Description | -| -------------------- | -------- | ----------- | ------- | ---------------------------------------------------------------- | -| `i18nextInstance` | Optional | I18n | | Your own i18next instance. If not provided, one will be created. | -| `options` | Optional | InitOptions | | The options passed to the i18next init function | -| `availableLocales` | Optional | Locale[] | | An array describing the available locales. Used to automatically include the locale selector menu in the default react-admin AppBar | - -### `i18nextInstance` - -This parameter let you pass your own instance of i18next, allowing you to customize its plugins such as the backends. - -```tsx -import { Admin } from 'react-admin'; -import { useI18nextProvider } from 'ra-i18n-i18next'; -import i18n from 'i18next'; -import Backend from 'i18next-http-backend'; -import LanguageDetector from 'i18next-browser-languagedetector'; - -const App = () => { - const i18nextInstance = i18n - .use(Backend) - .use(LanguageDetector); - - const i18nProvider = useI18nextProvider({ - i18nInstance: i18nextInstance, - options: { - fallbackLng: 'en', - debug: true, - interpolation: { - escapeValue: false, // not needed for react!! - }, - } - }); - - if (!i18nProvider) return (
      Loading...
      ); - - return ( - - ... - - ); -}; -``` - -### `options` - -This parameter let you pass your own options for the i18n `init` function. - -```tsx -import { Admin } from 'react-admin'; -import { useI18nextProvider } from 'ra-i18n-i18next'; -import i18n from 'i18next'; - -const App = () => { - const i18nProvider = useI18nextProvider({ - options: { - fallbackLng: 'en', - debug: true, - } - }); - - if (!i18nProvider) return (
      Loading...
      ); - - return ( - - ... - - ); -}; -``` - -## `availableLocales` - -This parameter let you provide the list of available locales for your application. This is used by the default react-admin AppBar to detect whether to display a locale selector. - -```tsx -import { Admin } from 'react-admin'; -import { useI18nextProvider } from 'ra-i18n-i18next'; -import i18n from 'i18next'; -import resourcesToBackend from 'i18next-resources-to-backend'; - -const App = () => { - const i18nInstance = i18n.use( - resourcesToBackend(language => { - if (language === 'fr') { - return import( - `ra-language-french` - ).then(({ default: messages }) => - convertRaTranslationsToI18next(messages) - ); - } - return import(`ra-language-english`).then(({ default: messages }) => - convertRaTranslationsToI18next(messages) - ); - }) - ); - - const i18nProvider = useI18nextProvider({ - i18nInstance, - availableLocales: [ - { locale: 'en', name: 'English' }, - { locale: 'fr', name: 'French' }, - ], - }); - - if (!i18nProvider) return (
      Loading...
      ); - - return ( - - ... - - ); -}; -``` \ No newline at end of file diff --git a/packages/ra-i18n-i18next/README.md b/packages/ra-i18n-i18next/README.md index 47d5ea4679e..745ffba503f 100644 --- a/packages/ra-i18n-i18next/README.md +++ b/packages/ra-i18n-i18next/README.md @@ -76,7 +76,7 @@ const App = () => { ##### `i18nextInstance` -This parameter let you pass your own instance of i18next, allowing you to customize its plugins such as the backends. +This parameter lets you pass your own instance of i18next, allowing you to customize its plugins such as the backends. ```tsx import { Admin } from 'react-admin'; @@ -91,14 +91,7 @@ const App = () => { .use(LanguageDetector); const i18nProvider = useI18nextProvider({ - i18nInstance: i18nextInstance, - options: { - fallbackLng: 'en', - debug: true, - interpolation: { - escapeValue: false, // not needed for react!! - }, - } + i18nextInstance }); if (!i18nProvider) return (
      Loading...
      ); @@ -113,7 +106,9 @@ const App = () => { ##### `options` -This parameter let you pass your own options for the i18n `init` function. +This parameter lets you pass your own options for the i18n `init` function. + +Please refer to [the i18next documentation](https://www.i18next.com/overview/configuration-options) for details. ```tsx import { Admin } from 'react-admin'; @@ -123,7 +118,6 @@ import i18n from 'i18next'; const App = () => { const i18nProvider = useI18nextProvider({ options: { - fallbackLng: 'en', debug: true, } }); @@ -140,7 +134,7 @@ const App = () => { #### `availableLocales` -This parameter let you provide the list of available locales for your application. This is used by the default react-admin AppBar to detect whether to display a locale selector. +This parameter lets you provide the list of available locales for your application. This is used by the default react-admin AppBar to detect whether to display a locale selector. ```tsx import { Admin } from 'react-admin'; @@ -149,15 +143,20 @@ import i18n from 'i18next'; import resourcesToBackend from 'i18next-resources-to-backend'; const App = () => { - const i18nInstance = i18n.use( + const i18nextInstance = i18n.use( + // Here we use a Backend provided by i18next that allows us to load + // the translations however we want. + // See https://www.i18next.com/how-to/add-or-load-translations#lazy-load-in-memory-translations resourcesToBackend(language => { if (language === 'fr') { + // Load the ra-language-french package and convert its translations in i18next format return import( `ra-language-french` ).then(({ default: messages }) => convertRaTranslationsToI18next(messages) ); } + // Load the ra-language-english package and convert its translations in i18next format return import(`ra-language-english`).then(({ default: messages }) => convertRaTranslationsToI18next(messages) ); @@ -165,7 +164,7 @@ const App = () => { ); const i18nProvider = useI18nextProvider({ - i18nInstance, + i18nextInstance, availableLocales: [ { locale: 'en', name: 'English' }, { locale: 'fr', name: 'French' }, diff --git a/packages/ra-i18n-i18next/src/index.stories.tsx b/packages/ra-i18n-i18next/src/index.stories.tsx index abe21a7e94d..50ad623e7a5 100644 --- a/packages/ra-i18n-i18next/src/index.stories.tsx +++ b/packages/ra-i18n-i18next/src/index.stories.tsx @@ -46,7 +46,7 @@ export const Basic = () => { }; export const WithLazyLoadedLanguages = () => { - const i18nInstance = i18n.use( + const i18nextInstance = i18n.use( resourcesToBackend(language => { if (language === 'fr') { return import( @@ -62,7 +62,7 @@ export const WithLazyLoadedLanguages = () => { ); const i18nProvider = useI18nextProvider({ - i18nInstance, + i18nextInstance, availableLocales: [ { locale: 'en', name: 'English' }, { locale: 'fr', name: 'French' }, diff --git a/packages/ra-i18n-i18next/src/useI18nextProvider.ts b/packages/ra-i18n-i18next/src/useI18nextProvider.ts index adc59001837..64a507315e8 100644 --- a/packages/ra-i18n-i18next/src/useI18nextProvider.ts +++ b/packages/ra-i18n-i18next/src/useI18nextProvider.ts @@ -56,11 +56,11 @@ import { I18nProvider, Locale } from 'ra-core'; * }; */ export const useI18nextProvider = ({ - i18nInstance = createInstance(), + i18nextInstance = createInstance(), options = {}, availableLocales = [{ locale: 'en', name: 'English' }], }: { - i18nInstance?: I18n; + i18nextInstance?: I18n; options?: InitOptions; availableLocales?: Locale[]; } = {}) => { @@ -73,26 +73,26 @@ export const useI18nextProvider = ({ } initializationPromise.current = getI18nProvider( - i18nInstance, + i18nextInstance, options, availableLocales ).then(provider => { setI18nProvider(provider); return provider; }); - }, [availableLocales, i18nInstance, options]); + }, [availableLocales, i18nextInstance, options]); return i18nProvider; }; export const getI18nProvider = async ( - i18nInstance: I18n, + i18nextInstance: I18n, options?: InitOptions, availableLocales: Locale[] = [{ locale: 'en', name: 'English' }] ): Promise => { let translate: TFunction; - await i18nInstance + await i18nextInstance .use(initReactI18next) .init({ lng: 'en', @@ -115,11 +115,11 @@ export const getI18nProvider = async ( }).toString(); }, changeLocale: async (newLocale: string) => { - await i18nInstance.loadLanguages(newLocale); - const t = await i18nInstance.changeLanguage(newLocale); + await i18nextInstance.loadLanguages(newLocale); + const t = await i18nextInstance.changeLanguage(newLocale); translate = t; }, - getLocale: () => i18nInstance.language, + getLocale: () => i18nextInstance.language, getLocales: () => { return availableLocales; }, From c44eed0184c041eb7fab777587f43937c82c57c9 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Mon, 2 Oct 2023 12:18:05 +0200 Subject: [PATCH 14/18] Update reference --- docs/Reference.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/Reference.md b/docs/Reference.md index fe345224473..857fb7763ca 100644 --- a/docs/Reference.md +++ b/docs/Reference.md @@ -250,7 +250,6 @@ title: "Index" **- I -** * [`useInfiniteGetList`](./useInfiniteGetList.md) * [`useInput`](./useInput.md) -* [`useI18nextProvider`](./useI18nextProvider.md) **- L -** * [`useList`](./useList.md) From c0a06efe916f1d8e5f7399dd8244ea8e32d5ea12 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Mon, 2 Oct 2023 17:15:26 +0200 Subject: [PATCH 15/18] Apply suggestions from review --- docs/Translation.md | 5 ++-- docs/navigation.html | 2 -- packages/ra-i18n-i18next/README.md | 13 +++++++--- packages/ra-i18n-i18next/package.json | 4 ++-- ...> convertRaTranslationsToI18next.spec.tsx} | 0 ...t.ts => convertRaTranslationsToI18next.ts} | 0 .../ra-i18n-i18next/src/index.stories.tsx | 10 ++++---- packages/ra-i18n-i18next/src/index.ts | 2 +- packages/ra-i18n-i18next/src/stories-en.ts | 24 +++++++++++++++++++ packages/ra-i18n-i18next/src/stories-fr.ts | 24 +++++++++++++++++++ 10 files changed, 68 insertions(+), 16 deletions(-) rename packages/ra-i18n-i18next/src/{convertRaMessagesToI18next.spec.tsx => convertRaTranslationsToI18next.spec.tsx} (100%) rename packages/ra-i18n-i18next/src/{convertRaMessagesToI18next.ts => convertRaTranslationsToI18next.ts} (100%) create mode 100644 packages/ra-i18n-i18next/src/stories-en.ts create mode 100644 packages/ra-i18n-i18next/src/stories-fr.ts diff --git a/docs/Translation.md b/docs/Translation.md index 446bed5055b..4f85120dc0f 100644 --- a/docs/Translation.md +++ b/docs/Translation.md @@ -145,6 +145,7 @@ You might prefer this package over `ra-i18n-polyglot` when: ```tsx // in src/i18nProvider.js +import i18n from 'i18next'; import { useI18nextProvider, convertRaTranslationsToI18next } from 'ra-i18n-i18next'; import en from 'ra-language-english'; import fr from 'ra-language-french'; @@ -172,7 +173,7 @@ export const useMyI18nProvider = useI18nextProvider({ ], }); -// in src/App.js +// in src/App.tsx import { Admin } from 'react-admin'; import { useMyI18nProvider } from './i18nProvider'; @@ -191,7 +192,7 @@ const App = () => { }; ``` -Check [the ra-i18n-18next documentation](https://github.com/marmelab/react-admin/tree/master/packages/ra-i18n-18next) for details. +Check [the ra-i18n-i18next documentation](https://github.com/marmelab/react-admin/tree/master/packages/ra-i18n-i18next) for details. ## Translation Files diff --git a/docs/navigation.html b/docs/navigation.html index fc337143d06..91e7c3bf93a 100644 --- a/docs/navigation.html +++ b/docs/navigation.html @@ -226,8 +226,6 @@
    • useTranslate
    • useLocaleState
    • <LocalesMenuButton>
    • -
    • useI18nextProvider
    • -
    • convertRaTranslationsToI18next
      Other UI components
      diff --git a/packages/ra-i18n-i18next/README.md b/packages/ra-i18n-i18next/README.md index 745ffba503f..81af7c7fcb7 100644 --- a/packages/ra-i18n-i18next/README.md +++ b/packages/ra-i18n-i18next/README.md @@ -2,6 +2,15 @@ i18next i18n provider for [react-admin](https://github.com/marmelab/react-admin), the frontend framework for building admin applications on top of REST/GraphQL services. It relies on [i18next](https://www.i18next.com/). +You might prefer this package over `ra-i18n-polyglot` when: +- you already use i18next services such as [locize](https://locize.com/) +- you want more control on how you organize translations, leveraging [multiple files and namespaces](https://www.i18next.com/principles/namespaces) +- you want more control on how you [load translations](https://www.i18next.com/how-to/add-or-load-translations) +- you want to use features not available in Polyglot such as: + - [advanced formatting](https://www.i18next.com/translation-function/formatting); + - [nested translations](https://www.i18next.com/translation-function/nesting) + - [context](https://www.i18next.com/translation-function/context) + ## Installation ```sh @@ -138,7 +147,7 @@ This parameter lets you provide the list of available locales for your applicati ```tsx import { Admin } from 'react-admin'; -import { useI18nextProvider } from 'ra-i18n-i18next'; +import { useI18nextProvider, convertRaTranslationsToI18next } from 'ra-i18n-i18next'; import i18n from 'i18next'; import resourcesToBackend from 'i18next-resources-to-backend'; @@ -204,8 +213,6 @@ const messages = convertRaMessagesToI18next(englishMessages); | `raMessages` | Required | object | | An object containing standard react-admin translations such as provided by ra-language-english | | `options` | Optional | object | | An object providing custom interpolation suffix and/or suffix | -@example Convert the english translations from ra-language-english to i18next format with custom interpolation wrappers - ##### `options` If you provided interpolation options to your i18next instance, you should provide them when calling this function: diff --git a/packages/ra-i18n-i18next/package.json b/packages/ra-i18n-i18next/package.json index 07dd91b5a61..6935e9f421d 100644 --- a/packages/ra-i18n-i18next/package.json +++ b/packages/ra-i18n-i18next/package.json @@ -26,12 +26,12 @@ }, "dependencies": { "i18next": "^23.5.1", - "ra-core": "^4.13.4" + "ra-core": "^4.13.4", + "react-i18next": "^13.2.2" }, "devDependencies": { "cross-env": "^5.2.0", "i18next-resources-to-backend": "^1.1.4", - "react-i18next": "^13.2.2", "rimraf": "^3.0.2", "typescript": "^5.1.3" }, diff --git a/packages/ra-i18n-i18next/src/convertRaMessagesToI18next.spec.tsx b/packages/ra-i18n-i18next/src/convertRaTranslationsToI18next.spec.tsx similarity index 100% rename from packages/ra-i18n-i18next/src/convertRaMessagesToI18next.spec.tsx rename to packages/ra-i18n-i18next/src/convertRaTranslationsToI18next.spec.tsx diff --git a/packages/ra-i18n-i18next/src/convertRaMessagesToI18next.ts b/packages/ra-i18n-i18next/src/convertRaTranslationsToI18next.ts similarity index 100% rename from packages/ra-i18n-i18next/src/convertRaMessagesToI18next.ts rename to packages/ra-i18n-i18next/src/convertRaTranslationsToI18next.ts diff --git a/packages/ra-i18n-i18next/src/index.stories.tsx b/packages/ra-i18n-i18next/src/index.stories.tsx index 50ad623e7a5..02c255afe7b 100644 --- a/packages/ra-i18n-i18next/src/index.stories.tsx +++ b/packages/ra-i18n-i18next/src/index.stories.tsx @@ -6,10 +6,10 @@ import englishMessages from 'ra-language-english'; import fakeRestDataProvider from 'ra-data-fakerest'; import { MemoryRouter } from 'react-router-dom'; import { useI18nextProvider } from './index'; -import { convertRaTranslationsToI18next } from './convertRaMessagesToI18next'; +import { convertRaTranslationsToI18next } from './convertRaTranslationsToI18next'; export default { - title: 'ra-i18n-18next', + title: 'ra-i18n-i18next', }; export const Basic = () => { @@ -49,13 +49,11 @@ export const WithLazyLoadedLanguages = () => { const i18nextInstance = i18n.use( resourcesToBackend(language => { if (language === 'fr') { - return import( - `ra-language-french` - ).then(({ default: messages }) => + return import(`./stories-fr`).then(({ default: messages }) => convertRaTranslationsToI18next(messages) ); } - return import(`ra-language-english`).then(({ default: messages }) => + return import(`./stories-en`).then(({ default: messages }) => convertRaTranslationsToI18next(messages) ); }) diff --git a/packages/ra-i18n-i18next/src/index.ts b/packages/ra-i18n-i18next/src/index.ts index a91c2bc04db..1059f73135e 100644 --- a/packages/ra-i18n-i18next/src/index.ts +++ b/packages/ra-i18n-i18next/src/index.ts @@ -1,2 +1,2 @@ export * from './useI18nextProvider'; -export * from './convertRaMessagesToI18next'; +export * from './convertRaTranslationsToI18next'; diff --git a/packages/ra-i18n-i18next/src/stories-en.ts b/packages/ra-i18n-i18next/src/stories-en.ts new file mode 100644 index 00000000000..86f61424798 --- /dev/null +++ b/packages/ra-i18n-i18next/src/stories-en.ts @@ -0,0 +1,24 @@ +import raMessages from 'ra-language-french'; +import { convertRaTranslationsToI18next } from './convertRaTranslationsToI18next'; + +export default { + ...convertRaTranslationsToI18next(raMessages), + resources: { + posts: { + name_one: 'Post', + name_other: 'Posts', + fields: { + id: 'Id', + title: 'Title', + }, + }, + comments: { + name_one: 'Comment', + name_other: 'Comments', + fields: { + id: 'Id', + body: 'Message', + }, + }, + }, +}; diff --git a/packages/ra-i18n-i18next/src/stories-fr.ts b/packages/ra-i18n-i18next/src/stories-fr.ts new file mode 100644 index 00000000000..8e577a8b747 --- /dev/null +++ b/packages/ra-i18n-i18next/src/stories-fr.ts @@ -0,0 +1,24 @@ +import raMessages from 'ra-language-french'; +import { convertRaTranslationsToI18next } from './convertRaTranslationsToI18next'; + +export default { + ...convertRaTranslationsToI18next(raMessages), + resources: { + posts: { + name_one: 'Article', + name_other: 'Articles', + fields: { + id: 'Id', + title: 'Titre', + }, + }, + comments: { + name_one: 'Commentaire', + name_other: 'Commentaires', + fields: { + id: 'Id', + body: 'Message', + }, + }, + }, +}; From 7b65502bc192f5e512125c9a35b147415449b66f Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Tue, 3 Oct 2023 09:04:36 +0200 Subject: [PATCH 16/18] Fix tests --- .../src/convertRaTranslationsToI18next.spec.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ra-i18n-i18next/src/convertRaTranslationsToI18next.spec.tsx b/packages/ra-i18n-i18next/src/convertRaTranslationsToI18next.spec.tsx index 822b0f64ec8..dbb243b2606 100644 --- a/packages/ra-i18n-i18next/src/convertRaTranslationsToI18next.spec.tsx +++ b/packages/ra-i18n-i18next/src/convertRaTranslationsToI18next.spec.tsx @@ -1,7 +1,7 @@ -import { convertRaTranslationsToI18next } from './convertRaMessagesToI18next'; +import { convertRaTranslationsToI18next } from './convertRaTranslationsToI18next'; describe('i18next i18nProvider', () => { - describe('convertRaMessagesToI18next', () => { + describe('convertRaTranslationsToI18next', () => { test('should convert react-admin default messages to i18next format', () => { expect( convertRaTranslationsToI18next( From ff0c668cdaea0464da8975aafe9490a45523895f Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Tue, 3 Oct 2023 09:34:34 +0200 Subject: [PATCH 17/18] Fix wrong custom translations in stories --- packages/ra-i18n-i18next/src/stories-en.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ra-i18n-i18next/src/stories-en.ts b/packages/ra-i18n-i18next/src/stories-en.ts index 86f61424798..9dcdb278742 100644 --- a/packages/ra-i18n-i18next/src/stories-en.ts +++ b/packages/ra-i18n-i18next/src/stories-en.ts @@ -1,4 +1,4 @@ -import raMessages from 'ra-language-french'; +import raMessages from 'ra-language-english'; import { convertRaTranslationsToI18next } from './convertRaTranslationsToI18next'; export default { From 1071f8cbb8cba069dfb358415bbc3a03e20a9406 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Tue, 3 Oct 2023 11:29:22 +0200 Subject: [PATCH 18/18] Fix documentation --- docs/Translation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Translation.md b/docs/Translation.md index 4f85120dc0f..ca214e71cbb 100644 --- a/docs/Translation.md +++ b/docs/Translation.md @@ -165,7 +165,7 @@ const i18nInstance = i18n.use( }) ); -export const useMyI18nProvider = useI18nextProvider({ +export const useMyI18nProvider = () => useI18nextProvider({ i18nInstance, availableLocales: [ { locale: 'en', name: 'English' },