sortableでin_placeなツリー操作まとめ

最近悩んでいたツリー操作のサンプル。自分用のメモ。ツリー構造の展開には、render(:partial)を利用するように変更してみた。(以前はヘルパメソッドを定義して展開していた。erbの方がツリー全体のイメージが理解し易いかも。)

環境

コマンドとコード

# ---------- ターミナルでコマンド操作 ----------

rails _2.1.2_ test_tree
cd test_tree
script/generate scaffold tree name:string position:integer parent_id:integer
script/plugin install acts_as_tree
script/plugin install http://super-inplace-controls.googlecode.com/svn/trunk/super_inplace_controls
  • acts_as_treeを有効にする。
# ---------- app/models/tree.rb ----------

class Tree < ActiveRecord::Base
  acts_as_tree :order=>'position'
end
  • マイグレーションの中でTree.children.createして、サンプルレコードを追加している。
  • Tree.childrenメソッドを利用するため、先に上記Treeモデルでacts_as_treeを追記しておかないとエラーになる。
# ---------- db/migrate/20090507025853_create_trees.rb ----------

class CreateTrees < ActiveRecord::Migration
  def self.up
    create_table :trees do |t|
      t.string :name
      t.integer :position
      t.integer :parent_id

      t.timestamps
    end

    # root
    #   |--child
    #         |--sub_child
    #         |--sub_child
    root        = Tree.create(:name => 'root')
    child_1     = root.children.create(:name => 'child_1')
    sub_child_1 = child_1.children.create(:name => 'sub_child_1')
    sub_child_2 = child_1.children.create(:name => 'sub_child_2')
  end

  def self.down
    drop_table :trees
  end
end
  • DB作成とサーバー起動。
# ---------- ターミナルでコマンド操作 ----------

rake db:migrate
script/server
  • ルートの設定。:insert, :delete, :sortメソッドを追加した。
# ---------- config/routes.rb ----------

ActionController::Routing::Routes.draw do |map|
  map.resources :trees, :member => [:insert, :delete, :sort]
...(中略)...
  map.connect ':controller/:action/:id'
  map.connect ':controller/:action/:id.:format'
end
  • Tree.find(:all) は、 Tree.allとシンプルに書けるようだ。
  • insert、delete、sortアクションを定義。
# ---------- app/controllers/trees_controller.rb ----------

class TreesController < ApplicationController
  in_place_edit_for :tree, :name, :highlight_endcolor => "#444444"

  # GET /trees
  # GET /trees.xml
  def index
    @trees = Tree.all :conditions=>{:parent_id=>nil}, :order=>:position
    
    respond_to do |format|
      format.html # index.html.erb
      format.xml  { render :xml => @trees }
    end
  end
...(中略)...
  def insert
    @tree = Tree.find(params[:id]).children.create(:name=>'untitled')
    
    if @tree
      render :update do |page|
        page.insert_html(:top, dom_id(@tree.parent, :parent), render(:partial=>@tree))
        page.visual_effect(:highlight, dom_id(@tree), :duration=>0.6)
        page.replace_html(:info, "Tree was successfully created.")
        # 新規追加したアイテムをドラッグ可能にするため
        page.replace_html('sortable_script', sortable_element_tree)
      end
    else
      render :update do |page|
        page.replace_html(:info, "Tree failed to insert child.")
      end
    end
  end

  def delete
    @tree = Tree.find(params[:id]).destroy
    
    render :update do |page|
      page.visual_effect(:highlight, dom_id(@tree), :duration=>0.6)
      page.delay(0.8) do
        page.visual_effect(:drop_out, dom_id(@tree))
      end
      page.delay(1.6) do
        page.remove(dom_id(@tree))
        page.replace_html(:info, "Tree was successfully deleted.")
      end
    end
  end

  def sort
    # 再描画の必要がなければ、render :nothing => true
    # paramsの内容を確認したいため、render :text => params.inspect
    # sortable_elementが送信するparamsの内訳を正確に表示するには、renderを最初に実行する必要あり
    render :text => params.inspect
    save_tree(params[:tree_], nil)
  end

private
  # ツリー構造("tree_" => )は、以下のハッシュが再帰的に繰り返されて、表現される
  #   {ポジション番号=>{id=>番号, 子ポジション番号=>{id=>番号, 孫ポジション番号=>{id=>番号, ...} } } }
  #   子を持たない世代(ポジション番号=>{id=>番号})まで繰り返される
  # 例:
  #   root(1)
  #     |--child(2)
  #          |--sub_child(3)
  #          |--sub_child(4)
  #                   |--sub_sub_child(5)
  #   "tree_" => {"0"=>{"id"=>"1", "0"=>{"id"=>"2", "0"=>{"id"=>"3"}, "1"=>{"id"=>"4", "0"=>{"id"=>5}}}}}
  #
  # ツリー構造全体を保存する
  def save_tree(tree, parent)
    tree.each do |order, hash|
      id = hash.delete(:id)
      item = Tree.find(id)
      item.update_attributes(:position=>order, :parent_id=>parent)
      save_tree(hash, id) unless hash.empty?
    end    
  end
