Go back Hotwire / Stimulus / Turbo ...

Drag and Drop Sortable items wit SortableJS and StimulusJS Rails



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


Note: there's a mirror article published on medium.com

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 dependensies 

$ 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:[email protected]/modular/sortable.esm.js"
pin "@rails/request.js", to: "https://ga.jspm.io/npm:@rails/[email protected]/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";

//<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 he realised he is not signed in