Skip to content

Internationalization

Accessibility in the app

To make our platform accessible from different regions, different cultures and different timezones we use internationalization normally abbreviated as i18n(18 is the number of letters between i and n in the English word). This document provides steps that should be taken by an engineer when implementing a feature that needs to support multiple languages or adding a new language to the platform.

Language locales

We plan to support a variety of languages in our app depending on regions where the cities using the app are located, we are starting with English and Spanish then we will add other languages iteratively.

Language Management - Admin/User

  • Under community settings, an admin should be able to add a language that community members can use with the application. e.g: by default we will have english then the admin can add Spanish and French.
  • An admin can also set the default language for that community.
  • A user is allowed to select a preferred language from the ones the admin added in community settings
  • If the admin set a default language then that is what the user will see if they don't choose a preferred language
  • The language fallsback to English if there is no either default language or preferred language set.

Language Management - Engineer

Tools used:
- i18next - I18n - react-i18next
- phrase.com
- json files - yml files

Translations files

Translations files are stored in public directory, this is because everything in the public directory is served as a static file so it doesn't need to compile everytime there are changes, this makes development faster but also reduces the compiled bundle size of the app. The other the advantage of this is that you don't have to import these files since they are automatically served as static files.

Translation files are in a json format with key:value pairs to indicate where translations are being used, following is an example of a translation file for the login page:

{  
    "login": {
        "welcome": "Welcome to {{appName}} app",
        "login_text": "Please log in with your phone number here",
        "login_button_text": "next",
        "login_google": "Sign in with Google",
        "login_facebook": "Sign in with Google"
    }
}

Structuring it this way makes it easy for other engineers to identify where this part of the translation is being used.
There are cases for text that is used in multiple places, e.g: form related text, buttons and others. These should be placed in a common.json file.

{
    "form_actions": {
        "send_email": "Send Email",
        "cancel": "Cancel",
        "submit": "Submit"
    }
}

Translations are organized according to their locale and each language locale contains same contents in a different language with other locales, to avoid errors the numbers of keys in common.json in en locale should be the same amount of keys in common in es same as ny.
It is also important to note that to make translations files manageable we have decided to keep them named according to their modules, this makes json files smaller and easier to add and find keys.
When using translations in components, you will have to mention the namespace in which your keys are in.
e.g: const { t } = useTranslation('dashboard') ref: Translation in components

├── en
│   ├── common.json
│   ├── dashboard.json
│   ├── login.json
│   └── scan.json
├── es
│   ├── common.json
│   ├── dashboard.json
│   ├── login.json
│   └── scan.json
└── ny
    ├── common.json
    ├── dashboard.json
    ├── login.json
    └── scan.json
Backend Translation Files

Backend translation files are in a yml format with key:value pairs to indicate where translations are being used.

To internationalize ActiveRecord models and their attributes (it can be the models with which they have belongs_to association or attributes on which validation have been added). For example:

en:
  activerecord:
    attributes:
      payments/transaction:
        amount: Amount
        bank_name: Bank name
        community: Community
        source: Source
        transaction_number: Transaction number
        user: User
    models:
      payments/transaction: Transaction

To add additional errors for activerecord models in backend, errors should be structured in the below mentioned way.

en:
  activerecord:
    errors:
      models:
        logs/event_log:
          attributes:
            acting_user_id: reporting_user_required: Must be associated with a reporting user

To add any other message or errors from backend should be grouped under a specific key to have clear understanding. To pass variable during runtime use %{}.

en:
  errors:
    payment_plan:
      not_found: Payment Plan not found
  general:
    thanks_for_using_our_app: Thank you for using our app, kindly use this link to give us feedback %{feedback_link}

├── en.yml
├── es.yml
Translating text in components

As we mentioned above, translation files are automatically served, so as an engineer you don't have to import them in your components.
To translate text in a component you need to use the helpers given by the react-i18next, these helpers include a useTranslation() hook that gives us access to a t and i18next instance, we can use the former to translate and the later to change the language locale or any other manipulation we can do with the i18next instance.

Following is an example of a translated component using the structure we shared above

import { useTranslation } from 'react-i18next'

