Local autocomplete with Rails, Prototype, and script.aculo.us

October 21, 2008 – 8:06 pm

Server vs. Client

First of all, I think server-based autocompletion is great. The _with_auto_complete plugin is solid, and I’ve used it in several projects. But there are some times when I just prefer to have the autocompletion run locally in the browser, usually to avoid a superfluous server-call. This isn’t always practical, particularly if you’re dealing with a large data set or if your autocompletion need some kind of server-side processing.

If you’re cool with having the entire array dumped down to the client, then a local autocompleter is a great way to avoid an extra AJAX call.

Autocompleter.Local

Scriptaculous provides Autocompleter.Local for just this purpose, which works very well. Unfortunately, I’ve been unable to find a good Rails plugin that enables this functionality in a Rails-like way.

The default JavaScript usage of this (from the scriptaculous wiki) is like:

new Autocompleter.Local('bands_from_the_70s', 'band_list', bandsList, { });

where ‘bands_from_the_70s’ is the DOM ID of the field to auto-complete, ‘band_list’ is the DOM ID of the div in which to show the autocompletion options, bandsList is the array of options, and {} simply signifies the potential for some additional options.

Autocompletion and ActiveRecord

There’s one additional issue: Sometimes the text in an autocompletion field is not actually the information you want to store in the current model. Consider this example:

# /app/modes/actor.rb
class Actor < ActiveRecord::Base
  belongs_to :agency # assume agency_id
end
 
# /app/models/agency.rb
class Agency < ActiveRecord::Base
  has_many :actors
end
 
# /app/views/actors/new.html.erb
<% form_for(@actor) do |f|
  First name: <%= f.text_field :firstname %><br />
  Last name: <%= f.text_field :lastname %><br />
  Agency: <%= f.text_field :agency %>
  <%= f.submit "Save" %>
<% end %>

The problem with the “Agency” field in the form is that we want to store agency_id, so that when the model saves, it associates with the correct Agency. However, we’d like to auto-complete based on the name of the Agency, and not the ID. We could always change actor_controller.rb to run a Agency.find_by_name(params[:agency]), but I’d prefer to do this without messing with create and update. That way, it becomes controller-agnostic and also prevents an unnecessary database call on save.

My solution

After including a helper local_autocomplete (below), the following functionality will work in my views.

# /app/views/actors/new.html.erb
<% form_for(@actor) do |f|
  First name: <%= f.text_field :firstname %><br />
  Last name: <%= f.text_field :lastname %><br />
  <%= local_autocomplete(
      :form => f,                         # the form
      :field => 'agency_name',            # unique field name to use
      :for => :agency_id,                 # ActiveRecord field
      :selection_class => 'autocomplete', # CSS for div
      :field_class => '',                 # CSS for input field
      :values => @agency_names,           # autocomplete options
      :target_values => @agency_ids,      # values for storage
      :options => { :fullSearch => true,  # script.aculo.us options
                    :choices => 5}) %>
  <%= f.submit "Save" %>
<% end %>

We can assume the @agency_names and @agency_ids were populated in the controller and contain a mapping of Agency Names to Agency ID’s. One way to do that would be:

class ActorsController < ApplicationController
  def new
    @talent = Talent.new
    agencies = Agency.find(:all)
    @agency_names = agencies.collect {|agency| agency.name} rescue []
    @agency_ids = agencies.collect {|agency| agency.id} rescue []
  end
...
end

Below is my code for the helper function that allows this magic to work. If anyone has recommendations for how I can improve upon it (or feedback in general), definitely let me know. I’ve had good luck with it, and have tested it under Safari and FireFox. You shouldn’t have to mess with it; just paste it into a helper file.

module ActorHelper
 
  def local_autocomplete(args)
    # Source: http://blog.jasoncrystal.com/2008/10/22/local-autocomplete-with-rails-prototype-and-scriptaculous
    # Partially modified from: http://www.jason-knight-martin.com/v2/?p=3
 
    field_name = args[:field].camelize(:lower)
    obj_name = args[:form].object.class.to_s.downcase
    select_div_id = obj_name+"_"+args[:field]+"_select"
    options = args[:options] ||= ''
 
    scriptaculous = "Event.observe(window, 'load' , function() {#{args[:field]+'_completer'} = new Autocompleter.Local('#{args[:field]}' , '#{select_div_id}' , #{field_name}, #{options_for_javascript(options)});});"
 
    source_array = ''
    args[:values].each { |v| source_array += "'#{v}'," }.to(-2) rescue ''
 
    target_array = ''
    args[:target_values].each { |v| target_array += "'#{v}'," }.to(-2) rescue ''
 
    update_hidden_field = "fill_hidden_from_array('#{args[:field]}','#{obj_name}_#{args[:for]}',#{field_name},#{field_name}Target)"
 
    output = args[:form].hidden_field args[:for]
 
    output += <<-STR_DELIM
 
      <script>
        #{scriptaculous}
        var #{field_name} = [#{source_array}];
        var #{field_name+'Target'} = [#{target_array}];
      </script>
 
      <div id="#{select_div_id}" class="#{args[:selection_class]}" onclick="#{update_hidden_field}">
      </div>
 
    STR_DELIM
 
    output += text_field_tag args[:field], nil, :onblur => update_hidden_field, :class => args[:field_class]
 
  end
 
end

The trick that the code uses is to generate a hidden field (as part of the model) that fills in the agency_id (in this case) and a field for the text that is external to the model. Then, when the auto-complete runs, the JavaScript function fill_hidden_from_array runs and it populates that hidden field (or clears it if the user types in a new value). So, the only thing that this helper depends on is the presence of that function in your app’s JavaScript.

// in application.js
function fill_hidden_from_array(source,target,sourceValues,targetValues) {
	source = $(source);
	target = $(target);
 
	var index = 0;
	var target_id = -1;
	while (index < sourceValues.length) {
		if (source.value == sourceValues[index]) {
			target_id = index;
			break;
		}
		index++;
	}
 
	if (target_id >= 0 && source.value != ""){
		target.value = targetValues[target_id];
	}
	else {
		target.value = "";
	}
}

And, some example CSS for your autocomplete field:

div.autocomplete {
  margin:0px;  
  padding:0px;  
  width:250px;
  background:#fff;
  border:1px solid #888;
  position:absolute;
}
 
div.autocomplete ul {
  margin:0px;
  padding:0px;
  list-style-type:none;
}
 
div.autocomplete ul li.selected { 
  background-color:#ffb;
}
 
div.autocomplete ul li {
  margin:0;
  padding:2px;
  height:32px;
  display:block;
  list-style-type:none;
  cursor:pointer;
}

Conclusion

I plan to keep using this functionality in future apps. If anyone comes across this page and has feedback from their own experiences, definitely let me know and I’ll give it an update. At some point I should try to turn this into a respectable Rails plugin, but for now, copy and paste away!

  1. 2 Responses to “Local autocomplete with Rails, Prototype, and script.aculo.us”

  2. Thanks! Exactly what I’m looking for! I’ll try to implement it and give you some feedback.

    By Will on Nov 26, 2008

  3. Thanks for the useful info. It’s so interesting

    By JamesD on Jun 11, 2009

Post a Comment