content-authoring

Tweak our Copy until it’s just right

Let’s take an interesting idea, and do it in least surprising way that we can.

Storing translations outside of config/locales/en.yml

Under the hood, Rails relies on the ruby-i18n/i18n gem for determining translations’ text. Without any additional configuration, Rails employs an instance of I18n::Backends::SimpleBackend. In this case, simple is analogous for reading from the filesystem. The definitions we’ve declared thus far have been made in our project’s config/locales/en.yml. During the application’s boot process, the SimpleBackend reads the contents of that file, parses the YAML into an instance of a Hash, and stores it in-memory.

Let’s imagine that we’d like to change a key’s translation.

No problem! Let’s write a new value to that key in the Hash.

This seems appealing at first, but there are some unfortunate quirks to consider. Since the value is store in-memory, modifications to that value would be lost on server restarts, and would be inaccessible from other server processes.

In the world of horizontally scalable infrastructure and load balancing, servers are restarted constantly, and there are no guarantees that subsequent requests are served by the same application process.

If the value is stored on the filesystem, let’s change the contents of the file.

Unfortunately, the I18n::Backends::SimpleBackend eager-loads the contents of the translation file once (and only once) during the application’s boot process, so subsequent changes to the file go un-read.

Calling I18n::Backend::SimpleBackend#reload! re-parses the YAML file, but any changes would be local to that server process, which runs afoul of horizontal scalability and load balancing.

This sounds like a problem for some sort of data persistence layer…

Fortunately, the ruby-i18n/i18n project provides integrations for alternate backends. I18n::PostgresJson is an I18n::Backend implementation that reads from and writes to [Postgres JSON and JSONB columns][]:

PostgreSQL’s support for storing objects as JSON and JSONB columns, integrated with ActiveRecord column-aware models makes it an ideal candidate to store dynamic, editable application translation text.

--- /dev/null
+++ b/config/initializers/i18n.rb
@@ -0,0 +1,8 @@
+ActiveSupport.on_load :i18n do
+  require "i18n/postgres_json/backend"
+
+  I18n.backend = I18n::Backend::Chain.new(
+    I18n::PostgresJson::Backend.new,
+    I18n.backend,
+  )
+end

Within the config/initializers/i18n.rb initializer, establish an order of precedence for resolving a translation key.

Combine the two backends via a I18n::Backends::Chain instance. The I18n::PostgresJson backend serves as an editable data source layered on top of the foundational I18::Backends::Simple defaults:

Outside of configuring the I18n.backend and declaring a correctly structured database table (included as a I18n::PostgresJson-generated database migration), there are no additional requirements. We can continue to transparently call translate in our views, oblivious to the translations source of data.

The Translation model

The I18n::Backend::Chain adheres to the same interface as other I18n::Backend classes. Its store_translations(locale, data, **options) implementation adheres to the same order of precedence as when writing values as its #translate method does when reading values. In our case, a call to I18n.backend.store_translation will write to the I18n::PostgresJson::Backend first.

Reading and writing values to and from a database (SQL or otherwise) is something that Rails (ActiveModel and ActiveRecord, specifically) excels at.

This commit introduces the Translation class. It’s a model class that is not backed by ActiveRecord::Base. The I18n.backend instance provides a means of writing to the correct data source with the exact structure needed. With this access to this method, there’s no value in interfacing directly with PostgreSQL via ActiveRecord. However, there is value in an ActiveRecord-like interface for reading, writing, and validating attributes. To get the best of both worlds, the Translation class mixes in the ActiveModel::Model and ActiveModel::Attributes modules.

The data integrity of our translations is crucial. To preserve that integrity, the Translation model declares several ActiveModel validations.

For example, reject nil or empty values. Since the PostgresSQL-backed translations are layer on top of the translations declared in the YAML file, reject any Translation#key value that does not already exist in our set of resolvable keys.

Once deemed valid, calls to Translation#save will persist the changes by invoking I18n.backend.store_translations &emdash; with one final quirk. To keep the scope of our first foray into copyediting tight and to limit exposure to various Cross-Site Scripting (XSS) vulnerabilities, limit the translations to plain text only by wrapping calls to Translation#value with calls to ActionText::Content#to_plain_text to strip out any HTML:

def value
  content = ActionText::Content.new(super)

  content.to_plain_text
end

The TranslationsController class

Backed by our I18n::Backends::Chain, our Translation class serves as a Model (the MVC) paradigm. The remaining two-thirds of the work will be done in our application’s View (the V) and Controller (the C) layers.

We’ll be managing changes to our Model by responding to HTTP requests that are routed to our Controllers.

The new TranslationsController class draws inspiration from an example in the i18n-postgres_json README file and transforms locale, key, and value parameters into Translation instances. Once the model instance is constructed, the controller attempts to update the translation by calling Translation#save.

The translations/form View partial

The final missing piece of the puzzle is our View layer (the V of MVC). The <form> element that we construct submits a POST /translations request with all the constituent parts of a Translation: its locale, key, and value.

