Moving An acts_as_list 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 acts_as_list.

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 acts_as_list 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 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.

Andy Stewart, 29 August 2007

Posted in Rails


  1. Nice!

    Ari
    11 September 2007
  2. 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
  3. 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

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!