DEV Community

Cover image for Build a Table Editor with Trix and Turbo Frames in Rails
julianrubisch for AppSignal

Posted on • Originally published at blog.appsignal.com

Build a Table Editor with Trix and Turbo Frames in Rails

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:

<action-text-attachment sgid="BAh7CEkiCG…"></action-text-attachment>
Enter fullscreen mode Exit fullscreen mode

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:

<action-text-attachment sgid="...">
  <table>
    <tbody>
      <tr>
        <td>Cell 1</td>
        <td>Cell 2</td>
      </tr>
      <!-- more rows -->
    </tbody>
  </table>
</action-text-attachment>
Enter fullscreen mode Exit fullscreen mode

To conform with the ActionText Attachment API, a class has to do only two things:

  1. Implement to_sgid by including GlobalID::Identification. By default, all ActiveRecord::Base descendants already do this.
  2. 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:

$ rails new trix-tables-turbo-frames
$ bin/rails action_text:install
$ bin/rails db:migrate
Enter fullscreen mode Exit fullscreen mode

Because I'm not feeling creative today, let's scaffold an Article model with a title and rich text content:

$ bin/rails g scaffold Article title:string content:rich_text
$ bin/rails g model ActionText::Table content:json
# or, if using postgres
$ bin/rails g model ActionText::Table content:jsonb

$ bin/rails db:migrate
Enter fullscreen mode Exit fullscreen mode

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: [["", ""], ["", ""]].

class CreateActionTextTablesTable < ActiveRecord::Migration[7.0]
  def change
    create_table :action_text_tables do |t|
      t.json :content, null: false, default: [["", ""], ["", ""]] # create a 2x2 table by default

      t.timestamps
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

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:

  // app/javascript/application.js
  import "@hotwired/turbo-rails";
  import "controllers";
- import "trix";
+ import Trix from "trix";
  import "@rails/actiontext";

+ const buttonHTML =
+   '<button type="button"
+      class="trix-button trix-button--icon trix-button--icon-table"
+      title="table" tabindex="-1"
+      data-action="trix-table#attachTable">table</button>';
+
+ const buttonGroupElement = document
+   .querySelector("trix-editor")
+   .toolbarElement.querySelector("[data-trix-button-group=file-tools]");
+
+ buttonGroupElement.insertAdjacentHTML("beforeend", buttonHTML);
Enter fullscreen mode Exit fullscreen mode

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:

/* app/assets/stylesheets/application.css */
/*
 *
 *= require_tree .
 *= require_self
 */

+  .trix-button--icon-table::before {
+    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='h-6 w-6' fill='none' viewBox='0 0 24 24' stroke='currentColor' stroke-width='2'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z' /%3E%3C/svg%3E");
+    top: 8%;
+    bottom: 4%;
+  }
+
+ table {
+   border: 1px solid black;
+   border-collapse: collapse;
+ }
+
+ td {
+   padding: 0.5rem!important;
+   border: 1px solid black;
+ }

Enter fullscreen mode Exit fullscreen mode

As you can see, we have successfully added it to the "file-tools" group:

Customized Trix Toolbar

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:

$ bin/rails g controller Tables create --no-helper --skip-routes
Enter fullscreen mode Exit fullscreen mode

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.

# app/controllers/tables_controller.rb

class TablesController < ApplicationController
  layout false

  def create
    @table = ActionText::Table.create

    render json: {
      sgid: @table.attachable_sgid,
      content: render_to_string(partial: "tables/editor", locals: {table: @table}, formats: [:html])
    }
  end
end
Enter fullscreen mode Exit fullscreen mode

We add the relevant resourceful table routes to our configuration:

  # config/routes.rb

  Rails.application.routes.draw do
    resources :articles
+   resources :tables
  end
Enter fullscreen mode Exit fullscreen mode

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:

  # app/models/action_text/table.rb

  class ActionText::Table < ApplicationRecord
+   include ActionText::Attachable
+
+   attribute :content_type, :string, default: "text/html"
+
+   def to_trix_content_attachment_partial_path
+     "tables/editor"
+   end
+
+   def to_partial_path
+     "tables/table"
+   end
  end
Enter fullscreen mode Exit fullscreen mode

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:

<!-- app/views/tables/_table.html.erb -->
<table>
  <% table.content.each do |row| %>
    <tr>
      <% row.each do |column| %>
        <td>
          <%= column %>
        </td>
      <% end %>
    </tr>
  <% end %>
</table>
Enter fullscreen mode Exit fullscreen mode

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:

