5.6. Hierarchical
Categories
When we generated scaffold code for categories,
we got some basic CRUD screens. But they ignore the fact that our
categories are hierarchical. The basic problem is every category
item has a parent (except for the root category), and there is no
way in the CRUD screens to specify the parent of a category.
For now, we are going to fix this in a very
simple way that will get you get quickly. There will
be plenty of time later for a fancier user interface.
Every category has a name, but these names are
not always individually unique because they are qualified by their
parents in the hierarchy. For example, you might have two
categories named Car, but one of
them might have a parent named Bruce while the other has a parent named
Curt. A unique identifier for a
category would prefix the category name with all of its parents. So
for these two Car categories, we
might have long names like Root:Bruce:Car and Root:Curt:Car.
Let's implement this attribute as a
long_name attribute in our Category model. Edit
app/models/category.rb to look like
this (the new lines are in bold):
class Category < ActiveRecord::Base
has_and_belongs_to_many :photos
acts_as_tree
def ancestors_name
if parent
parent.ancestors_name + parent.name + ':'
else
""
end
end
def long_name
ancestors_name + name
end
end
The long_name method returns a string
that is the concatenation of the names of all of its parents with
its own name. ancestors_name is a recursive method that
concatenates all of the parent names with a ":" separator.
You can see this working on our category list
page. Edit the categories controller, app/controllers/categories_controller.rb,
and change the list action to
this:
def list
@all_categories = Category.find(:all, :order=>"name")
end
Notice that we got rid of the pagination, and
that we are sorting the categories by name.
Now edit the corresponding view template,
app/views/categories/list.rhtml, to look like
this:
<h1>Listing categories</h1>
<table>
<tr>
<th>Name</th>
</tr>
<% for category in @all_categories %>
<tr>
<td><%=h category.long_name %></td>
<td><%= link_to 'Edit', :action => 'edit', :id => category %></td>
<td><%= link_to 'Destroy', { :action => 'destroy', :id => category },
:confirm => 'Are you sure?' %></td>
</tr>
<% end %>
</table>
<br />
<%= link_to 'New category', :action => 'new' %>
The new code is in bold, and the code dealing
with pagination and displaying multiple columns has been removed;
plus, the show link was removed
because the show page doesn't display anything you can't already
see on the list page.
The second bolded line calls the new
long_name method.
"#rubyrails-chp-5-fig-7">Figure 5-7 shows what you should see
when you browse to http://127.0.0.1:3000/categories/list
Now you need to modify creating and editing a
category to let you pick the category's parent. Both actions use
app/views/categories/_form.rhtml
to display a category form, so that's the only view template you
need to modify:
<%= error_messages_for 'category' %>
<!--[form:category]-->
<p><label for="category_name">Name</label><br/>
<%= text_field 'category', 'name' %></p>
<p><label for="category_parent_id">Parent Category</label><br/>
<%= collection_select(:category, :parent_id,
@all_categories, :id, :long_name) %></p>
<!--[eoform:category]-->
Again, the code in bold is new. This code uses
the form helper collection_select, which generates HTML
<select> and <option> tags to create
a drop-down select list.
The first two parameters to
collection_select give the name of the database table and
column whose value this control will set. The remaining three
parameters specify the list of choices the user will have.
@all_categories is a list of objects containing the valid
choices. :id and :long_name specify the object
attributes that get the key value and display value for each
choice.
For this new form to work, you need to set
@all_categories in the controller for the edit and new
methods:
def new
@category = Category.new
@all_categories = Category.find(:all, :order=>"name")
end
...
def edit
@category = Category.find(params[:id])
@all_categories = Category.find(:all, :order=>"name")
end
Click the Edit link for any category to see the
results of your handiwork (
"#rubyrails-chp-5-fig-8">Figure 5-8).
5.6.1. Assign a
Category to a Photo
Let's update our photo CRUD pages so you can
assign categories to a photo. For now, we will take a simple
approach like we did with categories.
As with categories, both the edit photo and new
photo pages use a common partial view template named _form.rhtml. As
mentioned earlier, a partial is
small template that does not render an entire page, but just a
small, reusable element. This is great for rendering elements that
are used on more than one page because the code won't have to be
duplicated. Edit the file app/views/photos/_form.rhtml, and add the
following to the end (just before the HTML comment):
<p>
<label for="categories">Categories:</label><br/>
<select id="categories" name="categories[]" multiple="multiple"
size="10" style="width:250px;">
<%= options_from_collection_for_select(@all_categories,
:id, :long_name,
@selected) %>
</select>
</p>
This code creates a multiple-selection HTML list
box populated with the category objects in the instance variable
@all_categories using the id of each category as
the select option's value and the long_name of each
category as the select option's display text. Additionally, each
category ID in @selected is displayed as already
selected.
Next, you need to add code to the photos
controller to set @all_categories and @selected
and then grab the form results that are posted back to update the
database. Edit app/controllers/photos_controller.rb, and change the
edit and update methods to look like this (new
lines are in bold):
def edit
@photo = Photo.find(params[:id])
@all_categories = Category.find(:all, :order=>"name")
@selected = @photo.categories.collect { |cat| cat.id.to_i }
end
def update
@photo = Photo.find(params[:id])
@photo.categories = Category.find(params[:categories]) if params[:categories]
if @photo.update_attributes(params[:photo])
flash[:notice] = 'Photo was successfully updated.'
redirect_to :action => 'show', :id => @photo
else
render :action => 'edit'
end
end
The edit method first retrieves the
photo object that has the target ID and then gets a list of all
categories, ordered by name. Finally, it assigns a
@selected a list of IDs for all categories already
assigned to this photo. @photo.categories returns a list
of category objects, one for each category assigned to the photo.
The Ruby collect method iterates through that list and,
using the attached block of code, creates a new list consisting of
just the category IDs (cat.id) converted to an integer
(cat.id.to_i).
When the user saves changes to the edited photo,
the form data is directed to the update method.
params[:categories] contains a list of the selected
categories (or nil if no categories were selected). The
new if modifier we just added to the update
method prevents the line from being executed when there are no
selected categories.
Category.find(params[:categories])
returns a list of category objects, one for each category ID in
params[:categories]. This category list is then assigned
to the target photo's categories attribute.
Let's now make a very similar set of changes to
the new and create methods. The only difference
is that a new photo doesn't have any existing selected categories,
so the @selected variable is not set:
def new
@photo = Photo.new
@all_categories = Category.find(:all, :order=>"name")
@selected = []
end
def create
@photo = Photo.new(params[:photo])
@photo.categories = Category.find(params[:categories]) if params[:categories]
if @photo.save
flash[:notice] = 'Photo was successfully created.'
redirect_to :action => 'list'
else
@all_categories = Category.find(:all, :order=>"name")
render :action => 'new'
end
end
Again, the new lines are bold.
That's all there is to it. You can now assign
multiple categories to each photo. Give it a try! You should be
starting to see how easy it is to incrementally build out your
Photo Share application. |