Skip to content

Blogging On Rails

Everything on Rails!

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

If you’ve been following the changes in Turbo 8, it looks incredibly promising for improving the perception of speed and interactivity on our web apps.

A lot of the Stimulus Tutorials could use an update since they were first written, so I thought it would be good to over existing tutorials and rethink them with the newest tools available. Join me as we rebuild the Stimulus Tutorial “How do I Drag and Drop Items in a list

Quick demo of Drag and Drop functionality

Setting Priority

We’ll build off the previous example and add a priority order. This will be used sort the Todos.

$ rails g migration AddPriorityToTodo priority:integer
$ rails db:migrate

If there are existing Todos in the database, go ahead and set the priority to the id of the existing Todo:

$ rails c
* Todo.all.each do |todo|
*   todo.priority = todo.id
*   todo.save
> end

In the todos_controller.rb, make the index method sort the Todos by this new priority field:

def index
  @todos = Todo.all.order("priority")
end

And when creating a new Todo, we should set the priority to its ID after creating it by using a callback. This is a simplification since we only have a single Todo model in our application, and you may want to set priority based on other models that you design, like a Project or a List.

class Todo < ApplicationRecord
  after_create :set_priority

  private

  def set_priority
    self.priority = id
    save
  end
end

There are two parts we need to build next. A stimulus controller for the front end, and new rails controller to shuffle around the Todos and put them in the correct order by priority.

Reorder Stimulus Controller

Like in the previous drag and drop tutorial, the controller needs to listen to a number of events to handle the dragging of an item. You can generate it with this command:

$ rails g stimulus reorder

The controller will use the css classes feature to apply styling as the list of Todos becomes active, as the dragged item moves around, and as a potential drop target is hovered over. This creates a very interactive effect, and helps show what the drag is doing. These go at the top of the controller:

  static classes = ["activeDropzone", "activeItem", "dropTarget"];

The first event the controller listens for is dragstart. The dragstart() method will add the CSS classes to the element and the dragged item to provide more visual information as the drag happens. The drag event can transfer information, and the controller will set the id of the item that’s being dragged so when it can be referred to when it’s dropped.

  dragstart(event) {
    this.element.classList.add(...this.activeDropzoneClasses);
    const draggableItem = getDataNode(event.target);
    draggableItem.classList.add(...this.activeItemClasses);
    event.dataTransfer.setData(
      "application/drag-key",
      draggableItem.dataset.reorderableId
    );
    event.dataTransfer.effectAllowed = "move";
  } 

The next event is dragover. It’s important to listen and respond to true so that we can eventually drop the Todo in the list.

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

At the bottom of the controller, there is a helper helper to get the parent div of the Todo that holds the reorderable id:

function getDataNode(node) {
  return node.closest("[data-reorderable-id]");
} 

This is used in the next few methods. dragenter and dragleave work together to change the appearance of the potential drop target. Both find the Todo node in the DOM. dragenter() adds the drop target styling with CSS classes, and then sets all the children pointer events to none so that the inner items, like the checkbox, the text, and the buttons don’t fire their own dragenter events. dragleave() removes the CSS classes, and unset’s the pointer events on the children elements.

  dragenter(event) {
    let parent = getDataNode(event.target);
    if (parent != null && parent.dataset.reorderableId != null) {
      parent.classList.add(...this.dropTargetClasses);
      for (const child of parent.children) {
        child.classList.add("pointer-events-none");
      }
      event.preventDefault();
    }
  }

  dragleave(event) {
    let parent = getDataNode(event.target);
    if (parent != null && parent.dataset.reorderableId != null) {
      parent.classList.remove(...this.dropTargetClasses);
      for (const child of parent.children) {
        child.classList.remove("pointer-events-none");
      }

      event.preventDefault();
    }
  }

The drop() method uses a helper method to get the CSRF token:

function getMetaValue(name) {
  const element = document.head.querySelector(`meta[name="${name}"]`);
  return element.getAttribute("content");
}

The drop method gets the id the Todo that was dragged and the Todo that was dropped down. It doesn’t do anything special to validate the ordering, but it uses the fetch API to send an update to the rails controller we’ll add in the next section. It also repositions the dragged item in the DOM by comparing the document position and then inserting the item as an adjacent element.

  drop(event) {
    this.element.classList.remove(...this.activeDropzoneClasses);

    const dropTarget = getDataNode(event.target);
    dropTarget.classList.remove(...this.dropTargetClasses);
    for (const child of dropTarget.children) {
      child.classList.remove("pointer-events-none");
    }

    var data = event.dataTransfer.getData("application/drag-key");
    const draggedItem = this.element.querySelector(
      `[data-reorderable-id='${data}']`
    );

    if (draggedItem) {
      draggedItem.classList.remove(...this.activeItemClasses);

      if (
        dropTarget.compareDocumentPosition(draggedItem) &
        Node.DOCUMENT_POSITION_FOLLOWING
      ) {
        let result = dropTarget.insertAdjacentElement(
          "beforebegin",
          draggedItem
        );
      } else if (
        dropTarget.compareDocumentPosition(draggedItem) &
        Node.DOCUMENT_POSITION_PRECEDING
      ) {
        let result = dropTarget.insertAdjacentElement("afterend", draggedItem);
      }

      let formData = new FormData();
      formData.append(
        "reorderable_target_id",
        dropTarget.dataset.reorderableId
      );

      fetch(draggedItem.dataset.reorderablePath, {
        body: formData,
        method: "PATCH",
        credentials: "include",
        dataType: "script",
        headers: {
          "X-CSRF-Token": getMetaValue("csrf-token"),
        },
        redirect: "manual",
      });
    }
    event.preventDefault();
  }

