Advertise here!

Prototype based Tag Autocompletion

I've been working on a website that uses tags internally. The acts_as_taggable plugin has been great, but it only takes care of the ActiveRecord portion of tagging. One of the pieces of functionality I needed was to create an autocompleter for users to enter a list of tags on a record. Below is a pure copy-paste dump of my implementation. I'm not claiming it's good code or the right way top do things, but for those looking to use an autocompleter for a text field holding tags might find it useful. The implementation is close to that used by delicious in the tags field on posting new bookmarks.

Here's an example usage:

<label for="tags">Tags</label>
<%= text_field_tag 'tags', @tags.join(' '), :size => 80, :autocomplete => "off" %>
<div class="auto_complete" id="tags_auto_complete"></div>

And the prototype-based class in javascript. You'll probably want to stick this in your application.js file if you're using Rails.

String.prototype.trim = function(){ return this.replace(/^\s+|\s+$/g,'') }

Autocompleter.Tag = Class.create();
Autocompleter.Tag.prototype = Object.extend(new Autocompleter.Base(), {
  lastEdit: '',
  currentTag: {},
  initialize: function(element, update, array, options) {
    this.baseInitialize(element, update, options);
    this.options.array = array;
  },
  
  getCurrentTag: function() {
	if(this.element.value == this.lastEdit) return true // no edit
	if(this.element == '') return false
	this.currentTag = {}
	var tagArray=this.element.value.toLowerCase().split(' '), oldArray=this.lastEdit.toLowerCase().split(' '), currentTags = [], matched=false, t,o
	for (t in tagArray) {
		for (o in oldArray) {
			if(typeof oldArray[o] == 'undefined') { oldArray.splice(o,1); break }
			if(tagArray[t] == oldArray[o]) { matched = true; oldArray.splice(o,1); break; }
		}
		if(!matched) currentTags[currentTags.length] = t
		matched=false
	}
	// more than one word changed... abort
	if(currentTags.length > 1) {
	  // hideSuggestions(); TODO Fix this!
	  return false;
	}
	this.currentTag = { text:tagArray[currentTags[0]], index:currentTags[0] }
	return true
  },

  getUpdatedChoices: function() {
    this.updateChoices(this.options.selector(this));
  },
  
  onKeyPress: function(event) {
    if(this.active)
      switch(event.keyCode) {
       case Event.KEY_TAB:
       case Event.KEY_RETURN:
         this.selectEntry();
         Event.stop(event);
       case Event.KEY_ESC:
         this.hide();
         this.active = false;
         Event.stop(event);
         return;
       case Event.KEY_LEFT:
       case Event.KEY_RIGHT:
         return;
       case Event.KEY_UP:
         this.markPrevious();
         this.render();
         if(navigator.appVersion.indexOf('AppleWebKit')>0) Event.stop(event);
         return;
       case Event.KEY_DOWN:
         this.markNext();
         this.render();
         if(navigator.appVersion.indexOf('AppleWebKit')>0) Event.stop(event);
         return;
      }
     else 
       if(event.keyCode==Event.KEY_TAB || event.keyCode==Event.KEY_RETURN || 
         (navigator.appVersion.indexOf('AppleWebKit') > 0 && event.keyCode == 0)) return;
    this.lastEdit = this.element.value;

    this.changed = true;
    this.hasFocus = true;

    if(this.observer) clearTimeout(this.observer);
      this.observer = 
        setTimeout(this.onObserverEvent.bind(this), this.options.frequency*1000);
  },
  
  updateElement: function(selectedElement) {
    if (this.options.updateElement) {
      this.options.updateElement(selectedElement);
      return;
    }
    var value = '';
    if (this.options.select) {
      var nodes = document.getElementsByClassName(this.options.select, selectedElement) || [];
      if(nodes.length>0) value = Element.collectTextNodes(nodes[0], this.options.select);
    } else
      value = Element.collectTextNodesIgnoreClass(selectedElement, 'informal');
    
    var lastTokenPos = this.findLastToken();
    if (lastTokenPos != -1) {
      var newValue = this.element.value.substr(0, lastTokenPos + 1);
      var whitespace = this.element.value.substr(lastTokenPos + 1).match(/^\s+/);
      if (whitespace)
        newValue += whitespace[0];
      this.element.value = newValue + value;
    } else {
      tagArray = this.element.value.trim().split(' ')
      if (!this.getCurrentTag()) return;  // We had a problem getting the current tag, just return?
      tagArray[this.currentTag.index] = value;
      this.element.value = tagArray.join(' ');
    }
    this.element.focus();
    
    if (this.options.afterUpdateElement)
      this.options.afterUpdateElement(this.element, selectedElement);
  },

  setOptions: function(options) {
    this.options = Object.extend({
      choices: 10,
      partialSearch: true,
      partialChars: 2,
      ignoreCase: true,
      fullSearch: false,
      selector: function(instance) {
          var ret       = []; // Beginning matches
        var partial   = []; // Inside matches
        var entry     = instance.getToken();
        
        entry = entry.trim().split(' ').pop()
        
        var count     = 0;

        for (var i = 0; i < instance.options.array.length &&  
          ret.length < instance.options.choices ; i++) { 

          var elem = instance.options.array[i];
          var foundPos = instance.options.ignoreCase ? 
            elem.toLowerCase().indexOf(entry.toLowerCase()) : 
            elem.indexOf(entry);

          while (foundPos != -1) {
            if (foundPos == 0 && elem.length != entry.length) { 
              ret.push("<li><strong>" + elem.substr(0, entry.length) + "</strong>" + 
                elem.substr(entry.length) + "</li>");
              break;
            } else if (entry.length >= instance.options.partialChars && 
              instance.options.partialSearch && foundPos != -1) {
              if (instance.options.fullSearch || /\s/.test(elem.substr(foundPos-1,1))) {
                partial.push("<li>" + elem.substr(0, foundPos) + "<strong>" +
                  elem.substr(foundPos, entry.length) + "</strong>" + elem.substr(
                  foundPos + entry.length) + "</li>");
                break;
              }
            }

            foundPos = instance.options.ignoreCase ? 
              elem.toLowerCase().indexOf(entry.toLowerCase(), foundPos + 1) : 
              elem.indexOf(entry, foundPos + 1);

          }
        }
        if (partial.length)
          ret = ret.concat(partial.slice(0, instance.options.choices - ret.length))
        return "<ul>" + ret.join('') + "</ul>";
      }    
    }, options || {});
  }
});
      