When passed a block, Rails’ translate helper yields the translation and the fully qualified translation key as block arguments.

For example, when calling translate(".hero.title") with a block from the marketings#index template, the value of the key block parameter is marketings.index.hero.title. When we render our translations using the “lazy” keys, we can access their fully-qualified counterparts through block arguments.

For calls to translate that we want to be able to edit, invoke them as a call to editable.translate. The editable.translate helper delegates to an instance of the Editable class, which decorates calls to #translate so that they accept a block.

Within the block, use the fully qualified translation key to construct a <form action="/translations" method="post"> element that contains the key encoded as an <input type="hidden"> element.

The submission’s locale value can be inferred from the current request-local value of I18n.locale, and is also encoded as an <input type="hidden"> element, invisible to the user.

Ideally, the <form> elements for value would serve double duty: presenting an field to edit the translation text when in focus, and rendering the contents of the translation text otherwise.

Visual parity between those two modes is achievable, but due to the subtle differences in how CSS rules are applied to certain Flow Content elements and Form-associated Content elements, it could become tedious to do so.

Luckily, browser support for the [contenteditable] attribute renders controls for editing content in the same context and styles it’s rendered within normally.

Trix-powered Rich Text

Trix is a Rich Text Editor for Everyday Writing. It serves as a progressive enhancement to [contenteditable], and is a tool under the Ruby on Rails umbrella since the introduction of the ActionText portion of the framework.

While Trix is a fully-featured, HTML-capable What you See is What you Get (WYSIWYG) editor, it’s utility comes in the form its ability to edit already styled content in-context.

Under the hood, calls to rich_text_area_tag produce a <trix-editor> element along with a corresponding <input type="hidden"> element that serves as its form-local value storage.

On initialization, <trix-editor> elements construct their own <trix-toolbar> elements to wrap a collection of <button> elements that are wired-up to modify the <trix-editor> selected contents.

In our use case, we don’t want to support anything more sophisticated than plain text, so we add trix-toolbar-specific CSS rules to style those elements with display: none;.

It’s important that we don’t entirely disable the <trix-toolbar> element, since there are two custom actions we’d like to support: canceling an edit and saving an edit. To implement these actions, we’ll declare the <button> elements in the translations/form partial’s HTML as descendants of the <trix-toolbar> element, and ensure that the <trix-editor toolbar="..."> attribute references the <trix-toolbar id="..."> attribute. On the initial page load, ensure the toolbar is hidden by declaring it with the [hidden] attribute.

To handle interactions withing the <trix-editor> element and its <trix-toolbar>, declare the trix-form Stimulus Controller, and ensure that our <form> element declares it as part of its [data-controller="trix-form"] attribute.

When the controller attaches behavior to the <form> element, ensure that the <trix-toolbar> is visually hidden by ensuring it declares hidden = true.

To declare the rest of its behavior, build the <form> element with various Stimulus-specific data-action values:

--- /dev/null
+++ b/app/views/translations/_form.html.erb
@@ -0,0 +1,44 @@
+<%= form_with model: translation, class: "relative", data: {
+  controller: "trix-form",
+  action: "
+    trix-file-accept->trix-form#reject
+    trix-focus@document->trix-form#dismissDismissUnlessFocused
+    click@document->trix-form#dismissUnlessContained
+    focusout->trix-form#dismissUnlessContained
+    reset->trix-form#dismiss
+  "
+} do |form| %>

Each line routes a DOM event to an action declared in the trix-form controller:

+    trix-focus@document->trix-form#dismissDismissUnlessFocused

The @document portion of the action routing declaration declares a trix-focus event listener on the document, instead of the <form> element. This means that whenever any of the <trix-editor> elements receive focus, all of the trix-form controllers will respond to the event. This means that we can hide all the trix-toolbar elements except for the editor with focus within a single action declaration.

+    trix-file-accept->trix-form#reject

This trix-file-accept event listener routes events to an action that invokes event.preventDefault(), effectively cancelling any file attachment attempts.

The other routing declarations will dismiss the toolbar when the <form> element is reset (via the reset event), focus leaves the editor (via the focusout event), or a mouse click event fires anywhere outside of the <trix-editor> or <trix-toolbar> element.

Testing the interactions

For each concept introduced here (i.e. the Translation class, the TranslationsController class, the translations/form view partial, and the Trix editor integration) is tested in turn at its corresponding layer of the Testing Pyramid:

The Test Pyramid

The Translation model is covered by a unit test, the TranslationsController is covered by a service-level integration test, and the translations/form view partial and the rest of the Trix behavior is covered by an end-to-end System Test.

In order to ensure that each test is run with test-specific data, the #with_translations test helper accepts a set of translations and a block, and ensures that those translations are only available within the block’s scope of execution.

Our progress so far

I wonder, what would it take to edit HTML translations?

Desktop

editing translation in desktop dimensions

Mobile

editing translation in mobile dimensions