Updated Tutorial: How Do I Drag and Drop Items in a List?

How do you configure a more complicated Stimulus controller that manages dragging items around in a list? Let’s start with our simple ordered Todo table in html:

<table class="table">
  <tbody>
    <% @todos.each do |todo| %>
      <tr 
        data-controller="todo" 
        data-todo-update-url="<%= todo_path(todo) %>"
        data-todo-id="<%= todo.id %>"
        draggable="true" 
        data-drag-item-url="<%= position_path(todo) %>" >
        <td>
          <input type="checkbox" 
              data-action="todo#toggle" 
              data-target="todo.completed" 
              <% if todo.completed %> checked <% end %> >
          <%= todo.title %>
        </td>
      </tr>
    <% end %>
  </tbody>
</table>

The todo order comes from a priority associated with each item, so that we’ll be able to keep track of which item needs to be moved. We also need to make each item draggable, so that the correct JavaScript events are sent to our controller.

Build off the previous Todo example with this migration to add a priority to each Todo:

rails g migration AddPriorityToTodo priority:integer

Run the migration, and clear the todos from your database so there aren’t any data issues. If you were adding this to an existing model, you’d have to write a script to set an initial ordering to all the elements.

Now, we’ll need to create the stimulus controller that is just going to handle all the events. We’ll name it drag-item, and in Rails, using webpacker, it would go in app/javascript/controllers/drag_item_controller.js:

import { Controller } from "stimulus"

export default class extends Controller {

}

Add the controller to your html and the drag events the controller is listening for:

<tbody 
  data-controller="drag-item" 
  data-action="dragstart->drag-item#dragstart dragover->drag-item#dragover dragenter->drag-item#dragenter dragleave->drag-item#dragleave drop->drag-item#drop dragend->drag-item#dragend"> 

The events are:

  • dragstart
  • dragover
  • dragenter
  • dragleave
  • drop
  • dragend

Notice how we can add multiple actions just by separating each one with a space. Add those actions to the Stimulus controller.

The controller will keep track of the item-todo-id when dragging starts so that it knows which todo to move:

  dragstart(event) {
    event.dataTransfer.setData("application/drag-key", event.target.getAttribute("data-todo-id"))
    event.dataTransfer.effectAllowed = "move"
  } 

The controller prevents the default action when dragging an item. This makes sure the drag operation isn’t handled by the browser itself. The dragenter and dragleave methods also dim an element to show the current drop target.

  dragover(event) {
    event.preventDefault()
    return true
  }

  dragenter(event) {
    event.target.style = "opacity: 75%;";
    event.preventDefault();
  }

  dragleave(event) {
    event.target.style = "";
    event.preventDefault();
  } 

On the drop event, we get the element that we were dragging based on its data-todo-id, and then to position the item correctly, we see where the dragged element compares to where it was dropped , and then insert it before or after the drop target so that the drop visually make sense.

  drop(event) {
    event.target.style = "";
    let data = getDragKey(event);
    let dropTarget = event.target;
    let draggedItem = this.element.querySelector(`[data-todo-id='${data}']`);
    if (dropTarget.compareDocumentPosition(draggedItem) &amp; 4) {
      event.target.parentNode.insertAdjacentElement("beforebegin", draggedItem);
    } else if (dropTarget.compareDocumentPosition(draggedItem) &amp; 2) {
      event.target.parentNode.insertAdjacentElement("afterend", draggedItem);
    }
    event.preventDefault();
  }

This is where we might want to post our action to the server so it can properly handle the movement, but there is no need to do anything at this moment, so we’ll leave it blank.

  dragend(event) {
    let formData = new FormData();
    formData.append("todo[priority]", event.target.rowIndex);
    fetch(event.target.dataset.dragItemUrl, {
      body: formData,
      method: "PATCH",
      credentials: "include",
      dataType: "script",
      headers: {
        "X-CSRF-Token": getMetaValue("csrf-token"),
      },
    });
  } 
} 

Now you have can drag and drop items in a list on the front end. On the rails side, add a ruby controller called priority_controller.rb, and update the routes.rb to include an update method.

resources :position, only: [:update]

The update method takes the new position of the Todo from the HTTP call. It saves the old position, and then pulls out the Todos between the new position and the old position. It goes one by one, and updates the positions, and then uses the UPSERT SQL command to save all the changes at once.

class PositionController < ApplicationController
  before_action :set_todo, only: [:update]

  def update
    old_priority = @todo.priority
    respond_to do |format|
      if @todo.update(todo_params)
        if todo_params['priority']
          if old_priority > @todo.priority
            todos = Todo.where(priority: @todo.priority..old_priority )
              .pluck(:id, :priority, :updated_at, :created_at)
              .map { |id, priority, created_at, updated_at| {id: id, priority: priority, up-dated_at: updated_at, created_at: created_at } }
            todos.each do |todo|
              if todo[:priority] <= old_priority and todo[:priority] >= @todo.priority and todo[:id] != @todo.id
                todo[:priority] += 1
              end
            end
            Todo.upsert_all(todos)
          elsif old_priority < @todo.priority
            todos = Todo.where(priority: old_priority..@todo.priority )
              .pluck(:id, :priority, :updated_at, :created_at)
              .map { |id, priority, created_at, updated_at| {id: id, priority: priority, up-dated_at: updated_at, created_at: created_at } }
            todos.each do |todo|
              if todo[:priority] >= old_priority and todo[:priority] <= @todo.priority and todo[:id] != @todo.id
                todo[:priority] -= 1
              end
            end
            Todo.upsert_all(todos)
          end
        end
        format.js
      else
        format.js
      end
    end
  end

  private
  # Use callbacks to share common setup or constraints between actions.
  def set_todo
    @todo = Todo.find(params[:id])
  end

  # Only allow a list of trusted parameters through.
  def todo_params
    params.require(:todo).permit(:priority)
  end
end

Now, the Todos can be ordered interactively, without the need for rewriting the full front end in Javascript.

Leave a Reply