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

Moving An actsaslist Child To A New Parent

UPDATE: this has been superseded by a technique that works on Rails 2. Use that instead.

This isn’t hard but I’m embarrassed by how long it took me to get right! To save others from the same feeling of ineptitude, here’s what to do.

Let’s say we’re building a Hot Or Not webapp. It’s so money that two whole people are adding and reordering items. So far it’s trivial with actsaslist.

After a while the top two in each category are:

Hot

  1. Angelina Jolie
  2. The Taj Mahal

Not

  1. Manchester United
  2. David Hasselhof

Moving An Item Within Its List

Suddenly one of our people decides The Hoff is Hot. (I know, bear with me, it’s a contrived example.) With actsaslist it’s not so obvious how to move a child from one parent to another. What to do?

Let’s say we have categories and items like this:

class Category < ActiveRecord::Base
  has_many :items, :order => :position
end

class Item < ActiveRecord::Base
  belongs_to :category
  acts_as_list :scope => :category
end

And the edit item page on the GUI gives you a dropdown list of categories and a dropdown list of positions from 1 to the number of items in the category.

To move an item around within a category, the user simply changes the position with the dropdown. Here’s the code that makes this work:

class ItemsController < ApplicationController
  def update
    @item = Item.find params[:id]
    # Extract the position so we can work with it
    # independently of the other attributes.
    new_position = params[:item].delete(:position).to_i
    if @item.update_attributes(params[:item])
      @item.move_to_position new_position
      flash[:notice] = 'You da man'
      redirect_to item_url(@item)
    else
      # Restore position user chose
      @item.position = new_position
      render :action => 'edit'
    end
  end
end

class Item < ActiveRecord::Base
  # ... As above

  def move_to_position(position)
    self.insert_at position
  end
end
So far, so good. But what happens if the user chooses a different category for an item? The old category will be left with a hole in its list of items where this one used to be and the item’s position will be all wrong in its new category. ## Moving An Item To A Different List Bearing in mind the [skinny controller, fat model](http://weblog.jamisbuck.org/2006/10/18/skinny-controller-fat-model) pattern, we push the logic down into the model:
class Item < ActiveRecord::Base
  # ... As above
  
  def category_id=(category_id)
    if self[:category_id] != category_id
      self.remove_from_list
      self[:category_id] = category_id
    end    
  end
end
When we change an item’s category we remove it from its parent’s list. This shuffles the other members of the list appropriately so no hole is left behind. But how do we insert the item into its new parent’s list? We want to preserve the position if possible but adjust it sensibly if it is beyond the range of the existing items. We use a couple of ActiveRecord’s lifecycle callbacks to insert the item into its new list once the category has been changed. In Item:
def before_update
  if self.category_id != Item.find(self.id).category_id
    @the_position, self.position = self.position, nil
  end
end
We set the position to nil so that the insertion in the after_update callback works as we would expect:
def after_update
  if @the_position
    pos, @the_position = @the_position, nil
    self.insert_at(position_in_bounds(pos))
  end
end
And we add a method to adjust the position if it is too high or low for the new list:
def position_in_bounds(pos)
  if pos < 1
    1
  elsif pos > self.category.items.length
    self.category.items.length
  else
    pos
  end
end
## Moving An Item To A New List And Changing Its Position But what if the user wants make The Hoff hot and, at the same time, bump Angelina from the top spot? Yup, you’re right: delete this idiot’s account and block his IP. Hypothetically, though, all we need to do is listen for changes in the categories' dropdown and update the positions' dropdown with the new category’s range in an AJAX stylee. Then our move_to_position code will sort it all out with this amendment:
def move_to_position(position)
  self.insert_at position_in_bounds(position)
end
## Conclusion Unit tests are essential. This isn’t desperately complicated but it was clearly a bit much for my brain when I started. Proceeding test-first allowed me to approach the problem methodically and solve it.

Comments

Nice!

Ari • 11 September 2007

Thank you for this excellent solution. You may be interested in my adaptation for a view using sortable_element dragging to reorder a list within a single model (acts_as_tree / acts_as_list).

The model:

class Category < ActiveRecord::Base

  acts_as_tree :order => :position
  acts_as_list :scope => :parent_id
  has_many :settings

  validates_presence_of :name

  # enable a reassignment 
  def parent_id=(parent_id)
    if self[:parent_id] != parent_id
      self.remove_from_list
      self[:parent_id] = parent_id
    end    
  end

  def before_update
    @new_parent = self.parent_id != Category.find(self.id).parent_id ? true : nil
  end

  def after_update
    if @new_parent
      self.insert_at
      self.move_to_bottom
    end
  end
end

The controller:

class CategoriesController < ApplicationController

    def update
      @category = Category.find(params[:id])
      posn = @category.position

      respond_to do |format|
        if @category.update_attributes(params[:category])
          @category.insert_at posn
          flash[:notice] = 'Category was successfully updated.'
          format.html { redirect_to(@category) }
          format.xml  { head :ok }
        else
          format.html { render :action => "edit" }
          format.xml  { render :xml => @category.errors, :status => :unprocessable_entity }
        end
      end
    end

end

I find it odd that #update_attributes writes a nil value for category.position, and I presume this is because I am not using that attribute in the edit view.

Gary

Gary Fleshman • 29 October 2007

Gary, thanks for sharing that code.

#update_attributes only updates values for the keys in the hash it is given. If your @category.update_attributes is writing nil for position, you must be passing in an empty value for position from the view somewhere. Your log should show the keys and values in the params hash.

Anyway, thanks for the contribution. I’ll use your approach when I next do draggable reordering.

Andy Stewart • 29 October 2007

I had the same problem on Rails 2.3.4, solved it this way:

  before_update :track_parent

  def track_parent
    # if moving to the new parent - update positions
    if changes.keys.include?('category_id') && self.position
      new_id = self.category_id 
      self[:category_id] = changes['category_id'][0]
      decrement_positions_on_lower_items
      self[:category_id] = new_id
      add_to_list_bottom
    end
  end

Laurynas • 26 February 2010

Andrew Stewart • 29 August 2007 • Rails
You can reach me by email or on Twitter.