Beyond YML Files - dynamic translations

Use your database to store and retrieve your Rails application's I18n translations

“Blue Planet Globe”:https://pixabay.com/en/earth-blue-planet-globe-planet-11015/ by “WikiImages”:https://pixabay.com/en/users/WikiImages-1897/ is licensed under “Creative Commons CC0”:https://creativecommons.org/publicdomain/zero/1.0/deed.en

The world is more connected than ever. Increasingly, web developers are called on to create web applications that are accessible to people from all over the globe, in their native languages.

The I18n library makes it relatively easy to offer translations for a variety of locales, and there are several tutorials to help you do exactly that. Just a few are as follows:

The standard I18n implementation has you store your translation in .YML files in your project. This solution requires developer involvement and project redeployment any time a translation needs to change. Translations are data - not code - so a better solution is to empower an admin to make these changes herself. The best way to achieve this is to use a database-driven backend to complement I18n's standard, YML-driven option. Fortunately, I18n makes using alternate backends painless.

i18n-active_record

This tutorial uses the i18n-active_record gem and its I18n::Backend::ActiveRecord class to supplement the default I18n::Backend::Simple class. Add the gem to your application's Gemfile:

gem 'i18n-active_record',
  github: 'svenfuchs/i18n-active_record',
  require: 'i18n/active_record'

Run bundle install.

Storing Translations

The locale-specific translations are stored in the translations table, so you need to write a migration to create this table.

$ rails g model Translation locale:string key:string value:text interpolations:text is_proc:boolean

Make sure you update the migration to default the is\_proc attribute to false:

class CreateTranslations < ActiveRecord::Migration
  def change
    create_table :translations do |t|
      t.string :locale
      t.string :key
      t.text :value
      t.text :interpolations
      t.boolean :is_proc, default: false

      t.timestamps null: false
    end
  end
end

Migrate the database.

$ rake db:migrate

Then create a locale.rb file in the /config/initializers directory, with the following content:

require 'i18n/backend/active_record'

Translation  = I18n::Backend::ActiveRecord::Translation

if Translation.table_exists?
  I18n.backend = I18n::Backend::ActiveRecord.new

  I18n::Backend::ActiveRecord.send(:include, I18n::Backend::Memoize)
  I18n::Backend::ActiveRecord.send(:include, I18n::Backend::Flatten)
  I18n::Backend::Simple.send(:include, I18n::Backend::Memoize)
  I18n::Backend::Simple.send(:include, I18n::Backend::Pluralization)

  I18n.backend = I18n::Backend::Chain.new(I18n.backend, I18n::Backend::Simple.new)
end

The final line of the conditional means that translations will be looked up first in the database, based on the key/locale combination. If no match is found, the translation is looked up in the corresponding YML file.

Now that the groundwork is laid, we need a way for our admins to manage the translations into the database.

In this tutorial, the controllers are not namespaced, and there is no authorization requirement. It's obviously unrealistic from that point of view. Lock this management functionality down to only those who should have access.

Editing Translations

Controller

Of course, we'll create a translations controller.

$ rails g controller Translations index new create edit update

Delete these auto-generated files:

  • /views/translations/update.html.erb
  • /views/translations/create.html.erb

and update the remainder of the controller as follows:

class TranslationsController < ApplicationController
  before_filter :find_locale
  before_filter :retrieve_key, only: [:create, :update]
  before_filter :find_translation, only: [:edit, :update]

  def index
    @translations = Translation.locale(@locale)
  end

  def new
    @translation = Translation.new(locale: @locale, key: params[:key])
  end

  def create
    @translation = Translation.new(translation_params)
    if @translation.value == default_translation_value
      flash[:alert] = "Your new translation is the same as the default."
      render :new
    else
      if @translation.save
        flash[:success] = "Translation for #{ @key } updated."
        I18n.backend.reload!
        redirect_to locale_translations_url(@locale)
      else
        render :new
      end
    end
  end

  def edit
  end

  def update
    if @translation.update(translation_params)
      flash[:notice] = "Translation for #{ @key } updated."
      I18n.backend.reload!
      redirect_to locale_translations_url(@locale)
    else
      render :edit
    end
  end

  def destroy
    Translation.destroy(params[:id])
    I18n.backend.reload!
    redirect_to locale_translations_url(@locale)
  end

  private

  def find_locale
    @locale = params[:locale_id]
  end

  def find_translation
    @translation = Translation.find(params[:id])
  end

  def retrieve_key
    @key = params[:i18n_backend_active_record_translation][:key]
  end

  def translation_params
    params.require(:i18n_backend_active_record_translation).permit(:locale,
      :key, :value)
  end

  def default_translation_value
    I18n.t(@translation.key, locale: @locale)
  end
end

Views

Add a form to the /views/translations/edit.html.erb file...

<%= form_for @translation, url: locale_translation_path do |form| %>
  <%= form.label :locale %> <%= form.text_field :locale, readonly: true %>
  <%= form.label :key %> <%= form.text_field :key, readonly: true %>
  <%= form.label :value %> <%= form.text_area :value %>

  <%= form.submit 'Save Translation' %> <%= link_to "Cancel", locale_translations_url(@locale) %>
<% end %>

... and to the /views/translations/new.html.erb file.

