In the project I'm working on we wanted to have a Category model which we wanted to be nestable. But we also liked the user to have a draggable interface to manage and rearrange the order of his categories. So we chose awesome_nested_set for the model and jQuery.nestedSortable for the UI.
It took me some time to arrange things to work properly so I wanted to share my work in case it helps anybody.
you might want to take a look at a demo app
- go to: http://awesomenestedsortable.heroku.com/groups/
- click in show of any group
- click in manage categories
- reorder as you want and click the save button
You can take a look at the source in: https://github.com/ariera/awesome_nested_sortable
we're using ruby 1.9.2 & rails 3.1-rc4
class Group < ActiveRecord::Base
has_many :categories
# ...
end
class Category < ActiveRecord::Base
belongs_to :group
# ...
end
We created an special action in the CategoriesController to manage it:
# app/controllers/categories_controller.rb
def reorder
if request.get?
@group = Group.find(params[:group_id])
@categories = @group.categories
elsif request.post?
Category.reorder(params[:order])
redirect_to categories_path(:group_id => @group.id)
end
end
(I wanted to use the action for both showing the categories and updating the order, hence the diferentiation between get and post request's)
Don't forget to update your routes:
# config/routes.rb
resources :categories do
collection do
get 'reorder'
post 'reorder'
end
end
Just a simple view with the categories of the current group and a form that the javascript will use to send the new arrangement of the categories.
%h2= "reordering #{@group.name} categories"
#sortable
= nested_list @categories do |category|
%p= "#{category.name}(#{category.id})"
= form_tag reorder_categories_path(:group_id => @group.id) do
= submit_tag 'save'
nested_list is a helper I made to display the categories nested in <ul>
's
It is going to travel across the categories recursively, so it expects to receive the roots categories and not its children
def nested_list(categories, &block)
return "" if categories.blank?
html = "<ul>"
categories.each do |cat|
html << "<li id='category_#{cat.id}'>"
html << capture(cat,&block)
html << nested_list(cat.children, &block)
html << "</li>"
end
html << "</ul>"
html.html_safe
end
Notice that the id attribute in <li id='category_#{cat.id}'>
it is necessary for jQuery.nestedSortable so it can later serialize the list.
NOTE: this helper suffers from the N+1 queries problem, while I work this out you might want to consider having a look at other solutions such as http://stackoverflow.com/questions/1372366/how-to-render-all-records-from-a-nested-set-into-a-real-html-tree
# app/assets/javascripts/categories.js.coffee
jQuery ->
$('#sortable ul:first-child').nestedSortable({
listType: 'ul',
items: 'li',
handle: 'p'
})
$("#sortable form").submit ->
serialization = $("#sortable ul:first-child").nestedSortable('serialize')
input = $("<input>").attr("type", "hidden").attr("name", "order").val(serialization)
$(this).append($ input)
We activate the jQuery.nestedSortable plugin and attach a function to trigger when the form is about to be submited.
This function will add an input hidden to the form which will contain the serialization of the categories rearranged. The input has the name 'order' so we receive it in the controller as params[:order]
(remember how in the controller we called Category.reorder(params[:controller])
?)
Well, we're getting to the tricky part
[example]: Say we had 3 categories ordered like this:
- category_1
- category_2
- category_3
- category_4
- category_5
and the user wants to reorder them like this:
- category_1
-- category_2
--- category_4
--- category_3
- category_5
notice how category_4 goes before category_3
So when the user clicks de 'save' button in the reorder view what we'll find in params[:order]
in the controller (product of the call to jQuery.nestedSortable serialization) will be this
"category[1]=root&category[2]=1&category[4]=2&category[3]=2&category[5]=root"
A string that represents exactly the order of the categories of the user in a very unpleasant way to work it with. Wouldn't it be much better to have a ruby hash like this one?:
{1=>nil, 2=>1, 4=>2, 3=>2, 5=>nil}
Each key being the id of a category and the value the id of its parent.
Well that's exactly what this method does:
def convert_url_params_to_hash(params)
params = Rack::Utils.parse_query(params)
# in this point _params_ looks like this:
# {"category[1]"=>"root", "category[2]"=>"1", "category[4]"=>"2", "category[3]"=>"2", "category[5]"=>"root"}
params.map do |k,v|
cat_id = k.match(/\[(.*?)\]/)[1]
parent_id = v=='root' ? nil : v.to_i
{cat_id.to_i => parent_id}
end.inject(&:merge)
end
class Category < ActiveRecord::Base
def self.reorder(params)
params=convert_url_params_to_hash(params)
categories = Category.find(params.keys)
ActiveRecord::Base.transaction do
self.restructure_ancestry(categories, params)
self.sort(categories, params)
end
end
end
So basically what this method will do 2 things:
- restructure the ancestry of the categories, ie. making sure that each category has for parent the category it should have (according to the params)
- sort the categories. Remember how in the exmple category_4 came before category_3? Well this function will take care of it.
Lets take a look at them.
def self.restructure_ancestry(categories, params)
categories.each do |cat|
cat.update_attributes({:parent_id => params[cat.id]})
end
end
Pretty straightforward, update the parent_id of each category.
The job of this method is to ask each category to sort itself with respect to its siblings. Lets take a look at the code and then I'll explain it with the help of the example:
def self.sort(categories, params)
hierarchy = convert_params_to_hierarchy(params)
categories.each do |cat|
cat.sort(hierarchy[cat.parent_id])
end
end
forget about the convert_params_to_hierarchy
method for the moment
In the example category_1
and category_5
are siblings. This sort method will begin asking the category_1
to position itself before category_5
. In code it would look like this:
category_1.sort([1,5])
The result of executing the categories.each
loop with our example would be like this:
category_1.sort([1,5])
category_2.sort([2])
category_3.sort([4,3])
category_4.sort([4,3])
category_5.sort([1,5])
Now at this point you might have figured out what convert_params_to_hierarchy
does (as well as a few potetion drawbacks).
This method converts our params hash
{1=>nil, 2=>1, 4=>2, 3=>2, 5=>nil}
into another hash that represents the set hierarchy
{nil=>[1, 5], 1=>[2], 2=>[4, 3]}
where each key is the id of a category (nil being the root category) and the value a list with the id's of each child
def convert_params_to_hierarchy(params)
params.hash_revert
end
the code of hash_revert
I took it from http://www.ruby-forum.com/topic/198009#862264
Again this code I took it from elsewhere, from the wiki of the ancestry gem, and added a few modifications:
def sort(ordered_siblings_ids)
return if ordered_siblings_ids.length==1
if ordered_siblings_ids.first == id
move_to_left_of siblings.find(ordered_siblings_ids.second)
else
move_to_right_of siblings.find(ordered_siblings_ids[ordered_siblings_ids.index(id) - 1])
end
end
All this logic can be moved into a separate module to be easily included. You can take a look a the result here: app/models/category_sortable.rb
Now in your category.rb you can include CategorySortable