sortableでin_placeなツリー操作まとめ
最近悩んでいたツリー操作のサンプル。自分用のメモ。ツリー構造の展開には、render(:partial)を利用するように変更してみた。(以前はヘルパメソッドを定義して展開していた。erbの方がツリー全体のイメージが理解し易いかも。)
関連リンク
コマンドとコード
- 初めの一歩。scaffold&プラグインのインストール。
# ---------- ターミナルでコマンド操作 ---------- 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] ||= " ∨ " options[:collapse_mark] ||= " ≫ " 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
# ---------- 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>
参考ページ
以下のページを見て、最もシンプルなツリー操作の方法が理解できました。感謝です!