Moving Between Lists With acts_as_list

How do you move an item managed by acts_as_list from one parent to another? Moving items within a list is well documented, but there’s scant information on moving between lists.

I wrote about this previously in a pre-Rails 2 world. This is an update for Rails 2 and — happy days — it’s simpler.

Intra-List Movement

Here’s the basic set-up. This is all you need for intra-list movement.


class Category
  has_many :products, :order => :position
end

class Product
  belongs_to   :category
  acts_as_list :scope => :category
end

Inter-List Movement

When we move a product between categories, we need first to remove it from its current category’s list; and afterwards to insert it at the correct position in its new category’s list.

This is slightly tricky because the instance methods acts_as_list adds to your model update records in the database directly, triggering callbacks you don’t necessarily want triggered.

So instead of using before_save and after_save callbacks to tinker with the old and new categories’ lists, you need to get involved at the point where you change the product’s category.


class Product
  belongs_to   :category
  acts_as_list :scope => :category

  def category_id=(category_id)
    p = position
    remove_from_list if (p && valid?)
    super
    insert_at position_in_bounds(p) if (p && valid?)
  end

  private

  def position_in_bounds(pos)
    length = category.products.length
    length += 1 unless category.products.include? self
    if pos < 1
      1
    elsif pos > length
      length
    else
      pos
    end
  end
end    

The Controller

Assuming your GUI for editing a product has:

  • a dropdown list of categories to which the product can belong;
  • a dropdown list of positions within its category’s list;
  • AJAX that refreshes the positions when a different category is chosen;

— then your controller would look like this:


class ProductsController
  def update
    @product = Product.find params[:id]
    new_position = params[:product].delete(:position).to_i
    if @product.update_attributes params[:product]
      @product.move_to_position new_position
      redirect_to product_url(@product)
    else
      @product.position = new_position
      render :action => 'edit'
    end
  end
end

The only difference from a normal update is the special handling of position. Why do we treat position differently? Because acts_as_list executes SQL updates directly on the database, independently of our model’s updates. We only want this under-the-covers SQL update to take place if the model’s own update goes through successfully.

For this to work, we need to define one further method on our model.


class Product
  def move_to_position(position)
    insert_at position_in_bounds(position)
  end
end

Conclusion

By its nature, acts_as_list needs to update multiple records when you change a model’s position. This update saves your model, which may come at an inconvenient time for you. It’s easier, therefore, to avoid the usual lifecycle callbacks and instead operate at the point where the parent is changed.

Andy Stewart, 19 March 2008

Posted in Rails


Have your say

You can use Markdown in your comments. If you want to post code, do this:

<pre><code class="ruby|javascript|css|html">your code here</code></pre>

Thanks!