Storing Rails translations inside the database with Mobility

In one of the previous posts we have discussed how to store Rails translations inside the database with the help of Globalize gem. This feature can come in really handy when you would like to translate user-generated content: for example, blog posts or documentation articles. While Globalize is still a solid and battle-tested solution, it is not actively maintained anymore. However, a new more advanced gem called Mobility has emerged, which shares many common concepts with Globalize. In this article I’m going to show you how to get started with Mobility, allow users to provide content in different languages, switch website and content locale, perform scoped queries, and more. By the end of reading this tutorial you’ll be ready to integrate Mobility into your own application. So, let’s get started!

The source code for this tutorial can be found on GitHub.

 

Preparing the application

Start by creating a new Rails application without the default testing suite:

rails new MobilityDemo -T

In this demo I’ll be using Rails 6.1 but the explained concepts are valid for older versions as well. Please note that Mobility supports Rails 5.0 and above. Also note that this solution currently works only with ActiveRecord and Sequel.

Our application will be a superheroes database therefore we’ll need to generate a new scaffold Superhero. Problem is, Rails will not pluralize this word properly by default. To fix this issue, add a new inflection rule inside the config/initializers/inflections.rb file:

ActiveSupport::Inflector.inflections(:en) do |inflect|
  inflect.plural /(hero)$/i, '\1es'
  inflect.singular /(hero)es/i, '\1'
end

Now the words “hero” and “superhero” will be pluralized properly.

Next, generate a new scaffold and run the migration:

rails g scaffold Superhero name:string description:text
rails db:migrate

Good stuff. The next step is to add support for multiple languages. I’ll use English and Russian but of course you can pick any other languages as needed. Tweak the config/application.rb file by adding the following lines:

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

So, the default locale is English, and Russian language is supported as well.

Translating application interface

Before integrating Mobility into our application, I would like to localize the interface properly. All translations added in this section will live inside the plain old YAML files. If you’d like to learn more about this process, check out my Rails I18n post that explains all ins and outs.

Views

Start by adding a main menu to the layouts/application.html.erb file:

<ul>
  <li><%= link_to t('main_menu.superheroes'), superheroes_path %></li>
  <li><%= link_to t('main_menu.add_superhero'), new_superhero_path %></li>
</ul>

Now open the views/superheroes/_form.html.erb partial and provide translation for the “Submit” button:

<div class="actions">
  <%= form.submit t('global.actions.submit') %>
</div>

Next, views/superheroes/edit.html.erb:

<h1><%= t '.title' %></h1>

<%= render 'form', superhero: @superhero %>

views/superheroes/index.html.erb will required a bit more tweaking:

<p id="notice"><%= notice %></p>

<h1><%= t '.title' %></h1>

<table>
  <thead>
    <tr>
      <th><%= t '.name' %></th>
      <th><%= t '.description' %></th>
      <th colspan="3"></th>
    </tr>
  </thead>

  <tbody>
    <% @superheroes.each do |superhero| %>
      <tr>
        <td><%= superhero.name %></td>
        <td><%= superhero.description %></td>
        <td><%= link_to t('.show'), superhero %></td>
        <td><%= link_to t('.edit'), edit_superhero_path(superhero) %></td>
        <td><%= link_to t('.destroy'), superhero, method: :delete, data: { confirm: 'Are you sure?' } %></td>
      </tr>
    <% end %>
  </tbody>
</table>

<br>

<%= link_to t('.new'), new_superhero_path %>

Now, open views/superheroes/new.html.erb:

<h1><%= t '.title' %></h1>

<%= render 'form', superhero: @superhero %>

And, finally, take care of the views/superheroes/show.html.erb:

<p id="notice"><%= notice %></p>

<h1>
  <%= @superhero.name %>
</h1>

<p>
  <%= @superhero.description %>
</p>

Great!

Controller

As for the superheroes_controller.rb, I would like to translate the flash messages for the create, update, and destroy actions using lazy loading in the following way:

def create
  @superhero = Superhero.new(superhero_params)

  if @superhero.save
    redirect_to @superhero, notice: t('.success')
  else
    render :new
  end
end

# PATCH/PUT /superheroes/1
def update
  if @superhero.update(superhero_params)
    redirect_to @superhero, notice: t('.success')
  else
    render :edit
  end
end

# DELETE /superheroes/1
def destroy
  @superhero.destroy
  redirect_to superheroes_url, notice: t('.success')
end

That’s it!

Rails translations inside the YAML files

We are done with localizing the interface elements. Here are the corresponding translations inside the en.yml file:

