How should I localize model fields using Rails?

Working with a localized Rails application is relatively easy. Rails Internationalization API (I18n) is quite well documented and straight forward to use once you get the hang of it. But what if you want to localize fields inside a form that should be populated by users. Imagine you have an Account model with a name field, and you need the user to fill in this name in English and German (chosen for simplicity) so that you can display the relevant name on each version of your website. By now you should probably know that I18n won’t help much, since it’s sole purpose is to localize predefined set of keys to their corresponding values.

Basically what we want to achieve is something that looks like this:

form

One might think

Maybe you can add two fields, one for the English name en_name and a similar one for the German de_name. But wait, not too fast. What if you were asked to add another language, French for example. Would it be suitable to add a migration for every field you want to localize whenever you want to add another language?
Or even worse, what if you were asked to add more fields to be localized? then you might eventually end up with something like this:

Account:
  - en_name
  - de_name
  - en_description
  - de_description
  - en_address
  - de_address

Not the prettiest thing, right? There has to be a better way to do it. Yes, you’re probably right.

Globalize to the rescue

Globalize (formerly known as Globalize 3) is a gem that builds on the I18n API in Ruby on Rails to add model translations to ActiveRecord models. You can either install it using:

gem install globalize

Or if you’re using bundler you can put this into your Gemfile:

gem 'globalize'

And use the bundle command to install the latest version.

Next, you should add translates :name to your model for the gem to identify which fields are you willing to translate. After you’ve done that, you should generate a new migration to add the translation field to your models. The migration should look like this:

class AddTranslationFieldsToAccounts < ActiveRecord::Migration
  def up
    Account.create_translation_table name: :string
  end

  def down
    Account.drop_translation_table!
  end
end

After running rake db:migrate your Account model should be all set. You can now run the Rails console to see Globalize in action:

account = Account.new
I18n.locale = :en
account.name = "My Account"

I18n.locale = :de
account.name = "Mein Konto"
account.save
account.translations
# => #<ActiveRecord::Associations::CollectionProxy [
#<Account::Translation id: nil, account_id: nil, locale: "en", name: "My Account">,
#<Account::Translation id: nil, account_id: nil, locale: "de", name: "Mein Konto">]>

account.name  # => "Mein Konto"
I18n.locale = :en
account.name  # => "My Account"

What Globalize does is that it adds a has_many association translations having a locale field, and all the translated fields represented as arguments to the translates method inside your model.

Building the controller and view

Now that we are nearly done with the model, it’s time to move to our controller and view. Basically what we want to do is to build a translation association for each locale we are going to use. So the accounts#new action should look like this:

def new
  @account = Account.new
  @account.translations.build locale: :en
  @account.translations.build locale: :de
end

Now moving to the view. At first glance, you should come up with a view that looks like this:

<%= form_for @account do |f| %>
  <%= f.fields_for :translations do |translation_fields| %>
    <%= translation_fields.hidden_field :locale %>
    <%= translation_fields.label :name %>
    <%= translation_fields.text_field :name %>
  <% end %>
<% end %>

We added a hidden field to be submitted along with the translated fields. Without this field, we would never know which name value corresponds to which language. But now we face another issue. The labels on both fields now display “Name”. How can we change this behavior so that each label displays the language?

One way to do it is to change the translations for our account model attributes, to make it accept a lang interpolation variable. If you are missing any of these concepts, make sure to check out Translations for ActiveRecord Models and Interpolation.

# config/locales/en.yml
en:
  activerecord:
    attributes:
      account:
        name: Name (in %{lang})
  locale_name:
    en: English
    de: German

#config/locales/de.yml
  activerecord:
    attributes:
      account:
        name: Name (in %{lang})
  locale_name:
    en: Englisch
    de: Deutsch

You also might’ve noticed that we added a key for locale_name. This will be used to map the en and de keys to their respective languages. So for the form to be rendered correctly, all we need to do is fix the labels displayed inside the fields_for block. It should look like this:

<%= translation_fields.label :name, Account.human_attribute_name(:name, lang: t(ff.object.locale, scope: :locale_name) %>

So that’s about it. Now both form labels should be displayed correctly, meaning the first one should be displaying “Name (in English)” and the second one should be “Name (in German)”. What’s remaining now is to implement the create action. It should be something like this:

def create
  @account = Account.new account_params
  if @account.save
    redirect_to accounts_path
  else
    render :new
  end
end

private
def account_params
  params.require(:account).permit translations_attributes: [:id, :locale, :name]
end

How to validate localized fields

There are lots of ways we can do custom validations with Rails. But since we’ve already used Globalize once, chances are we’re gonna use it again. Therefore it’s preferable to implement a class that extends ActiveModel::EachValidator (you could also extend ActiveModel::Validator class, but in this case the EachValidator class is more suitable). Let’s write our custom validation class, that should be responsible for validating the presence of a given field:

# app/validators/translation_presence_validator.rb
class TranslationPresenceValidator < ActiveModel::EachValidator
  LOCALES = [:en, :de]

  def validate_each(record, attribute, value)
    if values_for_locales(record, attribute).any?(&:blank?)
      record.errors.add attribute, options[:message] || :blank
    end
  end

private
  def values_for_locales(record, attribute)
    LOCALES.map { |locale| record.read_attribute(attribute, locale: locale) }
  end
end

A quick rundown:

  • The read_attribute method can provide the value of an attribute for a specific locale, given the locale option is set.
  • The values_for_locales method returns an array of values the attribute has for all locales used.
  • If any of those values are blank, then a :blank error would be added to the record (just like the default presence validator does).

Now for the final step. We need to add the following line to the Account model for validation to take place:

validates :name, translation_presence: true

And we’re done!

One thought to “How should I localize model fields using Rails?”

  1. Hi Ahmed! I invite you to have a look at this free online converter tool http://yml2po.com/ that can be useful when managing any Rail app localization project. It works in both ways, converting yaml/yml to po files or po to yaml.

Leave a Reply

Your email address will not be published. Required fields are marked *