Hierarchies, trees, jQuery, Prototype, script.aculo.us and acts_as_nested_set

by Justin Ball on January 18th, 2009

I've rarely worked on a web project that didn't require some type of hierarchical display of of data. You see a tree like organization of data all over - in navigation, in forums, in threaded comments, etc. I've done it three times and three different ways in three different Ruby on Rails projects lately so I thought I would share some of what I have found.

In two of the projects I used the 'awesome_nested_set' plugin from collectiveidea. I should have used it in the other project as well. It is very powerful and handles all the complicated details for you. Not only does it create a tree it creates an ordered tree which is typically what you want when you are trying to put nodes in very specific locations. All you need to do to get the functionality of awesome_nested_set is to add acts_as_nested_set to your model and then include a couple of migrations:

The Data and plugins and stuff

Here's the model:

 
class Message < ActiveRecord::Base
  acts_as_nested_set
end
 

Here's the migrations:

 
class CreateMessages < ActiveRecord::Migration
  def self.up
    create_table :messages do |t|
      t.text     :text
      t.integer :user_id
      t.integer  :parent_id
      t.integer  :lft
      t.integer  :rgt
      t.timestamps
    end
  end
 
  def self.down
    drop_table :messages
  end
end
 

Then you can do things like this:

 
node = Message.find(params[:id])
parent = Message.find(params[:parent_id])
 
# move the node to the first child of the parent node
node.move_to_child_of(parent_node)
 
# move the node to the left of the sibling node
sibling = Message.find(params[:sibling_id])
node.move_to_right_of(sibling)
 

More about why this is important later.

The HTML and rendering and stuff

You'll need to generate html for your tree structure. There are a couple of ways of doing this.

If you have a root message you can get all of its descendants very easily. This will only make one sql call which improves performance. You don't want to be hitting the database every time you need the children of a given node.
You can do something like this in your view

 
<% if @message.descendants.length > 0 -%>
  <%= render :partial => '/messages/message', :collection => @message.descendants %>
<% end -%>
 

The message partial will look like this. I do make a few assumptions about methods that are available on the model - dom_id for example. However 'level' is provided by the awesome_nested_set plugin and gives you the level of the given object in the tree. Thus you can render something that looks like a hierarchy by simply iterating over a list.

 
<div id="<%= message.dom_id %>" style="margin-left:<%= (20 * (message.level-1)) %>px">
  <%= message.text %>
</div>
 

If you need to calculate a level to indent the message without hitting the database you can add this as a helper:

 
  def get_level(message, single_message)
    return (message.level - 1) if single_message
    @levels = [] if !defined?(@levels)
    if level = @levels.index(message.parent_id)
      @levels.slice!((level + 1)..-1)
    else
      @levels << message.parent_id
      level = @levels.size - 1
    end
    level
  end
 

and then your code changes only slightly:

 
<div id="<%= message.dom_id %>" style="margin-left:<%= (20 * get_level(message, single_message)) %>px">
  <%= message.text %>
</div>
 

'single_message' should normally be set to false. I added it in just in case I needed to render a single message for an ajax call. If you aren't rendering an entire tree and thus have only one node then passing 'single_message = true' will force the method to call the database to get the level of the node in the tree.

If you want to render a true tree structure (not just indents) then you'll need to do a bit of recursion. Assuming @message is a root level message you can do this:

 
<div id="messageList">
<ul id="message_tree">
    <% render_messages(@message) do |message| -%>
      <%= message.text %>
    <% end -%>
  </ul>
</div>
 
 
module MessagesHelper
 