Posted at 3pm on 09/19/06 | Posted in , , , , | 11 responses | read on

RailsConf and Rails 1.2

I meant to blog as the Rails Conference progressed, but the wireless network (and my own shame of my Wintel notebook) kept me from doing so. So here's my very quickly summarized notes on what I saw...

The keynotes that stuck out most for me were Paul Graham's, Nathaniel Talbott's and DHH's. The first two were not technology focused, so much as people-oriented, and DHH's was very much about the future of Rails as he sees it.

Paul Graham's Keynote

Update: Looks like Paul already has posted this essay online.

First I should mention that Paul Graham is even more entertaining in person than his essays, which is saying quite a bit.

Paul Graham's keynote focused on technology and startups, particularly on the concept that being marginal/specialized is a great quality to pursue, and leads to most innovation. The talk was essentially about the fact that the conference attendees were highly marginalized in their technology choice - Ruby and Rails - but that being at the leading edge and risking yourself was the best way to get ahead. I guess the crux of it was that too many people play it safe in too many ways, not just about technology choices, but also in careers. The talk was really an extension of his series (and Y-Combinator), prodding us alpha geeks to go out and try our hand at becoming the next big startup.

Nathaniel Talbott's Keynote

Nathaniel's speech was actually quite similar to Paul's. Their speaking styles and the analogies they drew were quite dissimilar, but their basic aim was the same: to urge us to strike out on our own and pursue our passion.

Nathaniel drew analogies between homesteading in the 1800's to creating your own business in modern times. The technology we have now allows us to create services and business with minimal overhead and a very, very small team - similar to the way the Transcontinental Railroad gave way to east coast workers to strike out, stake some land and begin a new life.

The analogy I most enjoyed, that was added from a suggestion when he gave a preparatory run through of his talk at a local user group, is the idea of "barnraising" (yes that site got up over night after his talk). The basic premise being that we have a lot of smart rails geeks here, why not help start actually implementing one another's ideas? The idea isn't particularly unique, but the concept of the Rails community helping one another out in launching their businesses really hit home for a lot of attendees. I know I've personally got about 20 small Rails projects that I started quickly and scrapped just as quickly because I didn't have time, or needed encouragement. I'm really interested to see if this has sparked any groups of attendees into hacking up some quick projects (a la Railsday).

DHH's Keynote

David's keynote focused on a couple things:

First, that Rails was successful because it often said "no" where other frameworks would say yes in terms of accepting functionality. So the calls to improve and extened various parts of Rails to help extend it into the corporate/enterprise world were alluring but if heeded too blindly or easily would lead to feature creep and bloat to the point that we'd begin to start looking like the frameworks we were leaving in the dust.

Second, he talked about the immediate future for Rails, Rails 1.2. Rails is embracing REST. Really, really embracing REST. This means new routes like so:

map.resource '/person'

Which would give RESTful URLs, which would automagically map to the conventional controller method names.

Let's look at a controller in this scheme, with the URL mapped to the method it would invoke:

