Using Selectize.js Autocompletes with Django & AJAX

Selectize.js is an HTML widget that replaces your normal Select elements. We just integrated selectize.js into our Django project & found that it helped improve page load speeds and provide a better user experience.

We were using formsets with Select elements that had over 500 options in one of our Django projects. A 20-row formset would cause the generation of 10,000 Option elements with 9,500 of them being duplicates. Load times for new items were around 2.5 seconds per page. We used Selectize.js to load the options of Select element from an array of JSON objects. This reduced our edit page sizes from 1400KB to 260KB and our load times from 4.99s to 0.50s.

We were also able to add additional context for our users. Our original Select element only showed the names of all Account instances. Using selectize, we are able to show and filter based on any part of the name or the description.

We then used AJAX to fetch options as the user types, this adds any options that were added after the page was loaded.

You can see the commit that inspired this blog post on our bug tracker or on github.

Reducing Page Load Speeds by Sharing Options

To actually reduce the amount of HTML generated, we have to do two things - create a JSON array of possible options and prevent the Django fields from rendering Option elements.

Since a Select appears in the navigation of every page, we opted to create a context manager to inserted the JSON array into the context of every page instead of simply including it as a context variable in our view. We started by creating a context_managers.py in our core app:

# accounts/context_manager.py
import json
from accounts.models import (Account)

def all_accounts(request):
    accounts = Account.objects.order_by('name')
    values = [{'text': account.name,
            'description': account.description,
            'value': account.id} for account in accounts]
    return {'accounts_json': json.dumps(values)}

We create a list of dictionaries from Account instances. Selectize will use the text property as the options label and the value as the value to send in the request. We will use the description to add more information to the dropdowns. We can access this array using the accounts_json variable in any of our templates.

We'll include this in our TEMPLATE_CONTEXT_PROCESSORS setting:

# core/settings/base.py
TEMPLATE_CONTEXT_PROCESSORS = (
    # ...
    "django.contrib.messages.context_processors.messages",
    "constance.context_processors.config",
    "accounts.context_processors.all_accounts",
)

We set the JSON array as a variable in our base.html template:

<!-- core/templates/base.html -->
<script type="text/javascript">
    // A global variable containing accounts for ajax selects
    accounts_array = {{ accounts_json|safe }};
</script>

Now that we've got a single list of Accounts to use for our Selects, we can get rid of all the Option elements Django creates. However, we can't remove all the Options - if one is already selected(i.e., we are editing an existing entry that has Accounts set) we'll need to keep it so that Selectize can use it to initialize it's widget. We can do this by modifying the __init__ method of the Form we use to generate our Formset:

class TransactionForm(forms.ModelForm):
    class Meta:
        # ...
        widgets = {
            'account': forms.Select(attrs={'class': 'autocomplete'})
        }

    def __init__(self, *args, **kwargs):
        super(TransactionForm, self).__init__(*args, **kwargs)
        if self.is_bound:
            return
        if None not in (self.instance, self.instance.account_id):
            self.fields['account'].queryset = Account.objects.filter(
                id=self.instance.account_id)
        else:
            self.fields['account'].queryset = Account.objects.none()

We first checked to see if the form is bound - if it is, we don't modify the queryset so that they can be used in form validation(the fields could be removed later by an overidden is_valid method...). We've also added the autocomplete class to our account field so we can easily hook it up to Selectize - which we do in our base template:

<!-- core/templates/base.html -->
<script type="text/javascript">
    $(document).ready(function() {
        // Turn Select Widgets for Accounts into AJAX Autocompletes
        $('.autocomplete').each(function() {
            $(this).selectize({
                options: accounts_array,
            });
        });
    });
    </script>

Now every form in our formset should be using the values from accounts_array & be displayed using Selectize. If you inspect your HTML, there should only be a single Option element under each Select.

Adding Extra Context to Dropdowns

Now that we've got a fast loading page, we can use that description property we included in our JSON objects to provide extra context to users. This is easily done using Selectize's options. We'll use the render option to specify a custom rendering function for the dropdown options:

/* core/templates/base.html */
// ...
$(this).selectize({
    options: accounts_array,
    render: {
        option: function(data, escape) {
            return '<div class="option">' +
            '<span class="text">' + escape(data.text) + '</span>' +
            '<span class="description">' + escape(data.description) + '</span>' +
            '</div>';
        },
    },
// ...

We added some CSS to split up the text & description and to de-emphasize the description:

.selectize-control .option .text {
    display: block;
}
.selectize-control .option .description {
    font-size: 10px;
    display: block;
    font-style: italic;
}

Using AJAX to Fetch Options

This provides a much nicer interface than the default HTML Select elements. The only downside is that a refresh is required if an Account is added after the page was loaded. To counteract this, we will asynchronously load the latest options.

The first step is to provide a URL and view to return values based on a users query. This is super simple if you're using Django-AJAX:

# accounts/views.py
from django_ajax.decorators import ajax

@ajax
def accounts_query(request):
    if 'q' in request.GET:
        q = request.GET['q']
        accounts = Account.objects.filter(
            Q(name__icontains=q) | Q(description__icontains=q))
    else:
        accounts = Account.objects.all()
    return [{'text': account.name,
             'description': account.description,
             'value': account.id
             } for account in accounts]

# accounts/urls.py
urlpatterns = patterns('accounts.views',
    # ...
    url(r'^ajax/accounts/$', 'accounts_query', name='accounts_query')
)

Now we just hook Selectize up to the view by using the load option:

/* core/templates/base.html */
// ...
$(this).selectize({
    options: accounts_array,
    load: function(query, callback) {
        if (!query.length) return callback();
        $.ajax({
            url: '{% url accounts_query %}',
            type: 'GET',
            data: {q: query},
            error: function() { callback(); },
            success: function(res) {
                callback(res.content);
            }
        });
    }
// ...

Now Selectize will use the accounts_array to provide initial options for the user & will perform an AJAX request to retrieve options when the user starts typing into the widget.

Comments

There are currently no comments

New Comment

required

required (not published)

optional