Ruby on Rails’ ActionText is a simple, quick, and easy way to add rich text content to your models. Unfortunately, the Trix editor lacks an embedded table functionality, which, in my opinion, is essential for any rich text editor.

I found a great tutorial for adding tables with Stimulus JavaScript controllers: https://github.com/OnRailsBlog/actiontext-table. However, there are a few issues here. Tables are created but never deleted, only detached from the ActionText field. There are no database associations between a table and the ActionText parent resource. Without those associations, you can’t delete the tables when the resource is destroyed or check user permissions for attaching tables to a resource. I also wanted some extra styling for bootstrap and an optional table header row.

Using the original tutorial as a starting point, I addressed my full requirements with the code below. The data cleanup code still feels a little messy, but I got to try out polymorphic relationships in Ruby on Rails for the first time and dug deeper into using Stimulus JavaScript controllers (and it works!).

First, create a new rails application with the following specs:

Next, add the migration for the table model. The rows, columns, and data fields are used to store table data and generate the table views. The record_type and record_id are used to create a polymorphic association to the ActionText’s parent record.

The record_saved boolean is to help with data cleanup. As you will see below, tables are created and attached to rich text fields on a record’s new and edit forms. If those forms are not submitted, the tables do not get deleted automatically.

class CreateTables < ActiveRecord::Migration[7.0]
 def change
   create_table :tables do |t|
     t.integer :columns, default: 1
     t.integer :rows, default: 1
     t.json :data, default: {}
     t.string :record_type
     t.bigint :record_id
     t.boolean :header_row, default: false
     t.boolean :record_saved, default: false

     t.timestamps
   end
 end
end

Add model file app/models/table.rb. At the top, table html tags are added so they won’t be sanitized.

There are two cleanup methods. The first, delete_unmatched_tables, I’d like to put in a cron job eventually, but don’t want to set up sidekiq for my production app on Heroku. The second, delete_all_unmatched_tables, does the same as the first method, except it doesn’t limit it’s searching to a time period. I made Rake tasks for both and can run the second method manually ad hoc.

This data clean up is necessary for three scenarios:

  • A table is attached on a new resource form but the form is never submitted. These tables never gain a record association.
  • A table is attached on a resource being edited, but the edited resource is never saved. These tables do get a record association (so the controller can check permissions), but they are not actually attached to an ActionText body and shouldn’t be kept.
  • A table is detached from a resource and the resource is saved.
  • This is were I felt the code got a little messy. I tried several avenues track attached and unattached tables, but was only successful if I created and maintained an extra column on the tables table.
ActionText::ContentHelper.allowed_tags = ['table','tr','td','th','tbody','thead',
  'strong','em','b','i','p','code','pre','tt','samp','kbd','var','sub','sup','dfn',
  'cite','big','small','address','hr','br','div','span','h1','h2','h3','h4','h5','h6',
  'ul','ol','li','dl','dt','dd','abbr','acronym','a','img','blockquote','del',
  'ins','action-text-attachment','figure','figcaption']

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

  belongs_to :record, polymorphic: true, optional: true

  def to_trix_content_attachment_partial_path
	  "tables/editor"
  end

  #TODO make this a scheduled job that runs every 12 hours
  def self.delete_unmatched_tables
    # Delete tables updated older than 24 hours if their record was never saved
    Table.where(record_saved: false).and(Table.where(updated_at: ..1.day.ago)).destroy_all
  end

  def self.delete_all_unmatched_tables
    # Delete tables older than 24 hours if their record was never saved
    Table.where(record_saved: false).destroy_all
  end
end

lib/tasks/update_tables.rake

desc 'Deletes tables that are not attached to an ActionText Record updated between 24-48 hours ago'
task :delete_unmatched_tables => :environment do
  Table.delete_unmatched_tables
end

desc 'Deletes tables that are not attached to an ActionText Record'
task :delete_all_unmatched_tables => :environment do
  Table.delete_all_unmatched_tables
end

Create the controller for tables. There are some before_action calls you may not want for your installation. I am using devise, so I authenticate the user first. Resources in my app have varied permission settings, but each class type has the same method name for checking a user’s edit permissions. (@record.has_edit_privileges(current_user))

