public
Last active

Nestable, sortable and dragable categories

  • Download Gist
README.markdown
Markdown

Nestable, sortable and dragable categories:

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.

Before beginning

you might want to take a look at a demo app

  1. go to: http://awesomenestedsortable.heroku.com/groups/
  2. click in show of any group
  3. click in manage categories
  4. 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

Initial situation

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

The controller

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

Views

reorder.html.haml

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'

categories_helper.rb

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

The javascript

# 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

params[:order]

[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

The model category.rb

Category.reorder()

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: 1. restructure the ancestry of the categories, ie. making sure that each category has for parent the category it should have (according to the params) 2. 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.

Category.restructure_ancestry()

    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.

Category.sort()

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).

convert_params_to_hierarchy()

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

Category#sort()

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

Moving everything into a module

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

Thanks for posting this. I was looking for something like this some time ago. Now i know where to look next time.

Thanks looks good and has coffeescript too ;)

Glad to hear it : )

Forgot to add.
Really nice write up and explanation.

When i found the link i thought it would just be a bunch of code with some comments. so yeah....:) hehe

Please sign in to comment on this gist.

Something went wrong with that request. Please try again.