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:
- Rails Internationalization (I18n) API (Rails Guides)
- Go Global with Rails and I18n
- Working With Locales and Time Zones in Rails
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.
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.
Comments
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
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 =)
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)
Hi Dana , and thank you for writing this comprehensive guide.
I was wondering how does it compare to YML in performance area ?