August 23, 2012
Twitter Bootstrap and AJAX
However, I was writing the same bits of Javascript everywhere to do simply
little things with $.ajax
in jQuery. Code was fragile and often I would
defer making the experience of doing some less polished/snappy because it
would mean having to write some more of the same old Javascript that I was
bored writing. "Ah, a POST followed by the 302 redirect isn't too bad, let's
just roll with that for now...", I would say to myself.
So, I created bootstrap-ajax.js.
For the record, I don't think that all traditional form submissions followed by the standard 302 redirect are bad. In many cases it's exactly all that is needed and works well. You have to make the call for yourself and in the context of your own web apps what works best for each situation.
What if we could break down the problem and recognize the abstractions that we were solving repetitively, package it up, and make it dead simple to leverage on demand the way Twitter has made doing things like modals, so easy.
First, the abstractions:
- Simple posts like the ones used to toggle a vote, or submit a rating. The kinds of things we find ourselves either writing little tiny forms and a morass of Javascript because when that post happens, there are three other blocks of content on the page that should be updated.
- Handling a form submission via AJAX. We have entire plugins dedicated to doing this, but even then who wants to write the handler code for it. I just want to add a class and/or an attribute or two in my
- Content blocks should know how to update themselves instead of having bodies of fragile Javascript manipulate the DOM to add/update data.
I recognize some of this can be solved with backbone.js, but I didn't want to go that route. I wanted templates to continue to be rendered server side. I wanted to be able to (for the most part) already take advantage of a large body of reusable Django apps. When and if apps that I wanted to use didn't have an AJAX interface in it's views, or maybe one that didn't support the convention I was cooking up, adding to it was easily done in a backwards compatible way. Let me be clear, this is not a knock against backbone.js apps, there are some really great reasons to use it, but it's not the appropriate tool for every job.
To give you an idea, let's go with an example.
Imagine you have a web app that has a page where you can share status messages (original, I know). Also on this page, you have a side bar with stats about your account like the number of messages you have left.
You could just do a traditional form POST to log the status message and return a 302 Redirect to the same page, rendering the entire template again, this time with you new message in the feed and the message count updated in the side bar. This actually isn't a bad place to start, but you can do better.
views.py
from django.shortcuts import redirect, render from django.contrib.auth.decorators import login_required from .forms import MessageForm @login_required def message_list(request): if request.method == "POST": form = MessageForm(request.POST) if form.is_valid(): message = form.save(commit=False) message.user = request.user message.save() return redirect("message_list") else: form = MessageForm() return render(request, "messages/message_list.html", { "messages": request.user.messages.all(), "form": form })
message_list.html
{% extends "messages/base.html" %} {% load bootstrap_tags %} {% block body %} <h1>Your Messages</h1> <p class="lead">Here is a stream of your messages.</p> <form action="{% url message_list %}" method="post" class="form"> {% csrf_token %} {{ form|as_bootstrap }} <div class="form-actions"> <button type="submit" class="btn btn-primary">Submit</button> </div> </form> {% for message in messages %} {% include "messages/_message.html" with message=message %} {% endfor %} {% endblock %}
Now that you have the simple form POSTing working let's add in bootstrap- ajax.. The first thing we need to do is modify our views.py to add a POST only view and pull out the POST handling from our main list view.
views.py (rev 2)
from django.http import HttpResponse from django.shortcuts import redirect, render from django.template import RequestContext from django.template.loader import render_to_string from django.utils import simplejson as json from django.views.decorators.http import require_POST from django.contrib.auth.decorators import login_required from .forms import MessageForm @require_POST @login_required def message(request): form = MessageForm(request.POST) if form.is_valid(): message = form.save(commit=False) message.user = request.user message.save() form = MessageForm() data = { "html": render_to_string("messages/_message_form.html", { "form": form }, context_instance=RequestContext(request)) } return HttpResponse(json.dumps(data), mimetype="application/json") @login_required def message_list(request): return render(request, "messages/message_list.html", { "messages": request.user.messages.all(), "form": MessageForm() })
_message_form.html
{% load bootstrap_tags %} <form action="{% url message_list %}" method="post" id="message-form" class="form ajax" data-replace="#message-form"> {% csrf_token %} {{ form|as_bootstrap }} <div class="form-actions"> <button type="submit" class="btn btn-primary">Submit</button> </div> </form>
messages.html (rev 2)
{% extends "messages/base.html" %} {% block body %} <h1>Your Messages</h1> <p class="lead">Here is a stream of your messages.</p> {% include "messages/_message_form.html" with form=form %}> {% for message in messages %} {% include "messages/_message.html" with message=message %} {% endfor %} {% endblock %} {% block extra_body %} <script src="{% static "js/bootstrap-ajax.js" %}"></script> {% endblock %}
Now we have the original page that renders just the same as before except now the message will post via AJAX so it won't render the entire page, we are passing back a rendering of the same include with a fresh form (if it is success) or a form with validation errors (if it has an error) and that will just replace itself.
But what about the message list? Should we update that? I think so. Seems silly not to.
views.py (rev 3)
# only adding this request.is_ajax() block to the existing views.py @login_required def message_list(request): if request.is_ajax(): data = { "html": render_to_string("messages/_message_list.html", { "messages": request.user.messages.all() }, context_instance=RequestContext(request)) } return HttpResponse(json.dumps(data), mimetype="application/json") return render(request, "messages/message_list.html", { "messages": request.user.messages.all(), "form": MessageForm() })
_message_list.html
<div class="messages" data-refresh-url="{% url messages %}"> {% for message in messages %} {% include "messages/_message.html" with message=message %} {% endfor %} </div>
_message_form.html (rev 2)
{% load bootstrap_tags %} <form action="{% url message_list %}" method="post" id="message-form" class="form ajax" data-replace="#message-form" data-refresh=".messages,.message-count"> {% csrf_token %} {{ form|as_bootstrap }} <div class="form-actions"> <button type="submit" class="btn btn-primary">Submit</button> </div> </form>
messages.html (rev 3)
{% extends "messages/base.html" %} {% block body %} <h1>Your Messages</h1> <p class="lead">Here is a stream of your messages.</p> <{% include "messages/_message_form.html with form=form %}> {% include "messages/_message_list.html" with messages=messages %} {% endblock %} {% block extra_body %} <script src="{% static "js/bootstrap-ajax.js" %}"></script> {% endblock %}
I left out the view and template fragment for .message-count
refreshing for
the sake of brevity. Post a comment if you'd like me to explain how that works
or if it's in anyway unclear how you'd hook that up as well.
We could optimize this further by structuring things in such a way where we only needed refresh new messages, or add the one that we just posted, but I just wanted to give you a sense for how this thing works. Maybe another post later.