en:
  activerecord:
    attributes:
      superhero:
        name: Name
        description: Description
  main_menu:
    superheroes: All superheros
    add_superhero: Add superhero
  superheroes:
    create:
      success: Superhero was created!
    update:
      success: Superhero was updated!
    destroy:
      success: Superhero was removed!
    edit:
      title: Edit
    index:
      title: All heroes
      name: Name
      description: Description
      show: Show
      edit: Edit
      destroy: Remove
      new: Add new
    new:
      title: New superhero
    show:
      title: Edit
  global:
    actions:
      submit: Submit

And here’s the contents of the ru.yml:

ru:
  activerecord:
    attributes:
      superhero:
        name: Имя
        description: Описание
  main_menu:
    superheroes: Все супергерои
    add_superhero: Добавить супергероя
  superheroes:
    create:
      success: Супергерой создан!
    update:
      success: Супергерой обновлён!
    destroy:
      success: Супергерой удалён!
    edit:
      title: Редактировать
    index:
      title: Все герои
      name: Имя
      description: Описание
      show: Показать
      edit: Редактировать
      destroy: Удалить
      new: Добавить нового
    new:
      title: Новый супергерой
    show:
      title: Редактировать
  global:
    actions:
      submit: Отправить

Adding locale switcher

Currently our users cannot choose a desired locale, therefore let’s take care of this feature now. Add a locale switcher to the application.html.erb layout:

<ul>
  <li><%= link_to 'English', root_path(locale: :en) %></li>
  <li><%= link_to 'Русский', root_path(locale: :ru) %></li>
</ul>

I would like to modify the routes to make the URLs look like example.com/en/superheroes or example.com/ru/superheroes. The locale part should be optional and we also have to check that the requested locale is actually supported by the app. Here’s the contents of the config/routes.rb file:

Rails.application.routes.draw do
  scope "(:locale)", locale: /#{I18n.available_locales.join("|")}/ do
    resources :superheroes
    root 'superheroes#index'
  end
end

The last step is to process the :locale parameter and set the corresponding locale. Create a new concern inside the controllers/concerns/locale.rb file:

module Locale
  extend ActiveSupport::Concern

  private

  def extract_locale(attr = :locale)
    parsed_locale = params[attr]
    I18n.available_locales.map(&:to_s).include?(parsed_locale) ? parsed_locale : nil
  end

  included do
    before_action :set_locale

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

    def default_url_options
      { locale: I18n.locale }
    end
  end
end

Here we fetch the :locale parameter and check whether our application supports the requested language. If yes, we set it, otherwise utilize the default locale. Also note the default_url_options method that will automatically add the chosen locale to all links generated with helpers like link_to.

Finally, import this concern inside the application_controller.rb:

class ApplicationController < ActionController::Base
  include Locale
end

Now let’s proceed to integrating Mobility into our application!

Integrating Mobility gem into the Rails app

First of all, add a new gem into your Gemfile:

gem 'mobility', '~> 1.0.1'

Then run the following commands:

rails generate mobility:install
rails db:migrate

It will generate a new initializer mobility.rb as well as two migrations. These migrations, in turn, create two tables: mobility_text_translations and mobility_string_translations. As you’ve probably guessed, all Mobility-related translations will live inside these tables which we are going to connect to our own tables using polymorphic associations. This is the default backend but Mobility supports other backends as well:

  • Table — this backend is very similar to the approach found in Globalize. Simply speaking, this backend stores translations as columns on a model-specific table. For example, for our superheroes table we would have created a separate table superheroes_translations and place all translated content inside.
  • Column — this backend stores translations inside the same table. For example, to translate a superhero’s name we would have created name_en and name_ru columns inside the superheroes table.
  • Postgre-specific — as the name implies, this backend is supported only by PostgreSQL DBMS and implies creating additional columns in the same table with the :json, :jsonb, or :hstore type.

The default backend will work for us, but if you are willing to choose a different one, then provide --without-tables option when running the Mobility generator. Then follow instructions for setting up a specific backend.

Next, open your superhero.rb model and paste the following content:

extend Mobility
translates :name, type: :string
translates :description, type: :text

And this is it: you are ready to translate user-generated content!

Mobility accessors

To read or write superhero’s attributes, you can use the standard approach:

hero = Superhero.first
hero.name # => "Spider-man"
hero.description = "Can shoot web"
hero.description # => "Can shoot web"

However, if you switch the currently set locale, the attributes’ values will change as well:

I18n.locale = :ru
hero.name # => "Спайдермэн"
hero.description = "Стреляет паутиной"
hero.description # => "Стреляет паутиной"

If you need to manage translations for both languages without switching locale, open the mobility.rb initializer and add the following line of code:

plugins do
  locale_accessors # <===
end

