ツリー構造をマウスで並べ替える。

会社には組織がつきもので、組織は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

ツリー構造を表示してみる

  • ツリー構造を表示するために、以下のコントローラー、ビュー、ヘルパーを準備した。
# コントローラー: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>
  • それぞれのulliタグに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("&nbsp;∨&nbsp;", 
                  "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("&nbsp;≫&nbsp;", 
                  "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("&nbsp;∨&nbsp;", 
                  "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("&nbsp;≫&nbsp;", 
                  "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 << "&nbsp;<span class='handle'>#{h(item.name)}</span>"
        
        # 子追加、削除の操作リンクを設定
        html << "&nbsp;&nbsp;"
        html << link_to("子追加", {:action=>'new', :id=>item}, :class=>'action')
        html << "&nbsp;&nbsp;"
        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' %>

現在の見た目はこんな感じで、ツリーの追加、削除、階層を含めた順序の移動、子要素の表示/非表示が可能になった!