Sorting Blocks with Stimulus.js and CableReady::Updatable

In this post, we will take a spike on drag and drop sorting of blocks in a list. This is made possible by the stimulus-sortable component, which wraps the excellent sortable.js library. Let's take a look at what this module provides.

It requires some sort of automatic ordering mechanism to be present on the Rails model you want to be sortable. Typically, this is provided by the acts_as_list gem, but you are of course free to use any other module.

On the encompassing DOM element, in this example the unordered list, the data-controller attribute is placed, along with a handle selector. The stimulus-sortable module also boasts an automatic AJAX request via @rails/request.js. This requires a data-sortable-update-url attribute on the specific item to send a PATCH to the appropriate controller action.

We are going to configure

  • resource-name-value
  • response-kind-value, and
  • sortable-handle-value

in our application. Now let's head off to the implementation!

Preparations - ActsAsList and Stimulus-Sortable

First, we'll make sure that our ContentElement model acts as a list. As already mentioned, this is provided by the acts_as_list gem, and we scope the position attribute to one specific parent (a Section or Chapter).

class ContentElement < ApplicationRecord
  acts_as_list scope: [:parent_type, :parent_id]

  # ...
end

This gem will also make sure that all positions are updated accordingly if you insert or move an element in the list.

Next, we make sure that we permit the position attribute in the ContentElementsController's params. Furthermore, let's make sure the update action can respond to a turbo stream request. A 204 no_content response is sufficient (we'll see why in a moment), so we don't specify any block.

class ContentElementsController < ApplicationController
  # ...

  def update
    if @content_element.update(content_element_params)
      respond_to do |format|
        format.html { # ... }
        format.turbo_stream
    end
  end

  # ...

  private 

  def content_element_params
    params.require(:content_element).permit(:body, :position, :parent)
  end
end

Let's add stimulus-sortable to our javascript bundle. Because it refers to sortable.js as a peer dependency, we need to add it separately.

$ yarn add stimulus-sortable sortable.js

To make it available to our application, we must also register it with Stimulus.

// app/javascript/controllers/index.js

// ...

import Sortable from 'stimulus-sortable'
application.register('sortable', Sortable)

Adding an Action Bar to Content Blocks

We are going to render a sorting handle in our content elements' views, so we need to reserve some space for it. The comment partial uses the PanelComponent, so we create an actions slot there.

class PanelComponent < ViewComponent::Base
  renders_one :actions

  # ...
end
<!-- app/components/panel_component.html.erb -->
<div class="...">
  <div class="px-4 py-5 sm:px-6 flex justify-between">
    <h3 class="..."><%= title %></h3>
    <%= actions %>
  </div>
  <div class="px-4 pb-5 pt-0 sm:p-6 sm:pt-0">
    <%= content %>
  </div>
  <% if footer? %>
    <div class="...">
      <%= footer %>
    </div>
  <% end %>
</div>

Over in the content_element partial, we start out by adding the data-sortable-update-url to the enclosing <section> tag. Then we add a handle element. The crucial detail here is that we add the handle class here, which we will refer to later.

<!-- app/views/content_elements/_content_element.html.erb -->
<section data-sortable-update-url="<%= content_element_path(content_element) %>" ... >
  <!-- ... -->

  <span class="... handle">
    <%= fa_icon "grip-dots-vertical", class: "fa-lg" %>
  </span>

  <!-- ... -->
</section>

In our ContentElement box, we can already see our grip icon here - please ignore the CSS alignment error. Unsurprisingly though, we cannot interact with it yet.

A Content Element with a Sorting Handle

Let's complete the setup by heading over to the comment partial. Being a subclass of ContentElement, we have to give it the same treatment: Add the data-sortable-update-url to the top enclosing element, and add the grip handle - this time in the actions slot we defined previously.

<!-- app/views/comments/_comment.html.erb -->
<section data-sortable-update-url="<%= content_element_path(comment) %>" ...>
  <%= render(PanelComponent.new(title: comment.file_path, size: :sm, style: :card, ...) do |co| %>
    <!-- ... -->
    <% co.actions do %>
      <div class="transition ease-out transform opacity-0 group-hover:opacity-100 flex space-x-4">
        <span class="... handle">
          <%= fa_icon "grip-dots-vertical", class: "fa-lg" %>
        </span>
      </div>
    <% end %>

    <!-- ... -->

  <% end % >
</section>

Now the Comment box also has a grip handle. It doesn't seem to do anything yet, however.

A Comment with a Sorting Handle

Preparing Chapter and Section Containers

To complete this, we need to wrap the list that's to be sorted in a sortable Stimulus controller and configure it. For this, we have to head over to the section edit view and enclose the list of items in a new div.

<!-- app/views/chapters/edit.html.erb -->
<%= cable_ready_updates_for @chapter, :content_elements do %>  
  <div data-controller="sortable" data-sortable-handle-value=".handle"   
    data-sortable-resource-name-value="content_element" 
    data-sortable-response-kind-value="turbo-stream" class="space-y-4">
    <%= render @chapter.content_elements.includes(:parent) %>
  </div>
<% end %>

<!-- app/views/sections/edit.html.erb -->
<%= cable_ready_updates_for @section, :content_elements do %>  
  <div data-controller="sortable" data-sortable-handle-value=".handle"   
    data-sortable-resource-name-value="content_element" 
    data-sortable-response-kind-value="turbo-stream" class="space-y-4">
    <%= render @section.content_elements.includes(:parent) %>
  </div>
<% end %>

Apart from adding the data-controller attribute, we also specify which class denotes the handle, what is our resource name, and that we expect to receive turbo-stream responses. This is necessary to configure the respective PATCH request that's dispatched from the stimulus controller.

Back in the browser, now everything is set up for drag and drop sorting. We can verify in the Network tab that PATCH requests are being made with the appropriate FormData payload containing the new position for the respective content block.

A Content Element being sorted using Drag and Drop

Bonus: Zero-Config Reactivity using CableReady::Updatable

As a final treat, if we log in as a different review participant, we can observe side-by-side how the second user's view is updated reactively once the first user reorders content elements.

A Content Element being sorted using Drag and Drop and simultanuously updating another browser tab

Supercharge Your Rails App

Upgrade Your Tech Stack for a Smoother Dev Experience and Increased Profits.

Order a Review