マウスで握って並べ替える。

リストの順番を自由に並べ替えたい。こんな時、Railsにはヘルパメソッドsortable_elementがある。このメソッドを1行追加するだけで、マウス操作でドラッグして並べ替えが可能になる。但しいくつか注意することがある。それを知らないために、思うように動かず遠回りしてしまったので、忘れないように自分用のメモ。まずは以下のように使ってみた。

利用環境

必要最小限の基本動作を確認

<%# view %>

<%= javascript_include_tag :defaults %>

<ul id='sort'>
  <li id='item_1'>item_1</li>
  <li id='item_2'>item_2</li>
  <li id='item_3'>item_3</li>
</ul>

<%= sortable_element 'sort' %>
  • 素晴らしいシンプルさ。たったこれだけでマウスのドラッグで並べ替えが出来るようになる。
  • 但し、以下のように書いてしまうと、並べ替えが機能しない。id属性で指定される並び替えの範囲は、sortable_elementより手前で描画しておく必要がある。sortable_elementとの位置関係重要!
<%# view %>

<%= javascript_include_tag :defaults %>
<%= sortable_element 'sort' %>

<%# この順序ではマウスでドラッグ出来ない。 %>

<ul id='sort'>
  <li id='item_1'>item_1</li>
  <li id='item_2'>item_2</li>
  <li id='item_3'>item_3</li>
</ul>
  • デフォルトでは、liタグの内容をドラッグできるように設定されている。
    • :tag => 'div'のようにオプション設定すれば、ドラッグ可能なタグをdivタグに変更できる。
    • しかし:tag => 'tr'のように指定しても、テーブル内の行をドラッグすることは出来なかった。
      • Firefox2では辛うじてドラッグできたが、自分の環境では表示が乱れたりと動きが不完全。
      • Safari2では全くドラッグできない。
      • IE6はドラッグできるが、ドラッグ中のアイテムが表示されない...。
  • 不完全ではあるが、テーブルの行をドラッグする場合は、tbodyタグを利用してドラッグ可能範囲を指定する必要がある。以下のような書式。
<%# view %>

<%= javascript_include_tag :defaults %>

<table>
<tbody id='sort'>
  <tr id='item_1'><td>item_1</td></tr>
  <tr id='item_2'><td>item_2</td></tr>
  <tr id='item_3'><td>item_3</td></tr>
</tbody>
</table>

<%= sortable_element 'sort' %>

パラメーターの確認

  • このままではwebページ上での並び替えしか実現できていない。並び替え後の順序をデータベースに渡す必要がある。まずは、どのようなパラメータを受け取るのか、以下のように実験してみた。
<%# view %>

<%= javascript_include_tag :defaults %>

<ul id='sort'>
  <li id='item_1'>item_1</li>
  <li id='item_2'>item_2</li>
  <li id='item_3'>item_3</li>
  <li id='item4'>item4</li>
  <li id='item_test'>item_test</li>
  <li id='item_test_6'>item_test_6</li>
</ul>

<%= sortable_element 'sort', :update=>'info', :url=>{:action=>'sort_update'} %>

<div id="info"></div>
# controller

class LocksController < ApplicationController
  def sort_update
    render :text => params.inspect
  end
end
  • 上記を実行して、item_1を一つ下に移動すると、以下のようなパラメーターを受け取る。
{"sort"=>["2", "1", "3", "test", "6"], "action"=>"sort_update", "controller"=>"locks"}
  • つまり、コントローラーでparams[:sort]とすれば、並び替え後のid属性の順序を、配列として取得できる。
  • 但し、id属性の書式には、以下のような規約がある。
    • id="item_1"、id="item_2"のように、_アンダーバーで区切った表現が必要。この場合、パラメータとして["1", "2"]のように、_アンダーバー以下を配列で取得できることになる。
    • もし、id="item4"のように、_アンダーバーが含まれていないと、パラメータも取得できない。
    • id="item_test"、id="item_test_6"の場合は、["test", "6"]のような配列が取得できる。
  • 複数のリスト範囲の間を移動することも可能。その場合、:containmentオプションで、移動可能なリスト範囲のid属性を['sort1','sort2']のように配列で渡す。
  • 調子に乗って、二つのリスト範囲の間でドラッグを繰り返していると、たまたま、片方のリストの中身が空っぽになってしまった。最初、空っぽになってしまったリスト範囲の中にはアイテムをドラッグしても無反応で困ってしまった。そんな場合は:dropOnEmpty => trueを設定しておけば、ulタグの内容が空の状態でも、liタグをドラッグするとそれに反応してドロップが可能になる。
