Rails 6: Action Text

Introduction

Rails 6 is now out and can be used by the public. In a previous post, I took a look at Active Storage; a new framework bundled with Rails 5.2. The framework highlighted today comes fresh out of the Rails 6: Action Text. We'll be going over what Action Text is and how to set it up.

As I previously mentioned, we'll be using Active Storage through the process of using Action Text, so make sure you have it all set up and ready to go. If you don't want to implement it in your own project and want to get the end result, you can find that at the project's repository.

What is Action Text?

Action Text is a new Rich Text editing framework created and designed to get around browser inconsistent WYSIWYG HTML editors; requiring additional plugins to be used or JavaScript hacks to be implemented to get it working across all major browsers and devices.

Behind the scenes, Action Text is powered by a newer and more powerful Rich Text editor: Trix. Visit Trix's homepage to find out more information and how it's different than other rich text editors. Trix's claim is that it allows users to compose beautifully formatted text in their web applications. Trix comes to us from the creators of Rails, Basecamp.

Bonus

I'll be walking through how to remove the image both from the Trix editor and the server it's uploaded to as well as manipulate the image blob information in the bonus content below!

Setup

This app is very basic, which makes upgrading from Rails 5 to 6 very easy as it doesn't have any dependencies that don't already work with Rails 6. If you aren't using that project, please consult the official Rails guides for more information on upgrading a more sophisticated app to Rails 6. The only prerequisite, if you wish to follow along, is to have Active Storage already set up and working.

If you were following along with the previous post, there is a new controller added to the project. Please run rails g controller Attachments destroy in the console to generate that file or check the repository.

Moving right along, we will need Rails 6. To upgrade to Rails 6 as well as getting Trix to work, you will need to update the gem 'rails' line as well as adding the Trix gem in your Gemfile.rb file then run bundle install in your console.

# Gemfile.rb

# Add trix, Replace "gem 'rails', '~> 5.x.x'`":
gem 'rails', github: 'rails/rails'
gem 'trix'

The routes of the application are as follows:

# config/routes.rb

Rails.application.routes.draw do
  root 'posts#index'
  match 'blobs/:signed_id/*filename', to: 'blobs#show', via: [:get, :post]
  delete 'attachments/:signed_id/*filename', to: 'attachments#destroy'
  resources :attachments, only: [:destroy], as: :destroy_attachment
  resources :posts
end

After all of the dependencies have been installed by bundler, we'll need to install Action Text. To do this, run the following command in the console:

rails action_text:install

Note: The installer uses Yarn to download the dependencies so make sure you have Yarn installed and added to your PATH.

We will also need to make sure that we have the JavaScript set up for Trix. To do this, add this to your app/assets/javascripts/application.js file before //= require_tree .:

// app/assets/javascripts/application.js

//= require rails-ujs
//= require activestorage
//= require turbolinks
//= require trix
//= require_tree .

You'll also notice that a new migration was generated for you which creates a table called action_text_rich_texts. This table stores the rich text information from Trix as sanitized HTML. As with the Active Storage migrations, it contains a column called record, which is a polymorphic association. As was explained in the last post, this is important so that it can be used with any model in your app without tying the association down to a single or a limited amount of models.

Point of Interest: It is very helpful that Rails sanitizes the Trix content for you so you don't have to explicitly do it yourself. If that wasn't properly handled, you could open your app up to possible Cross-site Scripting (XSS) Attacks if someone would submit a <script src="https://www.bad-website.com/malicious_script.js></script> onto one of your views, for example.

Make sure that you run rails db:migrate to update your database with the new Action Text tables. That's enough for the setup, let's see how to use this new feature.

Implementation

Continuing from the previous project, we will need to tell Rails that we wish to use Trix with our Post model. Our Post model should look like this:

# app/models/post.rb
class Post < ApplicationRecord
  has_rich_text :content
  has_one_attached :main_image
  has_many_attached :other_images

  def attach_other_images(signed_blob_id)
    blob = ActiveStorage::Blob.find_signed(signed_blob_id)
    return other_images.attach(signed_blob_id) unless blob.present?

    other_images.attach(blob.signed_id) unless other_images.attachments.map(&:blob_id).include?(blob.id)
  end