<!-- app/views/tables/_editor.html.erb -->
<%= turbo_frame_tag "table_#{table.attachable_sgid}" do %>
  <table>
    <% table.content.each_with_index do |row, row_index| %>
      <tr>
        <% row.each_with_index do |column, column_index| %>
          <td>
            <div contenteditable><%= column %></div>
          </td>
        <% end %>
      </tr>
    <% end %>
  </table>
<% end %>
Enter fullscreen mode Exit fullscreen mode

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

$ bin/importmap pin @rails/request.js
Enter fullscreen mode Exit fullscreen mode

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:

// app/javascript/controllers/trix_table_controller.js

import { Controller } from "@hotwired/stimulus";
import Trix from "trix";
import { post } from "@rails/request.js";

export default class extends Controller {
  static values = {
    url: String,
  };

  async attachTable(event) {
    const response = await post(this.urlValue);

    if (response.ok) {
      const tableAttachment = await response.json;
      this.insertTable(tableAttachment);
    } else {
      // error handling
    }
  }

  insertTable(tableAttachment) {
    this.attachment = new Trix.Attachment(tableAttachment);
    this.element
      .querySelector("trix-editor")
      .editor.insertAttachment(this.attachment);
    this.element.focus();
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  <!-- app/views/tables/_form.html.erb -->
- <%= form_with(model: article) do |form| %>
+ <%= form_with(model: article, data: {controller: "trix-table", trix_table_url_value: tables_path}) do |form| %>
    <% if article.errors.any? %>
      <div style="color: red">
        <h2><%= pluralize(article.errors.count, "error") %> prohibited this article from being saved:</h2>

        <ul>
          <% article.errors.each do |error| %>
            <li><%= error.full_message %></li>
          <% end %>
        </ul>
      </div>
    <% end %>

    <div>
      <%= form.label :title, style: "display: block" %>
      <%= form.text_field :title %>
    </div>

    <div>
      <%= form.label :content, style: "display: block" %>
      <%= form.rich_text_area :content %>
    </div>

    <div>
      <%= form.submit %>
    </div>
  <% end %>
Enter fullscreen mode Exit fullscreen mode

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:

Inserting a Table Attachment into a Trix Editor

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:

  <!-- app/views/tables/_editor.html.erb -->
  <%= turbo_frame_tag "table_#{table.attachable_sgid}" do %>
+   <div style="display: flex">
+     <%= button_to "+ Row", table_path(id: table.attachable_sgid), method: :patch, params: {operation: "addRow"} %>
+     <%= button_to "- Row", table_path(id: table.attachable_sgid), method: :patch, params: {operation: "removeRow"} %>
+     <%= button_to "+ Column", table_path(id: table.attachable_sgid), method: :patch, params: {operation: "addColumn"} %>
+     <%= button_to "- Column", table_path(id: table.attachable_sgid), method: :patch, params: {operation: "removeColumn"} %>
+   </div>
    <table>
      <% table.content.each_with_index do |row, row_index| %>
        <tr>
          <% row.each_with_index do |column, column_index| %>
            <td>
              <div contenteditable><%= column %></div>
            </td>
          <% end %>
        </tr>
      <% end %>
    </table>
  <% end %>
Enter fullscreen mode Exit fullscreen mode

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.

  # app/controllers/tables_controller.rb

  class TablesController < ApplicationController
+   before_action :set_table, only: %i[show edit update destroy]

    layout false

+   def edit
+   end

    def create
      @table = ActionText::Table.create

      render json: {
        sgid: @table.attachable_sgid,
        content: render_to_string(partial: "tables/editor", locals: {table: @table}, formats: [:html])
      }
    end

+   def update
+     if params["operation"] == "addRow"
+       @table.add_row
+     elsif params["operation"] == "removeRow"
+       @table.remove_row
+     elsif params["operation"] == "addColumn"
+       @table.add_column
+     elsif params["operation"] == "removeColumn"
+       @table.remove_column
+     else
+       flash.alert = "Unknown table operation: #{params["operation"]}"
+     end
+
+     if @table.save
+       redirect_to edit_table_path(id: @table.attachable_sgid)
+     else
+       render :edit
+     end
+   end
+
+   private
+
+   def set_table
+     @table = ActionText::Attachable.from_attachable_sgid params[:id]
+   end
  end
Enter fullscreen mode Exit fullscreen mode

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.

<!-- app/views/tables/edit.html.erb -->
<%= render "tables/editor", table: @table %>
Enter fullscreen mode Exit fullscreen mode

Now we have to implement the missing commands on the Table model.

  # app/models/action_text/table.rb

  class ActionText::Table < ApplicationRecord
    include ActionText::Attachable

    attribute :content_type, :string, default: "text/html"

    def to_trix_content_attachment_partial_path
      "tables/editor"
    end

    def to_partial_path
      "tables/table"
    end

+   def rows
+     content.size
+   end
+
+   def columns
+     content.map(&:size).max
+   end
+
+   def add_row(index = rows - 1)
+     content << Array.new(columns, "")
+   end
+
+   def remove_row(index = rows - 1)
+     content.delete_at(index)
+   end
+
+   def add_column(index = columns - 1)
+     content.each do |row|
+       row << ""
+     end
+   end
+
+   def remove_column(index = columns - 1)
+     content.each do |row|
+       row.delete_at(index)
+     end
+   end
  end

Enter fullscreen mode Exit fullscreen mode

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:

Adding and Removing Table Rows and Columns

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.

// app/javascript/controllers/table_editor_controller.js

import { Controller } from "@hotwired/stimulus";
import { patch } from "@rails/request.js";

export default class extends Controller {
  static values = {
    url: String,
  };

  async updateCell(event) {
    const response = await patch(this.urlValue, {
      body: { value: event.target.textContent },
      query: {
        operation: "updateCell",
        row_index: event.target.dataset.rowIndex,
        column_index: event.target.dataset.columnIndex,
      },
      contentType: "application/json",
      responseKind: "json",
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  <!-- app/views/tables/_editor.html.erb -->
- <%= turbo_frame_tag "table_#{table.attachable_sgid}" do %>
+ <%= turbo_frame_tag "table_#{table.attachable_sgid}",
+    data: {controller: "table-editor", table_editor_url_value: table_path(id: table.attachable_sgid)} do %>
    <div style="display: flex">
      <%= button_to "+ Row", table_path(id: table.attachable_sgid), method: :patch, params: {operation: "addRow"} %>
      <%= button_to "- Row", table_path(id: table.attachable_sgid), method: :patch, params: {operation: "removeRow"} %>
      <%= button_to "+ Column", table_path(id: table.attachable_sgid), method: :patch, params: {operation: "addColumn"} %>
      <%= button_to "- Column", table_path(id: table.attachable_sgid), method: :patch, params: {operation: "removeColumn"} %>
    </div>
    <table>
      <% table.content.each_with_index do |row, row_index| %>
        <tr>
          <% row.each_with_index do |column, column_index| %>
            <td>
-             <div contenteditable><%= column %></div>
+             <div contenteditable
+                data-action="input->table-editor#updateCell"
+                data-row-index="<%= row_index %>"
+                data-column-index="<%= column_index %>">
+               <%= column %>
+             </div>
            </td>
          <% end %>
        </tr>
      <% end %>
    </table>
  <% end %>
Enter fullscreen mode Exit fullscreen mode

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.

  # app/controllers/tables_controller.rb

  class TablesController < ApplicationController
    before_action :set_table, only: %i[show edit update destroy]

    layout false

    def edit
    end

    def create
      @table = ActionText::Table.create

      render json: {
        sgid: @table.attachable_sgid,
        content: render_to_string(partial: "tables/editor", locals: {table: @table}, formats: [:html])
      }
    end

    def update
      if params["operation"] == "addRow"
        @table.add_row
      elsif params["operation"] == "removeRow"
        @table.remove_row
      elsif params["operation"] == "addColumn"
        @table.add_column
      elsif params["operation"] == "removeColumn"
        @table.remove_column
+     elsif params["operation"] == "updateCell"
+       @table.content[params["row_index"].to_i][params["column_index"].to_i] = params["value"]
      end

      if @table.save
-       redirect_to edit_table_path(id: @table.attachable_sgid)
+       respond_to do |format|
+         format.html { redirect_to edit_table_path(id: @table.attachable_sgid) }
+         format.json {}
+       end
      else
        render :edit
      end
    end

    private

    def set_table
      @table = ActionText::Attachable.from_attachable_sgid params[:id]
    end
  end
Enter fullscreen mode Exit fullscreen mode

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:

Bug When Syncing with Trix's Internal Document Model

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:

// app/javascript/controllers/trix_table_controller.js

// ...

connect() {
  this.element.addEventListener("turbo:submit-end", (e) => {
    this.element.closest("turbo-frame").reload();
  });
}

// ...
Enter fullscreen mode Exit fullscreen mode

If you enclose your form in a Turbo Frame that you load from src:

<!-- app/views/articles/edit.html.erb -->
<h1>Editing article</h1>

<%= turbo_frame_tag dom_id(@article, :form), src: form_article_path(@article) %>
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (0)