Sorry this page looks weird. It was automatically migrated from my old blog, which had a different layout and different CSS.

Moving Between Lists With actsaslist

How do you move an item managed by actsaslist 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 actsaslist adds to your model update records in the database directly, triggering callbacks you don’t necessarily want triggered.

So instead of using beforesave and aftersave 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:

— 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 actsaslist 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, actsaslist 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.

Comments

Thanks Andy. One thing, if you submit an unchanged category_id in your app (say, your plain old edit form has a category selection) this code will move it to the top of that category. I got around this by noting the old category_id at the beginning of the controller update method and only calling move_to_position if the category_id was changed.

Ian • 23 November 2009

Andrew Stewart • 19 March 2008 • Rails
You can reach me by email or on Twitter.