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

# Introduction
例えばTodoリストアプリなどを作ろうとすると、リストのアイテムを順番通りに整列させて、ドラッグ＆ドロップで並び替えて、その順番を保存したい場面があると思います。
jQuery UIのsortableを利用している記事が多かったのですが、モバイルでも利用する前提だと他のjsも使わなくてはいけなかったりと面倒だったのですが、Sortable.jsというライブラリを利用すればモバイルにも対応してくれていたので「Sortable.js」＋「acts_as_list」を使ってドラッグ＆ドロップで並び替えできるリストを実装してみました。

# 0. Sample Model
![er_rerecheck-Page-2 (1).png](https://qiita-image-store.s3.amazonaws.com/0/254216/ee748e2b-7d94-c6b7-ba52-fd2fe3c62d26.png)
とても単純なrelationshipを持った`Parent`と`Child`モデルがあるとします。
`Parent`は0個以上の`Child`を持ち（has_many）、`Child`は1つの`Parent`を持ちます（belongs_to）。

# 1. `Child`モデルに順番の概念を与える
まずは、モデルに順番の概念を与えるために`acts_as_list`を使っていきます。
[GitHub - swanandp/acts_as_list](https://github.com/swanandp/acts_as_list)

```text:Gemfile
gem 'acts_as_list'
```

```bash
$ bundle install
```
`acts_as_list`がインストールできたら、`Child`に`position:integer`のカラムを追加してください。

```bash
$ rails g migration AddPositionToTodoItem position:integer
$ rake db:migrate
```

これでER図はこんな感じに変わってます。
![er_rerecheck-Page-2.png](https://qiita-image-store.s3.amazonaws.com/0/254216/df1c241b-ad52-1975-3d91-795b7fa1c7b1.png)

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

```ruby:app/models/parent.rb
class Parent < ApplicationRecord
  has_many :children, -> { order(position: :asc) }
end
```

```ruby:app/models/child.rb
class Child
  belongs_to :parent
  acts_as_list scope: :parent
```
これで`Child`モデルに順番の概念を与えることができました。
`Child`がcreateされるごとに`position`カラムにシーケンシャルな数字が自動で追加してくれます。
また、用意されているメソッドを使うことで順番を変更することができます。

```ruby
@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](https://github.com/SortableJS/Sortable)
GitHubからSortable.min.jsをダウンロードして`app/assets/javascripts/`に配置します。

Sortable.jsでは`getElementById`でHTMLObjectを取得することでリストを並び替え可能にします。
例として、`/parents/:id`で`Parent`に紐づく`Child`を一覧で表示しているページを使います。

```rb:app/controllers/parents_controller.rb
def show
  @parent = Parent.find(params[:id])
  @children = @parent.children
end
```

```erb:app/views/parents/show.html.erb
<ul id="sortable_list">
  <% @children.each do |child| %>
    <li><%= child %></li>
  <% end %>
</ul>
```

```coffee: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/sort`、`from`（ドラッグ前の位置）と`to`（ドラッグ後の位置）を受け取る前提とします。

```ruby: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_at`は`1`から始まるところに注意して、`insert_at`向けの要素の順番には`+1`をしています。

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

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

```ruby:config/routes.rb
patch 'parent/:id/sort', to: 'parent#sort'
```

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

```erb: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`イベントで`oldIndex`と`newIndex`を取得できるようになっています。それぞれ「ドラッグ前の位置」と「ドラッグ後の位置」です。これを取得してバックエンドにpatchします。

```coffee: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フィールドに格納している`Parent`の`id`を取得して`url`を指定してます。`type`は`routes.rb`で定義したものに合わせて、`data`にはバックエンドに連携する情報を記載してます。

以上で完成！！

# Conclusion
割とシンプルにドラッグ＆ドロップでの並び替えができたと思います。しかもモバイル対応！
ただし、`acts_as_list`の仕様上、並び替えの度にけっこうなDB更新がかかっていそう（from〜toの間の全てのレコードを更新しているっぽい）。リストが大きくなることが想定されている場合は順番をつける方式は検討した方が良いかも。`ranked_model`の方がいいのかな？

# Reference
- [GitHub - swanandp/acts_as_list](https://github.com/swanandp/acts_as_list)
- [GitHub - SortableJS/Sortable](https://github.com/SortableJS/Sortable)
- [Rails 4で作るドラッグアンドドロップで表示順を変更できるサンプルアプリ(スクリーンキャスト付き) - Qiita](https://qiita.com/jnchito/items/391fb16d3f69fda9bdae)
- [jQuery.ajaxでRailsのコントローラを叩く | 初心者向け | DoRuby](https://doruby.jp/users/kisuzuki/entries/jQuery-ajax%E3%81%A7Rails%E3%81%AE%E3%82%B3%E3%83%B3%E3%83%88%E3%83%AD%E3%83%BC%E3%83%A9%E3%82%92%E5%8F%A9%E3%81%8F-1)
- [The ":nothing" option is deprecated and will be removed in Rails 5.1 - Stack Overflow](https://stackoverflow.com/questions/34688726/the-nothing-option-is-deprecated-and-will-be-removed-in-rails-5-1)