Reordering Records with acts_as_list and Metaprogramming

Posted by Brian in Howto, Metaprogramming, Rails (May 28th, 2010)

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.