Implementing i18n in the database with Rails 5
Recently I built a Rails 5 application for soolbarweek.com and learned a lot about how to make a customer happy dealing with translations in a Rails application. Like all things in programming, there is more than one way to fix the problem. So I present to you how I approached this issue, and look forward to hearing how you would improve on it.
I’m assuming you already know how to setup a Rails application with Devise as the authentication mechanism, and already did some of the suggestions from the awesome guide on i18n.
About The App
We’re using Devise for authentication. On the User model, we added a boolean column for `is_admin`. In ApplicationController we added this method:
def user_is_admin?
user_signed_in? && current_user.is_admin?
enddef authenticate_admin!
unless user_is_admin?
redirect_back(fallback_location: root_path)
end
end
Now in our controllers we can do this:
before_action :authenticate_admin!Setting Up
First add a couple of gems to your project:
gem 'i18n'
gem 'rails-i18n', '~> 5.0.0'
gem 'i18n-active_record', :require => 'i18n/active_record'Next we need to create a table to store translations, so generate a migration.
class Translations < ActiveRecord::Migration[5.1]
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
end
end
endGo ahead an rails db:migrate to add the table. The next thing is to tell Rails to use this new backend for translations.
Add config/initializers/locale.rb and put this in it:
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::Simple.send(:include, I18n::Backend::Memoize)
I18n::Backend::Simple.send(:include, I18n::Backend::Pluralization)
I18n.backend = I18n::Backend::Chain.new(I18n::Backend::Simple.new, I18n.backend)
endWhat this will do is make the app look in the database for a translation key first, then fall back to config/locales/*.yml files. That way you don’t have to lose anything or worry about moving from yaml files to the database. The application searches the database, and if not found refer to yaml files.
Managing Translations
Now that you have the foundation, let’s set up the walls and roof.
Open config/routes.rb and add the routes for managing locales:
resources :locales do
resources :translations, constraints: { id: /[^\/]+/ }
endThis means our URL’s would look like /locales/en/translations so when we navigate to the pages we’ll see the locale resourced in the address. This is useful if you want to add a new locale later just by referencing it by id in the address bar.
Create a TranslationsController and put this in it:
class TranslationsController < ApplicationController
before_action :find_locale
before_action :retrieve_key, only: [:create, :update]
before_action :find_translation, only: [:edit, :update]
before_action :authenticate_admin!
def index
@translations = Translation.order(:key).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
rescue ActiveRecord::RecordNotUnique
flash[:warning] = "Translation already exists"
render :new
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
endThis controller would only be used for operations on the translation table, so it is important to prevent non-admins from calling it from the web.
Now we need a little help in the views, so let’s create a app/helpers/translations_helper.rb file:
module TranslationsHelper
def translation_keys
Translation.select(:key).distinct.map(&:key)
end
def translation_for_key(translations, key)
hits = translations.to_a.select {|t| t.key == key}
hits.first
end
def all_locales
Translation.select(:locale).distinct.map(&:locale)
end
endLet’s hook up the views/translations/index.html.erb view:
<div class="row">
<div class="flex-column">
<h1>Translations for <%= @locale %></h1>
<div class="flex-column">
<%= link_to new_locale_translation_url(@locale, key: @key), class: "btn btn-success" do %>
<%= fa_icon('plus') %>
<%= t('new_translation') %>
<% end %>
</div>
<table class="table table-striped">
<thead>
<tr>
<th>Translation Key</th>
<th>Setting</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<% translation_keys.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 "New", new_locale_translation_url(@locale, key: key) %>
<% else %>
<%= link_to "Edit", edit_locale_translation_url(@locale, translation) %>
<%= link_to "Delete", locale_translation_url(@locale, translation), method: :delete, data: {confirm: "Are you sure?"} %>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<div class="row">
<div class="flex-column">
Switch to:
<ul class="list-group">
<% all_locales.each do |al| %>
<li class="list-group-item"><%= link_to al, locale_translations_url(al) %></li>
<% end %>
</ul>
</div>
</div>This lists out all the translations, and adds a couple buttons for adding new ones, editing existing ones, and deleting unused ones. In practice you’ll find when a view is added and a translation is missing, the result is each locale in the table gets a key assigned to it. For example if a profile_title key is in a view, and a locale does not have it, the i18n gem will add a missing locale value to the table, and this view will present it as such.
Knowing that makes it really easy to find missing translations because one can just search the page for the word ‘missing’ and fill in the blanks.
Pagination could be helpful here as well, but I left it out on purpose for the scope of the article to stay focused.
Next let’s make our new.html.erb view:
<%= form_for @translation, url: locale_translations_path do |form| %>
<% if @translation.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(translation.errors.count, "error") %> prohibited this translation from being saved:</h2>
<ul>
<% translation.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="form-group field">
<%= form.label :locale %>
<%= form.text_field :locale, readonly: true, class: 'form-control' %>
</div>
<div class="form-group field">
<%= form.label :key %>
<%= form.text_field :key, class: 'form-control' %>
</div>
<div class="form-group field">
<%= form.label :value %>
<%= form.text_area :value, class: 'form-control' %>
</div>
<div class="form-actions">
<%= form.submit 'Save Translation', class: 'btn btn-outline-info' %>
<%= link_to "Cancel", locale_translations_url(@locale), class: 'btn btn-secondary' %>
</div>
<% end %>And our edit.html.erb view:
<%= form_for @translation, url: locale_translation_path do |form| %>
<% if @translation.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(translation.errors.count, "error") %> prohibited this translation from being saved:</h2>
<ul>
<% translation.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="form-group field">
<%= form.label :locale %>
<%= form.text_field :locale, readonly: true, class: 'form-control' %>
</div>
<div class="form-group field">
<%= form.label :key %>
<%= form.text_field :key, class: 'form-control' %>
</div>
<div class="form-group field">
<%= form.label :value %>
<%= form.text_area :value, value: I18n.t(@translation.key, locale: @locale), class: 'form-control' %>
</div>
<div class="form-actions">
<%= form.submit 'Save Translation', class: 'btn btn-outline-info' %>
<%= link_to "Cancel", locale_translations_url(@locale), class: 'btn btn-secondary' %>
</div>
<% end %>Deploy i18n in the rest of the app
Now for the arduous gotcha’s of i18n in the rest of the app. From this point on the dev team has to accept never putting any language specific text in their presentation layer. The plus is this setup makes finding missing translations easy to do, but we really don’t want views to have missing_en_somekey all over! The best way I found to solve that is to make i18n default to English because more often than not dependencies are built supporting English first. It’s easy to diff the table for locales by count at that point.
In application_controller.rb I added these bits:
before_action :set_localedef set_locale
begin
if params[:locale] != nil
cookies.permanent[:locale] = params[:locale]
end
I18n.locale = (user_signed_in? && current_user.try(:locale)) || cookies[:locale] || read_lang_header || "en-us"
rescue I18n::InvalidLocale
I18n.locale = "en-us"
end
endprivate
def read_lang_header
lang_header = request.env['HTTP_ACCEPT_LANGUAGE']
lang_header.downcase.scan(/[a-z]{2}\-[a-z]{2}/).first unless lang_header.nil?
end
What this snippet does is pretty awesome. First it checks for a locale param, and if presented sets a cookie for that locale. That allows for easy language switching in a link or drop-down menu simply by refreshing the current page with the param[:locale] attached to the end. Because we set a cookie we can use it later as we fall through the search paths for locales.
In our user’s profile edit page, we let them set the locale they prefer, and use it as the default if set. To do that create the migration to add the locale column:
class AddLocaleToUser < ActiveRecord::Migration[5.1]
def change
add_column :users, :locale, :string, default: 'en-us'
end
endNotice we speficied ‘en-us’ here. What happens in practice with language headers is wildly varying from the spec. Some browsers only send ‘en’, and then what should we assume since en-gb, en-us, en, could get us far enough but not exactly the right experience. We could make the assumption the first two letters of lang_header are sufficient, but you can quicky see from the list that could be trouble. We just took the approach that ‘en’ from dependencies means ‘en-us’, but from the internet we want to derive the closest match possible. From our analytics so far about 15% of users are sending ‘ko’ but officially the spec requests ‘ko-kr’ as the correct value. It taught us it is absolutely critical to let user change the locale on their own, and understand the impact it has on the entire application.
In our views now we can use the keys from the translation table, and the i18n.translate helper:
t("model.#{@model.id}.some_column", default: @model.some_column)
This let’s us fall back to database values as the default (which we assume english), but now our translations are organized by database entity and row. For example if our model class is Event then this line would read event.22.title in the translations database.
To deal with time there are two steps to take. First is to update the locale yml file:
en-us:
date:
order:
- :day
- :month
- :yearThis is imporant in several places, most notably if you use the date select helpers in Rails. I caught this error in development and spend a bit too long debugging the bizzare error message it produced. The rails-i18n gem should cover most cases. Because we speficied en-us it created a gap in coverage. Just copy the important parts of the en.yml file into the en-us.yml locale.
Next to localize time in the views, instead the t method, we use l:
l(event.start_time, format: '%l:%M %p')This reads in the names for days, months, etc. and puts them in the expected order. In this particular example we’re wanting a non-zero padded hour and minute in 12 hour format. I used this tool to come up with the best formatting choices.
And that should cover it! Translating websites are quite a chore! I hope what I’ve learned helps you, and I look forward to hearing how you dealt with complex translations.