By default, Mobility will use the same locales as you’ve listed for the available_locales option, but it’s also possible to pass an array to locale_accessors directly:

locale_accessors %i[en ru ja de]

After adding this line, you can read and write values for any locale easily:

hero.name_ru # => "Спайдермэн"
hero.description_en # => "Can shoot web"

Under the hoods, these accessors call the ATTRIBUTE_backend method that you can also use directly:

hero.name_backend.read(:en) # => "Spider-man"
word.name_backend.read(:ru) # => "Спайдермэн"
word.description_backend.write(:en, "Can shoot web")

Translating user-generated content with Mobility

So, as I’ve explained in the previous section, the name and description accessors for our superheroes will just work out of the box, which means that the _form.html.erb partial does not require any modifications. However, in order to provide English and Russian content, you will need to switch the locales back and forth which is not very convenient. Why don’t we provide text fields for both locales on the same page?

To achieve that, make sure to add locale_accessors to the mobility.rb initializer if you hadn’t done so already. Next, modify the views/superheroes/_form.html.erb file in the following way:

<%= form_with(model: superhero) do |form| %>
  <% if superhero.errors.any? %>
    <div id="error_explanation">
      <ul>
        <% superhero.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <% all_locales.each do |locale| %>
    <div class="field">
      <% name = "name_#{Mobility.normalize_locale(locale)}" %>
      <%= form.label name %>
      <%= form.text_field name %>
    </div>

    <div class="field">
      <% description = "description_#{Mobility.normalize_locale(locale)}" %>
      <%= form.label description %>
      <%= form.text_area description %>
    </div>

    <hr>
  <% end %>

  <div class="actions">
    <%= form.submit t('global.actions.submit') %>
  </div>
<% end %>

For each supported locale we display two fields to modify the name and description respectively. normalize_locale method will properly convert the language name to a format that can be used with the form builder helpers.

all_locales is a helper method that you’ll need to define inside the superheroes_helper.rb:

module SuperheroesHelper
  def all_locales
    I18n.available_locales
  end
end

Next, make sure to mark the name_en, name_ru, description_en, and description_ru as permitted attributes inside the superheroes_controller.rb:

def superhero_params
  params.require(:superhero).permit(I18n.available_locales.map do |l|
    [:"name_#{Mobility.normalize_locale(l)}", :"description_#{Mobility.normalize_locale(l)}"]
  end.flatten)
end

Finally, provide translations for the new attributes inside the en.yml:

en:
  activerecord:
    attributes:
      superhero:
        name_en: Name (Eng)
        description_eng: Description (Eng)
        name_ru: Name (Rus)
        description_ru: Description (Rus)

ru.yml:

ru:
  activerecord:
    attributes:
      superhero:
        name_en: Имя (Англ)
        description_eng: Описание (Англ)
        name_ru: Имя (Рус)
        description_ru: Описание (Рус)

At this point you can open the “New superhero” page and provide content for both locales in one go!

Choosing the content locale

Now we can provide names and descriptions for our superheroes in both languages, however there’s a small problem. In order to actually view the translated content, our users will have to switch the website locale. This will result in translating both the superhero data and website interface (menus, forms, and other elements). While it might be okay, I don’t think this is the best approach. Why don’t we allow our users to set the content locale only? Luckily, Mobility has its own locale accessor that we are going to take advantage of.

First of all, add a new menu item to the application.html.erb layout:

<p><%= t 'main_menu.content_locale' %></p>
<ul>
  <li><%= link_to 'English', root_path(content_locale: :en) %></li>
  <li><%= link_to 'Русский', root_path(content_locale: :ru) %></li>
</ul>

Add a new translation to the en.yml file:

en:  
  main_menu:
    content_locale: Content language

ru.yml:

ru:  
  main_menu:
    content_locale: Язык контента

Next let’s process the content_locale parameter inside the locale.rb concern and set the Mobility locale accordingly:

module Locale
  extend ActiveSupport::Concern

  private

  def extract_locale(attr = :locale)
    parsed_locale = params[attr]
    I18n.available_locales.map(&:to_s).include?(parsed_locale) ? parsed_locale : nil
  end

  included do
    before_action :set_locale
    before_action :set_content_locale

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

    def set_content_locale
      Mobility.locale = extract_locale(:content_locale) || I18n.locale
    end

    def default_url_options
      { locale: I18n.locale, content_locale: Mobility.locale }
    end
  end
end

I’ve added a new method set_content_locale which modifies Mobility.locale only. Finally, don’t forget to add content_locale parameter to the default_url_options.

Great, now the users can view the translated content without switching the language of the interface!

Searching for localized content

