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 tablesuperheroes_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 createdname_en
andname_ru
columns inside thesuperheroes
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 yourGemfile
. - Run
rails g lokalise_rails:install
and open theconfig/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.