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
, andsortable-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.
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.
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.
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.