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="<%= priority_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) & 4) {
event.target.parentNode.insertAdjacentElement("beforebegin", draggedItem);
} else if (dropTarget.compareDocumentPosition(draggedItem) & 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 :priority, only: [:update]
The update method takes the new priority of the Todo from the HTTP call. It saves the old priority, and then pulls out the Todos between the new priority and the old priority. It goes one by one, and updates the priorities, and then uses the UPSERT
SQL command to save all the changes at once.
class PriorityController < 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.
3 comments on “Updated Tutorial: How Do I Drag and Drop Items in a List?”
[…] values as a designer. Your controllers are now more reusable in different contexts, such as as the reordering Todos example. I think these help developers save time, and make more interactive web […]
Hi John,
Was reading through this example in the book. I think the file name priority_controller.rb probably is a leftover from an older iteration of your tutorial/code. To match the route and class name of the controller it should be renamed to position_controller.rb
Erik
Thank you! I fixed it here, and I’ll fix it in the book.