NoobOnRails

Ruby on Rails tips, tricks and hints for the aspiring rails hero.



Wednesday, February 21, 2007

acts_as_list makes lists drop dead easy


Using ActiveRecord's acts_as_list makes dealing with listed items a breeze. A lot of the examples I have seen assume that you'd be using acts_as_list on a parent's child items, the belongs_to side of a has_many relationship. Chances are, most of the time you'll be using acts_as_list, you'll be using it in the same situation but you can use it on a childless object as well. Here's the code...

For this example, I'll go with a list of Books that I am displaying in a list, I have a Book.rb model and a Books controller and I'm listing books in my Books < style="font-weight: bold;">script/generate add_position_to_books_table

That creates the actual migration file. The insides of that file could look something like this...


class AddPositionToBook <>
def self.up
add_column :books, :position, :integer
#i didn't add a default becuase I'm going to set one in the controller

#if I currently had a list of books, I want to go through them and line them up
#so I need to reset the column information for the books table so that I can
#acutally use the position column in this same migration file
Book.reset_column_information


#have to grab all the books so I can cycle through them
book = Books.find(:all)

#iterate through the books and for each one, grab the actual book and it's
#position in the array so I can use their position to set their position, if
#that makes sense
book.each_with_index do |b, i|
b.position = i+1
#save it with the bang so it I did something naughty, it'll blow up on me
#it's not necessary b.save should work too
b.save!
end

end

def self.down
#remove the column I just added because not every migration is perfect
remove_column :books, :position
end
end


Cool. Now we can rake db:migrate from the command line to get the column in our database. Now we have the column, let's put it to work.

In my Book.rb model, I can stick acts_as_list near the top for readability. Something like...

class Book <>
acts_as_list
#other stuffs like validations, spiffy methods etc
end


will work. Hot, we're so close...kind of. Now Let's look at the actual list view.

In my view I'm keeping things real simple. I just have all my books listed as s in one big table, nothing special. of course I have the table and th declarations above this but here...


<% for book in @books %>
<tr>
<td><%=h book.title %></td>
<td><%=h book.description</td>
</tr>
<% end %>



...is where the real magic happens. Now let's throw our position goodies in there.

In my view, maybe after my description <td>, I can add a section for position. I would also have to add the corresponding <th> header but I'll just show you the position row...

<td><%=h book.position %> </td>

Ok so the positions are now showing on the list. But if I think about it, I also need to add it to the form for when I add a new book. It would also be cool if I could also have it fill with what should be the last position. So let's add a field to the form for position and then move on to the controller. Here's the field in the form partial...

<td></td>
<td><%= text_field 'book', 'position' %></td>


Cool. Now I have it in the form and I could submit a test one so I could see it create successfully in the server log. But I want it to already be filled in when I first get to the new book form. I want it to prefill with the last position number. I'm picky like that. Here's how I made it happen...

in the new method...


def new

#grab all of the books so we know how many we have
b = Book.find(:all)

#create a new book object because this is the new method
@book = Book.new

#if we have less than one book, let's just set this position value to 1
if b.size < 1
@book.position = 1
else

#if we have more than one book, let's get the position of the last book
#and set this new book's position (in the form field at least), to
#1+ that value
@book.position = b.last.position+1
end
end




Now we move into the Create method to make sure our users don't try to put in crazy spaced out values. If my last book is at a position of 6, I don't want to be able to put in something with the position of 4535. So, in my create method in my Book controller, I put this...


def create
#first with grab the book params hash from the request sent in by the form
@book = Book.new(params[:book])

#get an array of all of the books just so I can find out how many I have
#this is probably would not be the best use of resources if I had
#millions of books, beats me
b = Book.find(:all)

if @book.save
#if the book is saved, let's check it's position against that array
#of books we just created
if @book.position > b.last.position+2

#if the books position is greater than my last book in my list
#change it to fit right after the last one, for example
#if my last item has a position of 6, let's set this new one to
#a position of 7
@book.position = b.last.position+1
end
flash[:notice] = 'Your Book was successfully created.'
redirect_to :action => 'list'
else
render :action => 'new'
end
end



Cool. Now that's working. But currently, the only way to edit the position is to manually edit each book. We're better than that. Let's add some buttons to in the list view that will let us move the books up or down the list.

In the list view, I turn this...

