Ask A Question

Notifications

You’re not receiving notifications from this thread.

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?

Reply

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.

Reply

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
Reply

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!

Reply

Any hints how to solve this with the new Rails 7 system?

Reply

need to run
yarn add @rails/ujs

then add
import Rails from "@rails/ujs"
to my drag controller

Reply

What would this look like when used with Stimulus Reflex?

Reply

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?

Reply

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
    })
Reply

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
Reply

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.

Reply

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.

Reply

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

Reply

Will be great to see an updated version of this video with Stimulius Relfex and why not a demo with multiple list like Trello

Reply

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?

Reply

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.

Reply

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

Reply

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')
    }
  }
}
Reply

I also added a for loop to display the form parameters and their values as console.log does not work in that case.

Reply

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 :).

Reply

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

Reply

perhaps

.reverse
Reply

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
Reply
Join the discussion
Create an account Log in

Want to stay up-to-date with Ruby on Rails?

Join 86,946+ developers who get early access to new tutorials, screencasts, articles, and more.

    We care about the protection of your data. Read our Privacy Policy.