IdolHands.com :: Days in the Life of an Alpha Geek
If you're using Chris Bailey's Better Nested Set (or better yet, Brandon Keeper's awesome_nested_set) for your Ruby on Rails models, you may one day want to implement a drag-and-drop interface to reorganize your sets. Here's how I was able to implement it.
Let's take a dead simple example: a Category model that supports n levels of subcategories using better_nested_set.
>> Category
=> Category(id: integer, name: string, parent_id: integer, lft: integer, rgt: integer,
depth: integer, created_at: datetime, updated_at: datetime)
parent_id, lft, and rgt are all standard attributes required for better_nested_set. There's one extra attribute in there, though: depth.
I've found that in real-world applications, getting the level of a node in a nested set can be a very expensive operation (to the tune of 100ms), so I typically cache this value in the database and update it on save. Note that when you use better_nested_sets move methods, e.g. move_to_child_of, the plugin is making direct SQL calls to avoid object instantiation, so you'll need to manually call your update_depth method as well. This is annoying and easy to forget, but I think it's worth it from a performance perspective; I'd rather optimize for fast read operations than write operations.
So our category.rb looks like this:
class Category < ActiveRecord::Base
# Behaviours
acts_as_nested_set
# Callbacks
after_save :update_depth
# Validations
validates_presence_of :name
validates_uniqueness_of :name
def update_depth
unless self.level == self.depth
self.update_attribute(:depth, self.level)
self.children.each{|child| child.update_depth }
end
end
end
Simple enough so far.
In our view, we want to do a couple of things: display the nested categories with indentation, and allow the user to drag and drop between categories. This makes our view code a little complex.
Our index.html.erb file is pretty sparse:
<h1>Categories</h1>
<%- if @categories.empty? -%>
<p><em>No categories have been created yet.</em></p>
<%- else -%>
<br />
<table>
<thead>
<tr>
<th>Name</th>
</tr>
</thead>
<tbody id="catlist">
<%= render :partial => 'categories_tree' -%>
</tbody>
</table>
<%- end -%>
<br />
<%= link_to 'New Category', new_category_path -%>
Note that we're rendering a partial for the category tree itself. This will allow us to update the view using AJAX when the categories are restructured. So _categories_tree.html.erb looks like this:
<%- @categories.each do |category| -%>
<%= render :partial => 'category_row', :locals => {:category => category} -%>
<%- end -%>
One final partial to enable recursive display magick: _category_row.html.erb>
<tr class="<%= cycle('odd', 'even') -%>" style="cursor: move;" id="category_<%= category.id -%>">
<td style="padding-left: <%= (1 * category.level) + 1 -%>em;">
<%=h category.name -%>
</td>
<td>
<%= link_to 'Show', category -%> |
<%= link_to 'Edit', edit_category_path(category) -%>
</td>
</tr>
<%= draggable_element(
"category_#{category.id}",
:ghosting => true,
:revert => true
)
-%>
<%= drop_receiving_element(
"category_#{category.id}",
:update => 'catlist',
:with => "'subcategory_id=' + element.id",
:url => { :action => 'restructure', :category_id => category.id },
:hoverclass => "row_highlight"
)
-%>
<%- category.children.each do |subcat| -%>
<%= render :partial => 'category_row', :locals => {:category => subcat} -%>
<%- end -%>
Notice the two special blocks here? We're using the draggable_element helper method to define the DOM element tagged with category_# (in our case, the <tr>)as draggable, and the drop_receiving_element helper method to set the row as a receiver for other draggable elements. Since this partial is used to render each of our categories in rows, these definitions will apply to all of the categories in the table.
Each of these helper methods take several parameters, including snap-back behaviour (revert) and a CSS class to apply to an element when a dragged row hovers over it (hoverclass). I encourage you to explore these options to see the sort of nice effects that can be achieved.
When a row is dragged, it will 'ghost' and any receiving row will be highlighted. The result of the drop will be an AJAX call to the restructure method in our controller. Let's add that now.
We need to add a method for handling the restructuring of nested categories, and make a small change to the scoping of the index method:
class CategoriesController < ApplicationController
def restructure
_parent = Category.find(params[:category_id].gsub('category_',''))
_child = Category.find(params[:subcategory_id].gsub('category_',''))
_child.move_to_child_of(_parent)
_child.update_depth
@categories = Category.roots
render :partial => 'categories_tree'
end
This method is simply parsing the category_id and subcategory_id parameters, finding the appropriate category objects, and making the dragged category a child of the drop destination category.
def index
@categories = Category.roots
end
..
end
Rather than populating the @categories instance variable with all categories, the index method is only pulling root nodes (e.g. categories without parents) from the database. Because of our recursive view code, child nodes will be displayed under their parents, indented appropriately.
Here's a quick demo of what the drag and drop looks like. There are a number of improvements that could be made (indent/outdent? multiselect?) but I'm leaving those up to you.
Comments
erwin
06/16/2009
David
06/24/2009
John
07/12/2009
Ben
08/10/2010
Ben
08/10/2010