<ul id='sort1'>
  <li id='item_1'>item_1</li>
  <li id='item_2'>item_2</li>
  <li id='item_3'>item_3</li>
</ul>

<ul id='sort2'>
  <li id='item_4'>item_4</li>
  <li id='item_5'>item_5</li>
  <li id='item_6'>item_6</li>
</ul>

<%= sortable_element 'sort1', 
                     :update=>'info1', 
                     :url=>{:action=>'sort_update'}, 
                     :containment=>['sort1','sort2'], 
                     :dropOnEmpty=>true %>
<%= sortable_element 'sort2', 
                     :update=>'info2', 
                     :url=>{:action=>'sort_update'}, 
                     :containment=>['sort1','sort2'], 
                     :dropOnEmpty=>true %>

<div id="info1"></div>
<div id="info2"></div>
  • 上記の場合、パラメータは以下のように渡される。ajax更新が2箇所分、発生するようだ。(item_1をsort2のリストに移動した場合)
{"sort1"=>["2", "3"], "action"=>"sort_update", "controller"=>"locks"}
{"sort2"=>["4", "5", "1", "6"], "action"=>"sort_update", "controller"=>"locks"}

データベースとの連動

以上のことを踏まえて、データベースと連動して並び替え可能なリストを以下のように書いてみた。

  • 並べ替えた順序を保持するための:positionフィルードを追加した。(acts_as_listを利用しないのであれば、フィールド名は自由。)
# マイグレーション:db/migrate/001_create_locks.rb

class CreateLocks < ActiveRecord::Migration
  def self.up
    create_table :locks do |t|
      t.column :name, :string
      t.column :lock_version, :integer, :default=>0
      t.column :position, :integer
    end
  end

  def self.down
    drop_table :locks
  end
end
  • コントローラー名がlocksなのは、前回の「楽観的ロック」のテストコードに続けて書いているため。深い意味は無い...。
# コントローラー:app/controllers/locks_controller.rb

class LocksController < ApplicationController
  def list
    @lock_pages, @locks = paginate :locks, :per_page => 10, :order => :position
  end

  def sort_update
    params[:sort].each_with_index do |id, i|
      list = Lock.find(id)
      list.update_attributes(:position => i)
    end
    render :test => params.inspect
    # 実験として、パラメータの内容を描画しているが、
    # 描画の必要が無ければ、以下のように書いておく。
    # render :nothing => true
  end
end
<%# ビュー: app/views/locks/list.rhtml %>

<ul id="sort">
  <% for lock in @locks %>
  <li id="item_<%= lock.id %>">
    <%=h lock.id %>
    <%=h lock.name %>
    <%=h lock.position %>
  </li>
  <% end %>
</ul>

<%= sortable_element 'sort', :update=>'info', :url=>{:action=>'sort_update'} %>

<div id="info"></div>
  • <%= javascript_include_tag :defaults %>を忘れずに。
<%# レイアウト:app/views/layouts/locks.rhtml %>

<html>
<head>
  <title>Locks: <%= controller.action_name %></title>
  <%= stylesheet_link_tag 'scaffold' %>
  <%= javascript_include_tag :defaults %>
</head>
<body>

<p style="color: green"><%= flash[:notice] %></p>

<%= yield  %>

</body>
</html>


以上で、マウスでドラッグして並べ替えが自由にできるようになった。ページを更新しても、順序はちゃんと保持されている。