Convert a Select Drop-Down Box to an Autocomplete in Rails

I created a nice select drop-down box in Ruby on Rails 3.2.8 using SimpleForm 2.0.2 and Twitter Bootstrap 2.0.3, with authentication from CanCan 1.6.8. Since there are quite a few values in the selection list, I thought it would be nice if the user could make a selection using an autocomplete field instead. Since it’s Rails, it’ll be an easy change, right? Hoo boy.

My example is a to_contact field. Expectations:

  • After the user types two characters, display a sorted pick list of options.
  • Return the id of the selected name in to_contact_id.
  • If nothing is selected from the pick list, set the field itself and to_contact_id to an empty string.
  • The field is required. If it is not filled in, use Bootstrap formatting to highlight the field and display the message.

I use the same partial in both my new and edit views. Here is how the select box was defined in app/views/messages/_message_fields.html.erb before converting it to an autocomplete.

<%= f.association :to_contact, 
    :collection => @to_contacts, 
    :required => true,
    :value_method => :id, 
    :label_method => :display_name %>

And here are the steps to do the conversion:

1. Add 'rails3-jquery-autocomplete', '1.0.9' to your gemfile. (Had some trouble with 1.0.10 so I’m sticking with 1.0.9 for now.)

2. to_contact_id is in the Message model. Set up validation on to_contact (not to_contact_id) in the model (app/models/message.rb)l:

  validates :to_contact, :presence => true

3. to_contact_id is associated with the Contact model, which has first_name and last_name fields, and a display_name function to combine those on demand. We need a special messages route that will give us access to Contact.display_name. In config/routes.rb:

resources :messages
  get :autocomplete_contact_display_name,  :on => :collection
end 

4. Set up authorization for the new route. This will depend on the classes of users you have but basically the new authorization should mirror the :read privilege for Message and Contact. For example, in app/models/ability.rb:

can [:read, :autocomplete_contact_display_name],
       Message, :account_id => user.account_id 

The new route is named autocomplete_contact_display_name_messages_path, and will be referenced as such in the view (step 8 below). Restart your Rails web server to re-load the routes.

5. The Message controller needs a special “autocomplete” section which automagically ties back to the new route.  Also, because I want to search within two fields (first_name and last_name), I have to override the get_autocomplete_items function. Note the use of accessible_by to only return values that CanCan allows the user to see.  app/controllers/messages_controller.rb:

  # Suppress load_and_authorize_resource for actions that need special handling:
  load_and_authorize_resource :except => [:autocomplete_contact_display_name]
  # Bypass CanCan's ApplicationController#check_authorization requirement for these actions:
  skip_authorization_check :only => [:autocomplete_contact_display_name]

  # Special autocomplete function corresponds to    
  # autocomplete_contact_display_name route 
  autocomplete :contact, :display_name, :full => true

  # We want autocomplete to search within multiple fields, so override method
  # Note that first_name and last_name must be included in the SELECT list 
  # so Contact.display_name can use them to build the name returned for 
  # the autocomplete.
  def get_autocomplete_items(parameters)
    items = Contact.accessible_by(current_ability, :read).
              select("first_name, last_name, id").
              where(["LOWER(last_name || ', ' || first_name) " + 
                     "LIKE LOWER(?)", "%#{parameters[:term]}%"]).
              order("last_name, first_name")
  end

6. The autocomplete pick list won’t look right in Bootstrap until you add a bunch of CSS. Fortunately someone did that already. I’m using the bootstrap-sass gem, by the way, so I created app/assets/stylesheets/application/autocomplete.css.scss and pasted in this code:

/* Make rails3-jquery-autocomplete pick list appear correctly in Bootstrap.
   From https://gist.github.com/2168334
   Recommended by http://booleanbay.blogspot.com/2012/06/nest-of-inequity-and-despair-not-realy.html 
*/
.ui-autocomplete {
  position: absolute;
  top: 100%;
  left: 0;
  z-index: 1000;
  float: left;
  display: none;
  min-width: 160px;
  _width: 160px;
  padding: 4px 0;
  margin: 2px 0 0 0;
  list-style: none;
  background-color: #ffffff;
  border-color: #ccc;
  border-color: rgba(0, 0, 0, 0.2);
  border-style: solid;
  border-width: 1px;
  -webkit-border-radius: 5px;
  -moz-border-radius: 5px;
  border-radius: 5px;
  -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);
  -moz-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);
  box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);
  -webkit-background-clip: padding-box;
  -moz-background-clip: padding;
  background-clip: padding-box;
  *border-right-width: 2px;
  *border-bottom-width: 2px;

  .ui-menu-item > a.ui-corner-all {
    display: block;
    padding: 3px 15px;
    clear: both;
    font-weight: normal;
    line-height: 18px;
    color: #555555;
    white-space: nowrap;

    &.ui-state-hover, &.ui-state-active {
      color: #ffffff;
      text-decoration: none;
      background-color: #0088cc;
      border-radius: 0px;
      -webkit-border-radius: 0px;
      -moz-border-radius: 0px;
      background-image: none;
    }
  }
}

