How to get the right position with Sortable.js, acts_as_list and Stimulus for nested_attributes

1.3k Views Asked by At

I'm trying to implement the "Drag and Drop Sortable Lists" (https://gorails.com/episodes/rails-drag-and-drop-sortable) with nested_attributes.

Unfortunately, I'm getting the wrong :position. It jumps from 2 to 5 to 7, even when there should be only three positions in total.

I believe the problem is that hidden input tags are being counted as well. If this is the main problem, any way around this?

// config/routes.rb

  resources :recipes, except: :index do
    resources :steps do
      resource :step_position, only: :update
    end
  end

// app/views/recipes/_form.html.erb

<div data-controller="sortable" data-sortable-url="/recipes/:recipe_id/steps/:id/step_position">
  <%= f.fields_for :steps, @recipe.steps.order(position: :asc) do |step| %>
    <div data-id="<%= step.object.id %>">
      <%= step.text_area :description %>
    </div>
  <% end %>
</div>
// app/javascript/controllers/sortable_controller.js

import { Controller } from "stimulus";
import Rails from "@rails/ujs";
import { Sortable } from "sortablejs";

export default class extends Controller {
  connect() {
    this.sortable = Sortable.create(this.element, {
      onEnd: this.end.bind(this),
    });
  }

  end(event) {
    let id = event.item.dataset.id;
    let data = new FormData();
    data.append("position", event.newIndex + 1);
    Rails.ajax({
      url: this.data.get("url").replace(":id", id),
      type: "PATCH",
      data: data,
    });
  }
}
// app/controllers/step_positions_controller.rb

class StepPositionsController < ApplicationController
  def update
    @step = Step.find(params[:step_id])
    authorize @step.recipe

    @step.insert_at(params[:position].to_i)

    head :ok
  end
end
2

There are 2 best solutions below

0
Brendon Muir On

Firstly take a look at the parameters being passed to the server. Do these look ok? If not, then the javascript might be to blame.

Secondly, insert_at isn't the best tool for the job in this case. If you want to explicitly set the position explicitly without interference from the callbacks etc... I usually use where(:id => id).update_all(:position => the_position) on each element in the list (provided you know you have all of them in your params.

The best solution is to have the javascript send you the item being moved and the position in the list that you want it to be, then just set the position column to that value and acts_as_list will shuffle things out of the way for you. The only downside to this is concurrent updates from other users. Your list will get out of sync if this is the case.

3
Mirha Masala On

The problem was that hidden input tags were being counted as well.

I solved it by adding <%= f.hidden_field :id %>

<!-- app/views/recipes/_form.html.erb -->

<div data-controller="sortable" data-sortable-url="/recipes/:recipe_id/steps/:id/step_position">
  <%= f.fields_for :steps, @recipe.steps.order(position: :asc), include_id: false do |step| %>
    <%= render "step_fields", f: step %>
  <% end %>
</div>
<!-- app/views/recipes/_step_fields.html.erb -->

<%= content_tag :div, class: "nested-fields", data: { new_record: f.object.new_record?, id: f.object.id } do %>
  <%= f.hidden_field :id %>
  <%= f.text_area :description %>

  <small><%= link_to "Delete", "#", data: { action: "click->new-fields#removeField" } %></small>
  <%= f.hidden_field :_destroy %>
<% end %>