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.
After a while the top two in each category are:
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:
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 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
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
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
ActiveRecord’s lifecycle callbacks to insert the item into its new list once the category has been changed. In
We set the position to nil so that the insertion in the
def before_update if self.category_id != Item.find(self.id).category_id @the_position, self.position = self.position, nil end end
after_updatecallback works as we would expect:
And we add a method to adjust the position if it is too high or low for the new list:
def after_update if @the_position pos, @the_position = @the_position, nil self.insert_at(position_in_bounds(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
def position_in_bounds(pos) if pos < 1 1 elsif pos > self.category.items.length self.category.items.length else pos end end
move_to_positioncode will sort it all out with this amendment:
## 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.
def move_to_position(position) self.insert_at position_in_bounds(position) end