147: Sortable Lists 

(view original Railscast)

The site we’re working on has a page that shows a list of Frequently Asked Questions. The site administrators would like to be able to sort the list so that the FAQs can be seen in a specific order by end users instead of in the order they were created. We’ll implement a drag and drop interface that will post the updated order back via an AJAX call to enable them to do this.

The page showing our list of FAQs.

Changing The FAQ Model

The Faq model in our application has two fields: question and answer. The first thing we need to do is to add a position attribute so that we can give each FAQ a position in the list by which it can be sorted. To do that we’ll generate a migration.

script/generate migration add_position_to_faqs position:integer

The name of the migration and the argument we’ve passed to it are enough for it to generate all of the migration code, so we can now just run rake db:migrate. (For more information on this see the migrations page on the excellent Rails Guides website.)

Updating The View Code

Next we need to add the prototype and scriptaculous libraries to the application. To do this we just add the following line to the head section of our layout file.

<%= javascript_include_tag :defaults %>

Having done that we’ll now need to make some changes to our index view file. The FAQs are displayed as an unordered list; in order to be able to identify each one we’ll have to give it a unique id. We’ll give the list itself an id too. To do that we’re going to use the content_tag_for method, which will create an HTML element, in this case an <li>, with an id attribute based on the model’s numeric id. This is how the updated index view looks.

<ul id="faqs">
  <% @faqs.each do |faq| %>
    <% content_tag_for :li, faq do %>
      <%= link_to faq.question, faq_path(faq) %>
    <% end %>
  <% end %>
<%= link_to "New FAQ", new_faq_path %>

The index view with ids for each list element.

If we refresh the page and look at the source we can see that each li element in the list now has an id of the form faq_n, where n is the model’s id.

Making The List Sortable

All we have to do to make the list sortable is add the sortable_element helper method to the index view. We’ll pass it the id of the element we want to be sortable, and a URL that is called via an AJAX request so that the updated positions can be stored in the database. As we don’t yet have a method for storing the updated positions we’ll leave the URL for now and come back to it later.

<%= sortable_element('faqs', :url => 'TODO') %>

The items in the list can now be dragged and dropped, but the new order isn’t persisted back to the database. When the page is reloaded the items are shown back in their default position. We are going to have to write a method in our FaqsController that will store the updated position orders. The controller currently has the standard seven RESTful actions for listing, showing, creating and deleting FAQs; we’ll add a new method called sort.

def sort
  params[:faqs].each_with_index do |id, index|
    Faq.update_all([’position=?’, index+1], [’id=?’, id])
  render :nothing => true

The sort method will loop through each FAQ parameter passed to it and update the position for that FAQ. The FAQ’s ids are passed in the correct updated order so we use update_all to set the position attribute of each FAQ to be its index in the list plus one.We don’t need to send anything back from the AJAX call so the method finally returns nothing.

Now that the positions are being stored we’ll make a small change to our index action to tell it to get the list of FAQs ordered by position.

def index
  @faqs = Faq.all(:order => ’position’)

There’s one further change to make, which is to the routes file. Our sort action isn’t one of the seven default actions so we’ll have to add it.

map.resources :faqs, :collection => { :sort => :post}

The line above adds the new action and makes it a POST request, which is the type that our AJAX call uses when making XMLHTTP requests. Now that we have made these changes we can go back and add the URL to the sortable_element method call that we created earlier.

<%= sortable_element(’faqs’), :url => sort_faqs_path %>

The sort_faqs_path will call our sort method in the FaqsController via AJAX. Now when we drag items about and then reload the page the items new position is persisted to the database.

Improving The UI

The list of FAQs is now fully functional, but the user interface is still a little rough around the edges. For example if we use one of the links to drag an item around then the link will be triggered and we’ll be taken to the page for that item. We can improve the interface by adding a handle to each item that will become the draggable area. In the view code we’ll add a <span> element to each item in the list.

<% content_tag_for :li, faq do %>
  <span class="handle">[drag]</span>
  <%= link_to faq.question, faq_path(faq) %>
<% end %>

The span has a class of handle, so that we can tell the sortable_element method which element within each item should be draggable. This is done by adding a handle option to the method.

<%= sortable_element(’faqs’), :url => sort_faqs_path, :handle => ’handle’ %>

To make the draggable area more obvious we’ll add some CSS for the handle so that when the cursor is over it it changes to a ’move’ cursor.

li .handle { color: #777; cursor: move; font-size: 12px; }
Dragging using the handle.

Dragging Using The Handle

Using acts_as_list

While it’s not required for our application, we’re going to use the acts_as_list plugin to supply some more functionality. We’ll and install it in the usual way.

script/plugin install git://github.com/rails/acts_as_list.git

To use it we’ll add it to our Faq model.

class Faq < ActiveRecord::Base

Now the Faq model will be treated as a sortable list. One of the advantages of using acts_as_list is that when we create a new FAQ, the position column will automatically be filled for the new FAQ.

Our list is now fully draggable. If this was a real application the next part would be to restrict the dragging functionality to administrative users only, but that is out of the scope of this episode so we’ll stop here.