From cc168eb49faf7495bc653ff104f6cac1c090dbbe Mon Sep 17 00:00:00 2001 From: Daniel <10026790+dnlggr@users.noreply.github.com> Date: Wed, 9 Mar 2022 16:47:17 +0100 Subject: [PATCH] feat: i18n (#153) * Add basic i18n with i18next * Add transifex config * Add translation strings for wallets screen * Delete transifex config for now * Remove suspension * Add exemplary german translation * Add language switcher * Add link to telegram group for translations * Update tests * Fix hook deps * Add tx config * Move i18n code to subfolder * Update i18n usage in tests * Remove unused call * Pull de translation from transifex * Add npm scripts for pushing and pulling * Put back deps * Put deps into dev * Add tx gh config * Add readme * test: add empty new lang * Remove test langs * Remove cli integration * Update src/i18n/README.md Co-authored-by: Thebora Kompanioni * Update src/i18n/README.md Co-authored-by: Thebora Kompanioni * Update src/i18n/README.md Co-authored-by: Thebora Kompanioni * Update public/sprite.svg Co-authored-by: Thebora Kompanioni * Change link * Run npm install * Update readme * Update readme again Co-authored-by: Thebora Kompanioni --- .tx/transifex.yml | 9 ++ package-lock.json | 120 +++++++++++++++++++++++++++ package.json | 7 +- public/sprite.svg | 1 + src/__util__/AllTheProviders.jsx | 17 ---- src/components/App.test.jsx | 30 ++----- src/components/Settings.jsx | 26 ++++++ src/components/Wallets.jsx | 20 +++-- src/components/Wallets.test.jsx | 53 +++++------- src/i18n/README.md | 82 ++++++++++++++++++ src/i18n/config.js | 18 ++++ src/i18n/languages.js | 11 +++ src/i18n/locales/en/translation.json | 10 +++ src/i18n/testConfig.js | 10 +++ src/index.jsx | 1 + src/testUtils.jsx | 30 +++++++ 16 files changed, 365 insertions(+), 80 deletions(-) create mode 100644 .tx/transifex.yml delete mode 100644 src/__util__/AllTheProviders.jsx create mode 100644 src/i18n/README.md create mode 100644 src/i18n/config.js create mode 100644 src/i18n/languages.js create mode 100644 src/i18n/locales/en/translation.json create mode 100644 src/i18n/testConfig.js create mode 100644 src/testUtils.jsx diff --git a/.tx/transifex.yml b/.tx/transifex.yml new file mode 100644 index 00000000..5f676d8e --- /dev/null +++ b/.tx/transifex.yml @@ -0,0 +1,9 @@ +git: + filters: + - filter_type: file + file_format: KEYVALUEJSON + source_file: src/i18n/locales/en/translation.json + source_language: en + translation_files_expression: src/i18n/locales//translation.json + settings: + pr_branch_name: tx_translations_ diff --git a/package-lock.json b/package-lock.json index 0fba3d2e..3394674c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,8 @@ "conventional-changelog": "^3.1.25", "http-proxy-middleware": "^2.0.1", "husky": "^7.0.4", + "i18next": "^21.6.12", + "i18next-browser-languagedetector": "^6.1.3", "jest-watch-typeahead": "^0.6.5", "jest-websocket-mock": "^2.3.0", "lint-staged": "^12.3.1", @@ -32,6 +34,7 @@ "react": "^17.0.2", "react-bootstrap": "^2.1.1", "react-dom": "^17.0.2", + "react-i18next": "^11.15.5", "react-router-bootstrap": "^0.26.0", "react-router-dom": "^6.2.1", "react-scripts": "^5.0.0", @@ -9158,6 +9161,15 @@ "node": ">=12" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "dev": true, + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/html-webpack-plugin": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.5.0.tgz", @@ -9326,6 +9338,38 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/i18next": { + "version": "21.6.12", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-21.6.12.tgz", + "integrity": "sha512-xlGTPdu2g5PZEUIE6TA1mQ9EIAAv9nMFONzgwAIrKL/KTmYYWufQNGgOmp5Og1PvgUji+6i1whz0rMdsz1qaKw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "dependencies": { + "@babel/runtime": "^7.12.0" + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-6.1.3.tgz", + "integrity": "sha512-T+oGXHXtrur14CGnZZ7qQ07X38XJQEI00b/4ILrtO6xPbwTlQ1wtMZC2H+tBULixHuVUXv8LKbxfjyITJkezUg==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.14.6" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -15445,6 +15489,29 @@ "integrity": "sha512-mKR90fX7Pm5seCOfz8q9F+66VCc1PGsWSBxKbITjfKVQHMNF2zudxHnMdJiB1fRCb+XsbQV9sO9DCkgsMQgBIA==", "dev": true }, + "node_modules/react-i18next": { + "version": "11.15.5", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.15.5.tgz", + "integrity": "sha512-vBWuVEQgrhZrGKpyv8FmJ7Zs5jRQWl794Tte7yzJ0okZqqi3jd6j2pLYNg441WcREsbIOvWdiDXbY7W6E93p1A==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.14.5", + "html-escaper": "^2.0.2", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 19.0.0", + "react": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", @@ -18282,6 +18349,15 @@ "node": ">= 0.8" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha1-YU9/v42AHwu18GYfWy9XhXUOTwk=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", @@ -26188,6 +26264,15 @@ "terser": "^5.10.0" } }, + "html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "dev": true, + "requires": { + "void-elements": "3.1.0" + } + }, "html-webpack-plugin": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.5.0.tgz", @@ -26310,6 +26395,24 @@ "integrity": "sha512-vbaCKN2QLtP/vD4yvs6iz6hBEo6wkSzs8HpRah1Z6aGmF2KW5PdYuAd7uX5a+OyBZHBhd+TFLqgjUgytQr4RvQ==", "dev": true }, + "i18next": { + "version": "21.6.12", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-21.6.12.tgz", + "integrity": "sha512-xlGTPdu2g5PZEUIE6TA1mQ9EIAAv9nMFONzgwAIrKL/KTmYYWufQNGgOmp5Og1PvgUji+6i1whz0rMdsz1qaKw==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.0" + } + }, + "i18next-browser-languagedetector": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-6.1.3.tgz", + "integrity": "sha512-T+oGXHXtrur14CGnZZ7qQ07X38XJQEI00b/4ILrtO6xPbwTlQ1wtMZC2H+tBULixHuVUXv8LKbxfjyITJkezUg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.14.6" + } + }, "iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -30815,6 +30918,17 @@ "integrity": "sha512-mKR90fX7Pm5seCOfz8q9F+66VCc1PGsWSBxKbITjfKVQHMNF2zudxHnMdJiB1fRCb+XsbQV9sO9DCkgsMQgBIA==", "dev": true }, + "react-i18next": { + "version": "11.15.5", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.15.5.tgz", + "integrity": "sha512-vBWuVEQgrhZrGKpyv8FmJ7Zs5jRQWl794Tte7yzJ0okZqqi3jd6j2pLYNg441WcREsbIOvWdiDXbY7W6E93p1A==", + "dev": true, + "requires": { + "@babel/runtime": "^7.14.5", + "html-escaper": "^2.0.2", + "html-parse-stringify": "^3.0.1" + } + }, "react-is": { "version": "16.13.1", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", @@ -32937,6 +33051,12 @@ "version": "1.1.2", "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" }, + "void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha1-YU9/v42AHwu18GYfWy9XhXUOTwk=", + "dev": true + }, "w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", diff --git a/package.json b/package.json index 84d1d452..1ea7c852 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,10 @@ "react-router-bootstrap": "^0.26.0", "react-router-dom": "^6.2.1", "react-scripts": "^5.0.0", - "web-vitals": "^2.1.4" + "web-vitals": "^2.1.4", + "i18next": "^21.6.12", + "i18next-browser-languagedetector": "^6.1.3", + "react-i18next": "^11.15.5" }, "dependencies": { "serve": "13.0.2" @@ -75,7 +78,7 @@ "lint-staged": { "src/**/*.{js,jsx,json,css,md}": "prettier --no-error-on-unmatched-pattern --write" }, - "jest": { + "jest": { "extraGlobals": [ "Math", "localStorage" diff --git a/public/sprite.svg b/public/sprite.svg index 41320635..44a28e5f 100644 --- a/public/sprite.svg +++ b/public/sprite.svg @@ -21,4 +21,5 @@ + diff --git a/src/__util__/AllTheProviders.jsx b/src/__util__/AllTheProviders.jsx deleted file mode 100644 index 9c08ce5b..00000000 --- a/src/__util__/AllTheProviders.jsx +++ /dev/null @@ -1,17 +0,0 @@ -import { BrowserRouter } from 'react-router-dom' - -import { SettingsProvider } from '../context/SettingsContext' -import { WalletProvider } from '../context/WalletContext' -import { WebsocketProvider } from '../context/WebsocketContext' - -export const AllTheProviders = ({ children }) => { - return ( - - - - {children} - - - - ) -} diff --git a/src/components/App.test.jsx b/src/components/App.test.jsx index 3ac0c703..0796f1bf 100644 --- a/src/components/App.test.jsx +++ b/src/components/App.test.jsx @@ -1,46 +1,38 @@ import React from 'react' -import { render, screen } from '@testing-library/react' +import { render, screen } from '../testUtils' import { act } from 'react-dom/test-utils' import user from '@testing-library/user-event' -import { AllTheProviders } from '../__util__/AllTheProviders' - import App from './App' it('should display Onboarding screen initially', () => { - act(() => { - render(, { - wrapper: AllTheProviders, - }) - }) + render() // Onboarding screen expect(screen.getByText('Get started')).toBeInTheDocument() expect(screen.getByText('Skip intro')).toBeInTheDocument() // Wallets screen shown after Intro is skipped - expect(screen.queryByText('Your wallets')).not.toBeInTheDocument() + expect(screen.queryByText('wallets.title')).not.toBeInTheDocument() act(() => { const skipIntro = screen.getByText('Skip intro') user.click(skipIntro) }) - expect(screen.getByText('Your wallets')).toBeInTheDocument() + expect(screen.getByText('wallets.title')).toBeInTheDocument() }) it('should display Wallets screen directly when Onboarding screen has been shown', () => { global.__DEV__.addToAppSettings({ showOnboarding: false }) act(() => { - render(, { - wrapper: AllTheProviders, - }) + render() }) // Wallets screen - expect(screen.getByText('Your wallets')).toBeInTheDocument() - expect(screen.getByText('Create new wallet')).toBeInTheDocument() + expect(screen.getByText('wallets.title')).toBeInTheDocument() + expect(screen.getByText('wallets.button_new_wallet')).toBeInTheDocument() // footer expect(screen.getByText('Docs')).toBeInTheDocument() @@ -53,9 +45,7 @@ it('should display a modal with alpha warning information', () => { global.__DEV__.addToAppSettings({ showOnboarding: false }) act(() => { - render(, { - wrapper: AllTheProviders, - }) + render() }) expect(screen.getByText('Read this before using.')).toBeInTheDocument() @@ -74,9 +64,7 @@ it('should display a websocket connection indicator', async () => { global.__DEV__.addToAppSettings({ showOnboarding: false }) act(() => { - render(, { - wrapper: AllTheProviders, - }) + render() }) expect(screen.getByText('•').classList.contains('text-danger')).toBe(true) diff --git a/src/components/Settings.jsx b/src/components/Settings.jsx index 205131c6..9b348489 100644 --- a/src/components/Settings.jsx +++ b/src/components/Settings.jsx @@ -4,10 +4,13 @@ import Sprite from './Sprite' import PageTitle from './PageTitle' import { useSettings, useSettingsDispatch } from '../context/SettingsContext' import { SATS, BTC } from '../utils' +import { useTranslation } from 'react-i18next' +import languages from '../i18n/languages' export default function Settings({ currentWallet }) { const settings = useSettings() const settingsDispatch = useSettingsDispatch() + const { i18n } = useTranslation() const setTheme = (theme) => { if (window.JM.THEMES.includes(theme)) { @@ -86,6 +89,29 @@ export default function Settings({ currentWallet }) { /> Use {settings.useAdvancedWalletMode ? 'magic' : 'advanced'} wallet mode + + + + + {languages.find((lng) => lng.key === i18n.language).description} + + + + {languages.map((lng, index) => { + return ( + i18n.changeLanguage(lng.key)}> + {lng.description} + + ) + })} + + Missing your language? Help us out! + + + ) diff --git a/src/components/Wallets.jsx b/src/components/Wallets.jsx index 78143d3e..e58f7fad 100644 --- a/src/components/Wallets.jsx +++ b/src/components/Wallets.jsx @@ -5,29 +5,31 @@ import Alert from './Alert' import Wallet from './Wallet' import PageTitle from './PageTitle' import { useCurrentWallet } from '../context/WalletContext' +import { useTranslation } from 'react-i18next' import { walletDisplayName } from '../utils' import * as Api from '../libs/JmWalletApi' export default function Wallets({ startWallet, stopWallet }) { + const { t } = useTranslation() const currentWallet = useCurrentWallet() const [walletList, setWalletList] = useState(null) const [isLoading, setIsLoading] = useState(false) const [alert, setAlert] = useState( currentWallet?.name && { variant: 'info', - message: `There can be only one active wallet. If you want to open another wallet, please lock ${walletDisplayName( - currentWallet.name - )} first.`, + message: t('wallets.alert_wallet_open', { currentWalletName: walletDisplayName(currentWallet.name) }), dismissible: true, } ) + const walletsFailedError = t('wallets.error_loading_failed') + useEffect(() => { const abortCtrl = new AbortController() setIsLoading(true) Api.getWalletAll({ signal: abortCtrl.signal }) - .then((res) => (res.ok ? res.json() : Promise.reject(new Error(res.message || 'Loading wallets failed.')))) + .then((res) => (res.ok ? res.json() : Promise.reject(new Error(res.message || walletsFailedError)))) .then((data) => { const { wallets = [] } = data if (currentWallet) { @@ -47,20 +49,20 @@ export default function Wallets({ startWallet, stopWallet }) { }) return () => abortCtrl.abort() - }, [currentWallet]) + }, [currentWallet, walletsFailedError]) return (
{alert && } {isLoading && (
)} {walletList?.map((wallet, index) => ( @@ -81,7 +83,7 @@ export default function Wallets({ startWallet, stopWallet }) { to="/create-wallet" className={`btn mt-4 ${walletList?.length === 0 ? 'btn-lg btn-dark' : 'btn-outline-dark'}`} > - Create new wallet + {t('wallets.button_new_wallet')}
diff --git a/src/components/Wallets.test.jsx b/src/components/Wallets.test.jsx index 21bdf14d..bddc3e0e 100644 --- a/src/components/Wallets.test.jsx +++ b/src/components/Wallets.test.jsx @@ -1,9 +1,8 @@ import React from 'react' -import { render, screen, waitForElementToBeRemoved } from '@testing-library/react' +import { render, screen, waitForElementToBeRemoved } from '../testUtils' import { act } from 'react-dom/test-utils' import * as apiMock from '../libs/JmWalletApi' -import { AllTheProviders } from '../__util__/AllTheProviders' import Wallets from './Wallets' @@ -13,14 +12,12 @@ it('should render without errors', () => { apiMock.getWalletAll.mockResolvedValueOnce(new Promise((r) => setTimeout(r, 1_000))) act(() => { - render(, { - wrapper: AllTheProviders, - }) + render() }) - expect(screen.getByText('Your wallets')).toBeInTheDocument() - expect(screen.getByText('Loading wallets')).toBeInTheDocument() - expect(screen.getByText('Create new wallet')).toBeInTheDocument() + expect(screen.getByText('wallets.title')).toBeInTheDocument() + expect(screen.getByText('wallets.text_loading')).toBeInTheDocument() + expect(screen.getByText('wallets.button_new_wallet')).toBeInTheDocument() }) it('should display error message when loading wallets fails', async () => { @@ -29,18 +26,16 @@ it('should display error message when loading wallets fails', async () => { }) act(() => { - render(, { - wrapper: AllTheProviders, - }) + render() }) - expect(screen.getByText('Loading wallets')).toBeInTheDocument() - expect(screen.getByText('Create new wallet')).toBeInTheDocument() + expect(screen.getByText('wallets.title')).toBeInTheDocument() + expect(screen.getByText('wallets.button_new_wallet')).toBeInTheDocument() - await waitForElementToBeRemoved(screen.getByText('Loading wallets')) + await waitForElementToBeRemoved(screen.getByText('wallets.text_loading')) - expect(screen.getByText('Loading wallets failed.')).toBeInTheDocument() - expect(screen.getByText('Create new wallet')).toBeInTheDocument() + expect(screen.getByText('wallets.error_loading_failed')).toBeInTheDocument() + expect(screen.getByText('wallets.button_new_wallet')).toBeInTheDocument() }) it('should display big call-to-action button if no wallet has been created yet', async () => { @@ -50,22 +45,20 @@ it('should display big call-to-action button if no wallet has been created yet', }) act(() => { - render(, { - wrapper: AllTheProviders, - }) + render() }) - expect(screen.getByText('Loading wallets')).toBeInTheDocument() + expect(screen.getByText('wallets.text_loading')).toBeInTheDocument() - const callToActionButtonBefore = screen.getByText('Create new wallet') + const callToActionButtonBefore = screen.getByText('wallets.button_new_wallet') expect(callToActionButtonBefore.classList.contains('btn')).toBe(true) expect(callToActionButtonBefore.classList.contains('btn-lg')).toBe(false) - await waitForElementToBeRemoved(screen.getByText('Loading wallets')) + await waitForElementToBeRemoved(screen.getByText('wallets.text_loading')) - expect(screen.getByText('It looks like you do not have a wallet, yet.')).toBeInTheDocument() + expect(screen.getByText('wallets.subtitle_no_wallets')).toBeInTheDocument() - const callToActionButtonAfter = screen.getByText('Create new wallet') + const callToActionButtonAfter = screen.getByText('wallets.button_new_wallet') expect(callToActionButtonAfter.classList.contains('btn-lg')).toBe(true) }) @@ -76,20 +69,18 @@ it('should display login for available wallets', async () => { }) act(() => { - render(, { - wrapper: AllTheProviders, - }) + render() }) - expect(screen.getByText('Loading wallets')).toBeInTheDocument() - await waitForElementToBeRemoved(screen.getByText('Loading wallets')) + expect(screen.getByText('wallets.text_loading')).toBeInTheDocument() + await waitForElementToBeRemoved(screen.getByText('wallets.text_loading')) - expect(screen.queryByText('It looks like you do not have a wallet, yet.')).not.toBeInTheDocument() + expect(screen.queryByText('wallets.alert_wallet_open')).not.toBeInTheDocument() expect(screen.getByText('wallet0')).toBeInTheDocument() expect(screen.getByText('wallet1')).toBeInTheDocument() - const callToActionButton = screen.getByText('Create new wallet') + const callToActionButton = screen.getByText('wallets.button_new_wallet') expect(callToActionButton.classList.contains('btn')).toBe(true) expect(callToActionButton.classList.contains('btn-lg')).toBe(false) }) diff --git a/src/i18n/README.md b/src/i18n/README.md new file mode 100644 index 00000000..6d793834 --- /dev/null +++ b/src/i18n/README.md @@ -0,0 +1,82 @@ +# Translating Jam + +We use [Transifex](https://www.transifex.com/joinmarket/jam/) for managing Jam's translations. + +## For Developers + +Our source language, i.e. the language we develop in, is English. +Any missing translations will fall back to using the English translation. +If you add text to the app, make sure not to use plain language in source files. +Instead add any strings to the English translation file: + +``` +src/i18n/locales/en/translation.json +``` + +Then, use the `i18next-react` [translation hook](https://react.i18next.com/latest/usetranslation-hook) in source files. +Don't worry about other languages. + +### Example + +Source File: + +```jsx +// src/components/Hello.jsx + +import React from 'react' +import { useTranslation } from 'react-i18next' + +export default function HelloComponent() { + const { t } = useTranslation() + + return

{t('hello.heading')}

+} +``` + +Translation file: + +```json +// src/i18n/locales/en/translation.json + +{ + "hello": { + "heading": "Hello, world!" + } +} +``` + +### Background + +Transifex [is connected with our GitHub repo](https://docs.transifex.com/transifex-github-integrations/github-tx-ui). +Whenever new translation strings land on `master` (e.g. when being added as part of a new feature in a PR), [Transifex](https://www.transifex.com/joinmarket/jam/) will automatically add those new strings to the Transifex web app. +Whenever translations on Transifex are done and reviewed, Transifex will open a PR on GitHub to integrate newly translated texts into the app. + +### Adding a new Language + +To add a new language: + +1. [Add it](https://docs.transifex.com/projects/adding-and-removing-project-languages) on [Transifex](https://www.transifex.com/joinmarket/jam/languages/). +1. Trigger a [manual sync](https://docs.transifex.com/transifex-github-integrations/github-tx-ui#manual-sync) from Transifex to GitHub. +1. Transifex will open a pull request. On this branch, add the new language in `src/i18n/languages.js`. For example, when adding `de`: + +```js +import en from './locales/en/translation.json' +import de from './locales/de/translation.json' // This file should have been created by Transifex in the PR. + +const languages = [ + { + key: 'en', + description: 'English', + translation: en, + }, + { + key: 'de', + description: 'Deutsch', + translation: de, + }, +] +``` + +## Translators + +Let us know [on Telegram](https://t.me/JoinMarketWebUI) or [GitHub](https://github.com/joinmarket-webui/joinmarket-webui/issues/new) if you would like to help out translating! diff --git a/src/i18n/config.js b/src/i18n/config.js new file mode 100644 index 00000000..6208d08f --- /dev/null +++ b/src/i18n/config.js @@ -0,0 +1,18 @@ +import i18n from 'i18next' +import { initReactI18next } from 'react-i18next' +import LanguageDetector from 'i18next-browser-languagedetector' +import languages from './languages' + +const resources = languages.reduce((acc, lng) => { + return { + ...acc, + [lng['key']]: { translation: lng['translation'] }, + } +}, {}) + +i18n.use(LanguageDetector).use(initReactI18next).init({ + resources, + fallbackLng: 'en', +}) + +export default i18n diff --git a/src/i18n/languages.js b/src/i18n/languages.js new file mode 100644 index 00000000..22d088e7 --- /dev/null +++ b/src/i18n/languages.js @@ -0,0 +1,11 @@ +import en from './locales/en/translation.json' + +const languages = [ + { + key: 'en', + description: 'English', + translation: en, + }, +] + +export default languages diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json new file mode 100644 index 00000000..4401f8d3 --- /dev/null +++ b/src/i18n/locales/en/translation.json @@ -0,0 +1,10 @@ +{ + "wallets": { + "title": "Your Wallets", + "subtitle_no_wallets": "It looks like you do not have a wallet, yet.", + "text_loading": "Loading wallets", + "button_new_wallet": "Create new wallet", + "alert_wallet_open": "There can be only one active wallet. If you want to open another wallet, please lock '{{ currentWalletName }}' first.", + "error_loading_failed": "Loading wallets failed." + } +} diff --git a/src/i18n/testConfig.js b/src/i18n/testConfig.js new file mode 100644 index 00000000..a450e42a --- /dev/null +++ b/src/i18n/testConfig.js @@ -0,0 +1,10 @@ +import i18n from 'i18next' +import { initReactI18next } from 'react-i18next' + +i18n.use(initReactI18next).init({ + lng: 'en', + fallbackLng: 'en', + resources: { en: { translations: {} } }, +}) + +export default i18n diff --git a/src/index.jsx b/src/index.jsx index d57b3d01..64eb5be9 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -8,6 +8,7 @@ import { WebsocketProvider } from './context/WebsocketContext' import reportWebVitals from './reportWebVitals' import 'bootstrap/dist/css/bootstrap.min.css' import './index.css' +import './i18n/config' ReactDOM.render( diff --git a/src/testUtils.jsx b/src/testUtils.jsx new file mode 100644 index 00000000..58dd0745 --- /dev/null +++ b/src/testUtils.jsx @@ -0,0 +1,30 @@ +import React from 'react' +import { render } from '@testing-library/react' +import { BrowserRouter } from 'react-router-dom' +import { I18nextProvider } from 'react-i18next' +import { SettingsProvider } from './context/SettingsContext' +import { WalletProvider } from './context/WalletContext' +import { WebsocketProvider } from './context/WebsocketContext' +import i18n from './i18n/testConfig' + +const AllTheProviders = ({ children }) => { + return ( + + + + + {children} + + + + + ) +} + +const customRender = (ui, options) => render(ui, { wrapper: AllTheProviders, ...options }) + +// re-export everything +export * from '@testing-library/react' + +// override render method +export { customRender as render }