How to store Rails translations in database with Mobility

Mobility Gem: How to store Rails translations inside a database

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 Gem 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::GlobalConfig.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 articles
    Stop wasting time with manual localization tasks. 

    Launch global products days from now.