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.