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:
- ActiveStorage installed https://guides.rubyonrails.org/active_storage_overview.html (required for ActionText file attachments)
- ActionText installed https://guides.rubyonrails.org/action_text_overview.html
- At least one model with a rich_text association
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
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
app/javascript/controllers/table_editor_controller.js
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
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/