export function Button(){
    const { t } = useTranslation() // This defaults to the translation namespace
    // const { t } = useTranslation(['translation', 'common']) // this allows you to load multiple namespaces in your file
    return <button>{t('login.login_button_text')}</button> 
    // return <button>{t('common:form_actions.send_email')}</button> // If you are using a common namespace
    // This will pick text from either locale based on the default language locale.
}

If your translated text has a variable that needs to be replaced at runtime like "welcome": "Welcome to {{appName}} app" in this case appName is the variable and we have to show the actual value of this variable to our users despite the selected language.
Here is an example of how that would be handled:

import { useTranslation } from 'react-i18next'

export function Text(){
    const { t } = useTranslation() 
    // with our example above this will translate to "Welcome to DoubleGDP app"
    return <p>{t('login.welcome', { appName: 'DoubleGDP' })}</p> 
}

Refer to the documentation of i18next and react-i18next for more information on how to use them respectively.

Translating text in backend files

In rails we don't need to import the translation files. The translations mentioned in yml files can be used using I18n.t(''). Inside the bracket nested keys can be used using I18n.t('errors.payment_plan.not_found'). To pass a variable in the message, use the following format:

def send_notifications(user)
  number = user.phone_number
  feedback_link = "https://#{HostEnv.base_url(user.community)}/feedback"
  return if number.nil?

  Sms.send(number, I18n.t('general.thanks_for_using_our_app', feedback_link: feedback_link))
end

In models or controller we don't need to provide full lookup keys if we have used the proper format to define model specific errors under activerecord, example:

def validate_visitor_entry
  errors.add(:data, :visitor_name_required) unless data['ref_name']
  return if acting_user

   errors.add(:acting_user_id, :reporting_user_required)
end
Refer to the documentation of I18n for more understanding of usage of I18n.

Changing the language in React

Languages are to be managed under community settings, an admin for a community can add different languages that their community supports, once this has been added a user can be able to choose their preffered language among the accepted languages.

Follow is a code snippet that you can use to change the language in the app.

import { useTranslation } from 'react-i18next'

export function Button(){
    const { t, i18n } = useTranslation()
    // In the doublegdp case, es-ES would come from languages added by the administrators
    return <button onClick={() => i18n.changeLanguage('es-ES')}>{t('button.text')}</button> 
}

There might be a need to change the language as soon as the user opens the app, this means we check for different things: - Has the user previously choose their preferred language - If the above is false, it fallsback to the language the admin set as the default - Has the admin choose a default language, if false then it fallsback to English

import { useTranslation } from 'react-i18next'
import { useEffect } from 'react'

export function App(){
    const { i18n } = useTranslation()
    // This will change the language when the page mounts
    useEffect(() => {
        i18n.changeLanguage('en-En')
    }, []);
    return <WholeApp>{children}</WholeApp> 
}

To keep the user's choice, we can save their choices using cookies with a longer expiry time or localStorage so that once the user comes back to the app we show them in their preferred language without saving to the db.

Changing the language in Rails

To add new language we use: config.i18n.available_locales To set default locale we use, config.i18n.default_locale

Currently we have the following settings in application.rb,

config.i18n.available_locales = %i[es en]
config.i18n.default_locale = :en

In application_controller.rb, following code helps to change the language.

before_action :set_locale

def set_locale
  I18n.locale = extract_locale || I18n.default_locale
end

def extract_locale
  return unless current_community&.language

  current_community.language[/.*(?=-)|.*/]
end

i18n Module

As part of keeping our application modular, i18n is a module itself, however as mentioned above the translation files are kept separate in a public directory, this module has configuration files for the i18n. Any future configuration file for i18n or any other file related to translation should be placed in this directory.

Testing Translated Components

We already have a mock in place that will make sure you don't encounter issues like t(...) is undefined.
You can freely call the useTranslation() in any component and test it freely, when running test what you want to check for is if the translation keys are as expected, here is an example of a test using the example of our button above:

it('checks if the button has proper keys', () => {
    expect(button.queryByText('button.text')).toBeInTheDocument()
})
Another way would be to isolate the hook into a higher level component but that won't make it easy for you to test the actual keys that shows up in the browser.
For more examples on tests, you can check here https://github.com/i18next/react-i18next/blob/master/example/test-jest/src/UseTranslation.test.js#L9

