Friday 5 October 2007

select tags for acts_as_tree

Like many of you, I came accross the issue of having a select tag used in an edit or new form for a model that acts_as_tree. The common models are a category that belongs to a product. The category is chosen in a tree of categories.

After some search, I found some entries in the Rails wiki like HowToUseActsAsTree and its brothers. I wasn't really happy with them. I already put my options_from_collection_for_select in the views so I wanted to replace it easily.

The helpers

I divided the problem and made a first function that will construct the array of items (an ID and a name) that can be passed to options_for_select. This adds some flexibility so that you can wrap your own variation of options_for_select. This function is named options_from_tree.

It is resonsible of formatting the names with correct indentation and order. Here it is:

def options_from_tree(roots, value_method, text_method, initial_options = nil)
sub_items = lambda do |items, depth|
items.inject([]) do |options, item|
options << [block_given? ? yield(item, depth).to_s : (" "*depth + item.send(text_method)), item.send(value_method)]
options += sub_items.call(item.children, depth+1)
end
end
(initial_options || []) + sub_items.call(roots, 0)
end


The one-liner version (copy and paste directly into your code)

def options_from_tree(roots, value_method, text_method, initial_options = nil)
sub_items = lambda {|items, depth| items.inject([]) {|options, item| options << [block_given? ? yield(item, depth).to_s : (" "*depth + item.send(text_method)), item.send(value_method)]; options += sub_items.call(item.children, depth+1) }}
(initial_options || []) + sub_items.call(roots, 0)
end



The second function is a helper wrapping the previous one and acting like options_from_collection_for_select, I called it options_from_tree_for_select. Here it is:

# _roots_ is a collection of root items that will be traversed
# other params are the same as options_from_collection_for_select
# _initial_options_ is a collection of options added in front of the result (for the format see options_for_select)
def options_from_tree_for_select(roots, value_method, text_method, selected_value = nil, initial_options = nil)
options_for_select(options_from_tree(roots, value_method, text_method, initial_options), selected_value)
end


Because I prefer spaces to indent the content of the select tag and because they are coded as HTML entities, they got escaped by options_for_select. So to workaround I used a not so known rails helper, take a look at that:

# override options_for_select to fix any double escaped HTML entities
def options_for_select(*args)
fix_double_escape(super(*args))
end

Neat isn't it?

All of this code is to be put in a helper, you may of course put it in application_helper.rb.

The view

Now to generate such a select tag in your views, put something like that:

<select name="product[category_id]">
<%= options_from_tree_for_select(Category.find_roots, :id, :name) %>
</select>


Notice the find_roots method on the Category model, it simply find(:all, :conditions => 'parent_id IS NULL').

'Cerise sur le gâteau'

I saw some of you, why the heck is there a call to block_given? in the options_from_tree code?

This adds flexibility in how the items are output in the select tag. Some of you may dislike the indentation made of spaces, you'd prefer arrows. Or maybe you want to add each parent as in:

Meat
Meat->Poultry
Meat->Poultry->Chicken

So how to do that? simply pass a block to options_from_tree or to options_from_tree_for_select which will receive the item name and the depth as argument, create your own string and return it. Example:

options_from_tree_for_select(Category.find_all_by_parent_id(nil), :id, nil) do |item, depth|
(item.ancestors.reverse <<>")
end


That's all folks, feel free to comment !

Welcome aboard

Now is the time. You've just entered in my mad brain or at least some of its output.

This blog will concentrate on IT development and these time I'm working with Rails. So expect some code and ideas.

Have a good trip.