Finally, the dragend event is used to remove CSS classes:

  dragend(event) {
    this.element.classList.remove(...this.activeDropzoneClasses);
  }

Priority Controller

First, put in a new directive to the routes.rb file to add a route to the priorities_controller.rb:

  resources :todos do
    resource :priority, only: [:update]
  end

The priorities_controller.rb will only have one method, update. It will find the Todo that was dragged, and the Todo that was the drop target. Then, depending on the ordering, the priority on the all the Todos between those two items are swapped. The Upset command is used to update all the Todos at once.

class PrioritiesController < ApplicationController
  def update
    @todo = Todo.find_by_id params[:todo_id]
    @new_priority_todo = Todo.find_by_id params[:reorderable_target_id]

    if !@new_priority_todo.nil?
      old_priority = @todo.priority
      new_priority = @new_priority_todo.priority

      if old_priority > new_priority
        todos = todos(new_priority..old_priority)
        (0..(todos.length - 2)).each do |i|
          first_todo = todos[i]
          second_todo = todos[i + 1]
          temp_priority = first_todo[:priority]
          first_todo[:priority] = second_todo[:priority]
          second_todo[:priority] = temp_priority
        end
        todos[todos.length - 1][:priority] = new_priority
        Todo.upsert_all(todos)
      elsif old_priority < new_priority
        todos = todos(old_priority..new_priority)
        (todos.length - 1).downto(1).each do |i|
          first_todo = todos[i - 1]
          second_todo = todos[i]
          temp_priority = first_todo[:priority]
          second_todo[:priority] = first_todo[:priority]
          second_todo[:priority] = temp_priority
        end
        todos[0][:priority] = new_priority
        Todo.upsert_all(todos)
      end
    end
  end

  private

  def todos(range)
    Todo.where(priority: range).order(:priority)
      .pluck(:id, :priority, :updated_at, :created_at)
      .map { |id, priority, created_at, updated_at| {id: id, priority: priority, updated_at: updated_at, created_at: created_at} }
  end
end

Wiring up the Front End

Finally, let’s add the annotations Stimulus needs in order to work. First, add the controller directives in todos\index.html.erb. Note the six drag actions that direct the drag and drop actions. The CSS classes are also setup with a number of Tailwind CSS classes.

<div id="todos" 
    class="min-w-full mt-1 overflow-hidden border rounded-lg" 
    data-controller="reorder"
    data-action="dragstart->reorder#dragstart dragover->reorder#dragover dragenter->reorder#dragenter dragleave->reorder#dragleave drop->reorder#drop dragend->reorder#dragend"
    data-reorder-active-dropzone-class="border-dashed bg-gray-50 border-slate-400"
    data-reorder-active-item-class="shadow"
    data-reorder-drop-target-class="shadow-inner shadow-gray-500">

The todos/_todo.html.erb needs three values, and then a visual drag icon is added. The first one is setting draggable to true, and set the reorderable-id and the reorderable-path that are used by Stimulus.

<div id="<%= dom_id todo %>" 
  class="flex items-center py-3 bg-white border-b gap-x-4" 
  data-reorderable-id="<%= todo.id %>" 
  data-reorderable-path="<%= todo_priority_path(todo) %>"
  draggable="true">
  <div class="w-4 h-full -my-3 text-gray-900 rounded-sm hover:bg-gray-300 active:bg-gray-400 hover:cursor-grab active:cursor-grabbing" >
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-4 h-10">
      <path d="M8 2a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM8 6.5a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM9.5 12.5a1.5 1.5 0 1 0-3 0 1.5 1.5 0 0 0 3 0Z" />
    </svg>
  </div>

<!--... remaining Todo HTML is untouched -->

Now there is some very interactive Drag and Drop functionality on the Todo list.

You can find the code on Github here.

Looking to make your Rails app more interactive?

Subscribe below, and you won’t miss the next Rails tutorial, that will teach you how to make really interactive web apps that just work.

We won’t send you spam. Unsubscribe at any time.

One comment on “Hotwire Tutorial: How Do I Drag and Drop Items in a List?”

Leave a Reply

Your email address will not be published. Required fields are marked *