Adding Sort Capabilities to a Model with acts_as_list

Problem

You need to present the data in a table sorted according to one of the table's columns.

For example, you are creating a book and have a database to keep track of the book's contents. You know the chapters of the book, for the most part, but their order is likely to change. You want to store the chapters as an ordered list that is associated with a book record. Each chapter needs the ability to be repositioned within the book's table of contents.

Solution

First, set up a database of books and chapters. The following migration inserts an initial book and some recipes associated with it:

db/migrate/001_build_db.rb:

class BuildDb < ActiveRecord::Migration
 def self.up
 create_table :books do |t|
 t.column :name, :string
 end
 mysql_book = Book.create :name => 'MySQL Cookbook'
 create_table :chapters do |t|
 t.column :book_id, :integer
 t.column :name, :string
 t.column :position, :integer
 end
 Chapter.create :book_id => mysql_book.id, 
 :name => 'Using the mysql Client Program',
 :position => 1
 Chapter.create :book_id => mysql_book.id, 
 :name => 'Writing MySQL-Based Programs',
 :position => 2
 Chapter.create :book_id => mysql_book.id, 
 :name => 'Record Selection Techniques',
 :position => 3
 Chapter.create :book_id => mysql_book.id, 
 :name => 'Working with Strings',
 :position => 4
 Chapter.create :book_id => mysql_book.id, 
 :name => 'Working with Dates and Times',
 :position => 5
 Chapter.create :book_id => mysql_book.id, 
 :name => 'Sorting Query Results',
 :position => 6
 Chapter.create :book_id => mysql_book.id, 
 :name => 'Generating Summaries',
 :position => 7
 Chapter.create :book_id => mysql_book.id, 
 :name => 'Modifying Tables with ALTER TABLE',
 :position => 8
 Chapter.create :book_id => mysql_book.id, 
 :name => 'Obtaining and Using Metadata',
 :position => 9
 Chapter.create :book_id => mysql_book.id, 
 :name => 'Importing and Exporting Data',
 :position => 10
 end
 def self.down
 drop_table :chapters
 drop_table :books
 end end

Set up the one-to-many relationship, and add the acts_as_list declaration to the Chapter model definition:

app/models/book.rb:

class Book < ActiveRecord::Base
 has_many :chapters, :order => "position" 
end

app/models/chapter.rb:

class Chapter < ActiveRecord::Base
 belongs_to :book
 acts_as_list :scope => :book end

Next, display the list of chapters, using link_to to add links that allow repositioning chapters within the book:

app/views/chapters/list.rhtml:

<h1><%= @book.name %> Contents:</h1>
<ol>
 <% for chapter in @chapters %>
 <li> 
 <%= chapter.name %>
 <i>[ move: 
 <% unless chapter.first? %>
 <%= link_to "up", { :action => "move", 
 :method => "move_higher", 
 :id => params["id"],
 :ch_id => chapter.id } %> 
 <%= link_to "top", { :action => "move", 
 :method => "move_to_top",
 :id => params["id"],
 :ch_id => chapter.id } %> 
 <% end %>
 <% unless chapter.last? %>
 <%= link_to "down", { :action => "move", 
 :method => "move_lower", 
 :id => params["id"],
 :ch_id => chapter.id } %> 
 <%= link_to "bottom", { :action => "move", 
 :method => "move_to_bottom",
 :id => params["id"],
 :ch_id => chapter.id } %> 
 <% end %>
 ]</i> 
 </li> 
 <% end %>
</ol>

The list method of the Chapters Controller loads the data to be displayed in the view. The move method handles the repositioning actions; it is invoked when the user clicks on one of the up, down, top, or bottom links.

app/controllers/chapters_controller.rb:

class ChaptersController < ApplicationController
 def list
 @book = Book.find(params[:id])
 @chapters = Chapter.find(:all, 
 :conditions => ["book_id = %d", params[:id]],
 :order => "position") 
 end
 def move
 if ["move_lower","move_higher","move_to_top",
 "move_to_bottom"].include?(params[:method]) \
 and params[:ch_id] =~ /^\d+$/
 Chapter.find(params[:ch_id]).send(params[:method])
 end
 redirect_to(:action => "list", :id => params[:id])
 end end

Discussion

The solution enables you to sort and reorder chapter objects in a list. The first step is to set up a one-to-many relationship between Books and Chapters. In this case, the has_many class method is passed the additional :order argument, which specifies that chapters are to be ordered by the position column of the chapters table.

The Chapter model calls the acts_as_list method, which gives Chapter instances a set of methods to inspect or adjust their position relative to each other. The :scope option specifies that chapters are to be ordered by book, which means that if you were to add another book (with its own chapters) to the database, the ordering of those new chapters would be independent of any other chapters in the table.

The view displays the ordered list of chapters, each with its own links to allow the user to rearrange the list. The up link, which appears on all but the first chapter, is generated with a call to link_to, and invokes the move action of the Chapters Controller. move calls eval on a string, which then gets executed as Ruby code. The string being passed to eval interpolates :ch_id and :method from the argument list of move. As a result of this call to eval, a chapter object is returned, and one of its movement commands is executed. Next, the request is redirected to the updated chapter listing.

shows a sortable list of chapters from the solution.

Figure 3-8. A sortable list of chapters using acts_as_list

Because move uses eval on user-supplied parameters, some sanity checking is performed to make sure that potentially malicious code won't be evaluated.

The following instance methods become available to objects of a model that has been declared to act as a list:

  • decrement_position
  • first?
  • higher_item
  • in_list?
  • increment_position
  • insert_at
  • last?
  • lower_item
  • move_higher
  • move_lower
  • move_to_bottom
  • move_to_top
  • remove_from_list

See Also