<td><%=h book.position %> </td>

into this...


<td><%=h book.position %>

<% unless @book.first.position == book.position %>

<%= link_to "up", { :action => 'move', :method => 'move_higher', :book_id => book.id } %>
<%= link_to "top", { :action =>'move', :method => 'move_to_top', :book_id => book.id } %>

<% end %>

<% unless book.last? %>

<% unless book.position == (@books.first.position || @books.last.position) %> | <% end %>

<%= link_to "down, { :action => 'move', :method => 'move_lower', :book_id => book.id } %>
<%= link_to "bottom, { :action => 'move', :method => 'move_to_bottom', :book_id => book.id } %>

<% end %>

</td>




Woah. Now what the heck is that doing right? If you want to know, either go grab Rob Orsini's Rails Cookbook from which I grabbed most of this code. The links, each one really, call the 'move' method in our books controller as well as pass a method param which specifies which acts_as_list method we want that link to trigger. We also pass the book_id so our app knows which book to move. "But there's no move method in the books controller!!" I can hear you scream. Don't fret, here it is...



def move

if ["move_lower", "move_higher", "move_to_top", "move_to_bottom"].include?(params[:method]) and params[:book_id] =~ /^\d+$/
#if the incoming params contain any of these methods and a numeric book_id,
#let's find the book with that id and send it the acts_as_list specific method
#that rode in with the params from whatever link was clicked on
Book.find(params[:book_id]).send(params[:method])
end
#after we're done updating the position (which gets done in the background
#thanks to acts_as_list, let's just go back to the list page,
#refreshing the page basically because I didn't say this was an RJS
#tutorial, maybe next time
redirect_to :action => :list
end



And that is it! Clicking on the up, top, down, bottom buttons on your list page will move the items up and down. If you need to tweak acts_as_taggable just a touch, mess with the scope or anything like that, check out what the rails edge docs have to say here.

4 Comments:

  • At 7:42 AM, Blogger Unknown said…

    Appreciate the help - I have a noob question tho. The table I want to add the position column to is many-to-many, the (usually elegant) rails syntax is tripping me up a bit. If my table is called books_users what would the syntax of reset_column_information be? ie: Books is to Book.reset_column_information as BooksUsers is to? I have tried a number of combos but nothing seems to work for me! Feedback appreciated.

     
  • At 5:21 AM, Blogger elemental said…

    Thank you, saved me a chunk of time working this out!! Nice one.

    One small error: in the migration,
    book = Books.find(:all)

    should be:
    book = Book.find(:all)

    Cheers

     
  • At 6:11 AM, Blogger elemental said…

    Some more errors/typos:

    missing quotes in book index - should be


    <%= link_to "up", { :action => 'move', :method => 'move_higher', :book_id => book.id } %>
    <%= link_to "top", { :action =>'move', :method => 'move_to_top', :book_id => book.id } %>


    Also - to make it Rails 2.0 REST compatible I moved the "move" functionality to the index method:
    (note I have also got sorting functionality here... you might want to comment it out if you dont need it)

    # GET /books
    # GET /books.xml
    def index

    @page_title = 'Listing books'
    sort_by = params[:sort_by] || 'position' # <-- you might not need this

    if ["move_lower", "move_higher", "move_to_top", "move_to_bottom"].include?(params[:method]) and params[:book_id] =~ /^\d+$/
    #if the incoming params contain any of these methods and a numeric book_id,
    #let's find the book with that id and send it the acts_as_list specific method
    #that rode in with the params from whatever link was clicked on
    Book.find(params[:book_id]).send(params[:method])

    respond_to do |format|
    format.html { redirect_to(admin_books_url) }
    format.xml { head :ok }
    end
    return
    end

    # take out ":order => sort_by," if you dont need it.
    @books = Book.paginate :page => params[:page], :order => sort_by, :per_page => 10

    respond_to do |format|
    format.html # index.html.erb
    format.xml { render :xml => @books }
    end
    end

     
  • At 12:27 PM, Anonymous Anonymous said…

    there seems to be a formatting problem on this post... when you are trying to display (for example) rails view pages, the code open/close delimiters are not rendered as the browser thinks they're tags. You need to substitute & lt; (I had to put a space in there to make it render and not translate!)

    so I'm not seeing much of your code examples

     

Post a Comment

<< Home