Reordering Records with acts_as_list and Metaprogramming
Using acts_as_list, you can reorder items by adding a “position” column to your database and then easily sort records.
class Project < ActiveRecord::Base
acts_as_list
end
class Task < ActiveRecord::Base
acts_as_list
end
The acts_as_list plugin also provides you methods to manipulate the position.
- @project.move_to_top
- @project.move_to_bottom
- @project.move_higher
- @project.move_lower
In a current project, I have multiple items that need reordering, which means I'd be duplicating a lot of controller actions and other code across my controllers and views. I'll show you how I used metaprogramming to significantly reduce that duplication.
The Route To Reordering
First, we need some routes. Add this to your routes.rb
file:
%w{projects tasks}.each do |controller|
%w{move_higher move_lower move_to_top move_to_bottom}.each do |action|
instance_eval <<-EOF
map.#{action}_#{controller.singularize} "#{controller}/:id/#{action}", {:controller => "#{controller}", :action => "#{action}"}
EOF
end
end
We use an array of controllers, then we have another array of the four actions, and we just make the named routes. This generates
move_higher_project /projects/:id/move_higher {:action=>"move_higher", :controller=>"projects"} move_lower_project /projects/:id/move_lower {:action=>"move_lower", :controller=>"projects"} move_to_top_project /projects/:id/move_to_top {:action=>"move_to_top", :controller=>"projects"} move_to_bottom_project /projects/:id/move_to_bottom {:action=>"move_to_bottom", :controller=>"projects"} move_higher_task /tasks/:id/move_higher {:action=>"move_higher", :controller=>"tasks"} move_lower_task /tasks/:id/move_lower {:action=>"move_lower", :controller=>"tasks"} move_to_top_task /tasks/:id/move_to_top {:action=>"move_to_top", :controller=>"tasks"} move_to_bottom_task /tasks/:id/move_to_bottom {:action=>"move_to_bottom", :controller=>"tasks"}
Metaprogramming really cuts down on work when you use it correctly. This turns out to be a pretty nice way of generating route patterns that share common attributes.
Keep The Control Centralized
Now let's step it up a notch. Each controller is going to need the four reordering methods. The logic will be identical except for the object they work on and the URL they redirect to when they're finished.
Let's create a module that adds controller methods to match these routes. We'll add it into our controllers where we need it.
Create the file lib/reorder_controller_actions.rb
:
module ReorderControllerMethods
%w{move_higher move_lower move_to_top move_to_bottom}.each do |action|
define_method action do
klass = self.class.name.split("::").last.gsub("Controller", "").downcase.singularize.camelize.constantize
item = klass.find(params[:id])
item.send(action)
flash[:notice] = "The #{klass.to_s} was reordered."
redirect_to :action => "index"
end
end
end
That module defines four controller actions and calls the corresponding method on the model, then does a redirect back to the index action. It'll be the same everywhere I use it, so I also gain consistency with metaprogramming.
We need to add this to the bottom of config/environment.rb
so that our controllers can make use of it.
require 'reorder_controller_methods'
Then, in each controller where we need this module, we mix it in with include
class ProjectsController < ApplicationController
include ReorderControllerMethods
end
class TasksController < ApplicationController
include ReorderControllerMethods
end
Stop and REST for a second
Is it appropriate for me to do it this way? I'm going to argue that while technically the position of something could be sent to the update action, I want specific redirection and notification to occur when I change the order of elements. The logic was getting too nasty in the update action, so I split each thing apart, so I went with this approach instead.
An Improved View
Next, we need some buttons for our index pages with arrows on them so the user can trigger the ordering. How about a simple helper that can generate all four buttons for us?
def reorder_buttons(object)
thing = object.class.name.camelize.downcase
result = ""
[
{:action => "move_higher", :label => "↑"},
{:action => "move_lower", :label => "↓"},
{:action => "move_to_top", :label => "↑↑"},
{:action => "move_to_bottom", :label => "↓↓"}
].each do |item|
result << button_to("#{item[:label]}", send("#{item[:action]}_#{thing}_path", object) )
end
result
end
We use an array hold the buttons. We use hashes within the array to map the action to the label of the button. We then iterate and generate buttons for each one, concatenating to a string that we then return.
Then in our index views, we just need to call this:
<%=reorder_buttons @project %>
That generates exactly what I need - four buttons, one for each reordering action.
Wrapping Up
This solution easily allowed me to add reordering to a lot of controllers in only a few minutes. Hopefully it will help you do the same. You could improve this by storing the methods in a constant so that you didn't have to duplicate the array all the time, and I'm sure there are other improvements that I could make as well. I'd love to hear from you.