In this post, we will implement a basic ActionText table editor for your Rails application. We'll learn how:
- ActionText and Trix handle attachments
- To implement our own
Attachable
type, and leverage this to build a basic table editor - Turbo Frames can be used to edit the table
- Turbo helps and gets in the way at the same time
This article draws inspiration from the excellent 'Adding Tables to ActionText With Stimulus.js' blog post from 2020. That was written before the advent of Turbo though, which we can expect to simplify matters quite a bit.
Let's get going!
ActionText Attachments in Rails 101
Note: This demonstration assumes some understanding of Trix and Turbo Frames. You might find our 'Get Started with Hotwire in Your Ruby on Rails App' post helpful in learning the basics of Hotwire and Turbo Frames.
You can follow along with the code demonstration with this GitHub repo.
As described in the ActionText documentation:
Action Text brings rich text content and editing to Rails. It includes the Trix editor that handles everything from formatting to links to quotes to lists to embedded images and galleries.
At a high level, attachments are part of ActionText's document model. They render custom templates for any resource resolvable by a Signed Global ID (SGID). In other words, ActionText stores a reference to a certain SGID as an <action-text-attachment>
element:
Whenever ActionText encounters such an element, it calls the to_attachable_partial_path
method on the respective resource. By default, this method delegates to to_partial_path
.
So, as a preview, this is how our Table
's representation in ActionText is going to look when rendered back to HTML:
To conform with the ActionText Attachment API, a class has to do only two things:
- Implement
to_sgid
by includingGlobalID::Identification
. By default, allActiveRecord::Base
descendants already do this. - Include the
ActionText::Attachable
module.
The ActionText::Attachable
module offers canonical ways to convert any model to and from an SGID via the attachable_sgid
and from_attachable_sgid
methods. We will make use of this later on.
It also provides convenience accessors for attachment metadata, such as file size and name, as well as content type.
Finally, it provides the default locations for the partials used to render an attachment in the editor and rich text views.
Adding a Table Model
We will capitalize on ActionText's Attachment API to implement our table solution. For this, we have to create a custom model capturing our tables' data and include Attachable
. We'll use a simple JSON(B) column to hold a two-dimensional array for the table data.
To start our exploration, let's create a new Rails app with ActionText enabled:
Because I'm not feeling creative today, let's scaffold an Article
model with a title and rich text content:
Watch out, here's a surprising gotcha! The above install command created a CreateActionTextTables
migration, so we need to rename it to CreateActionTextTablesTable
. Additionally, we'll have it default to a 2x2 table using null: false, default: [["", ""], ["", ""]]
.
Add a Table to a Rails ActionText Model
Before we continue with actually adding a table to rich text, we need to patch Trix's toolbar:
Here, we manually append a button to Trix's toolbarElement
. Wiring this up to a trix-table
Stimulus controller (that we've yet to build) will insert a table into the document. Let's give this button a nice SVG as content in CSS and set up some table styles while we're at it:
As you can see, we have successfully added it to the "file-tools" group:
Now let's return to adding and manipulating tables with the help of Turbo. For this, we will first need a controller with a create
action:
This action can be more or less borrowed from the 'On Rails' blog post we cited in the introduction. It constructs the JSON necessary to insert an attachment on the client side: including an SGID and content
rendered from an editor
partial, as we shall see later.
We add the relevant resourceful table routes to our configuration:
Now comes the moment to plunge into the deep end: we need to build our table model. First, let's include ActionText::Attachable
and define the relevant partial paths:
Note that we haven't defined how the table's content is stored yet. Because we declared it as a JSON(B) column in our database, we are free to choose any format. Deviating from the cited blog post a bit, let's go with a two-dimensional array. Thus, we can simply do a nested loop over the content
like this:
The above partial will render whenever it is requested by ActionView
, for example. Next, we also have to devise an editor
partial to be used inline in Trix:
The only difference, as you have probably noticed, is that we now have wrapped it in a Turbo Frame, using the SGID as a DOM id. Furthermore, we provide row and column indexes to the separator blocks and prepare for inline editing by making the inner DIV contenteditable
— we'll get to that later.
We will now connect our toolbar's table button to the server-side controller action we have just written. To do this, we first need to bring Rails' request.js library into the project. This library will help us administer post
requests from the client, including proper CSRF-tokens, etc.:
Build a New Trix Table Stimulus Controller
Now that everything is set up, let's create a new trix-table Stimulus controller. In it, we will implement the attachTable
action referenced by our toolbar button:
It will POST to the table's create
route, inserting the JSON response as a Trix attachment. This again borrows from the OnRails blog post, exchanging the deprecated rails-ujs calls for the newer request.js library.
Now we have to actually make use of this controller in our app by adding it to the form's markup:
The beauty of Stimulus.js is that only adding two data attributes to the form
element achieves the desired result. We are now able to add tables to our article's content with a single button click:
Manipulating the Table via Turbo Frames
Now that we can create table attachments, let's shift our focus to manipulating the content. As it turns out, Turbo Frames are almost a natural fit here.
Add and Delete Table Rows and Columns
To add and delete table rows and columns, we create a mini-toolbar consisting of four buttons, one for each operation. Make use of the button_to
helper and set the URL to the update
route for the respective table. Let's add the respective operation we want to trigger as additional parameters:
In turn, we also need to add the respective controller action(s) to our TablesController
. Observe that the update
action delegates those actions to the model.
After changes to the table's structure are saved, we redirect to the table's edit view. It renders the same editor
partial, which has the side-effect of referring to the same Turbo Frame. Thus Turbo can detect the matching frame and substitute one for the other.
Now we have to implement the missing commands on the Table
model.
Notably, due to our simple data structure of a two-dimensional array, the add/remove<sub>column</sub>/row
methods are mere proxies to modify the column and row count. Once that is in place, we can change our table's structure with button clicks:
Edit the Content of Table Cells
In addition to changing the number of columns and rows, we also want to edit the cells' content. To achieve this, we will again lean heavily on the cited blog post and create a Stimulus table editor controller.
The updateCell
method will issue a PATCH request whenever a cell is edited, passing the row and column index as parameters. Now, all we have to do is connect it to our DOM:
The server-side TablesController
, of course, now needs a way to handle this operation. Luckily, this is easily done in our simplified proof of concept by adding another branch to our condition. We also make sure that the update
action can now handle JSON-type requests, even if it's merely returning an empty object here.
Note that in a production app, I would advise you to choose a different strategy for sanitizing the operation than an if/elsif/else
condition. I would probably reach for a Mediator or Proxy in this case.
The Limitations of Trix in Ruby
Up to this point, I assume this account has made perfect sense, but I have left out a critical detail. While we are persisting the underlying database model just fine, we are not syncing it to Trix's internal shadow representation. That's why the table snaps back to the previously stored representation when we focus out of it:
If we were to refresh the page now, the added content would appear, because Trix's document is freshly initialized.
I have pinned this problem down to where Trix syncs its internal document when the selection changes. It just unfurls it from the shadow element here.
I tried hooking into the turbo:submit
event and preventing the sync just when blurring a table, but the solutions I came up with all seem very hairy and highly dependent on the internal API.
The most Turbo-esque way of dealing with this, I guess, would be to wrap the whole form in an eager-loaded Turbo Frame and tell it to reload whenever Trix's content changes.
Something like this should do the trick:
If you enclose your form in a Turbo Frame that you load from src
:
This approach only works with already persisted base records, though.
Final Words of Warning on Trix
The proof of concept we've built uses server-rendered HTML to do away with the added complexity of serializing tables to JSON and listening for JavaScript events. It is portable to any ActionText installation and could be easily extracted to a gem.
There are a couple of drawbacks, though, the most obvious one being the necessary re-syncing with Trix's document model. There might be situations where the proposed workaround is workable and others where it's a no-go. Until Trix gains a Turbo-compatible interface, there's no way around it.
The second catch is that it does not use Trix's undo
functionality (but that is true of any Trix attachment). Likewise, it would be wise to wait for upstream changes instead of tweaking the internal API.
Wrap Up
In this post, we started by taking a quick look at the basics of ActionText Attachments. We then added a table to an ActionText model before tweaking it using Turbo Frames. Finally, we touched on some limitations of using Trix.
Given that Trix v2 is underway, featuring a translation from CoffeeScript to plain modern JavaScript, now would be a good time to address its Turbo compatibility. Currently, the scope of what such a wrapper might look like is beyond my capabilities, but it sure looks like a window of opportunity.
Happy coding!
P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!