class PeopleController < ApplicationController
  def index # GET /people
  end

  def create  # POST /people
  end

  def edit # GET /people/1;edit
  end

  def new # GET /people;new
  end

  def show  # GET /people/1
  end

  def destroy  # DELETE /people/1
  end

  def update  # PUT /people/1
  end
end

We're moving beyond GET and POST in Rails 1.2 - it'll be done by doing some hacking in terms of setting an HTTP header with the HTTP method we want to actually invoke (since HTML doesn't actually support PUT and DELETE).

Neat stuff, wqe're trying to move more towards convetniosn in our controller and views - and embracing the single URL approach of an URL as a resource and handling representation in different ways. There's also a new mime type idea, where we start using the URL's extension as the clue as to which format we'd want back. (We pushed to drop extensions long ago because .jsp, .php, or .asp didn't mean anything to the user - they were leaking the implementation language. DHH wants to re-introduce extensions as a means of letting the server know what format we want the representation in). Let's look at an example:

class PeopleController < ApplicationController

  def show
    @person = Person.find(params[:id])
    respond_to do |wants|
      wants.html # renders 'show.rhtml' template
      wants.js # renders 'show.rjs' 
      wants.xml { render :xml => @person.to_xml }
    end
  end

end

Now we can add extensions to recognize using their mime-type and use that to determine what to return to the user.

Building on all of this, David wants to create a new way of convention over configuration in web services: Enter ActiveResource. When we have REST APIs, everything gets pretty simple, we can start doing things across web services almost like ActiveRecord.

Person = ActiveResource::Struct.new do |p| 
  p.uri "http://www.example.com/people"   
  p.credentials :name => "dhh", :password => "secret" 
end

#Issues GET http://www.example.com/people/1
matz = Person.find(1) 
matz.name # => "Matz"

No more SOAP, no XML-RPC, no nothing. Just grab the XML representation and translate it back into a Ruby object.

The talk certainly worried a number of people, but the promise of this is incredible. We're getting nice simple web services, and we're getting URIs that really represent resources, and it's becoming very easy to do multiple representations quickly. Really exciting stuff here.

Posted at 5pm on 06/26/06 | Posted in , , , | 5 responses | read on

Switchtower changes

Just a note to people who may have run into the same problem: I had been running an old version of Switchtower (from back when it was announced but only available via SVN), and recently upgraded to using the gem. Here's the process of migrating for any of you who had been on the bleeding edge and want to get up to date now:

  1. Remove the vendor/switchtower.rb and vendor/switchtower directory. (I'm assuming this is where you installed it - it should have been.)
  2. Remove scripts/switchtower
  3. Download and install the Switchtower gem
  4. Make a backup copy of config/deploy.rb just in case.
  5. Run 'switchtower --apply-to your_rails_app_dir' - Do NOT overwrite your config/deploy.rb recipes!

You should be ready to go. However, the first time I deployed I ran into some weirdness in my custom deploy task. I was referring to current_release to do some symlinking and chmodding. If your old switchtower labelled releases with an integer number and you refer to current_release in your customized tasks, you may run into the same problem. Switchtower was picking up one of my old release directories as the current_release (it was something like 355), I'm assuming because numerically it looks "more recent" than the release it just checked out (into the new timestamp format - something like 20051214XXXXX). I had to rename the old release directories so they wouldn't be picked up as "newer" _and I also modified my task to refer to release_path instead of current_release (since I was using it for the path anyhow).

Posted at 6pm on 12/19/05 | Posted in , , | no responses | read on

First Rochester Rails / Ruby Group Meeting

This past Thursday saw the very first meeting for the Rochester Ruby and Rails group. We met at the Golisano College of Computing and Information Sciences at RIT, and had a significant RIT student and alumni contingent. As far as logistics go, it looks like we'll be meeting there regularly on the first and third Thursdays of each month starting up January 19th.

The group seems to be more dominated by those interested in Rails, but I'm hoping as we go along they'll want to learn more about the underlying langauge - after all, to get some of the significant gains in productivity they'll have to exploit language features like blocks and meta-programming. Happily, the format we decided on devotes time to two interactive discussion/presentations per meeting - one Ruby-language focused and the other Rails focused. It was a bit of a strange night - the Rochester area had a nice coating of slick ice (though I won't call it an "ice storm" because of the true Ice Storm when I was a kid. We lost power for two weeks!) - and almost no one brought their laptops. I suppose we all figured it was just our first meeting so it would be a meet and greet.

I'm also proud to say that the Rochester Ruby and Rails group has the entire RadRails development team as members as well as myself from the RDT developers. So we have a nice showing of ruby related eclipse experts ;)

Posted at 5pm on 12/19/05 | Posted in , , , , | 2 responses | read on

Older posts: 1 2 3 4 ... 9