Now let’s add a search form that will allow users to find superheroes by their names. The search should be scoped to the currently set content locale as well. To achieve that, add a new form to the application.html.erb layout:

<div>
  <%= form_with url: superheroes_path, method: :get do |f| %>
    <%= f.text_field :name_q, value: params[:name_q], placeholder: t('main_menu.search.name'), required: true, minlength: 2 %>
    <%= hidden_field_tag :content_locale, Mobility.locale %>
    <%= f.submit t('global.actions.submit') %>
  <% end %>
</div>

This form will send a GET request, therefore we have to add a content_locale hidden tag to preserve the currently chosen language.

Provide a new translation inside the en.yml file:

en:
  main_menu:
    search:
      name: Find by name

ru.yml:

ru:
  main_menu:
    search:
      name: Найти по имени

Now modify the index action inside the superheroes_controller.rb:

def index
  @superheroes = if params[:name_q]
    Superhero.search params[:name_q]
  else
    Superhero.all
  end
end

Let’s define the search method as a scope inside the superhero.rb model:

scope :search, ->(name_q) do
  i18n do
    name.lower.matches("%#{name_q.downcase}%")
  end
end

i18n is a Mobility method which allows to perform scoped queries using the currently set locale. When you pass a block to this method, it is possible to construct complex Arel queries inside. Specifically, in this example we are performing a case-insensitive partial search against the name attribute. Nice!

Other Mobility features

Fallbacks and default values

To provide translation fallbacks, enable the corresponding plugin inside the mobility.rb accessor:

fallbacks

Now you may configure fallbacks inside your model in the following way:

translates :name, type: :string, fallbacks: { ru: :en, de: :ja }

As for the default values, you will also have to enable the corresponding plugin:

default "" # <= this is the global default

Here we are setting the global default value which you can override on per-attribute basis:

translates :name, type: :string, default: 'No name'

Dirty tracking

To enable dirty tracking (which will allow you to check whether an attribute was changed), add a new plugin to the mobility.rb initializer:

dirty

Now dirty tracking works globally but you can disable it for the specific attributes:

translates :name, type: :string, dirty: false

At this point you can utilize dirty tracking as usual:

hero.name_was
hero.changed
hero.previous_changes

Migrating from Globalize to Mobility

Migration from Globalize to Mobility is quite straightforward as these solutions share many common aspects. First of all, replace Globalize with Mobility in your Gemfile and then run:

bundle i
rails generate mobility:install --without_tables

Next, enable the following plugins inside the mobility.rb initializer:

Mobility.configure do
  plugins do
    backend :table
    dirty
    locale_accessors
  end
end

Now simply extend your models with Mobility:

extend Mobility
translates name

This is basically it. You might also need to make additional changes to your code as explained in this migration guide.

Simplify your life with Lokalise

While Mobility allows us to store user-generated data inside the database, you will still need to translate interface-related elements. These translations live inside YAML files and managing them may become quite tedious, especially if your translators are not tech-savvy people. Therefore, Lokalise is here to help you! We provide a very convenient user interface which allows to import/export translation files, manage translations, integrate with other services like Trello or Slack, and much, much more. To integrate your Rails application with Lokalise follow these simple steps:

  • Get a free trial. No credit card is required, and the free plan is available for small and open source projects.
  • Create a new translation project with a base language set to English. Then simply add all other locales that your application supports.
  • On the project page click the “More” button and choose “Settings”. On this page you should see the project ID.
  • Open your personal profile page, navigate to the “API tokens” section, and generate a read/write token.
  • Add the lokalise_rails gem into your Gemfile.
  • Run rails g lokalise_rails:install and open the config/lokalise_rails.rb file. Provide your project ID and the API token:
require 'lokalise_rails'
LokaliseRails.config do |c|
  c.api_token = ENV['LOKALISE_API_TOKEN']
  c.project_id = ENV['LOKALISE_PROJECT_ID']
  # ...other options
end
  • Export your YAML translation files to Lokalise by running rails lokalise_rails:export.
  • Proceed to Lokalise and perform translations as needed. You may add more languages, invite new collaborators, and perform other actions.
  • When you are ready, import translation files back to your Rails project by running rails lokalise_rails:import.
  • This is it!

Conclusion

So, in this post we have seen how to store user-generated content inside the database using Mobility gem. We have seen all the main features of this solution, and by now you are ready to apply the received knowledge into practice. To learn more about Rails internationalization in general, check out the following post.

And this is it for today, folks! Thank you for staying with me, and until the next time.

Related posts

Sign up to our newsletter

Get the latest articles on all things localization and translation management delivered straight to your inbox.

Read also
Localization made easy. Why wait?
The preferred localization tool of 1500+ leading global companies