Posted at

【Rails 5】ドラッグ&ドロップで並び替えて順番を保存できるリストを作る


Introduction

例えばTodoリストアプリなどを作ろうとすると、リストのアイテムを順番通りに整列させて、ドラッグ&ドロップで並び替えて、その順番を保存したい場面があると思います。

jQuery UIのsortableを利用している記事が多かったのですが、モバイルでも利用する前提だと他のjsも使わなくてはいけなかったりと面倒だったのですが、Sortable.jsというライブラリを利用すればモバイルにも対応してくれていたので「Sortable.js」+「acts_as_list」を使ってドラッグ&ドロップで並び替えできるリストを実装してみました。


Goal Image

準備中...


0. Sample Model



とても単純なrelationshipを持ったParentChildモデルがあるとします。

Parentは0個以上のChildを持ち(has_many)、Childは1つのParentを持ちます(belongs_to)。


1. Childモデルに順番の概念を与える

まずは、モデルに順番の概念を与えるためにacts_as_listを使っていきます。

GitHub - swanandp/acts_as_list


Gemfile

gem 'acts_as_list'


$ bundle install

acts_as_listがインストールできたら、Childposition:integerのカラムを追加してください。

$ rails g migration AddPositionToTodoItem position:integer

$ rake db:migrate

これでER図はこんな感じに変わってます。

Childの順番はParentごとに管理をしたいので、モデルの定義を次のように書き換えます。


app/models/parent.rb

class Parent < ApplicationRecord

has_many :children, -> { order(position: :asc) }
end


app/models/child.rb

class Child

belongs_to :parent
acts_as_list scope: :parent

これでChildモデルに順番の概念を与えることができました。

Childがcreateされるごとにpositionカラムにシーケンシャルな数字が自動で追加してくれます。

また、用意されているメソッドを使うことで順番を変更することができます。

@child.insert_at(2)   #position=2に移動

@child.move_lower #position++
@child.move_higher #position--
@child.move_to_top #position=1
@child.move_to_bottom #position=last


2. ドラッグ&ドロップで並び替え可能なリストを作る

これにはSortable.jsを使います。

GitHub - SortableJS/Sortable

GitHubからSortable.min.jsをダウンロードしてapp/assets/javascripts/に配置します。

Sortable.jsではgetElementByIdでHTMLObjectを取得することでリストを並び替え可能にします。

例として、/parents/:idParentに紐づくChildを一覧で表示しているページを使います。


app/controllers/parents_controller.rb

def show

@parent = Parent.find(params[:id])
@children = @parent.children
end


app/views/parents/show.html.erb

<ul id="sortable_list">

<% @children.each do |child| %>
<li><%= child %></li>
<% end %>
</ul>


app/assets/javascripts/parents.coffee

$ ->

el = document.getElementById("sortable_list")
if el != null
sortable = Sortable.create(el, delay: 200)

これで<li>要素をドラッグ&ドロップで並び替えできるようになります。

Sortable.jsのdelayオプションは200msの長押しをしないと並び替え可能な状態にならないことを定義しています。デフォルトでは0msなのですが、スマホだとページのスクロールができなくなってしまうので適切な値を設定するといいと思います。


3. 並び替えをしたらDBを更新する

Sortable.jsでは並び替えが行われたときにonUpdateが呼び出されます。

これをトリガーにajaxでドラッグ&ドロップされた要素の「ドラッグ前の位置」と「ドラッグ後(ドロップ)の位置」をバックエンドに連携して並び順をDB更新するようにします。

まずは「ドラッグ前後の位置」を受け取れるようにコントローラー側にsortアクションを追加します。

urlはparent/:id/sortfrom(ドラッグ前の位置)とto(ドラッグ後の位置)を受け取る前提とします。


app/controllers/parents_controller.rb

def sort

@parent = Parent.find(params[:id])
child = @parent.children[params[:from].to_i)
child.insert_at(params[:to].to_i + 1)
head :ok
end

@parent.children[params[:from].to_i]で対象のモデルを取得しています。

そして、child.insert_at(params[:to].to_i + 1)で対象のpositionを更新しています。

Sortable.jsで取得するリストの位置は0から始まるのに対して、insert_at1から始まるところに注意して、insert_at向けの要素の順番には+1をしています。

Rails4ではrender nothing: trueなどが有効だったようですがRails5では使えなくなっているのでhead :okでフロント側に200OKを返却。

ルーティングも追加しておきます。


config/routes.rb

patch 'parent/:id/sort', to: 'parent#sort'


ajax通信をする際にParentidが必要になるのでhiddenフィールドに隠しておいてajaxの前に取得できるようにしておきます。


app/views/parents/show.html.erb

<ul id="sortable_list">

<% @children.each do |child| %>
<li><%= child %></li>
<% end %>
</ul>
<%= hidden_field_tag :parent_id, @parent.id %>

あとはajaxを実装すれば完成です。

Sortable.jsではonUpdateイベントでoldIndexnewIndexを取得できるようになっています。それぞれ「ドラッグ前の位置」と「ドラッグ後の位置」です。これを取得してバックエンドにpatchします。


app/assets/javascripts/parents.coffee

$ ->

el = document.getElementById("sortable_list")
if el != null
sortable = Sortable.create(el,
delay: 200,
onUpdate: (evt) ->
$.ajax
url: 'parent/' + $("#parent_id").val() + '/sort'
type: 'patch'
data: { from: evt.oldIndex, to: evt.newIndex }
)

$("#parent_id").val()でhiddenフィールドに格納しているParentidを取得してurlを指定してます。typeroutes.rbで定義したものに合わせて、dataにはバックエンドに連携する情報を記載してます。

以上で完成!!


Conclusion

割とシンプルにドラッグ&ドロップでの並び替えができたと思います。しかもモバイル対応!

ただし、acts_as_listの仕様上、並び替えの度にけっこうなDB更新がかかっていそう(from〜toの間の全てのレコードを更新しているっぽい)。リストが大きくなることが想定されている場合は順番をつける方式は検討した方が良いかも。ranked_modelの方がいいのかな?


Reference