Engineering

Maintainable Spree Part 2: Custom Model Translations

Piotr Matlag
Piotr Matlag
September 14, 2020
Night mode

*This is the second part of The Maintanable Spree series. [In the first article we are focusing on Command Pattern, read it here.*

Most e-commerce solutions, if they exist long enough, reach a point when their makers start considering expansion beyond their native, single-country market. These thoughts may find their origin in many different stages of shop's existence and under multiple circumstances. In some cases single country customers' needs prove to be insufficient to fuel company's growth anymore. In others, the store could be designed from the ground up with multiple languages and currencies in mind. One day however, the decision is made. And it's usually: "We're going international".

The international market is a great opportunity. It can supply booming e-commerce solution with masses upon masses of eager customers. Nowadays this is especially true, since conducting business across borders has never been easier, cheaper or faster. Shipping companies offer competitively priced services to deliver your packages to anywhere in the world.

However, international market is also a challenge. With so many online stores available, it takes a superior user experience to attract customers to a particular one and keep them there. Many factors come to play in that experience, but one of the most important ones is availability in the customer's preferred language. When user is not able to understand the interface, the confusion ensues, which is an obstacle for a pleasant experience.

Supporting multiple languages is non-optional for international stores that aim for the best user experience. Fortunately, modern e-commerce solutions, such as Spree, allow us to do just that, while also not introducing too much overhead to the store's administrators. Here's how.

Spree internationalization capabilities

As you can read in its [documentation](https://guides.spreecommerce.org/developer/customization/i18n.html), Spree comes with English as a default language, but you can easily extend it to support more languages using extensions. To be specific, `spree_i18n` and `spree_globalize`. Let's have a look at them now.

Spree I18n

Extension based on [rails-i18n](https://github.com/svenfuchs/rails-i18n) project, aiming to provide translations based on YAML config files. Out-of-the-box it comes with a large repository of transatable terms that find use in every online store, like like address information, products information and interface elements. The list of translations can be very easily extended, by simply adding YAML key-value pairs in the translation files. The usage is also extremely straight-forward, and very familiar if you've ever translated any Rails application.

Let's say we have two locales enabled, `en` for English and `pl` for Polish. In that situation we'd also have two YAML config files: `config/locales/en.yml` and `config/locales/pl.yml`. Their content would look a lot like this:

```YAML
en:
 spree:
   product: Product
```
```YAML
pl:
 spree:
   product: Produkt
```

We can access those translations in the view by using just calling `Spree.t(:product)`. From the documentation:

> The `Spree.t()` helper method looks up the currently configured locale and retrieves the translated value from the relevant locale YAML file. Assuming a default locale, this translation would be fetched from the en translations collated from the application, `spree_i18n` and `rails-i18n`.

Spree Globalize

Extension based on [Globalize](https://github.com/globalize/globalize) gem for Rails. It enables translations of model data, especially Spree built-ins, like `Product` or `OptionType`. It requires very little interaction to activate. Add it to your Gemfile, configure supported locales and you're good to go: Spree models are enriched with a convenient link for their translations UI, fully manageable by the store administrator.

When it's not enough

These two extensions that I mentioned can, and usually are, used simultaneously, since they serve similar, but ultimately different purposes.

Spree I18n works great for translating interface elements that are not part of any model. It does not require any modifications to the database, since it keeps translations in config files. But it also means that, for every new translation, source files need to be updated, which is not comfortable for things that are updated often and with no interaction from the developer, like products. Things like buttons, notices, popups are great examples where it shines.

Spree Globalize, on the other hand, is perfect for translating models and their attributes. It offers store administrators a handy UI for managing translations and does not require changing the source files to do that. But, for every entity it serves, it requires a separate database table. This is a big overhead when you want to translate things like buttons or popups.

One limitation of Spree Globalize is that it was meant first and foremost for built-in Spree resources. However, with a little bit of work we can extend it to serve custom models that might come into existence over the course of application development, that were not foreseen by Spree creators.

This story is actually based on my experience from some time ago, when I was tasked with adding a new, translatable model to our application. For maximal clarity I set up another Spree instance and replicated the whole process, while also noting down my actions here.

To replicate these steps you need to install and enable Spree Globalize extension. Follow the [documentation](https://github.com/spree-contrib/spree_globalize) to do that.

Model

Let's say that we want to add a new, translatable entity to our application. A notification, that can be displayed for customers in their respective languages. Store administrators should be able to manage notifications with the same set of CRUD operations that they use for managing products.

Ruby
class Notification < ApplicationRecord
 translates :title, :content, fallbacks_for_empty_translations: true
 include SpreeGlobalize::Translatable
end

We start by creating the Rails model. The notification will have title and content and both these attributes will be translatable.

The first code decision that needs to be made is whether to put your new model inside the Spree module, ending up with `Spree::Notification` or not, which gives us just `Notification`. As you can see I went with the latter. It's really a matter of convention. Pick one and stick with it for all models.

I mention that because both of these options resulted in some different technical problems for me. I will mention the both as we go, so you'll have a solid base to build on in case you face them yourself.

Migrations

We can create the main model and its translations in one migration.

Ruby
class CreateNotifications < ActiveRecord::Migration[5.2]
 def change
   create_table :notifications do |t|
     t.timestamps
   end
   reversible do |dir|
     dir.up do
       Notification.create_translation_table!(title: :string, content: :text)
     end
     dir.down do
       Notification.drop_translation_table!
     end
   end
 end
end

Take notice that we don't put title and content attributes inside the `notifications` table and instead we delegate that to translations table. After running the migration these lines have been added to the database schema:

Ruby
create_table "notification_translations", force: :cascade do |t|
 t.bigint "notification_id", null: false
 t.string "locale", null: false
 t.datetime "created_at", null: false
 t.datetime "updated_at", null: false
 t.string "title"
 t.text "content"
 t.index ["locale"], name: "index_notification_translations_on_locale"
 t.index ["notification_id"], name: "index_notification_translations_on_notification_id"
end
create_table "notifications", force: :cascade do |t|
 t.datetime "created_at", null: false
 t.datetime "updated_at", null: false
en

We can confirm that notification model has been created together with its translations using the Rails console:

Ruby
irb(main):001:0> Notification
=> Notification(id: integer, created_at: datetime, updated_at: datetime, title: , content: )
irb(main):002:0> Notification::Translation
=> Notification::Translation(id: integer, notification_id: integer, locale: string, created_at: datetime, updated_at: datetime, title: string, content: text)

Hack #1

If you put your model inside the Spree module you might stumble upon the following behavior when you try to create a new notification:

Ruby
irb(main):001:0> Spree::Notification.create!(title: "Hello!", content: "Nice to see you here!")
  (0.1ms)  begin transaction
 Spree::Notification Create (0.7ms)  ...
  (1.5ms)  rollback transaction
Traceback (most recent call last):
       1: from (irb):1
ActiveModel::UnknownAttributeError (unknown attribute 'notification_id' for Spree::Notification::Translation.)

Notification's translation should reference its parent by `spree_notification_id` attribute, but here it tries to do this by just `notification_id`. Obviously, there's no such attribute in `Spree::Notification::Translation`, hence the exception.

I spent some time to find the root cause of this behavior, but ultimately I was unable to. I managed, however, to work around this issue with a simple hack. I simply aliased `notification_id`, that's assigned to during the translation's creation to `spree_notification_id`, which is an actual translation attribute name. I put a small monkey-patch in `app/models/spree/notification/translation_decorator.rb`:

Ruby
module Spree::Notification::TranslationDecorator
 def self.prepended(base)
   base.alias_attribute(:notification_id, :spree_notification_id)
 end
end
::Spree::Notification::Translation.prepend (Spree::Notification::TranslationDecorator)

After that it works just fine. You can validate it by creating a notification in the Rails console:

Ruby
irb(main):005:0> Spree::Notification.create!(title: "Hello!", content: "Nice to see you here!")
  (0.1ms)  begin transaction
 Spree::Notification Create (2.6ms)  ...
 Spree::Notification Load (0.3ms)  ...
 Spree::Notification::Translation Create (1.7ms)  ...
  (1.7ms)  commit transaction
=> #<Spree::Notification id: 1, title: "Hello!", content: "Nice to see you here!", ...>

If you decided to put model outside of Spree module you should not have this problem.

Controller

Once the model is ready we need to create the controller to manage our new translatables. Fortunately, Spree does most of the work here for us. It offers programmers `Spree::Admin::ResourceController` which, with slight customizations, can serve pretty much any RESTful resources. In most cases all we need to do is define the attributes that are available for mass assignment (by default all of them are, so not very safe) and write views for the new resource. This is the controller part:

Ruby
module Spree
 module Admin
   class NotificationsController < ResourceController
     def index; end
     protected
     
     def model_class
       Notification
     end
     def permitted_resource_params
       params
         .require(:notification)
         .permit(
           :title, :content,
           translations_attributes: %i[id locale title content]
         )
     end
   end
 end
end

Most actions, except `index` are defined in the `ResourceController`. It also takes care of creating or finding appropriate resources before performing any operations on them. Check out its [source code](https://github.com/spree/spree/blob/3-7-stable/backend/app/controllers/spree/admin/resource_controller.rb), it's really clever and saves a lot of work. Because we keep Notification outside of Spree module we also need to explicitly tell the controller what is it's resource model class.

## Router

Now that we have controller set up, we can define routes leading to it, so that users' requests are actually able to reach it. I put this in my routes file:

Ruby
Spree::Core::Engine.add_routes do
 namespace :admin do
   resources :notifications
 end
end

Since Spree routes are mounted at the root of the application, this entry will hand requests to `/admin/notifications` over to `Spree::Admin::NotificationsController`. Which is exatcly what we want.

Hack #2

If you, like me, didn't wrap your model class in Spree module you need to patch Spree's translations controller a bit. When you take a look at its [source](https://github.com/spree-contrib/spree_globalize/blob/master/app/ controllers/spree/admin/translations_controller.rb) you'll notice that it's tailored to work with Spree models. Even though we can reference translatables inside translation views via names like `@option_type`, for their model class names the controller transforms them to something like `OptionType` and prefixes with `Spree::`. We can make an exception in our case:

Ruby
module Spree
 module Admin
   module TranslationsControllerDecorator
     def klass
       resource_class_name = params[:resource].classify
       if resource_class_name == 'Notification'
         resource_class_name.constantize
       else
         "Spree::#{resource_class_name}".constantize
       end
     end
   end
 end
end
Spree::Admin::TranslationsController.prepend(
 Spree::Admin::TranslationsControllerDecorator
)

I put that in `app/controllers/spree/admin/translations_controller_decorator.rb`. Now the translation views that we'll write in a moment will work just fine.

Views

Again, we're mostly basing on pre-existing Spree structures. For the index view we'll supply our own content for page title, page actions and our custom notifications listing.

Ruby
<% content_for :page_title do %>
 <%= plural_resource_name(Notification) %>
<% end %>
<% content_for :page_actions do %>
 <%= button_link_to Spree.t(:new), new_object_url, { class: 'btn-success', icon: 'add', id: 'admin_new_notification_link' } %>
<% end %>
<table class="table">
 <thead>
   <tr>
     <th><%= Spree.t(:title) %></th>
     <th></th>
   </tr>
 </thead>
 <tbody>
 <% @notifications.each do |notification|%>
   <tr>
     <td class="align-center"><%= link_to notification.title, object_url(notification) %></td>
     <td class="align-center actions">
       <%= link_to_edit notification, no_text: true, class: 'edit' %>
       <%= link_to_delete notification, no_text: true %>
       <%= link_to_with_icon 'translate', nil, admin_translations_path('notification', notification.id), title: 'Translate', class: 'btn btn-sm btn-primary' %>
     </td>
   </tr>
 <% end %>
 </tbody>
</table>

Links for editing, deleting and translating are placed next to every notification entry. This, of course, is a very simple, proof-of-concept view that can be extended and prettified in real world use cases. It is fully functional though, so we'll just roll with it here.

In a similar fashion we create views for creating and editing notifications.

Ruby
<% content_for :page_title do %>
 <%= Spree.t(:new) %>
<% end %>
<% content_for :page_actions do %>
 <%= button_link_to Spree.t(:back), spree.admin_notifications_path, icon: 'arrow-left' %>
<% end %>
<%= form_for [:admin, @notification] , html: { multipart: true } do |f| %>
 <fieldset class="no-border-top">
   <%= render partial: 'form', locals: { f: f } %>
   <hr />
   <div>
     <%= render partial: 'spree/admin/shared/new_resource_links' %>
   </div>
 </fieldset>
<% end %>
Ruby
<% content_for :page_title do %>
 <%= Spree.t(:edit) %>
<% end %>
<% content_for :page_actions do %>
 <%= button_link_to Spree.t(:back), spree.admin_notifications_path, icon: 'arrow-left' %>
<% end %>
<%= form_for [:admin, @notification] , html: { multipart: true } do |f| %>
 <fieldset class="no-border-top">
   <%= render partial: 'form', locals: { f: f } %>
   <hr />
   <div>
     <%= render partial: 'spree/admin/shared/edit_resource_links' %>
   </div>
 </fieldset>
<% end %>

The central point in both of these views is a form in which we can define notification's attributes. I extracted it to a partial:

Ruby
<%= f.field_container :title do %>
 <%= f.label :title, t(:title) %>
 <%= f.text_field :title, class: 'fullwidth form-control' %>
<% end %>
<%= f.field_container :content do %>
 <%= f.label :content, t(:content) %>
 <%= f.text_area :content, {cols: 60, rows: 4, class: 'fullwidth form-control'} %>
<% end %>

This set of views is enough to create, edit and delete a simple resource. We still need a way to manage its translations though. Fortunately, Spree takes care of most of that as well. But before that, let's go ahead and create a notification. Just go to `/admin/notifications` and click on a `New` button in the top right corner. You should be taken to a form like this:

Fill in the fields and create the notification. Now we can move on to translations.

Translation view

A part of work left for us to do in addition to that performed by Spree consists of supplying our translation page with title and action buttons. I put that in `app/views/spree/admin/translations/notification.html.erb` file:

Ruby
<% content_for :page_title do %>
 <%= Spree.t(:editing_resource, resource: Notification.model_name.human) %> <span class="green">"<%= @notification.title %>"</span>
<% end %>
<% content_for :page_actions do %>
 <%= button_link_to Spree.t(:back_to_resource_list, resource: Notification.model_name.human), spree.admin_notifications_path, class: 'btn-primary', icon: 'arrow-left' %>
<% end %>
<%= render 'form' %>

That's actually it. From now on, when you click a globe button next to the notification entry you'll be taken to the translation page where you can configure all translatable attributes for every enabled locale. The result, for the amount of code that we wrote, is pretty compelling:

Result and final thoughts

I added a simple div on the store's home page to demonstrate what we've accomplished. It's something along these lines:

Ruby
<div style="border: 1px solid; padding: 5px; margin-bottom: 5px; text-align: center;">
 <% n = Notification.first %>
 <h1><%= n.title %></h1>
 <p><%= n.content %></p>
</div>

Notice how we don't care about locale at all. Despite that, when we navigate to the site's root with different language settings we get expected results:

With relatively little work we've managed to bring a comfortable way of managing translatable resources to our application. Customers are happy to see the store in their preferred language. Administrators are happy that in a few simple steps they can add translations to new and existing models. We, as developers, are happy that everything works and the translation process does not require further changes to the source code.

I wouldn't go as far as to say that internationalization is a cure for every e-commerce problems. But working internationalization sure makes everybody involved happier.

Contat us

Let's do a project together

Leave your information below:

*
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Or contact us direclty

Message