Using Phrase.com (To be continued)

Phrase.com provides an easier way of translating files and manage these translations in multiple languages with multiple languages with support of machine translation(You can translate directory using third party tools like Microsoft Translate)

  • Create a project in phrase.com with the following details
    • Title of the project
    • Main format as i18next.json
    • Main Technology should be set to React and point of contact as the owner of the application.
  • Create a language
    Here you select the language you want to translate from and to and you specify the descriptive names for these languages.
  • Upload translation files
    Phrase.com automatically recognizes keys in your json files and will help translate them to new language locales you've set.
Synchronization

Phrase.com allows Synchronization of translation files from Phrase to Gitlab, with this engineers don't have to worry about finding Spanish translations while working on a ticket.
The benefit of this are:
- Engineers can quickly translate page without worrying about other languages
- We can hire translators and they wouldn't have to worry about or know our application
- Translations can be handled by a different team
- Reduces mistakes that may be made by engineers who do not understand the language they are translating in.
- We can make use of third party tools in phrase.com for automatic machine translation

How to best make use of this feature:
- Keep the phrase.yml up to date
- Merge your changes to master
- Get translations ready in phrase.com
- Export translations from phrase.com, you can find the export feature here

The configuration file(phrase.yml)

The phrase.yml file tells Phrase.com which files should be imported and which ones should be exported, in the following file we are allowing importing of both translations(en & es), we specify the format, the locale_id and the tags key for every file. Tags are important and they should match the name of the file as phrase.com uses these to classify the source and destination of translations. We are also telling phrase.com to export the specified Spanish translations, the reason for this is that mostly engineers will be using the English translation so we don't need to export this.

phrase:
  project_id: xxxxxxx-project-id
  push:
    sources:
    - file: ./public/locales/en/login.json
      params:
        file_format: i18next
        locale_id: xxxxxxxxxx-locale-id
        tags: login
        update_translations: true
    - file: ./public/locales/en/dashboard.json
      params:
        file_format: i18next
        locale_id: xxxxxxxxxx-locale-id
        tags: dashboard
        update_translations: true
    - file: ./config/locales/en.yml
      params:
        file_format: yml
        locale_id: xxxxxxx-locale-id
        tags: backend-translation
    - file: ./public/locales/es/login.json
      params:
        file_format: i18next
        locale_id: xxxxxxx-locale-id
        tags: login
    - file: ./public/locales/es/dashboard.json
      params:
        file_format: i18next
        locale_id: xxxxxxx-locale-id
        tags: dashboard
    - file: ./config/locales/es.yml
      params:
        file_format: yml
        locale_id: xxxxxxx-locale-id
        tags: backend-translation
  pull:
    targets:
    - file: ./public/locales/es/login.json
      params:
        file_format: i18next
        locale_id: xxxxxxx-locale-id
        tags: login
    - file: ./public/locales/es/dashboard.json
      params:
        file_format: i18next
        locale_id: xxxxxxx-locale-id
        tags: dashboard
    - file: ./config/locales/es.yml
      params:
        file_format: yml
        locale_id: xxxxxxx-locale-id
        tags: backend-translation

If you have created a new namespace, add its English version to the files that should be pushed(imported) and the Spanish version to the ones that need to be pulled(exported) This way if on master there is no Spanish translation for this namespace and you have added translations in phrase.com and you export, a merge request will be created for you with the new keys and you can merge with master to have your translation working. For backend if we want to add a new language, add the file in above format in phrase.yml with file location: ./config/locales/*.yml

Using Phrase.com as a translator

As a translator or someone verifying translations, You need to login and have access to the DoubleGDP project.
Head over to the dashboard using this link https://app.phrase.com/accounts/doublegdp/projects/app/dashboard from here you can see a list of languages and a list of tags, click on the tag or the language, Phrase.com will show you the keys that are not translated or the ones that are not verified and from here you can verify and save the translation

In-Context Editor

One of the best features of Phrase.com is the ability to edit in context of the actual application. To enable this feature you need to go to project settings > in-context editor from here you can set the url of the translated website. For this to work you need to make use of the appId and the API Key given by phrase.com