end

The has_rich_text method from Action Text associates the rich text content from Trix onto this model by via the content property.

We will need to tell Rails that we expect the content property to be coming in from a form so we need to update the post_params method in the PostsController file:

# app/controllers/posts_controller.rb

...
def post_params
  params.require(:post).permit(:title, :body, :posted_at, :content, :main_image, other_images: [])
end
...

As a refresher, we already are using Active Storage to upload photos to our Photo model:

<!-- app/views/posts/_form.html.erb -->

<div class="field">
  <%= form.label :main_image %>
  <%= form.file_field :main_image %>
</div>

<% if post.main_image.attached? %>
  <p>
    <%= link_to 'Remove Main Image', "#{destroy_attachment_path(post.main_image.id)}?post_id=#{post.id}", method: 'delete', data: { confirm: 'Delete the main image attachment?' } %>
  </p>

  <div>Current main image: </div>
  <%= image_tag post.main_image.variant(resize: '200x200') %>
  <%= tag.input type: 'hidden', name: 'post[main_image]', value: post.main_image.blob.signed_id %>
<% end %>

If you're NOT following along from the previous blog post, ignore this note. You'll notice that I removed the :remove_main_image attribute from the Post model. This was replaced in favor of a better way of removing that image, which we'll go over below.

What this allows us to do is remove the main image from the post when the box is checked upon saving the form, if we so desire to do so.

Below that, add the following code for the Trix content editor:

<!-- app/views/posts/_form.html.erb -->

<div class="field">
  <%= form.label :content %>
  <%= form.rich_text_area :content %>
</div>

The editor allows for dragging and dropping of files into the content box which makes it easy to attach images inside the editor in real-time!

After starting up your Rails server, if you haven't done that already, and navigating to http://localhost:3000/posts/new, you should see the Trix text editor:

Trix Screenshot

Note: If you get a Rails error that states couldn't find file 'trix/dist/trix' with type 'text/css' then make the following adjustments to your app/assets/stylesheets/actiontext.scss file:

// app/assets/stylesheets/actiontext.scss

// Original configuration
//= require trix/dist/trix

// New Configuration
//= require trix

Upon saving the form, you'll notice that it STILL doesn't work, what gives?

We need to tell Trix how to behave in our project with a little bit of customization via JavaScript. Including the code directly into this post would make the post longer than I would like so you can find the code for that on my github account for this project. The comments throughout the file explains how that code works. Feel free to open any issues or comment on any code in GitHub with any feedback you may have.

Now when you save the form, you'll see your rich text content on the page exactly how you wrote it in the editor, super slick!

Rich Text HTML

Trix will save the image as the same size that you uploaded it. Basecamp is very explicit that Trix is not made to modify images in any way but to display them as they are. I like to have control over the content that I put on websites and how they look. Let's setup the controller action that is called from the aforementioned JavaScript file:

class BlobsController < ActiveStorage::BlobsController
  def show
    return super unless @blob.image?

    redirect_to @blob.variant(resize: '250x250').processed.service_url(disposition: params[:disposition])
  end
end

I want to take a little time to break down what is going on here. Although the controller action is pretty straight forward, there is a little bit of background knowledge to understand as well. Yay Rails! 🎉

Behind the scenes, ActionText is using the MiniMagick gem to do image processing. Although the ActionText methods abstract away direct interaction with the MiniMagick gem, it's still useful to know how the gem works.

To start with a very important note about this snippet: we are not inheriting from ApplicationController like in most other cases. To be able to access varaibles like @blob during the HTTP GET action, we need to inherit from ActiveStorage::BlobsController so that we can interject our own logic into this action or skip the modification if the blob isn't an image. In the latter case, we just call the method from the super class: ActiveStorage::BlobsController

In the show method, we can see by using the @blob.variant method, we get the data coming through the GET XHR request. What this allows us to do is resize any image on the fly but that is not enough to persist those changes to that file which is why we chain the .processed.service_url method which then gives it a place to be stored on disk. I've arbitrarily chosen the 250x250 dimensions for all images for simplicity.