class TablesController < ApplicationController
  before_action :authenticate_user!
  before_action :set_table, only: [:show, :update, :destroy]
  before_action :set_record, only: [:show, :update, :destroy]
  before_action :has_create_privileges, only: [:create]
  before_action :has_edit_privileges, only: [:show, :update, :destroy]

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

  def create
    @table = Table.create(record_type: params[:record_type], record_id: params[:record_id])
    render json: {
      sgid: @table.attachable_sgid,
      content: render_to_string(partial: "tables/editor", locals: { table: @table }, formats: [:html])
    }
  end

  def update
    if params["method"] == "addRow"
      @table.rows += 1
    elsif params["method"] == "addColumn"
      @table.columns += 1
    elsif params["method"] == "removeRow"
      @table.rows -= 1
    elsif params["method"] == "removeColumn"
      @table.columns -= 1
    elsif params["method"] == "updateCell"
      @table.data[params['cell']] = params['value']
    elsif params["method"] == "headerRow"
      @table.header_row = params['header_row']
    end
    @table.save
    render json: {
      sgid: @table.attachable_sgid,
      content: render_to_string(partial: "tables/editor", locals: { table: @table }, formats: [:html])
    }
  end

  private

  def set_table
    @table = ActionText::Attachable.from_attachable_sgid params[:id]
  end

  def set_record
    @record = @table.record
  end

  def has_create_privileges
    if params[:record_type].present? && params[:record_id].present?
      record = params[:record_type].constantize.find(params[:record_id])
      redirect_to root_path, alert: 'Not authorized.' unless record.has_edit_privileges(current_user)
    end
  end

  def has_edit_privileges
    if @record
      redirect_to root_path, alert: 'Not authorized.' unless @record.has_edit_privileges(current_user)
    end
  end
end

Add the new controller actions to your config/routes.rb

Rails.application.routes.draw do

...

  resources :tables, only: [:show, :create, :update]
end

Add two view partials for the table editor and the table display.

app/views/tables/_table.html.erb

<div class="table-responsive">
    <table class="table table-hover mb-3 mt-3">

      <% if table.header_row %>

      <thead>

        <tr>
          <% (0...table.columns).each do |c| %>
              <th><%= table.data["0-#{c}"]%></th>
          <% end %>
        </tr>

      </thead>
      <tbody>

      <% else %>

      <tbody>

        <tr>
          <% (0...table.columns).each do |c| %>
              <td><%= table.data["0-#{c}"]%></td>
          <% end %>
        </tr>

      <% end %>

        <% (1...table.rows).each do |r| %>
        <tr>
          <% (0...table.columns).each do |c| %>
            <td><%= table.data["#{r}-#{c}"]%></td>
          <% end %>
        </tr>
        <% end %>

    </tbody>
  </table>
</div>

app/views/tables/_editor.html.erb

<div data-controller="table-editor" data-table-editor-id="<%= table.attachable_sgid %>"> <div class="btn-group" role="group" aria-label="Table Data" class="mt-3 mb-5"> <button type="button" data-action="table-editor#addRow" class="btn btn-sm<%= status_button_class %>"><i class="bi bi-plus"></i> Add Row</button> <% if table.rows > 1 %> <button type="button" data-action="table-editor#removeRow" class="btn btn-sm<%= status_button_class %>"><i class="bi bi-x"></i> Remove Row</button> <% end %> <button type="button" data-action="table-editor#addColumn" class="btn btn-sm<%= status_button_class %>"> <i class="bi bi-plus"></i> Add Column</button> <% if table.columns > 1 %> <button type="button" data-action="table-editor#removeCol" class="btn btn-sm<%= status_button_class %>"><i class="bi bi-x"></i> Remove Column</button> <% end %> <% if table.header_row %> <button type="button" data-action="table-editor#removeHeader" class="btn btn-sm<%= status_button_class %>"><i class="bi bi-type-bold"></i> Unbold First Row</button> <% else %> <button type="button" data-action="table-editor#headerRow" class="btn btn-sm<%= status_button_class %>"><i class="bi bi-type-bold"></i> Bold First Row</button> <% end %> </div> <table class="table is-bordered"> <% (0...table.rows).each do |r| %> <tr> <% (0...table.columns).each do |c| %> <td><input class="form-control form-control-sm<%= input_class %>" data-key="<%= r %>-<%= c %>" data-action="table-editor#updateCell" value="<%= table.data["#{r}-#{c}"]%>" /></td> <% end %> </tr> <% end %> </table> </div>

The new Trix editor button to attach a table will need some extra styling.

trix-toolbar .trix-button--icon-table {
  padding: 5px;
}

trix-toolbar .trix-button--icon-table::before {
  background-image: url("table.svg");
  top: 3px;
  bottom: 3px;
}

I added this table.svg from Bootstrap into my asset path for the button.

You will need two Stimulus JavaScript controllers. One for creating and inserting new tables and another for editing tables.

Originally, tables were deleted on the trix-attachment-remove event. This event fires for all attachments. To prevent one removed attachment from deleting all attachments, I utilized JavaScript’s localStorage property to save and reset a variable when the remove action comes from a specific table. (localStorage.setItem(‘delete-table’, ‘yes’);)

I did end up removing the delete functionally from the Stimulus controller because tables that are detached in the form of an unsaved resource should not actually get deleted or detached from the resource. This is handled now in the cleanup methods instead.

There was also the issue of the DOM not being fully loaded before the table controller connected. I added a setTimeout function which runs if the DOM is not ready.

app/javascript/controllers/rich_text_table_controller.js

