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 <theborakompanioni@users.noreply.github.com>

* Update src/i18n/README.md

Co-authored-by: Thebora Kompanioni <theborakompanioni@users.noreply.github.com>

* Update src/i18n/README.md

Co-authored-by: Thebora Kompanioni <theborakompanioni@users.noreply.github.com>

* Update public/sprite.svg

Co-authored-by: Thebora Kompanioni <theborakompanioni@users.noreply.github.com>

* Change link

* Run npm install

* Update readme

* Update readme again

Co-authored-by: Thebora Kompanioni <theborakompanioni@users.noreply.github.com>
This commit is contained in:
Daniel 2022-03-09 16:47:17 +01:00 committed by GitHub
parent 26ffe8cdeb
commit cc168eb49f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 365 additions and 80 deletions

9
.tx/transifex.yml Normal file
View file

@ -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/<lang>/translation.json
settings:
pr_branch_name: tx_translations_<br_unique_id>

120
package-lock.json generated
View file

@ -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",

View file

@ -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"

View file

@ -21,4 +21,5 @@
<symbol id="checkmark" viewBox="0 0 24 24" fill="currentColor"><path fill-rule="evenodd" d="M18.381 5.354a.75.75 0 01.265 1.027l-7.087 12a.75.75 0 01-1.164.16L5.48 13.838a.75.75 0 011.038-1.084l4.23 4.051 6.605-11.185a.75.75 0 011.027-.265z" clip-rule="evenodd"></path></symbol>
<symbol id="caret-up" viewBox="0 0 24 24" fill="currentColor"><path fill-rule="evenodd" d="M12.248 8.237a.25.25 0 00-.354 0L5.53 14.601a.75.75 0 11-1.06-1.06l6.363-6.364a1.75 1.75 0 012.475 0l6.364 6.364a.75.75 0 01-1.06 1.06l-6.364-6.364z" clip-rule="evenodd"></path></symbol>
<symbol id="caret-down" viewBox="0 0 24 24" fill="currentColor"><path fill-rule="evenodd" d="M4.47 9.4a.75.75 0 011.06 0l6.364 6.364a.25.25 0 00.354 0L18.612 9.4a.75.75 0 011.06 1.06l-6.364 6.364a1.75 1.75 0 01-2.475 0L4.47 10.46a.75.75 0 010-1.06z" clip-rule="evenodd"></path></symbol>
<symbol id="globe" viewBox="0 0 24 24" fill="currentColor"><path fill-rule="evenodd" d="M10.51 6.617c-.518 1.341-.855 3.245-.855 5.385 0 2.14.337 4.044.856 5.385.26.673.552 1.166.838 1.478.285.312.506.385.651.385.145 0 .366-.073.652-.385.285-.312.577-.805.837-1.478.52-1.341.856-3.245.856-5.385 0-2.14-.337-4.044-.856-5.385-.26-.673-.552-1.166-.838-1.478-.285-.312-.506-.385-.651-.385-.145 0-.366.073-.651.385-.286.312-.578.805-.838 1.478zm-.268-2.49c.455-.499 1.048-.873 1.758-.873s1.303.374 1.758.872c.455.497.83 1.175 1.13 1.95.601 1.553.957 3.649.957 5.926s-.356 4.373-.957 5.926c-.3.774-.675 1.453-1.13 1.95-.455.497-1.048.872-1.758.872s-1.303-.375-1.758-.872c-.455-.497-.83-1.175-1.13-1.95-.601-1.553-.957-3.649-.957-5.926s.356-4.373.957-5.926c.3-.775.675-1.453 1.13-1.95z" clip-rule="evenodd"></path><path fill-rule="evenodd" d="M12 4.746a7.25 7.25 0 100 14.5 7.25 7.25 0 000-14.5zm-8.75 7.25a8.75 8.75 0 1117.5 0 8.75 8.75 0 01-17.5 0z" clip-rule="evenodd"></path><path fill-rule="evenodd" d="M3.25 11.99a.75.75 0 01.75-.75l16 .012a.75.75 0 110 1.5l-16-.011a.75.75 0 01-.75-.75z" clip-rule="evenodd"></path></symbol>
</svg>

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Before After
Before After

