Filtering auto_complete pick lists

I’ve written a lot here during the past few months about the auto_complete plugin and how to get it to work with repeated text fields on a complex form. Back in January I modified Ryan Bates’s complex forms sample app to illustrate how to use my version of the auto complete plugin to handle repeated text fields. Here’s what the form looks like in that sample app:

Here as the user types into a “Task” text field, a list of all of the existing task names in the system that match the characters typed in by the user are displayed in a drop down list. But what if I didn’t want to display all of the matching task names? What if I wanted to display only the tasks for a given project? Or if I wanted to filter the task names in some other way based on other field values?

In this simple example, what if I only wanted to display Tasks 2a, 2b and 2c, since they belonged to Project Two?

Today I took a look at this problem and expected to see a number of simple solutions, but instead I was surprised to find that it is fairly difficult to do this. I got started by reading this nice solution from Andrew Ng (nice work Andrew!). Andrew explains how to get the Prototype javascript code to pass an additional HTTP parameter to the server when the user types into an autocomplete field. This additional parameter can then be used to filter the list of auto complete options differently. I’ll let you read the details, but basically Andrew found that you can use a Javascript callback function like this to load a value from another field on your form, and pass it to the server in the Ajax request as an additional query string parameter:

<script type="text/javascript">
  new Ajax.Autocompleter(
    'task_name', 
    'task_name_auto_complete', 
    '/projects/auto_complete_for_task_name', 
    { callback: function(e, qs) {
        return qs + '&project=' + $F('project_name');
      }
    }
  );
</script>

(I’ve renamed the variables to use my project/tasks example.) What I didn’t like about this was the need to manually code all of this javascript; there must be a way to get the auto_complete plugin to do this instead… and there is! If you look at the definition of text_field_with_auto_complete in auto_complete_macros_helper.rb, you’ll see that it takes both tag_options and completion_options as parameters, and eventually calls auto_complete_field with the completion_options. Here’s what auto_complete_field looks like in the auto_complete plugin:

def auto_complete_field(field_id, options = {})
  function =  "var #{field_id}_auto_completer = new Ajax.Autocompleter("
  function << "'#{field_id}', "

etc...

  js_options = {}
  js_options[:tokens] = etc...
  js_options[:callback]   =
    "function(element, value) { return #{options[:with]} }" if options[:with]
  js_options[:indicator]  = etc...
  js_options[:select]     = etc...
  js_options[:paramName]  = etc...

etc...

  function << (', ' + options_for_javascript(js_options) + ')')
  javascript_tag(function)
end

If you look closely at the line I bolded above, you’ll see that we can actually generate Andrew’s Javascript callback function automatically by simply passing in a value for the “with” completion option when we call text_field_with_auto_complete in our view, like this:

text_field_with_auto_complete :task, :name, {},
  {
    :method => :get,
    :with =>"value + '&project=' + $F('project_name')"
  }

Again, this line of Javascript code is called when the user types into the task name field, and appends “&project=XYZ” to the query string for the Ajax request. “XYZ” is the name of the project typed in by the user on the same form, loaded with prototype’s “$F” (Form element get value) function. The “:method => :get” completion option is used to avoid problems with CSRF protection; see http://www.ruby-forum.com/topic/128970. If you look at your server’s log file, you’ll see HTTP requests that look something like this now:

127.0.0.1 - - [13/Mar/2009:16:17:03 EDT] "
GET /projects/auto_complete_for_task_name?task%5Bname%5D=T&project=Project%20Two
HTTP/1.1" 200 57

Here we can see the “auto_complete_for_task_name” route is called and given two request parameters: “task[name]” and “project”. The task name is the standard parameter generated by the autocompleter javascript, and “project” is the additional parameter created by the callback function generated by the :with option.

Now… how do we handle the “project” parameter in our controller code? Without modifying the auto_complete plugin itself, you would have to write your own controller method and not use the “auto_complete_for” macro at all. Andrew shows how to do this on his blog. What I want to explore here now is whether there’s a way to change the auto_complete_for method to allow for customizations of the query used to load the auto complete options.

To understand the problem a bit better, let’s take a look at how “auto_complete_for” is implemented in the auto_complete plugin:

def auto_complete_for(object, method, options = {})
  define_method("auto_complete_for_#{object}_#{method}") do
    find_options = { 
      :conditions => [ "LOWER(#{method}) LIKE ?", '%' +
        params[object][method].downcase + '%' ], 
      :order => "#{method} ASC",
      :limit => 10 }.merge!(options)
    @items = object.to_s.camelize.constantize.find(:all, find_options)
    render :inline => "<%= auto_complete_result @items, '#{method}' %>"
  end   
end

When this is called as your Rails application initializes, it adds a new method to your controller called something like “auto_complete_for_task_name” with your model and column names instead. What we want to do is filter the query results differently, by using a new HTTP parameter – so we need to modify the “conditions” hash passed into find :all. At first I tried to do this by passing in different values for the “options” parameter, since that’s merged with the default options and then passed into find :all. However, the problem with this approach is that whatever you pass in using “options” will not have access to the request parameters, since it’s passed in when the controller is initialized, and not when the HTTP request is received.

So the solution is to pass in a block that is evaluated when the request is received, and when the generated method is actually called. I wrote a variation on auto_complete_for called “filtered_auto_complete_for:”

def filtered_auto_complete_for(object, method)
  define_method("auto_complete_for_#{object}_#{method}") do
    find_options = { 
      :conditions => [ "LOWER(#{method}) LIKE ?", '%' +
        params[object][method].downcase + '%' ], 
      :order => "#{method} ASC",
      :limit => 10 }
    yield find_options, params
    @items = object.to_s.camelize.constantize.find(:all, find_options)
    render :inline => "<%= auto_complete_result @items, '#{method}' %>"
  end
end

Filtered_auto_complete_for takes a block and evaluates it when the actual HTTP Ajax request is received from the auto complete Javascript. The block is provided with the find options hash and also the request parameters. This enables the controller’s block to modify the find options in any way it would like, possibly using the HTTP request parameters provided. I’ve also removed the options parameter since that’s not necessary any more.

As an example, here’s my sample app’s controller code:

class ProjectsController < ApplicationController

  # Handle auto complete for project names as usual:
  auto_complete_for :project, :name

  # For task name auto complete, only display tasks
  # that belong to the given project: 
  filtered_auto_complete_for :task, :name do | find_options, params|
    find_options.merge!(
      {
        :include => :project,
        :conditions => [ "LOWER(tasks.name) LIKE ? AND projects.name = ?",
                         '%' + params['task']['name'].downcase + '%',
                         params['project'] ],
        :order => "tasks.name ASC"
      }
    )
  end

  def index
    @projects = Project.find(:all)
  end

  etc...

The code in this sample block modifies the find_options by adding “:include => :project”. This causes ActiveRecord to use a JOIN to include columns from the project table in the query (in this sample app Project has_many Tasks, and each Task belongs_to a Project). Then it matches on the project name, in addition to the portion of the task name typed in by the user so far. This limits the auto complete values to just the tasks that belong to the given project:

When I have time during the next few days I’ll add “filtered_auto_complete_for” to my forked version of the auto_complete plugin… first I need to write some unit tests for it, and be sure it works as intended. After that, I’ll post this sample app back on github and you can try it yourself.