7. The rails3-jquery-autocomplete gem version 1.0.9 does not clear the id properly if the user blanks out the field. This is being worked on (see issue 167) and may be fixed by now. In the meantime, I’m using the following jQuery (which has a couple extra tweaks, namely adding autoFocus and clearing the main field as well as the id and data-elements). Create a new file app/assets/javascripts/autocomplete.js and paste in this code (special thanks to mechanicalfish for this StackOverflow answer):

// Automatically put focus on first item in drop-down list
$('input[data-autocomplete]').autocomplete({ autoFocus: true });

// Bind a new function to the autocomplete change event.  If   
// the "base" rails3-jquery-autocomplete also has a change event, 
// that code will execute as well. 
$('input[data-autocomplete]').bind('autocompletechange', function(event, ui) {
  // rails3-jquery-autocomplete fills in ui.item with selected
  // value, or sets it to null if no value selected.
  if(!ui.item) {            // if nothing selected
    $(this).val('');        // clear this field's value
    clearDataValues(this);  // clear values in "id" and "update_elements" fields
  }
});

function clearDataValues(autocompleteField) {
  if ($(autocompleteField).attr('data-id-element')) {      // data-id-element found
    $($(autocompleteField).attr('data-id-element')).val('');
  }
  if ($(autocompleteField).attr('data-update-elements')) { // data-update-elements found
    $.each( $(autocompleteField).data('update-elements'),function(k, v) { 
      // alert('key='+ k + ' value=' + v);
      $(v).val('');
    })
  }
};

8. Now you are finally ready to add an autocomplete field to your view. This gets tricky, because you ideally want to use SimpleForm to bubble any errors up to the field’s outermost div so that Bootstrap will turn the label and field border red. SimpleForm guru and contributor Carlos Antonio da Silva to the rescue! In this Google Groups thread, he provided a solution using the block input form. Below is the autocomplete field in app/views/messages/_message_fields.html.erb. The name of the main field is to_contact, which must correspond to the name being validated in the model (step 2 above). Note the addition of a hidden field to receive the to_contact_id and pass it back to the controller for updating the database. There are also a couple extra lines to retrieve and populate the actual field value if it is already filled in:

<% to_contact_name = @message.to_contact ? 
      @message.to_contact.display_name : '' %>
<%= f.input :to_contact, :required => true do %>
  <%= autocomplete_field_tag "to_contact_name", '',
        autocomplete_contact_display_name_messages_path,
        :value => to_contact_name, 
        :id_element => "#to_contact_id" %>
  <%= f.hidden_field :to_contact_id, :id => "to_contact_id" %>
<% end %>

The Completed Autocomplete

And now, you should see a working autocomplete on your page:

rails autocomplete

So yeah, that wasn’t as easy as I’d hoped but at least it’s working.

Update November 3, 2012:  Testing

I had quite a bit of trouble getting RSpec/Capybara tests to fill in the autocomplete field. My current approach, which only works with the Selenium driver, is outlined in this StackOverflow answer.

Update November 5, 2012:  Autofocus

If you want to use the HTML5 autofocus attribute to automatically focus on an autocomplete field when the form loads, add the following to your autocomplete.js file:

// If autocomplete field has an HTML5 autofocus attribute, re-focus after 
// the form load completes. This causes rails3-jquery-autocomplete to  
// initialize the autocomplete, including setting up the JSON data source. 
$(document).ready(function(){
   $('input[data-autocomplete][autofocus]').focus(); 
}); 

For details on why this is needed, see rails3-jquery-autocomplete issue #185.

2 thoughts on “Convert a Select Drop-Down Box to an Autocomplete in Rails

  1. Vojtech Kusy

    Hi Mark,

    I’ve had similar issue and I’ve found better solution IMHO.

    In your model:

    validates :to_contact_id,   :presence => true
    after_validation :validate_contact
    
    protected
    
    def validate_contact
      if errors[:to_contact_id].present?
        errors[:to_contact_id].each { |message| errors.add :to_contact, message }
      end
    end

    This way errors on to_contact_id are just copied to the right form element, to_contact in your case, so you can define the simple_form in a usual way without any blocks magic and as a benefit there is no additional DB query…

Leave a Reply

Your email address will not be published. Required fields are marked *

Notify me of followup comments via e-mail. You can also subscribe without commenting.