end
  • index.html.erbで、最初のulタグのみ設定して...
  • _tree.html.erbで、<li>自分の内容<ul>子のリスト</ul></li>を再帰的に繰り返す。
<%# ---------- app/views/trees/index.html.erb ---------- %>

<h1>Listing trees</h1>

<ul class="sortable_tree" id="tree_">
  <%= render :partial => 'tree', :collection => @trees %>
</ul>

<div id="sortable_script">
  <%= sortable_element_tree %>
</div>

<div id="info"></div>
<%# ---------- app/views/trees/_tree.html.erb ---------- %>

<% content_tag_for :li, tree do %>
  <%= toggle_link_for(tree, :parent, :default_display=>tree.children.empty?) %>
  <%= in_place_text_field(:tree, :name, :object=>tree, :endcolor=>"#444444", :restorecolor=>"#444444") %>
  <%= link_to_remote("子追加", {:url=>insert_tree_path(tree)}, :class=>'action') %>
  <%= link_to_remote("削除", {:url=>delete_tree_path(tree), :confirm=>'Are you sure?'}, :class=>'action') %>
  <%= content_tag(:span, "↑↓", :class=>'handle') %>  
  
  <% content_tag_for(:ul, tree, :parent, :style=>("display:none" unless tree.children.empty?)) do %>
    <%= content_tag(:li) %>
    <%= render(:partial=>'tree', :collection=>tree.children) unless tree.children.empty? %>
  <% end %>
<% end %>

  • toggle_link_forは、ツリーを開閉するリンク。
  • sortable_element_treeは、子アイテムを新規追加する度に呼び出される。(新規アイテムをドラッグ可能にするため必要だった。)
# ---------- app/helpers/trees_helper.rb ----------

module TreesHelper
  # 子ツリーを開閉する矢印リンクを作成
  def toggle_link_for(record, *args)
    prefix  = args.first.is_a?(Hash) ? nil : args.shift
    options = args.extract_options!
    options[:default_display] ? collapse_link_display = "display:none" : expanded_link_display = "display:none"
    options[:expanded_mark] ||= "&nbsp;∨&nbsp"
    options[:collapse_mark] ||= "&nbsp;≫&nbsp"
    
    link_to_function(options[:expanded_mark], 
      update_page do |page|
        page.visual_effect(:slide_up, dom_id(record, prefix), :duration=>0.2)
        page.toggle(dom_id(record, 'expanded'), dom_id(record, 'collapse'))
      end, 
      :class=>dom_class(record, 'expanded'), :id=>dom_id(record, 'expanded'), :style=>expanded_link_display ) + 
    
    link_to_function(options[:collapse_mark], 
      update_page do |page|
        page.visual_effect(:slide_down, dom_id(record, prefix), :duration=>0.2)
        page.toggle(dom_id(record, 'expanded'), dom_id(record, 'collapse'))
      end, 
      :class=>dom_class(record, 'collapse'), :id=>dom_id(record, 'collapse'), :style=>collapse_link_display )
  end

  # indexアクションで、ツリー構造をドラッグ可能にする
  # insertアクションで、新規追加アイテムもドラッグ可能にする
  def sortable_element_tree
    sortable_element('tree_', 
                     :update=>'info', 
                     :url=>{:action=>'sort'}, 
                     :handle=>'handle', 
                     :tree=>true)
  end
end
  • Railsプラグインの既存の仕様に満足できないところをコピーして修正している。(オレンジ色の部分)
# ---------- app/helpers/application_helper.rb ----------

# Methods added to this helper will be available to all templates in the application.
module ApplicationHelper
end

