Maintainable Spree Part 2: Custom Model Translations

Piotr Matląg

Mon, September 14, 2020

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, 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 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:

en:
  spree:
    product: Product
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 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.

Screenshot-2020-05-08-at-12.24.35

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

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.

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:

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:

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:

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:

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:

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:

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, 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:

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

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.

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

<% 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 %>
<% 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:

<%= 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:

Screenshot-2020-06-04-at-11.57.21

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:

<% 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:

Screenshot-2020-06-04-at-12.31.31

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:

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

Screenshot-2020-06-04-at-12.51.43

Screenshot-2020-06-04-at-13.09.34

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.

Let's Do a Project Together.

Leave your information below

or contact us directly

Company.

Upside Lab sp. z o.o.

VATID: PL6762565519

Długa 74/4

31-147 Kraków

Poland

Rheinsberger Str. 76/77

10115 Berlin

Germany

New Business.