If you recall the routes.rb file above, you will notice that this line hits the BlobsController#show action:

# config/routes.rb

match 'blobs/:signed_id/*filename', to: 'blobs#show', via: [:get, :post]

Another side note but equally important to know: when modifying the @blob directly via the .variant method, Rails will check if this image is already cached in your system so that it will NOT create a new variant blob for the same image over and over again. In your Rails logs, you will see a line like this if the image is cached or not:

Disk Storage (0.1ms) Checked if file exists at key: variants/0015bpmf9n6d78dzcn0eiot5ttrn/a9c43bd9b22f280abc66c247e0d1de3fe8d49b2600367a9c8b3750dd0fc2645e (yes)

But what if we want to remove an image from the editor? How would we do that?

Note: Taking this approach won't remove it from the server and the file will still be stored on disk. This will just remove it from the post's :content property.

We need to add a little bit of JavaScript to remove the image from the editor via an event listener that Trix exposes. This snippet of code comes directly from the JavaScript file I mentioned a little earlier in the post:

// app/assets/javascripts/trix-upload.js#L195

attachment.releaseFile();

Trix makes it easy to interact and manipulate data inside the editor. As long as we have a reference to an attachment, we just need to release the file from the editor. After doing this and saving the Post to the database, it will no longer be included in the data of the :content property.

Conclusion

What did we learn? We learned that Rails 6 is an AWESOME update to the framework that allows us to write beautifully formatted, rich text content for our websites which, with a little customization, can even upload images without using any 3rd party gems! We learned how to configure the routes, views, and controllers, with a helpful JavaScript file thrown in, so that we can upload and remove files directly from the editor.

I believe that which Rails 6 is poising to bring to the Rails family of frameworks is very exciting and I can't wait to dive more into its newer features.

Bonus Content

YOU MADE IT! I really appreciate you sticking through this post as I know it was a LOT of content and longer than I originally planned. We'll look at how to remove the image blob from the server while we are removing it from the Trix editor.

We will be adding a whole new controller: AttachmentsController! To generate the controller, we'll run rails g controller Attachments destroy on the console. I won't be showing all of the code on this post as that would be a little much. The code isn't complicated and you can find the full, commented version on the repository.

Essentially, we are calling that code through an HTTP DELETE request from JavaScript to purge it. You can find the definition and use-case recommendations of the ActiveStorage::Blob#purge method on the Rails api documentation website.

After hitting this route, the logs will reflect that we did indeed delete the resource and any variants of it from the server:

Disk Storage (2.4ms) Deleted file from key: 0015bpmf9n6d78dzcn0eiot5ttrn
Disk Storage (1.3ms) Deleted files by key prefix: variants/0015bpmf9n6d78dzcn0eiot5ttrn/

To point out the route for this in the routes.rb file:

# config/routes.rb

resources :attachments, only: [:destroy], as: :destroy_attachment

Bonus bonus

How did I know which controller to inherit from so that I could manipulate the blob information?

A little bit of research reasearch, reading the source code *cough* *cough*, and knowing a little bit of Rails background knowledge can help you find answers to many questions like these. In this particular case, I found what the definition that Rails defines as a route, which we can find with the rails routes cli command, to upload a file:

rails_service_blob  GET
/rails/active_storage/blobs/:signed_id/*filename(.:format)
active_storage/blobs#show

I'll break it down:

  • rails_service_blob is the name used by any route helpers in Rails. For our purposes, you can ignore this.
  • GET tells me that this controller action can only be handled with an HTTP GET request. This is why we didn't use a POST request when sending the XHR request from JavaScript.
  • /rails/active_storage/ tells there is an internal namesapce called ActiveStorage.
  • /blobs/ tells me that on the ActiveStorage namespace, there is a BlobsController controller class.
  • /:signed_id/*filename shows me how Rails handles this and what is required to be passed through the URL so I just copied this part verbatim into my own route definition.
  • active_storage/blobs#show tells me that the ActiveStorage::BlobsController class has a show method that I can override.