Drag and Drop sortable lists with Rails & Stimulus JS Discussion
Chris, thank you so much for this! I was actually going through the old drag and drop tutorial yesterday and today to update it for Rails 6.
I seem to be having an issue both with this tutorial and the other where the Rails.ajax line in both of them is causing the error message:
Uncaught ReferenceError: Rails is not defined
In the old tutorial, I was able to get it to work by changing Rails.ajax to $.ajax but that only works with jQuery and not Stimulus. Do you know what might be causing this?
Add import Rails from "@rails/ujs";
to the top of your drag_controller.js
file and it should work fine.
I believe Chris starts apps with his Jumpstart template, which includes this by default.
Tim, you may need to register Rails globally inside webpack config. Than you will not need to import it inside every controller.
// config/webpack/environment.js
const { environment } = require('@rails/webpacker')
const webpack = require('webpack')
environment.plugins.prepend('Provide',
new webpack.ProvidePlugin({
Rails: ['@rails/ujs']
})
)
module.exports = environment
Thanks John and Dino! That fixed both the old and new versions. I've been trying to figure that out for awhile now. Much appreciated!
I've got a question on this. What if the todo is nested within another model. Like todolist has many todos. I'm having trouble setting up the data-drag-url to be something like /todolist/:id/todos/:id/move. How do you bring the todolist :id?
hey, I have an image model. image belongs to product, and product has many images. this is how I did it.
<div data-controller="drags" data-drags-url="/products/:product_id/images/:id/move">
and in the stimulus controller, I replaced the url by this
let id = event.item.dataset.id
let product_id = event.item.dataset.productId
let data = new FormData()
data.append("position", event.newIndex + 1)
let url = this.data.get("url")
let mapUrl = { product_id: product_id, id: id}
url = url.replace(/product_id|id/gi, function(matched){
return mapUrl[matched];
})
Rails.ajax({
url: url,
type: 'PATCH',
data: data
})
Just coming back to this now. I set it up similar to what you did. Instead of products I have compitems. Instead of images I have tasks. It can't seem to find the correct task to update the position on. I get the error
ActiveRecord::RecordNotFound (Couldn't find Task with 'id'=:3 [WHERE "tasks"."compitem_id" = $1]):
13:16:20 web.1 |
13:16:20 web.1 | app/controllers/tasks_controller.rb:81:in "set_task"
Any thoughts on why that is?
drag controller:
import { Controller } from "stimulus"
import Sortable from "sortablejs"
export default class extends Controller {
connect() {
this.sortable = Sortable.create(this.element, {
group: 'shared',
animation: 150,
onEnd: this.end.bind(this)
})
}
end(event) {
let id = event.item.dataset.id
let compitem_id = event.item.dataset.compitemId
let data = new FormData()
data.append("position", event.newIndex + 1)
let url = this.data.get("url")
let mapUrl = { compitem_id: compitem_id, id: id}
url = url.replace(/compitem_id|id/gi, function(matched){
return mapUrl[matched];
})
Rails.ajax({
url: url,
type: 'PATCH',
data: data
})
}
}
Task.html.erb
<div data-controller="drag" data-drag-url="/compitems/:compitem_id/tasks/:id/move ">
<div class="border border-gray-400 mx-4 my-4 p-6 w-1/2" data-id="<%= task.id %>">
<div><%= check_box_tag nil, nil, task.completed_date?, data: { reflex: "click->ExampleReflex#toggle", id: task.id } %></div>
<div><h3 class="text-gray-900 text-sm leading-5 font-medium truncate"><%= task.description %></h3></div>
<div><h3 class="text-gray-900 text-sm leading-5 font-medium truncate"><%= task.completed_date %></h3></div>
</div>
</div>
In compitems show:
<div class="mt-5 border-t border-gray-200 pt-5">
<div id="taskitems_wrapper">
<%= render @compitem.tasks.order(position: :asc), :locals => {:compitem_id => @compitem.id} %>
</div>
</div>
tasks controller:
class TasksController < ApplicationController
before_action :set_compitem
before_action :set_task, except: [:create]
# GET /tasks
# GET /tasks.json
def index
@task = Task.all
end
# GET /tasks/1
# GET /tasks/1.json
def show
end
# GET /tasks/new
def new
@task = Task.new
end
# GET /tasks/1/edit
def edit
end
# POST /tasks
# POST /tasks.json
def create
@task = @compitem.tasks.create(task_params)
@task.user_id = current_user.id
respond_to do |format|
if @task.save
format.html { redirect_to @task, notice: 'Task was successfully created.' }
format.json { render :show, status: :created, location: @task }
else
format.html { render :new }
format.json { render json: @task.errors, status: :unprocessable_entity }
end
end
end
# PATCH/PUT /tasks/1
# PATCH/PUT /tasks/1.json
def update
respond_to do |format|
if @task.update(task_params)
format.html { redirect_to @compitem, notice: 'Task was successfully updated.' }
format.json { render :show, status: :ok, location: @task }
else
format.html { render :edit }
format.json { render json: @task.errors, status: :unprocessable_entity }
end
end
end
# DELETE /tasks/1
# DELETE /tasks/1.json
def destroy
@task.destroy
respond_to do |format|
format.html { redirect_to tasks_url, notice: 'Task was successfully destroyed.' }
format.json { head :no_content }
end
end
def move
@task.insert_at(params[:position].to_i)
head :ok
end
def set_compitem
@compitem = Compitem.find(params[:compitem_id])
end
private
# Use callbacks to share common setup or constraints between actions.
def set_task
@task = @compitem.tasks.find(params[:id])
end
# Only allow a list of trusted parameters through.
def task_params
params.require(:task).permit(:description, :position, :user_id)
end
end
I am having the same issue. I did find out that in the event, event.item.dataset.id
is empty. I am not sure why.
I was working on this today. I am rendering the partial from the compitem page in my case passing through the compitem_id as a locale. I get the error that the ID value is undefined so I'm not sure if I am passing it through correctly. It would be great to see a full working example of this.
Perhaps constructive for future readers, here's a Trello example with Turbo, Stimulus and Sortable, to work with multiple nested resources (:compitems/:lists): https://github.com/john-hamnavoe/rails-trello-clone
Will be great to see an updated version of this video with Stimulius Relfex and why not a demo with multiple list like Trello
I get the following error when grabbing the url:
TypeError: null is not an object (evaluating 'this.data.get('url').replace'). I set data: {drag_url: 'xx'} on the wrapper div (same div that has data-controller on it). For some reason this.data is always null.
Any ideas why this would be?
I just changed it up a bit. I assigned the drag-url to the item....not the container. That way I don't need to replace :id with id. And, then to get that url in the end method I use event.item.dataset.dragUrl.
What would be the solution for the drag_controler.js code to make it serialized like it was with the jQuery sortable episode?
The route would need to be updated to collect and the move action would need to use each_with_index. Just can't figure out how to serialize it correctly from the drag_controller.js
Using request.js to pass the data to the controller:
import { Controller } from "stimulus"
import { patch } from '@rails/request.js'
import Sortable from "sortablejs"
export default class extends Controller {
connect() {
this.sortable = Sortable.create(this.element, {
group: 'shared',
animation: 150,
onEnd: this.end.bind(this)
})
}
async end(event) {
let id = event.item.dataset.id
var data = new FormData()
data.append("position", event.newIndex + 1)
for (var key of data.entries()) {
console.log(key[0] + ', ' + key[1]);
}
const response = await patch(this.data.get("url").replace(":id", id), { body: data })
if (response.ok) {
console.log('Sequence reordered')
} else {
console.error('Error')
}
}
}
I also added a for loop to display the form parameters and their values as console.log
does not work in that case.
Thanks!
I think you can even use the Rails path helper with the place holder :id (i.e. move_todos_path(":id")
) – probably better than to write urls manually :).
Is there anyway to reverse the index and position order? For example:
<div>First div</div> <%# index: 3, position: 3 %>
<div>Second div</div> <%# index: 2, position: 2 %>
<div>Third div</div> <%# index: 1, position: 1 %>
<div>Fourth</div> <%# index: 0, position: 0 %>
This way it appears and is applied in descending order instead of ascending
that will display the items in reverse, sure. But it is not quite what I'm meaning.
Let's say I drag and drop the first div to the second div. The event will still contain the following:
newIndex: 1
oldIndex: 0
what I want it to be:
newIndex: 2
oldIndex: 3