Drag and Drop sortable items with SortableJS and StimulusJS Rails 7

Tomas Valent
3 min readFeb 1, 2024

--

How to create drag&drop sortable reorder of items based on Stimulus JS & Sortable JS (bonus: works under TailwindCSS)

My solution is based on Rapid Ruby’s video drag and drop sortable list using Hotwire (source code) but with more straight forward urls + works out of the box with Rails 7 importmaps

Step 0 — DB

I will assume we work with Entry model that has #title and #position where the later represents the position of the entry where first item has value of 0 .

You can achieve this with acts_as_list gem

# Gemfile
# ...
gem "acts_as_list"

Declaring it on a model

class Entry < ApplicationRecord
# ...
acts_as_list top_of_list: 0
end

given Schema

create_table "entries", force: :cascade do |t|
t.string "title"
t.integer "position"
#...
end

Step 1 — add importmaps dependencies

$ cd my_rails_project
$ bin/importmap pin sortablejs @rails/request.js

which will add :

// config/importmap.rb
// ...
pin "sortablejs", to: "https://ga.jspm.io/npm:sortablejs@1.15.2/modular/sortable.esm.js"
pin "@rails/request.js", to: "https://ga.jspm.io/npm:@rails/request.js@0.0.9/src/index.js"

Note1: no, you don’t have to install requestjs-rails gem. Pinned JS is enough
Note2: no you don’t need to add anything to app/javascript/application.js

Step 2 — Stimulus controller

// app/javascript/controllers/sortable_controller.js
import { Controller } from "@hotwired/stimulus"
import Sortable from "sortablejs";
import { put } from "@rails/request.js";

// example use:
//
// <ul data-controller="sortable">
// <li data-sortable-url="/entries/211/positions"><i data-sortable-handle>(H)</i> one</li>
// <li data-sortable-url="/entries/222/positions"><i data-sortable-handle>(H)</i> two</li>
// <li data-sortable-url="/entries/233/positions"><i data-sortable-handle>(H)</i> three</li>
// </ul>

export default class extends Controller {
static values = { url: String };

connect() {
this.sortable = Sortable.create(this.element, {
animation: 350,
ghostClass: "bg-gray-200",
onEnd: this.onEnd.bind(this),
handle: "[data-sortable-handle]",
});
}

disconnect() {
this.sortable.destroy();
}

onEnd(event) {
const { newIndex, item } = event;
const url = item.dataset["sortableUrl"]
put(url, {
body: JSON.stringify({ position: newIndex })
});
}
}

Step 3 — ERB, Controller, Routes

When you move item it will send PUT request to data-sortable-url (whatever that is) with new position of that Entry. First element in the list has position 0

I like to have separate controller to handle position setting. This is up to you what routes/contollers you set.

<ul data-controller="sortable">
<% Entry.order(:position).each do |entry| %>
<li data-sortable-url="<%= entry_positions_path(entry) %>"><i data-sortable-handle>(H)</i> <%= entry.title %></li>
<% end %>
</ul>
# config/routes.rb
# ...
resources :entries, only: [] do
resource :positions, only: [:update], module: :entries
end
# app/controllers/entries/positions_controller.rb
class Entries::PositionsController < ApplicationController
def update
@entry = Entry.find(params[:entry_id])
# @entry = current_user.entries.find(params[:entry_id]) # if you use Authentication (e.g Devise)

@entry.insert_at(params[:position].to_i)
head :no_content
end
end

Other solutions & notes

One more thing if you are getting 401 error when executing request.js put requests make sure you are signed in. Yes it sounds obvious but guess who spent 2 hours debugging request headers before realising he is not signed in 🙂

Photo by Tomas Valent on Unsplash

--

--