Rails 4で作るドラッグアンドドロップで表示順を変更できるサンプルアプリ(スクリーンキャスト付き)


# はじめに

データの並び順を自由自在に入れ替えたい、という要求があったとき、ドラッグアンドドロップで順番が変えられるとなかなか便利です。

そこで本記事ではドラッグアンドドロップによる表示順変更機能をRails 4で実装する手順を説明します。

# 補足資料

理解しやすくするための補足資料をいろいろ用意してみました。

## デモサイト

Herokuにデモサイトを作ってみました。
元々のサンプルアプリケーションはCRUDができるようになっていますが、このデモサイトではデータの更新はできません。(変更できるのは順番のみ)

ドラッグアンドドロップするとどうなるか、実際に動かしてみてください。

http://sortable-table-sandbox.herokuapp.com/

![sample-app.gif](https://qiita-image-store.s3.amazonaws.com/0/7465/cc9fe568-5427-7715-8093-5b3eaf8ad12a.gif "sample-app.gif")


## スクリーンキャスト

サンプルアプリケーションを作る過程を録画してみました。
フルスクラッチでrails newするところから始めています。

http://www.youtube.com/watch?v=YQ-6HkBhVyc&feature=youtu.be
<a href="http://www.youtube.com/watch?v=YQ-6HkBhVyc&feature=youtu.be" target="_blank">
![Screen Shot 2014-07-31 at 7.57.58.png](https://qiita-image-store.s3.amazonaws.com/0/7465/7507c678-30f0-5a77-e76c-eab52de5783c.png "Screen Shot 2014-07-31 at 7.57.58.png")
</a>


トータルで35分ぐらいあるのでソート機能を実装するところから見たい人は、9分40秒あたりから再生してください。

https://www.youtube.com/watch?v=YQ-6HkBhVyc&feature=youtu.be&t=9m40s

あと、画質も上げた方が字が潰れなくて見やすいはずです。

## ソースコード

サンプルアプリケーションのソースコードはGitHubにアップしています。
こちらも参考にしてみてください。

https://github.com/JunichiIto/sortable-table-sandbox/tree/1st-implementation

## 参考文献

本記事はこちらのweb記事をアレンジして作成しました。

http://benw.me/posts/sortable-bootstrap-tables/

# サンプルアプリケーションの仕様

Scaffoldで作った果物管理アプリを使います。
果物管理アプリといってもなんてことはない、単に果物の名前を入力できる単純なアプリケーションです。

ちなみに、表示順変更機能を実装する前のテーブルのマークアップはこんな感じになっています。(viewはSlimを使っています)

fruits/index.html.slim

````slim
table.table.table-bordered
  thead
    tr
      th Name
      th
      th
      th
  tbody
    - @fruits.each do |fruit|
      tr
        td = fruit.name
        td = link_to 'Show', fruit
        td = link_to 'Edit', edit_fruit_path(fruit)
        td = link_to 'Destroy', fruit, data: {:confirm => 'Are you sure?'}, :method => :delete
````


# ドラッグアンドドロップで表示順を変更できる機能を実装する

ここから実装の手順を説明します。

## 1. ranked-model gemで順番を管理できるようにする

Railsで順番を管理するgemとして、[ranked-model](https://github.com/mixonic/ranked-model) gemを使います。
[acts_as_list](https://github.com/swanandp/acts_as_list)も有名ですが、ranked-modelの方がパフォーマンス的に優れています。

以下の手順でranked-modelをインストールしてください。

Gemfile

````diff
+gem 'ranked-model'
````

````bash
$ bundle install
````

Fruitモデルの並び順をranked-modelで管理できるようにします。

fruit.rb

````diff
 class Fruit < ActiveRecord::Base
+  include RankedModel
+  ranks :row_order
 end
````

````bash
$ rails g migration AddRowOrderToFruits row_order:integer
$ rake db:migrate
````

fruits_controller.rb

````diff
  def index
-  @fruits = Fruit.all
+  @fruits = Fruit.rank(:row_order)
 end
````

上のコードのように、row_order順に並び替える場合は`rank`メソッドを使います。

## 2. jQuery UIをインストールする

ドラッグアンドドロップを実現するためにjQuery UIを使います。
jQuery UIのインストールには jquery-ui-rails gemを使うと手軽です。

Gemfile

````diff
+gem 'jquery-ui-rails'
````

````bash
$ bundle install
````

application.js

````diff
+//= require jquery.ui.sortable
````

注：バージョンによって参照パスが異なります

* jquery-ui-rails 5.x  `//= require jquery-ui/sortable`（@md1961@githubさんのコメントを参照）
* jquery-ui-rails 6.x  `//= require jquery-ui/widgets/sortable`（[Docs: Mention how the widget files moved in 6.0.0](https://github.com/jquery-ui-rails/jquery-ui-rails/pull/118/files)）

## 3. sort アクションを用意する

Ajaxでデータを更新できるように、sort アクションを用意します。

routes.rb

````diff
 Rails.application.routes.draw do
-  resources :fruits
+  resources :fruits do
+    put :sort
+  end
 
   root to: 'fruits#index'
 end
````

fruits_controller.rb

````diff
+# this action will be called via ajax
+def sort
+  fruit = Fruit.find(params[:fruit_id])
+  fruit.update(fruit_params)
+  render nothing: true
+end

 private

   # Never trust parameters from the scary internet, only allow the white list through.
   def fruit_params
-    params.require(:fruit).permit(:name)
+    params.require(:fruit).permit(:name, :row_order_position)
   end
````

sort アクションではリクエストされたレコードの row_order を更新します。
ただし、実際には row_order ではなく、row_order_position という属性を更新している点に注意してください。(ranked-model gemの仕様です)

## 4. JavaScriptでAjaxリクエストを送信する

次に、JavaScriptでAjaxリクエストを送信できるようにします。
ここでは table_sort.js.coffee という新しいファイルを作成しました。

table_sort.js.coffee

````diff
+$ ->
+  $('.table-sortable').sortable
+    axis: 'y'
+    items: '.item'
+
+    update: (e, ui) ->
+      item = ui.item
+      item_data = item.data()
+      params = { _method: 'put' }
+      params[item_data.modelName] = { row_order_position: item.index() }
+      $.ajax
+        type: 'POST'
+        url: item_data.updateUrl
+        dataType: 'json'
+        data: params
````

View側もJSにあわせてdata属性やCSSクラスを追加します。

fruits/index.html.slim

````diff
 h1 Listing fruits
 
-table.table.table-bordered
+table.table.table-bordered.table-sortable
   thead
     tr
       th Name
       th
       th
       th
   tbody
     - @fruits.each do |fruit|
-      tr
+      tr.item(data = { model_name: fruit.class.name.underscore, update_url: fruit_sort_path(fruit) })
         td = fruit.name
         td = link_to 'Show', fruit
         td = link_to 'Edit', edit_fruit_path(fruit)

````

## 5. 動作確認する

これでドラッグアンドドロップ操作が行えるようになっているはずです。
画面を動かしてみてください。

問題なく動いていれば画面をリロードしてもドラッグアンドドロップしたときの順番が保持されているはずです。

うまく動かないときはログを開いてエラーが出ていないか確認してください。
正常に動いていればこんな感じのログが出ているはずです。

development.log

````log
Started PUT "/fruits/4/sort" for 127.0.0.1 at 2014-07-31 06:33:27 +0900
Processing by FruitsController#sort as JSON
  Parameters: {"fruit"=>{"row_order_position"=>"1"}, "fruit_id"=>"4"}
  Fruit Load (0.1ms)  SELECT  "fruits".* FROM "fruits"  WHERE "fruits"."id" = ? LIMIT 1  [["id", 4]]
   (0.1ms)  begin transaction
  Fruit Load (1.1ms)  SELECT  "fruits"."id", "fruits"."row_order" FROM "fruits"  WHERE ("fruits"."id" != 4)  ORDER BY "fruits"."row_order" ASC LIMIT 2 OFFSET 0
  Fruit Load (0.1ms)  SELECT  "fruits"."id", "fruits"."row_order" FROM "fruits"  WHERE ("fruits"."id" != 4) AND "fruits"."row_order" = 1376257  ORDER BY "fruits"."id" ASC LIMIT 1
  SQL (1.2ms)  UPDATE "fruits" SET "row_order" = ?, "updated_at" = ? WHERE "fruits"."id" = 4  [["row_order", 1376257], ["updated_at", "2014-07-30 21:33:27.996058"]]
   (0.7ms)  commit transaction
  Rendered text template (0.0ms)
Completed 200 OK in 23ms (Views: 1.9ms | ActiveRecord: 3.4ms)
````

## 6. Viewをお化粧する

機能的にはこれでもOKなのですが、少しだけ見栄えを良くしてみましょう。
ここは好みなので、好きなように変更してもOKですし、何もしなくても構いません。

### カーソルのタイプを変更する

テーブルの上にカーソルが当たったときにカーソルのタイプを変えて、ソートできることをアピールします。

![Screen Shot 2014-07-31 at 8.11.18.png](https://qiita-image-store.s3.amazonaws.com/0/7465/802e4fdd-1319-eee5-f321-f58d19e107c9.png "Screen Shot 2014-07-31 at 8.11.18.png")


ここでは table_sort.css.scss というファイルを追加しました。

table_sort.css.scss

````diff
+.table-sortable {
+  tr.item {
+    cursor: row-resize;
+  }
+}
````

### ドロップ時にハイライトさせる

ドロップしたときに行全体を黄色くハイライトさせます。

![highlight2.gif](https://qiita-image-store.s3.amazonaws.com/0/7465/d210e951-383c-a6b7-0ca7-cb0cf0c56979.gif "highlight2.gif")


application.js

````diff
+//= require jquery.ui.effect-highlight
````

table_sort.js.coffee

````diff
         url: item_data.updateUrl
         dataType: 'json'
         data: params
+    stop: (e, ui) ->
+      ui.item.children('td').effect('highlight')

````

### ドラッグ中の幅をテーブルに合わせる

そのままだとドラッグ中の行の幅が縮んでしまうので、テーブルの幅に合わせます。

table_sort.js.coffee

````diff
         url: item_data.updateUrl
         dataType: 'json'
         data: params
+    start: (e, ui) ->
+      tableWidth = $(this).width()
+      cells = ui.item.children('td')
+      widthForEachCell = tableWidth / cells.length + 'px'
+      cells.css('width', widthForEachCell)
     stop: (e, ui) ->
       ui.item.children('td').effect('highlight')
````

## 7. Turbolinks特有の問題を回避する

Rails 4で新規にアプリケーションを作ると、デフォルトでTurbolinksが有効になっています。
そのため、他の画面(showやedit)に進んでからindex画面に戻ってくるとドラッグアンドドロップができなくなります。(table_sort.jsが呼び出されない)

この問題を回避するために、jquery-turbolinks gemを導入します。

Gemfile

````diff
+gem 'jquery-turbolinks'
````

````bash
$ bundle install
````

application.jsでturbolinksを一番最後にrequireするのがポイントです。

application.js

````diff
 //= require jquery
+//= require jquery.turbolinks
 //= require jquery_ujs
 //= require jquery.ui.sortable
 //= require jquery.ui.effect-highlight
-//= require turbolinks
 //= require bootstrap
 //= require_tree .
+//= require turbolinks
````

# 補足: 既存レコードがある場合

既存レコードがある場合は何らかのルールでrow_orderカラムを更新しておいた方が良いと思います。

以下はid順にrow_orderカラムを更新する際のサンプルスクリプトです。

````ruby
ActiveRecord::Base.record_timestamps = false
Fruit.transaction do
  Fruit.order(id: :desc).each do |fruit|
    fruit.update_attribute :row_order_position, 0
  end
end
ActiveRecord::Base.record_timestamps = true
````


# まとめ

実現するまでに若干のステップを踏まなくてはなりませんが、gemやjQueryをうまく組み合わせると意外と簡単に実現できます。
同じようなUIを作成する必要が出てきたら、ぜひ参考にしてみてください！

## 2014.08.02追記: テストコードを書く方法も公開しました！

RSpecを使ってこのドラッグアンドドロップ機能をテストする方法を説明してみました。
こちらもスクリーンキャスト付きです。RSpecのセットアップ方法から丁寧に説明しています。

良かったらこちらも読んでみてください。

[初心者大歓迎！RSpec 3でドラッグアンドドロップ機能をテストする方法(スクリーンキャスト付き)](http://qiita.com/jnchito/items/bdd80c1085fa082e7d7a)