Auto-completion for tag lists

See the end of this post for an important update.

Standard Rails auto-completion is geared toward completing a single value entered in a text field. For most cases this is exactly what’s needed, but there is one prominent case that is different: auto-completion of tags. If something can have a tag at all, it’s part and parcel of the deal that it can have multiple tags.

As far as I can tell, some configuration tweak is not enough to make standard auto-completion do our bidding. But not all is lost. It’s just a small matter of programming to get what we want.

I’m assuming that you’re using Acts As Taggable or Acts As Taggable On Steroids for the model-level tagging, or otherwise that you know what you’re doing.

First, we need client-side support for picking out only the tag we’re currently editing.

Ajax.TagAutocompleter = Class.create();
Object.extend(Object.extend(Ajax.TagAutocompleter.prototype, Ajax.Autocompleter.prototype), {
  getToken: function() {
    var range = this.rangeForCaret();
    var value = this.element.value.substring(range.start, range.end);
    return value;
  },
  updateElement: function(selectedElement) {
    var oldValue = this.element.value;
    var range = this.rangeForCaret();
    var prefix = oldValue.substring(0, range.start);
    var suffix = oldValue.substring(range.end);
    var value = Element.collectTextNodesIgnoreClass(selectedElement, 'informal');
    this.element.value = prefix + value + suffix;
    this.element.focus();
  },
  rangeForCaret: function() {
    var value = this.element.value;
    var start = 0, end = value.length;
    var pos;
    if (typeof this.element.selectionStart != 'undefined') {
      pos = this.element.selectionStart;
    } else if (document.selection) {
      var sel = document.selection.createRange();
      var selLength = sel.text.length;
      sel.moveStart('character', -(value.length));
      pos = sel.text.length - selLength;
    }
    if (pos != undefined) {
      end = pos;
      while (value.charAt(end) != ',' && end < value.length) {
        end++;
      }
      start = end - 1;
      while (value.charAt(start) != ',' && start > 0) {
        start--;
      }
      while (start < end && (value.charAt(start) == ' ' || value.charAt(start) == ',')) {
        start++;
      }
      while (start < end && value.charAt(end) == ' ') {
        end--;
      }
    }
    return {start: start, end: end};
  }
});

Yes, the code of rangeForCaret is a bit convoluted, let’s say traditional JavaScript-style. The general approach for getting the position of the text cursor (“caret”) is explained here. The purpose of all the stuff is to support auto-completion in the middle of the text field, not just at its end.

On the server-side, in the relevant controller, there’s still some work to do as the standard auto_complete_for can’t handle tags. Put the following method in your controller base class, app/controllers/application.rb.

def self.tag_auto_complete_for(object)
  define_method("auto_complete_for_#{object}_tag_list") do
    @items = Tag.find(:all,
      :conditions => [ 'LOWER(name) LIKE ?', '%' + params[object][:tag_list].downcase + '%' ], 
      :\order => 'name ASC',
      :limit => 10)
    render :inline => "<%= auto_complete_result @items, :name %>"
  end
end

Then, in an individual controller, create the tag auto-completion method like this

class PeopleController < ApplicationController
  tag_auto_complete_for :person
end

Almost done!

What’s still missing is a way to activate auto-completion for the tag input field. It would surely be possible to copy & change text_field_with_auto_complete or to monkey patch it appropriately. I prefer another way, in fact, I’m not using text_field_with_auto_complete at all, not even where it would work. Instead, I prefer more unobtrusive way.

The way starts with the class autocomplete to text fields that are amenable to auto-completion — including, of course, the tag list field.

  <%= f.text_field :tag_list, :class => 'autocomplete' %>

As this in itself is completely inert, we need to nudge it a bit in application.js. Here’s a function that looks for all text fields with class autocomplete and actually makes them do what they say.

function installAutocompletion() {
  $$('input.autocomplete[type=text]').each(function(element) {
    var fieldId = element.id;
    var completions = document.createElement('div');
    completions.id = fieldId + '_auto_complete';
    completions.className = 'auto_complete';
    completions.style.display = 'none';
    element.parentNode.insertBefore(completions, element.nextSibling);

    var url = 'auto_complete_for_' + fieldId;
    if (/_tag_list$/.test(fieldId)) {
      new Ajax.TagAutocompleter(element, completions, url);
    } else {
      new Ajax.Autocompleter(element, completions, url);
    }
  });
}

Be sure to call this method some time after the page is loaded. If you don’t have a function from where to call it, add this to application.js

  Event.observe(window, 'load', installAutocompletion);

Update, 2007-02-05

I may not have seen the wood for the trees when looking at the original code of the Rails/Script.aculo.us autocompleter. Of course it is fully capable of auto-completing more than one token in the same text field — with a very little configuration tweak, no less.

What it can’t do is auto-completion in the middle of the line; it only works at the end. That’s an acceptable restriction, to my mind. So, forget about Ajax.TagAutocompleter from above and instead install your autocompleters like this

function installAutocompletion() {
  $$('input.autocomplete[type=text]').each(function(element) {
    var fieldId = element.id;
    var completions = document.createElement('div');
    completions.id = fieldId + '_auto_complete';
    completions.className = 'auto_complete';
    completions.style.display = 'none';
    element.parentNode.insertBefore(completions, element.nextSibling);

    var url = 'auto_complete_for_' + fieldId;
    var options = {};
    if (/_tag_list$/.test(fieldId)) {
      options.tokens = ',';
    }
    new Ajax.Autocompleter(element, completions, url, options);
  });
}