Hierarchies, trees, jQuery, Prototype, script.aculo.us and acts_as_nested_set
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.
More from jbasdf
-
Steve
-
djocubeit
-
jbasdf
-
djocubeit
-
jbasdf
-
djocubeit
-
djocubeit
-
gobetter
Justin Ball is a software consultant and entrepreneur with a passion for Ruby. He evolved from a C++ and .Net monkey into a python programmer and finally found Ruby. In the rare moments when he isn't writing code, talking about code or measuring his code productivity in profanity per hour, you can find him on his bike in the mountains or on the roads surrounding Cache Valley. 