import { Controller } from "@hotwired/stimulus" import Trix from "trix" import Rails from "@rails/ujs" let lang = Trix.config.lang; export default class extends Controller { connect() { Trix.config.lang.table = "Table" var tableButtonHTML = `<button type="button" class="trix-button trix-button--icon trix-button--icon-table" data-action="rich-text-table#attachTable" title="Attach Table" tabindex="-1">${lang.table}</button>`; var fileToolsElement = this.element.querySelector('[data-trix-button-group=file-tools]'); if (!fileToolsElement) { setTimeout(function (){ fileToolsElement = document.querySelector('[data-trix-button-group=file-tools]'); fileToolsElement.insertAdjacentHTML("beforeend", tableButtonHTML); }, 1000); } else { fileToolsElement.insertAdjacentHTML("beforeend", tableButtonHTML); } } attachTable(event) { const tableData = document.querySelector("#tables-data"); Rails.ajax({ url: `/tables`, type: 'post', data: `record_type=${tableData.dataset.recordType}&record_id=${tableData.dataset.recordId}`, success: this.insertTable.bind(this) }); } insertTable(tableAttachment) { this.attachment = new Trix.Attachment(tableAttachment); this.element.querySelector('trix-editor').editor.insertAttachment(this.attachment); this.element.focus(); } }

app/javascript/controllers/table_editor_controller.js

import { Controller } from "@hotwired/stimulus" import Trix from "trix" import Rails from "@rails/ujs" const MAX_PARENT_SEARCH_DEPTH = 5; export default class extends Controller { addRow(event) { Rails.ajax({ url: `/tables/${this.getID()}`, data: 'method=addRow' , type: 'patch', success: this.attachTable.bind(this) }); } removeRow(event) { Rails.ajax({ url: `/tables/${this.getID()}`, data: 'method=removeRow' , type: 'patch', success: this.attachTable.bind(this) }); } addColumn(event) { Rails.ajax({ url: `/tables/${this.getID()}`, data: 'method=addColumn' , type: 'patch', success: this.attachTable.bind(this) }); } removeCol(event) { Rails.ajax({ url: `/tables/${this.getID()}`, data: 'method=removeColumn' , type: 'patch', success: this.attachTable.bind(this) }); } headerRow(event) { Rails.ajax({ url: `/tables/${this.getID()}`, data: 'method=headerRow&header_row=true' , type: 'patch', success: this.attachTable.bind(this) }); } removeHeader(event) { Rails.ajax({ url: `/tables/${this.getID()}`, data: 'method=headerRow&header_row=false' , type: 'patch', success: this.attachTable.bind(this) }); } updateCell(event) { Rails.ajax({ url: `/tables/${this.getID()}`, data: `method=updateCell&cell=${encodeURIComponent (event.target.dataset.key)}&value=${encodeURIComponent(event.target.value)}` , type: 'patch' }); } getID() { return this.data.get('id'); } attachTable(tableAttachment) { let attachment = new Trix.Attachment(tableAttachment); var parent = this.element.parentNode; var editorNode = null; for (let i = 0; i < MAX_PARENT_SEARCH_DEPTH; i++) { editorNode = parent.querySelector('trix-editor'); if (editorNode != null) { i = MAX_PARENT_SEARCH_DEPTH; } else { parent = parent.parentNode; } } editorNode.editor.insertAttachment(attachment); } }

Finally, you need to add the data-controllers to forms containing rich text and add the table associations to their models. In my example below, the model Note contains a rich text field called text.

app/views/notes/_form.html.erb

<%= form_with(model: note) do |form| %> ... <div class="mb-3" id="tables-data" data-controller="rich-text-table" data-record-type="Note" data-record-id="<%= @note.id %>"> <%= form.label :text, style: "display: block", class: "form-label" %> <%= form.rich_text_area :text, class: "form-control"+ input_class %> </div> <div class="mb-3"> <%= form.submit "Submit", class: "btn btn-primary btn-sm" %> </div> <% end %>

In the Note model, tables are added as associations that get deleted if the Note is deleted. After a Note is saved, it first marks all table records associated to this Note as not saved to clean up detached items. Then the model checks each attachment in the text field, and updates it’s record and record_saved field. Since there is already an attachment type for ActiveStorage files, the method checks the class type of each attachment as well.

app/models/note.rb

class Note < ApplicationRecord
  belongs_to :user
  has_rich_text :text
  has_many :tables, as: :record, dependent: :destroy

...

  after_save do
    Table.where(record: self).each do |table|
      table.update_attribute(:record_saved, false)
    end

    text.body.attachables.each do |attachment|
      obj = ActionText::Attachable.from_attachable_sgid(attachment.attachable_sgid)
      if obj.is_a? Table
        obj.update_attribute(:record, self)
        obj.update_attribute(:record_saved, true)
      end
    end
  end

...

And…you’re done! Check out my gitlab page for my app’s full source code: https://gitlab.com/endtoendpaper/study-notes/-/tree/main/