Dugan Chen's Homepage

Various Things

Django ModelChoiceFields as autocomplete lists

Every Django user knows about its ModelChoiceFields. Pass them to a form template, and they render as HTML Select elements via which a user may choose an ORM moodel. Use them for form binding, and they’ll find you, from the form POST data, the ORM model that the user chose. But what if you don’t want a select element? What if you want an autocomplete box?

For me, a solution requires the following:

  • maintenance of the separation of concerns, where any Javascript consists of static files loaded separately from the markup
  • continued usage of Django’s form binding and validation
  • graceful degradation into an HTML Select element
  • flexibility to easily add behavior, such as having one autocompletion list clear another

With no offence meant to the people who have build solutions before me, I couldn’t find one that satisfied these. The correct approach, once these requirements have been stated, is obvious. You write the solution entirely in Javascript. What the Javascript will do is progressively enhance the Select elements into autocomplete boxes.

After working on it for a day, I have a production-ready solution. It uses the jquery-ui autocomplete widget, but should be adaptable to others, such as the Twitter Bootstrap typeahead widget Here’s how it works.

  1. a text box and a hidden input are created, next to the select list so that Django’s validation error messages continue to work
  2. the text box gets the select element’s id, so that the label continues to work
  3. the hidden input gets the select element’s name, so that data binding continues to work
  4. the text box is progressively enhanced into an autocomplete box
  5. the autocomplete box’s select event is overridden to store the selected item’s value in the hidden input
  6. the select element is removed

I also store the value and text of the selected element in a closure. On each change event, I check if the autocomplete box’s text is the same as the text that was last selected. If it isn’t, I clear the hidden input. If it is, I set it to the value that was last selected. This is reasonable. The drawback is that it still only works if the user actually selects elements (as opposed to, say, pasting them in).

My demo application uses a Tastypie web services API to provide data to the autompletion list. The data model is as simple as possible:

class School(models.Model):
    name = models.CharField(max_length=255)

    class Meta:
        ordering = ['name']

    def __unicode__(self):
        return unicode(self.name)

The web service provides simplified output:

from tastypie.resources import ModelResource
from models import City, School


class SimplifiedResource(ModelResource):

    def alter_list_data_to_serialize(self, request,
            data):
        return data['objects']

    def dehydrate_id(self, bundle):
        return int(bundle.data['id'])

    class Meta:
        limit = 0
        include_resource_uri = False

class SchoolsResource(SimplifiedResource):

    class Meta(SimplifiedtResource.Meta):

        queryset = School.objects.all()
        filtering = {'name': 'startswith'}

And here, in CoffeeScript, is the jQuery plugin. It takes the exact same parameters as a jQuery-ui autocomplete box:

jQuery.extend jQuery.fn,

    modelComplete: (args...) ->

        text = ""
        value = @val()
        set = $()

        handleSelect = (event, ui) ->
            ($ this).val ui.item.label
            idInput.val ui.item.value

            # cache these to check during change events
            text = ui.item.label
            value = ui.item.value

            event.preventDefault()

        handleChange = (event, ui) ->
            changedTo = $.trim ($ this).val()
            if changedTo is "" or changedTo isnt text
                idInput.val ""
                $(this).val ""
            else
                idInput.val value

        if value isnt ""
            text = this[0][this[0].selectedIndex].text

        textInput = $ "<input type=\"text\">"
        textInput.attr "id", @attr "id"
        textInput.val text

        idInput = $ "<input type=\"hidden\">"
        idInput.val value
        idInput.attr "name", @attr "name"

        if args.length is 1
            args.push {}

        if args[1].select?
            selectFn = args[1].select
            args[1].select = (event, ui) =>
                handleSelect event, ui
                selectFn event, ui
        else
            args[1].select = handleSelect

        if args[1].change?
            changeFn = args[1].change
            args[1].change = (event, ui) =>
                handleChange event, ui
                changeFn event, ui
        args[1].change = handleChange

        @after textInput
        textInput.after idInput
        this.remove()

        textInput = textInput.autocomplete args...
        set.add(textInput)
        set.add(idInput)
        return set

Using it, and setting its data source to the Tastypie web services API, is the same as if it were a jquery-ui autocompletion widget:

$ ->

    $("#id_school").modelComplete {"source": (request, response) ->
        $.getJSON "/api/v1/schools/?name__startswith=#{$.trim request.term}", null, (data) ->
            response ({"label": x.name, "value": x.id} for x in data)}
        "select": (event, ui) -> console.log "item #{ui.item.value} selected"