def render_messages(message, &block)
  concat('
<li id="message_' + message.id.to_s + '" class="messageContainer delete-container">', block.binding)
  yield(message)
  concat('
<ul style="display:none;" id="ul_' + message.dom_id + '">', block.binding)
  if has_children?(message.id)
    children_of(message.id).each do |child|
      render_messages(child, &block)
    end
  end
  concat('</li>
</ul>
 
', block.binding)
end
 
# HACK these methods assume a variable named @messages is defined.
# This hack prevents us from having to pass messages all over
def has_children?(message_id)
  @messages.any?{|message| message.parent_id == message_id}
end
 
def children_of(message_id)
  @messages.find_all{|message| message.parent_id == message_id}
end  
 
end
 

(*Note that I've not fully tested the code above and I am betting it is not the most efficient. At the very least you'll want to cache the resulting html.)

Next you'll need to add some script to get the drag and drop to work. It will look something like this. Honestly I can't remember if I got this code from somewhere online or if I wrote it. I am sure someone could make it generic, but in this instance we use css class names to add drag and drop functionality to the various nodes:

 
jQuery(document).ready(function() {
 
	jQuery(".messageContainer").draggable({
		zIndex : 1000000,
		revert : 'invalid',
		opacity : 0.5,
		scroll : true,
		helper : 'clone'
	});
 
	jQuery("#messageList").droppable({
			accept: ".messageContainer",
			drop: function(ev, ui) {
				var source_li = jQuery(ui.draggable);
				var child_ul = jQuery(this).children('ul');
				var message_id = source_li.children('input').val();
				var parent_id = 0;
				if(same_parent(source_li, child_ul)){
					return;
				}
				insert_alphabetic(child_ul, source_li);
				update_parent(message_id, parent_id);
	    }
		});
 
	jQuery(".messageContainer").droppable({
	  accept: ".messageContainer",
	  hoverClass: 'messageContainer-hover',
	  tolerance : 'pointer',
		greedy : true,
    drop: function(ev, ui) {
			var source_li = jQuery(ui.draggable);
			var target_li = jQuery(this);
			var message_id = source_li.children('input').val();
			var parent_id = target_li.children('input').val();
			if(target_li.children('ul').length <= 0){
				target_li.append('
<ul></ul>
 
');
			}
			var child_ul = target_li.children('ul');
			if(same_parent(source_li, child_ul)){
				return;
			}
			jQuery(this).children('ul:hidden').slideDown();
			insert_alphabetic(child_ul, source_li);
			update_parent(message_id, parent_id);
    }
	});
 
	jQuery(".submit-delete").click(function() {
		if(jQuery(this).parents('li:first').siblings('li').length <= 0){
			jQuery(this).parents('li:first').parents('li:first').children('.expander').remove();
		}
		return false;
	});
 
	function insert_alphabetic(child_ul, source_li){
		var kids = child_ul.children('li');
		var source_text = source_li.children('span.link').children('a').html().toLowerCase();
		for(i=0; i<kids.length; i++){
			var current_text = jQuery(kids[i]).children('span.link').children('a').html().toLowerCase();
			if(source_text < current_text){
				source_li.insertBefore(kids[i]);
				return;
			}
		}
		source_li.appendTo(child_ul);
	}
 
	function same_parent(source_li, child_ul){
		return source_li.parent() == child_ul;
	}	
 
	function update_parent(message_id, parent_id){
		var path = jQuery('#updatePath').val();
		jQuery.post(path + '/' + message_id + '.js', {parent_id: parent_id, action: 'update', _method: 'put', only_parent: 'true' },
		  function(data){
				apply_expander();
				if(data.length > 0){
					var result = eval('(' + data + ')');
					if(!result.success){
						jQuery.jGrowl.error(result.message);
					}
				}
		  });
		return false;
	}
 
	apply_expander();
	function apply_expander(){
		jQuery(".expander").remove();
		jQuery(".messageContainer ul:hidden li:first-child").parent().parent().prepend('<a class="expander" href="#"><img src="/images/expand.png" /></a>');
		jQuery(".messageContainer ul:visible li:first-child").parent().parent().prepend('<a class="expander" href="#"><img src="/images/collapse.png" /></a>');
		jQuery(".expander").click(function(){
			var img = jQuery(this).children('img');
			var target_ul = jQuery(this).siblings('ul');
			if(img.attr('src') == '/images/expand.png'){
				img.attr('src', '/images/collapse.png');
				target_ul.slideDown();
			} else {
				img.attr('src', '/images/expand.png');
				target_ul.slideUp();
			}
			return false;
		});
	}
 
});
 

In two of the projects I have removed prototype and script.aculo.us in favor of jQuery. Personally I prefer jQuery and the jRails plugin makes the transition simple. However, there are probably more people using the default libraries. Prototype actually comes with the ability to create a nice drag and drop ordered tree built in. However, I don't love the fact that their 'onUpdate' callback doesn't give the node that was dropped. Instead you are supposed to serialize the entire tree. awesome_nested_set makes it very easy to move just one node and that seems more efficient so you'll see a hack in the code below that constantly records the dropped node into a hash table on the 'onChange' event. That data is then sent to the server on the 'onUpdate' event.

 
window._token = '#{form_authenticity_token}'; // Rails requires this token to validate forms so we'll need to pass it in the ajax request
window._message_updates = new Hash;
Sortable.create('message_tree', {tree:true,
                                    dropOnEmpty:true,
                                    scroll:window,
                                    constraint:false,
                                    onChange:function(element) {
                                      // this is a bit of a hack, but basically we just pull the message id from the id of the html element
                                      var child_id = element.id.replace('message_', '');
                                      var parent_id = element.up().id.replace('ul_message_', '');
                                      var previous = element.previous();
                                      var sibling_id = '';
                                      if(previous){
                                        var sibling_id = previous.id.replace('message_', '');
                                      }
                                      window._message_updates.set(child_id, [parent_id, sibling_id]);
                                    },
                                    onUpdate:function(element) {
                                      window._message_updates.each(function(pair) {
                                        var child_id = pair.key;
                                        var parent_id = pair.value[0];
                                        var sibling_id = pair.value[1];
                                        window._message_updates.unset(child_id);
                                        var url = '/messages/' + child_id + '.js?parent_id=' + parent_id + '&sibling_id=' + sibling_id;
                                        new Ajax.Request(url, {
                                          method: 'PUT',
                                          parameters: {
                                            authenticity_token: window._token
                                          }
                                        });
                                      });
                                    }
                                    });
 

Here is a link to the default script.aculo.us tree example
http://script.aculo.us/playground/test/functional/sortable_tree_test.html

I borrowed many of the ideas from there. I'm sure there are a few bugs in this so if anyone tries out the code and has problems let me know and I'll make changes as needed.

  • Steve
    You spelled "Hierarchies" wrong in the title.
  • djocubeit
    Justin,

    What I'm finding is that when using the following block:

    <% render_messages(@message) do |message| -%>
    <%= message.text %>
    <% end -%>

    That the message (singular object) is not in fact a singular object. It represents the entire array of messages. So the error I get is:

    NoMethodError Exception: undefined method `text' for #<array:0x2313e48>

    This is because the message object is an array. I can get the message text by addressing one of the elements in the array, like this:

    <%= message[0].text %>

    Which proves this. But I don't know how to fix the problem.


    </array:0x2313e48>
  • jbasdf
    Here's the project where I used the code:
    http://github.com/jbasdf/luvfoo/tree/master

    You might have a tough time getting the project running but look in views/admin/pages to see how I put together a drag and drop tree solution. Let me know if that helps or if you get stuck again.
  • djocubeit
    Also I forgot to mention I'm on Rail 2.3.3 and Ruby 1.8.7. I'm not sure if this is affecting anything.
  • jbasdf
    I think most everything is client side so it should work with the latest version of Rails. The files to look at are:
    views/admin/pages/index.html.erb which renders:
    views/admin/pages/_page.html.erb

    Notice the class 'pageContainer' and then look in /public/javascripts/tree.js. I'm using jQuery to apply the required client side functionality. Also, to deal with indenting I'm using nested lists (notice that _page.html.erb calls itself). I added css to deal with the styling. Look in admin.css and search for '#pageList'. That will show you how I styled the tree.
  • djocubeit
    I've checked it out, and the project looks really interesting, but I can't find the code in there - are you sure it's checked in? I've taken a look around the git repository in case it was elsewhere, but I can't see it. I did find it interesting that you might be using the functionality the same way that I want to though - for a hierarchy of pages. My idea is to have a Page model which 'acts_as_nested_set'. One of it's properties is a template. The template would be a liquid template. So this eliminates the need to have blog. The page could use a blog liquid template. At the moment though I'm stuck at the Page hierarchy bit. I'm using jRails and awesome_nested_set too. I did find another article about awesome_nested_set at http://www.idolhands.com/ruby-on-rails/drag-and-drop-with-nested-sets-in-rails but it doesn't deal with changing the level of indentation unfortunately.
  • djocubeit
    Hey Justin, great article and just what I was looking for.

    I'm wondering whether you have this all packaged up into a working example - probably not, but I thought it might be worth asking?
  • gobetter
    Hello

    My english is very poor(i stay in Thailand) and new for Rails. I try to do follow your suggestion, but still failed.
    Can you send me Email a simple project?

    Thanks
    Bye
blog comments powered by Disqus