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 (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 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?