Implementing i18n in the database with Rails 5

Todd Baur
Todd Baur
Aug 24, 2017 · 7 min read

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?
end
def 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
end

Go 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)
end

What 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: /[^\/]+/ }
end

This 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
end

This 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
end

Let’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
end
private

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
end

Notice 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
- :year

This 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.

)
Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade