Rails - Custom copy per customer / user through I18n

12th November 2020 – 829 words

In B2B you have usually few customers but more features or requirements come up. Especially more “key” customers might have very special requirements: texts, labels that need to be changed. Another customer needs the exact opposite. What to do? Littering the view with if customer1 else is hacky and crappy.

I want to show you a solution, that just uses Ruby on Rails I18n (Internationalization) API to create a virtual “locale” per customer and overrides only those keys, that are needed for that customer.

In our example customer1 has the user id=1, customer2 has user id=2 and want to change some labels for forms (e.g. simple_form.labels.the_form.label), customer1 wants to change a German key, customer2 an English key.

Creating locales per customer

Because the fallback mapping would be needed in a couple of places, We can add that information into an config/customer-translations.yml:

# config/customer-translations.yml
# customer1 needs some german strings fixed
de_1: de
# customer2 only needs some english strings fixed
en_2: en

We just have a convention here: “#{LOCALE}_#{CustomerID}” to find the correct translation per customer later on. The fallback defines the “parent” locale for this specific version.

And we load that file and tell Rails to search for locales into a nested directory of locales to keep things tidy:

# config/application.rb
module MyApp
  class Application < Rails::Application
    # ...
    config.i18n.load_path += Dir[Rails.root.join('config/locales/customers/*.{rb,yml}')]
    cms = YAML.load_file('config/customer-translations.yml')
    config.i18n.available_locales = [:en, :de] + cms.keys.map(&:to_sym)
    config.i18n.fallbacks = cms

Doing so, we create to new “virtual” locales: de_1 and en_2, which are using Rails fallback mechanism. Thus, Rails still uses the main translations and only uses overridden keys from the new “locales”

config/locales
├── de.yml
├── en.yml
├── customers
│   ├── customer1.yml
│   └── customer2.yml
# customer1.yml
de_1:
  simple_form:
    labels:
      the_form:
        label: Dieser Text muss unbedingt angepasst werden sagt ein Stakeholder!
# customer2.yml
en_2:
  simple_form:
    labels:
      the_form:
        label: Could you please adjust that text, that is very important for an important stakeholder!

Loading the correct locale at the right time

To have the correct locale applied, we must make sure that every time, when we generating a text, that locale is applied correctly, that includes:

  • Controller before action
  • Background-Jobs / Cron-Jobs / One-Off-Scripts / Migrations

In our case, the “User” are people, Identities, that belong to that company, and every User can adjust their locale in the profile settings

class User < ApplicationRecord
  belongs_to :company
  # field language:string de / en is available

  def adjusted_locale
    company.company_locale(language || I18n.default_locale)
  end
end

class Company < ApplicationRecord
  has_many :users

  def company_locale(language)
    locale = "#{language}_#{id}".to_sym
    if I18n.available_locales.include?(locale)
      locale
    else
      language
    end
  end
end

Now it is simple to change the language in a controller before action:

class ApplicationController
  # auth before
  before_action do
    I18n.locale = current_user.adjusted_locale
  end
end

This will set the locale to our “magic” de_1 for user’s that wanting to read German text for that specific company only.

If you need to set the locale in a background job, just use I18n.with_locale:

I18n.with_locale(user.adjusted_locale) do
  SomeMailer.send_that_mail(user).deliver_now
end

Integration with rails-i18n-js

To also generate frontend Javascript for this specific locale (Previously blogged here), I had to make the following adjustments:

# config/initializers/assets.rb
cms = YAML.load_file('config/customer-translations.yml')
Rails.application.config.assets.precompile += cms.keys.map { |i| "locales/#{i}.js" }
# config/i18n-js.yml
# ...
fallbacks: :default_locale

Now, I18n-js will also generate frontend javascript translations for that locale, too.