<%= form_for @translation, url: locale_translations_path do |form| %>
  <%= form.label :locale %> <%= form.text_field :locale, readonly: true %>
  <%= form.label :key %> <%= form.text_field :key, readonly: true %>
  <%= form.label :value %> <%= form.text_area :value, value: I18n.t(@translation.key, locale: @locale) %>

  <%= form.submit 'Save Translation' %> <%= link_to "Cancel", locale_translations_url(@locale) %>
<% end %>

Update the index view at /views/translations/index.html.erb so you can see your existing translations.


<h1>Translations for <%= @locale %></h1>
<table>
  <thead>
    <tr>
      <th>Translation Key</th>
      <th>Setting</th>
      <th>Actions</th>
    </tr>
  </thead>
  <tbody>
    <% translation_keys(@locale).each do |key| %>
      <% translation = translation_for_key(@translations, key) %>
      <tr id="<%= key %>">
        <td><%= key %></td>
        <td><%= translation.nil? ? I18n.t(key, locale: @locale) : translation.value %></td>
        <td>
          <% if translation.nil? %>
            <%= link_to "Edit", new_locale_translation_url(@locale, key: key) %>
          <% else %>
            <%= link_to "Edit", edit_locale_translation_url(@locale, translation) %>
            <%= link_to "Reset", locale_translation_url(@locale, translation), method: :delete, data: { confirm: "Are you sure?" } %>
          <% end %>
        </td>
      </tr>
    <% end %>
  </tbody>
</table>

Helpers

The view in the preceding section uses a translation\_keys helper. This is where you decide which translated values an admin can change within your application for a given locale. Let’s assume for the sake of this tutorial that you want to allow different translations for the en and jp locales. We do this with the TranslationsHelper, stored in /app/helpers/translations\_helper.rb.

module TranslationsHelper
  def translation_keys(i18n_locale)
    case i18n_locale
    when "en"
      en_keys
    when "jp"
      jp_keys
    else
      default_keys
    end
  end

  private

  def en_keys
    [ "welcome", "site_description", "contact_name" ]
  end

  def jp_keys
    [ "welcome", "site_description" ]
  end

  def default_keys
    [ "welcome", "site_description" ]
  end
end

Within the same helper file, we need to define the public translation\_for\_key function.

def translation_for_key(translations, key)
  hits = translations.to_a.select{ |t| t.key == key }
  hits.first
end

Routing

Of course, none of these views are reachable if we don't update our config/routes.rb file. Delete all of the automatically-generated "get" routes for translations actions, and replace them with:

resources :locales do
  resources :translations, constraints: { :id => /[^\/]+/ }
end

The constraints key forces translation ids to match the supplied regex pattern. This regex matches any combination of characters, except a slash (/). Without this additional routing parameter, we could not match on ids with dots (.) in them.

Now, when you visit /locales/en/translations, you can administer all of the English translations. Japanese translations can be administered at /locales/jp/translations, and so forth.

Retrieving Translations

All that remains now is to put this dynamic translations approach into action. The i18n-active\_record library we used makes this happen automatically, without any extra effort on your part. If you have a view that resides within the en locale, you can retrieve the active translation for the welcome key very simply:

Hello, and <%= t('welcome') %> to my beautiful website!

This snippet will retrieve the most recent translation for that key. If your admin has not edited the value for the welcome key, then whatever value exists in your en.yml file will be returned instead.

Photo of Dana Jones

Dana was born and raised in Dallas, Texas. She moved to eastern Washington after she married her husband, Mike, who is also a programmer. She now resides in Newburgh, Indiana with her husband and four children, ages ranging from 10-16.

Dana started programming in raw HTML and VBA, but moved on to C#/.NET. She did a six month stint writing Erlang, which she still applies to the way she approaches object-oriented programming. She has been writing Ruby on Rails since 2008 and was a founding board member of RailsBridge. After working freelance for many years and in the employment/product space for a couple more, Dana enjoys the challenges and variety that working for Collective Idea brings.

In her spare time Dana likes to read, sew, quilt, crochet, do puzzles, bake, and learn to play the violin. She also loves public speaking, going to conferences/meetups, getting to know new people, and learning about new technologies.

Comments

  1. benjamin.durin@wanadoo.fr
    Benjamin
    October 23, 2016 at 17:19 PM

    Nice article, it was very helpful!

    But what about interpolations in the translations table? What are they used for and how do we use them?

    Thanks

  2. Viktor
    November 30, 2016 at 19:41 PM

    In the method: “translation_keys(i18n_locale)”, parameter “i18n_locale” is useless, and all we need in this method is this:
    “Translation.select(:key).distinct.map(&:key)” it selects all unique keys. That`s my opinion =)

  3. Foton
    May 23, 2017 at 9:49 AM

    Why is in the IF statement in initializers/locale.rb the first assignment to I18n.backend? It is overwrited 5 lines later.
    Can be first one deleted and second be
    I18n.backend = I18n::Backend::Chain.new(I18n::Backend::ActiveRecord.new, I18n::Backend::Simple.new)

  4. Siros
    June 07, 2017 at 7:16 AM

    Hi Dana , and thank you for writing this comprehensive guide.

    I was wondering how does it compare to YML in performance area ?