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
Not
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.
Posted in Rails

3 Comments
Jump to comment form