ツリー構造をマウスで並べ替える。
会社には組織がつきもので、組織はOSのファイルシステムのような階層構造を持っている。そして、会社組織というものは、しょっちゅう変更される運命だ。この変更に迅速、柔軟に対応するために、OSのファイルシステムのように、データベース上のツリー構造をマウスで自在に操作できるようにしたい。Railsにはツリー構造を扱うための仕組み、acts_as_treeがある。これと、前回使ったsortable_elementを組み合わせて、マウスによるツリー構造の操作に対応してみた。
acts_as_treeの組込み
- parent_idフィールドの追加。(前回のマイグレーションファイルにオレンジ色の1行を追加した。)
# マイグレーション: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 t.column :parent_id, :integer end end def self.down drop_table :locks end end
- モデルで、acts_as_treeを宣言。(メソッド呼出し)
# モデル:app/models/lock.rb class Lock < ActiveRecord::Base acts_as_tree :order=>'position' end
- これで、以下のような組織を表現するには...
株式会社RAILS |--営業1部 |--販売1課 |--販売2課
- id、name、parent_idの関係は以下のようになる。
id | name | parent_id |
---|---|---|
1 | 株式会社RAILS | NULL |
2 | 営業1部 | 1 |
3 | 販売1課 | 2 |
4 | 販売2課 | 2 |
ツリー構造を表示してみる
- ツリー構造を表示するために、以下のコントローラー、ビュー、ヘルパーを準備した。
-
-
- 右ページがとても参考になりました。感謝です!:DZone Snippets - acts_as_tree unordered list printout
-
# コントローラー:app/controllers/locks_controller.rb class LocksController < ApplicationController def tree @locks = Lock.find(:all, :conditions=>["parent_id IS NULL"], :order=>:position) end
<%# ビュー:app/views/locks/tree.rhtml %> <%= list_trees(@locks) %>
# ヘルパー:app/helpers/application_helper.rb module ApplicationHelper def list_trees(items) if items.size > 0 html = "<ul>\n" items.each do |item| html << "<li>" html << "#{h(item.name)}\n" if item.children.size > 0 html << list_trees(item.children) end html << "</li>\n" end html << "</ul>\n" end end end
- 上記を実行すると、以下のようなhtmlコードが得られる。(見やすいように、インデントを追加した。)
<ul> <li>株式会社RAILS <ul> <li>営業1部 <ul> <li>販売1課 </li> <li>販売2課 </li> </ul> </li> </ul> </li> </ul>
- ツリー構造は、すべて展開された状態で表示される。
- ツリーを表示するだけで、まだマウスによるドラッグ操作は出来ない。
マウスによる並べ替えに対応してみる
- ヘルパメソッドsortable_elementは、ツリー構造の操作にも対応している。オプション設定に:tree => trueを指定するだけでOK。
<%# ビュー:app/views/locks/tree.rhtml %> <%= list_trees(@posts) %> <%= sortable_element 'tree_', :update=>'info', :url=>{:action=>'sort_update'}, :tree=>true %> <div id="info"></div>
- それぞれのul、liタグにid属性を設定した。
-
-
- ulに対するid属性は、本来は第一階層のid="tree_"だけで十分。最初、:tree => trueオプションの存在を知らずに試行錯誤していたため、このようなコードになった。(結局treeオプション無しではうまく動かなかったが...。)その後、treeオプションの存在を知った後も、余分なid属性が設定してあっても正常に動いたので、そのまま残した状態にしてある。
-
# ヘルパー:app/helpers/application_helper.rb module ApplicationHelper def list_trees(items) if items.size > 0 tree_id = "tree_#{items.first.parent_id}" html = "<ul id='#{tree_id}'>\n" items.each do |item| html << "<li id='item_#{item.id}'>" html << "#{h(item.name)}\n" if item.children.size > 0 html << list_trees(item.children) end html << "</li>\n" end html << "</ul>\n" end end end
- これでマウスによる並べ替えが出来るようになったが、ここで問題が発生。販売1課の「子」として販売2課を移動する方法が分からなくて悩んでしまった...。苦し紛れの対応で、すべてのリストに「子」として空のリスト<li></li>を追加してしまった。以下のようにやってみた。
# ヘルパー:app/helpers/application_helper.rb module ApplicationHelper def list_trees(items) if items.size > 0 tree_id = "tree_#{items.first.parent_id}" html = "<ul id='#{tree_id}'>\n<li></li>\n" items.each do |item| html << "<li id='item_#{item.id}'>" html << "#{h(item.name)}\n" if item.children.size > 0 html << list_trees(item.children) else html << "<ul id='tree_#{item.id}'>\n<li></li>\n</ul>\n" end html << "</li>\n" end html << "</ul>\n" end end end
- これで、ツリー構造の自由な階層、自由な位置にマウスで移動できるようになったが、表示結果を見ると、リストの間隔が無駄に広がってしまう感じ。移動中に子要素を追加する、もうちょっとスマートな解決方法があると思うが、ちょっと調べきれなかった...。しょうがなく、あらかじめ空リストを追加する方式で妥協してしまった。
-
-
- どこかに参考になるサンプルコードは無いだろうか...。
-
パラメーターを受け取って、並べ替えた状態を保存する
- :tree => trueのオプションを設定すると、パラメーターは配列でなく、ハッシュとして渡される。例えば、販売1課と販売2課の順番を入れ替えた場合は、以下のようなパラメーターを受け取る。
{"tree_"=>{"0"=>{"0"=>{"0"=>{"id"=>"4"}, "id"=>"2", "1"=>{"id"=>"3"}}, "id"=>"1"}}, "controller"=>"posts", "action"=>"sort_update"}
- params[:tree_]だけ取り出して整形してみると、以下のようになる。
- ポジションN => {ポジションNに対応するid情報と、中身のポジションリスト}
{"0"=>{"id"=>"1", "0"=>{"id"=>"2", "0"=>{"id"=>"4"}, "1"=>{"id"=>"3"} } } }
- 並び替え後のパラメーターを受け取るアクション、sort_updateでは以下のようにしてみた。
# コントローラー:app/controllers/locks_controller.rb class LocksController < ApplicationController def sort_update render :text => params.inspect save_tree(params[:tree_], nil) # 本来は再描画の必要がなければ、以下のrender :nothing => trueでOK。 # ここではパラメータの内容を確認したいので、render :textで描画してみた。 # render :nothing => true end def save_tree(tree, parent) tree.each do |order, hash| id = hash.delete(:id) item = Lock.find(id) item.update_attributes(:position=>order, :parent_id=>parent) save_tree(hash, id) unless hash.empty? end end
- これでツリー構造をデーターベースに保存できるようになった。
子要素の表示/非表示に対応してみる
- 常にすべて展開された状態のツリーだと、組織が大きくなると全体が1ページに収まりきらなくなり、扱い難い。
- 最初はparent_idがNULLのルート以外は非表示の状態にして、
- リスト先頭のマーク≫、∨をクリックすると、子要素の表示/非表示を切り替えられるようにしてみた。
# ヘルパー:app/helpers/application_helper.rb module ApplicationHelper def list_trees(items) if items.size > 0 tree_id = "tree_#{items.first.parent_id}" style_tree = tree_id == 'tree_' ? 'display:show' : 'display:none' html = "<ul id='#{tree_id}' style='#{style_tree}'>\n<li></li>\n" items.each do |item| html << "<li id='item_#{item.id}'>" if item.children.size > 0 style_show = 'display:none' else style_hide = 'display:none' end html << link_to_function(" ∨ ", "Element.toggle('tree_#{item.id}'); Element.toggle('show_#{item.id}'); Element.toggle('hide_#{item.id}')", :class=>"toggle_show", :id=>"show_#{item.id}", :style=>style_show) html << link_to_function(" ≫ ", "Element.toggle('tree_#{item.id}'); Element.toggle('show_#{item.id}'); Element.toggle('hide_#{item.id}')", :class=>"toggle_hide", :id=>"hide_#{item.id}", :style=>style_hide) html << "#{h(item.name)}\n" if item.children.size > 0 html << list_trees(item.children) else html << "<ul id='tree_#{item.id}'>\n<li></li>\n</ul>\n" end html << "</li>\n" end html << "</ul>\n" end end end
子追加、削除のリンクも設定して、スタイルシートも設定する。
- 試行錯誤の結果、以下のようなコードに落ち着いた。
<%# ビュー:app/views/locks/tree.rhtml %> <%= list_trees(@posts) %> <%= sortable_element 'tree_', :update=>'info', :url=>{:action=>'sort_update'}, #:constraint=>false, :dropOnEmpty=>true, :hoverclass=>"'Doo'", :ghosting=>true, :handle=>"handle", :tree=>true, :complete=>visual_effect(:highlight, 'tree_') %> <div id="info"></div>
# ヘルパー:app/helpers/application_helper.rb module ApplicationHelper def list_trees(items) if items.size > 0 # ルート以外は非表示の設定で描画する tree_id = "tree_#{items.first.parent_id}" style_tree = tree_id == 'tree_' ? 'display:show' : 'display:none' html = "<ul id='#{tree_id}' style='#{style_tree}'>\n<li></li>\n" items.each do |item| html << "<li id='item_#{item.id}'>" # 折畳み、展開マーク ≫ ∨ の描画 if item.children.size > 0 style_show = 'display:none' else style_hide = 'display:none' end html << link_to_function(" ∨ ", "Element.toggle('tree_#{item.id}'); Element.toggle('show_#{item.id}'); Element.toggle('hide_#{item.id}')", :class=>"toggle_show", :id=>"show_#{item.id}", :style=>style_show) html << link_to_function(" ≫ ", "Element.toggle('tree_#{item.id}'); Element.toggle('show_#{item.id}'); Element.toggle('hide_#{item.id}')", :class=>"toggle_hide", :id=>"hide_#{item.id}", :style=>style_hide) # リスト名称を描画、この部分をドラッグする時にマウスで握る部分として設定 html << " <span class='handle'>#{h(item.name)}</span>" # 子追加、削除の操作リンクを設定 html << " " html << link_to("子追加", {:action=>'new', :id=>item}, :class=>'action') html << " " html << link_to_remote("削除", {:url=>{:action=>'destroy_remote', :id=>item}, :confirm=>'Are you sure?'}, :post=>true, :class=>'action') # 子の存在を確認して、存在していたらlist_treesを再帰呼出し if item.children.size > 0 html << list_trees(item.children) else html << "<ul id='tree_#{item.id}'>\n<li></li>\n</ul>\n" end html << "</li>\n" end html << "</ul>\n" end end end
# コントローラー:app/controllers/locks_controller.rb class LocksController < ApplicationController def tree @locks = Lock.find(:all, :conditions=>["parent_id IS NULL"], :order=>:position) end def sort_update render :text => params.inspect save_tree(params[:tree_], nil) # 本来は再描画の必要がなければ、以下のrender :nothing => trueでOK。 # ここではパラメータの内容を確認したいので、render :textで描画してみた。 # render :nothing => true end def save_tree(tree, parent) tree.each do |order, hash| id = hash.delete(:id) item = Lock.find(id) item.update_attributes(:position=>order, :parent_id=>parent) save_tree(hash, id) unless hash.empty? end end def new @lock = Lock.new(:parent_id=>params[:id]) end def create @lock = Lock.new(params[:lock]) if @lock.save flash[:notice] = 'Lock was successfully created.' redirect_to :action => 'tree' else render :action => 'new' end end def destroy_remote Post.find(params[:id]).destroy render :update do |page| page.visual_effect(:highlight, "item_#{params[:id]}", :duration=>0.6) page.delay(0.8) do page.visual_effect(:drop_out, "item_#{params[:id]}") end end end
/* スタイルシート:public/stylesheets/tree.css */ div.tree { width: 50%; } ul { list-style-type: none; list-style-position: inside; padding: 0px; margin: 0 0 0 2em; } li { text-decoration: underline; padding: 0 0px 6px 0px; margin: 0; } a.toggle_show { text-decoration: none; font-size: 66%; color: #000; background-color: #ccc; } a.toggle_hide { text-decoration: none; font-size: 66%; color: #000; background-color: #ccc; } a.action { text-decoration: none; font-size: 66%; color: #000; background-color: #ccc; } .handle { text-decoration: none; font-size: 100%; color: #eee; background-color: #444; cursor: move; } .handle:hover, a.toggle_show:hover, a.toggle_hide:hover, a.action:hover { color: #fff; background-color: #000; } li.Doo>ul { } li.Doo a { background-color: #888; } li.Doo li a { background:none; }
忘れがちなこと
- scriptaculous-js-1.7.0を利用
- レイアウトファイルに以下の宣言を追加
<%= javascript_include_tag :defaults %> <%= stylesheet_link_tag 'tree' %>