View file

@ -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 (
<BrowserRouter>
<SettingsProvider>
<WalletProvider>
<WebsocketProvider>{children}</WebsocketProvider>
</WalletProvider>
</SettingsProvider>
</BrowserRouter>
)
}

View file

@ -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(<App />, {
wrapper: AllTheProviders,
})
})
render(<App />)
// 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(<App />, {
wrapper: AllTheProviders,
})
render(<App />)
})
// 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(<App />, {
wrapper: AllTheProviders,
})
render(<App />)
})
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(<App />, {
wrapper: AllTheProviders,
})
render(<App />)
})
expect(screen.getByText('•').classList.contains('text-danger')).toBe(true)

View file

@ -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
</rb.Button>
<rb.Dropdown>
<rb.Dropdown.Toggle variant="outline-dark" className="border-0 mb-2 d-inline-flex align-items-center">
<Sprite symbol="globe" width="24" height="24" className="me-2" />
{languages.find((lng) => lng.key === i18n.language).description}
</rb.Dropdown.Toggle>
<rb.Dropdown.Menu variant={settings.theme === 'light' ? 'light' : 'dark'}>
{languages.map((lng, index) => {
return (
<rb.Dropdown.Item key={index} onClick={() => i18n.changeLanguage(lng.key)}>
{lng.description}
</rb.Dropdown.Item>
)
})}
<rb.Dropdown.Item
href="https://github.com/joinmarket-webui/joinmarket-webui/tree/master/src/i18n/README.md"
rel="noopener noreferrer"
>
Missing your language? Help us out!
</rb.Dropdown.Item>
</rb.Dropdown.Menu>
</rb.Dropdown>
</div>
</div>
)

View file

@ -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 (
<div className="wallets">
<PageTitle
title="Your wallets"
subtitle={walletList?.length === 0 ? 'It looks like you do not have a wallet, yet.' : null}
title={t('wallets.title')}
subtitle={walletList?.length === 0 ? t('wallets.subtitle_no_wallets') : null}
center={true}
/>
{alert && <Alert {...alert} />}
{isLoading && (
<div className="d-flex justify-content-center align-items-center">
<rb.Spinner as="span" animation="border" size="sm" role="status" aria-hidden="true" className="me-2" />
<span>Loading wallets</span>
<span>{t('wallets.text_loading')}</span>
</div>
)}
{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')}
</Link>
</div>
</div>

View file

@ -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(<Wallets />, {
wrapper: AllTheProviders,
})
render(<Wallets />)
})
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(<Wallets />, {
wrapper: AllTheProviders,
})
render(<Wallets />)
})
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(<Wallets />, {
wrapper: AllTheProviders,
})
render(<Wallets />)
})
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(<Wallets />, {
wrapper: AllTheProviders,
})
render(<Wallets />)
})
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)
})

82
src/i18n/README.md Normal file
View file

@ -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 <h1>{t('hello.heading')}</h1>
}
```
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!

18
src/i18n/config.js Normal file
View file

@ -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

11
src/i18n/languages.js Normal file
View file

@ -0,0 +1,11 @@
import en from './locales/en/translation.json'
const languages = [
{
key: 'en',
description: 'English',
translation: en,
},
]
export default languages

View file

@ -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."
}
}

10
src/i18n/testConfig.js Normal file
View file

@ -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

View file

@ -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(
<BrowserRouter basename={window.JM.PUBLIC_PATH}>

30
src/testUtils.jsx Normal file
View file

@ -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 (
<BrowserRouter>
<I18nextProvider i18n={i18n}>
<SettingsProvider>
<WalletProvider>
<WebsocketProvider>{children}</WebsocketProvider>
</WalletProvider>
</SettingsProvider>
</I18nextProvider>
</BrowserRouter>
)
}
const customRender = (ui, options) => render(ui, { wrapper: AllTheProviders, ...options })
// re-export everything
export * from '@testing-library/react'
// override render method
export { customRender as render }