# visual_effectのオプション指定で、クォート込みの"'文字列'"、クォート無しの"文字列"、どちらの指定も可能にするための修正
# 例:
#   visual_effect(:highlight, id_string, :endcolor=>"'#ffffff'")
#   visual_effect(:highlight, id_string, :endcolor=>"#ffffff")
module ActionView
  module Helpers
    module ScriptaculousHelper
      def visual_effect(name, element_id = false, js_options = {})
        element = element_id ? element_id.to_json : "element"
        
        js_options[:queue] = if js_options[:queue].is_a?(Hash)
          '{' + js_options[:queue].map {|k, v| k == :limit ? "#{k}:#{v}" : "#{k}:'#{v}'" }.join(',') + '}'
        elsif js_options[:queue]
          "'#{js_options[:queue]}'"
        end if js_options[:queue]
        
        [:endcolor, :direction, :startcolor, :scaleMode, :restorecolor].each do |option|
          js_options[option] = "'#{js_options[option]}'" if js_options[option] && !(/\A(['"]).+\1\z/ =~ js_options[option])
        end

        if TOGGLE_EFFECTS.include? name.to_sym
          "Effect.toggle(#{element},'#{name.to_s.gsub(/^toggle_/,'')}',#{options_for_javascript(js_options)});"
        else
          "new Effect.#{name.to_s.camelize}(#{element},#{options_for_javascript(js_options)});"
        end
      end
    end
  end
end

# super_inplace_controlの修正
module Flvorful  
  module SuperInplaceControls
    module HelperMethods
      protected
      # :startcolor, :endcolor, :restorecolorオプション設定を追加
      def in_place_field(field_type, object, method, options)
        object_name = object.to_s
        method_name = method.to_s
        @object = self.instance_variable_get("@#{object}") || options[:object] 
        display_text = set_display_text(@object, method_name, options)
        ret =  html_for_inplace_display(object_name, method_name, @object, display_text, options)
        ret << form_for_inplace_display(object_name, method_name, field_type, @object, options)
      end
      
      def html_for_inplace_display(object_name, method_name, object, display_text, opts)
        options = {}
        options.merge!(:startcolor => opts.delete(:startcolor)) if opts[:startcolor]
        options.merge!(:endcolor => opts.delete(:endcolor)) if opts[:endcolor]
        options.merge!(:restorecolor => opts.delete(:restorecolor)) if opts[:restorecolor]
        
        id_string = id_string_for(object_name, method_name, object)
        content_tag(:span, display_text, 
                    :onclick => update_page do |page|
                      page.hide "#{id_string}"
                      page.show "#{id_string }_form"
                    end, 
                    :onmouseover => visual_effect(:highlight, id_string, options), 
                    :title => "Click to Edit", 
                    :id => id_string ,
                    :class => "inplace_span #{"empty_inplace" if display_text.blank?}" 
                    )
      end

      # 最終行の改行タグを削除
      def form_for_inplace_display(object_name, method_name, input_type, object, opts)
        retval = ""
        id_string = id_string_for(object_name, method_name, object)
        set_method = opts[:action] || "set_#{object_name}_#{method_name}"
        save_button_text = opts[:save_button_text] || "OK"
        loader_message = opts[:saving_text] || "Saving..."
        retval << form_remote_tag(:url => { :action => set_method, :id => object.id },
        :method => opts[:http_method] || :post,
        :loading => update_page do |page|
          page.show "loader_#{id_string}"
          page.hide "#{id_string}_form"
        end,
        :complete => update_page do |page|
          page.hide "loader_#{id_string}"
        end,
        :html => {:class => "in_place_editor_form", :id => "#{id_string}_form", :style => "display:none" } )

        retval << field_for_inplace_editing(object_name, method_name, object, opts, input_type )
        retval << content_tag(:br) if opts[:br]
        retval << submit_tag( save_button_text, :class => "inplace_submit")
        retval << link_to_function( "Cancel", update_page do |page|
          page.show "#{id_string}"
          page.hide "#{id_string}_form"
        end, {:class => "inplace_cancel" })
        retval << "</form>"
        retval << invisible_loader( loader_message, "loader_#{id_string}", "inplace_loader")
        #retval << content_tag(:br)
      end
    end
  end
end
  • スタイルシートの設定。アイテムの1行表示やマウスオーバーの挙動。
  • 子無しアイテムにドラッグするには、liのpaddingに上下幅が必要。
  • 下のpaddingを多めにした方が自然にドラッグできるような気がした。
/* ---------- public/stylesheets/tree.css ---------- */

ul.sortable_tree, 
.sortable_tree ul {
  list-style-type: none;
  list-style-position: inside;
  padding: 0px;
  margin: 0 0 0 2em;
}

.sortable_tree li {
  text-decoration: underline;
  padding: 0 0px 6px 0px;
  margin: 0;
}

a.expanded_tree, 
a.collapse_tree, 
a.action {
  text-decoration: none;
  font-size: 66%;
  color: #000;
  background-color: #ccc;
}

a.expanded_tree:hover, 
a.collapse_tree:hover, 
.handle:hover,
a.action:hover {
  color: #fff;
  background-color: #000;
}

.handle {
  text-decoration: none;
  font-size: 100%;
  cursor: move;
}

.in_place_editor_field, 
.inplace_span {
  text-decoration: none;
  font-size: 100%;
  color: #eee;
  background-color: #444;
}

div.inplace_loader,
form.in_place_editor_form,
form.in_place_editor_form * {
    display: inline;
}
  • 忘れずに書いておかないと、動かなくて悩む。
    • stylesheet_link_tag 'tree'
    • javascript_include_tag :defaults
<%# ---------- app/views/layouts/trees.html.erb ---------- %>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
       "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
  <meta http-equiv="content-type" content="text/html;charset=UTF-8" />
  <title>Trees: <%= controller.action_name %></title>
  <%= stylesheet_link_tag 'tree', 'scaffold' %>
  <%= javascript_include_tag :defaults %>
</head>
<body>

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

<%= yield  %>

</body>
</html>

参考ページ

以下のページを見て、最もシンプルなツリー操作の方法が理解できました。感謝です!

サンプルデモ