It can be fairly common to have situations where your users need to quickly add items to a list, edit those items, and perhaps even sort them. Ideally, we can keep the user focused on their data and tasks rather than how to navigate the software facilitating the work.


The more we can keep the user from having to bounce around and make extra clicks, the more we can keep them focused on their work. The less they are being taken to different pages for different tasks like create or updates, before being redirected back, the calmer the interaction will be, and the more in tune with the overall task the user can be.

This particular UX is better suited for fine-grained, simple tasks. As each item to be created grows in complexity, the more we want to deliver to the user an opposite experience where they can go to a page dedicated to creation or updating free from distraction so they can focus time and effort on just that (e.g. writing a blog post). For now let's focus on the UX for small, simple, quick tasks (e.g. managing notes in a notebook).

Here is a quick sketch of what this UI looks like:

The UX works as follows:

  1. Fill out details in the form on the left to create a new item.
  2. After hitting submit, the item is added to the list on the right.
  3. Once added to list, you have a fresh form to add a new item.
  4. If you click an item in the list to the right, the form is loaded with data to edit.
  5. Upon saving your edits, the item in the list to the right is updated.
  6. After the update, the form is reset back to being ready to create a new item.
  7. You can drag/drop items in the list to change their sort order.

In order to make things smoother for the user we have to think about doing more within a single UI. This is going to require the use of some AJAX. And it's precisely for these situations that we've built eldarion-ajax.

While a rich frontend library like React or Vue could build this interface with ease, at times it can be overkill to build and maintain something with a heavy frontend.

I've prepared a demo project so you can see how all this can be coded up rather simply with a handful of short views and a wee bit of javascript, leveraging eldarion-ajax to handle most of the heavy lifting.

Let's walk through some of the highlights starting with an introduction to eldarion-ajax.

Ajax with HTML Markup

That's right. You can get a lot of Ajax functionality by just updating your markup and hooking up some views. No need to write javascript.

First off you'll want to include eldarion-ajax into your project either by adding it in a <script> tag or with require('eldarion-ajax') in your bundle after installing with npm install eldarion-ajax --save.

Once you have that, then it will look for <form> and <a> tags that have the class ajax added to them. If you have a <form class="ajax"> or <a href="" class="ajax"> then eldarion-ajax will hook into the submit or click events and send requests to the server using jQuery's $.ajax method.

To control the behavior, you mark up your form or link with different data tags. For example, if you want to instruct the link Ajax submission to use POST instead of the default GET you would:

<a href="url to post to" class="ajax" data-method="post">Post to Server</a>

Most of the directives are about what to do with the response from the server. They map pretty closely with the jQuery DOM manipulation commands.

For example:

<form id="my-form" method="post" action="url to post to" class="ajax" data-replace="#my-form">

Would take the contents in the html node of the server response and replace the <form> element.

You can read more about the options in the eldarion-ajax README.

The Demo App

The demo app is a contrived app designed to allow us to focus on the how rather than get caught up in the what.

Get started running locally:

pip install -r requirements.txt npm install ./manage.py migrate npm run dev

Now when you open http://localhost:3000 you can create a notebook and add, edit, and sort notes in that notebook. There is no auth and this certainly isn't a production app.

Templates

With eldarion-ajax we will be rendering small bits of HTML that we call fragments in response to different Ajax calls. This allows us to leverage the Django template langage. It also lets us reuse the fragments to render in full pages as well as in returning updated fragments via Ajax.

By convention we name fragments with a leading underscore making it easy to see what's a fragment and what's a full page when viewing the code tree in our editors.

notes.html

This is the main interface and loads as an entire page from the server as a DetailView for the Notebook object. The view, as we will see shortly, also includes an ordered list of notes for the notebook.

We include the form fragment, _note_form.html and loop over any existing notes and include a _note.html fragment for each note.

The data-save-order-url attribute is how we feed an end point to some of our javascript so we can avoid hard coding urls. We will learn more about sorting in just a bit.

{% extends "site_base.html" %}

{% block body %}
<div class="row notes-container">
    <div class="col notes-form-container">
        {% include "_note_form.html" %}
    </div>
    <div class="col notes-list-container">
        <h4>{{ notebook.name }} Notes</h4>
        <div id="note-list" data-save-order-url="{% url "ajax_notes_update_order" notebook.pk %}">
            {% for note in note_list %}
                {% include "_note.html" %}
            {% endfor %}
        </div>
    </div>
</div>
{% endblock %}

_note_form.html

The form will be used for both creates and updates. This is why we pass in the post_url variable so that we can control where the form will post to depending on the context of the render.

The data-replace value of #note-form means that eldarion-ajax will replace the entire form with a new one that is the content we will render in the html node of the JsonResponse. We'll see how that's done in the section below where we cover the views.

{% load bootstrap %}
<form id="note-form" action="{{ post_url }}" method="post" class="ajax" data-replace="#note-form">
    {% csrf_token %}
    {{ form|bootstrap }}
    <div class="mt-5">
        <button class="btn btn-primary btn-block">
            {% if note %}Update{% else %}Create{% endif %}
            Note
        </button>
    </div>
</form>

_note.html

This fragment is pretty simple. It's just a link. We style this as a bootstrap .list-group-item.list-group-item-action but that's really not critical for what we are learning in this post.

The data-replace with a #note-form value will replace the form the same way that the form's data-replace will replace itself. There is not data-method because we want the default action of GET.

The data-id is the unique identifier for this note that is needed by Sortable that we use for drag/drop sorting and will discuss a bit later.

<a id="note-{{ note.pk }}"
    data-id="{{ note.pk }}"
    class="note ajax note-edit-link"
    data-replace="#note-form"
    href="{% url "ajax_notes_update" note.notebook.pk note.pk %}">
    <i class="handle"></i>
    {{ note.title }}
</a>

Views

NotebookNotesView

This is the top level view. It's a standard DetailView that loads up a Notebook object based on the primary key in the URL. Nothing too special here. I will just point out that the note_list is loaded from a custom property on Notebook that returns a sorted list of notes. Also the post_url is initially set for note creation.

class NotebookNotesView(DetailView):

    template_name = "notes.html"
    model = Notebook

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["note_list"] = self.object.notes
        context["form"] = NoteForm()
        context["post_url"] = reverse("ajax_notes_create", kwargs=dict(notebook_pk=self.object.pk))
        return context

ajax_notes_create

We either like to prefix our ajax views with ajax_ or put them in their own ajax.py module. This is not required and is just convention. There is also probably some DRYing up we could do between these views if we used class based views with a mixin or two.

That said, this view should look pretty much like a standard form processing view where you check if the form is valid and do a save. The difference is in how we build up a response.

If the form is valid, we create a fresh instance of the form, otherwise we want to use the form with the validation errors when we render the _note_form.html.

There are two things we are adding to the response:

The html node will be what the data-replace attribute uses to replace the content found when taking its value and using as a DOM selector. In our case it is always the #note-form form.

The other node, append-fragments is how we can send extra data to the browser to update the UI as needed. In our case we want to add a new note link to the #note-list container. eldarion-ajax takes items in append-fragments, finds the DOM element with the selector found in the key, and then appends the string.

@require_POST
def ajax_notes_create(request, notebook_pk):
    notebook = get_object_or_404(Notebook, pk=notebook_pk)
    data = {}
    note = None
    form = NoteForm(request.POST)
    if form.is_valid():
        note = form.save(commit=False)
        note.notebook = notebook
        note.save()
        form = NoteForm()
        data["append-fragments"] = {
            "#note-list": render_to_string("_note.html", {"note": note})
        }
    data["html"] = render_to_string("_note_form.html", {
        "note": note,
        "form": form,
        "post_url": reverse("ajax_notes_create", kwargs=dict(notebook_pk=notebook_pk))
    }, request)
    return JsonResponse(data)

ajax_notes_update

Very similar to the create view, here we are handling both GET and POST. The GET is called in response to the link being clicked. Once again we render the the form, but this time we render the template with the context that includes the note object we are updating as well as a post_url that points back to this view to process the update.

Instead of using append-fragments in the processing of the POST after a valid form, we use fragements. What this will do is replace the note in the list identified with $('#note-PK') with the contents we render here.

def ajax_notes_update(request, notebook_pk, pk):
    notebook = get_object_or_404(Notebook, pk=notebook_pk)
    data = {}
    note = get_object_or_404(notebook.note_set, pk=pk)
    if request.method == "POST":
        form = NoteForm(request.POST, instance=note)
        post_url = reverse("ajax_notes_update", kwargs=dict(notebook_pk=notebook.pk, pk=pk))
        if form.is_valid():
            note = form.save()
            form = NoteForm()
            post_url = reverse("ajax_notes_create", kwargs=dict(notebook_pk=notebook.pk))
            data["fragments"] = {
                "#note-{}".format(note.pk): render_to_string("_note.html", {"note": note})
            }
    else:
        form = NoteForm(instance=note)
        post_url = reverse("ajax_notes_update", kwargs=dict(notebook_pk=notebook.pk, pk=pk))
    data["html"] = render_to_string("_note_form.html", {
        "note": note,
        "form": form,
        "post_url": post_url
    }, request)
    return JsonResponse(data)

ajax_notes_update_order

This view will handle the post back from Sortable that will be a string with concanated PKs that are pulled from data-id in the links. We will see how this data is used in just a bit. As you can see this is a very simple view that doesn't really need much explanation.

@require_POST
def ajax_notes_update_order(request, notebook_pk):
    notebook = get_object_or_404(Notebook, pk=notebook_pk)
    notebook.note_order = request.POST.get("order")
    notebook.save()
    return JsonResponse({})

Javascript

While you don't need to write javascript to use Ajax with eldarion-ajax there are a few important things to note and one reason we needed to add a bit of custom javascript.

First of all, you need to read the CSRF token cookie and that's done with the ajaxSendMethod. I won't go into that here other that know that this is where we provide a function to the jQuery ajaxSend hook that will set the X-CSRFToken header that Django needs to comply with CSRF verification. If you don't do this you'll be stumped on why you are getting 403 Forbidden responses.

One of the reasons we are discussing javascript is I wanted to show you how to reload other javascript widgets that you might be using in your form. Here we have a date picker which is loaded on initial page load, but when you pump back content to the DOM, you need to reload the plugin otherwise you won't have the picker attached anymore.

We do this by hooking the eldarion-ajax:complete event that is sent after every Ajax response is processed. The $el parameter that is passed to handlers of this event is the object that originally had the the ajax class and was the trigger of the Ajax request.

Since this event will fire for every request, we want to inspect the $el object and see if we need to loadDatePicker or not. Also, while we were in here, I thought it useful to trigger the active state on the list-group-item so that it shows as selected while you are editing the note.

require('eldarion-ajax');
require('bootstrap-daterangepicker');

import moment from 'moment';

import ajaxSendMethod from './ajax';

const loadDatePicker = () => {
    $('#id_date').daterangepicker({
        singleDatePicker: true,
        startDate: $('#id_date').val() || moment(),
        locale: {
            format: 'YYYY-MM-DD'
        }
    });
};

$(() => {
    $(document).ajaxSend(ajaxSendMethod);

    loadDatePicker();
    $(document).on('eldarion-ajax:complete', (event, $el, responseData, textStatus, jqXHR) => {
        if ($el.attr('id') === 'note-form' || $el.hasClass('note-edit-link')) {
          loadDatePicker();
        }
        if ($el.hasClass('note-edit-link')) {
          $el.parent().find('.note').toggleClass('active', false);
          $el.toggleClass('active');
        }
    });
});

A Note on Ordering

The Sortable feature of this is not related to eldarion-ajax at all and useful to discuss in its own context. We've seen the view that handles the post back of ids.

That post back originates from this Javascript:

const $noteList = $('#note-list');
if ($noteList.length > 0) {
    const saveUrl = $noteList.data('save-order-url');
    Sortable.create($noteList[0], {
    store: {
        get: sortable => {
        return [];
        },
        set: sortable => {
        $.ajax({
            url: saveUrl,
            method: 'post',
            data: {
            order: sortable.toArray().join('|')
            }
        });
        }
    }
    });
}

We get the saveUrl from that data-save-order-url that we saw earlier when discussing the templates. We take the ids found in data-id and passed to the sortable object and concatenate to send as a string to the server which as you saw previously we just store directly on the Notebook object.

We use that list of ids to instruct the order_by for the Notebook.notes property that we send to the UI to preseve the ordering of your notes.

@property
def notes(self):
    order_by = "pk"
    if self.note_order:
        pks = self.note_order.split("|")
        order_by = models.Case(*[
            models.When(pk=pk, then=pos)
            for pos, pk in enumerate(pks)
        ])
    return self.note_set.all().order_by(order_by)

We use the models.Case and models.When clauses to build a SQL statement that will order the results by the same order that we have the PKs for the note objects stored in the note_order field.

Conclusion

Hopefully, we've provided a clear sense for how you can build a very useful experience for your users without diving into rich frontend libraries. You might eventually need to graduate up to React or Vue depending on how much more you want to do, but when you have just a handful of activities like we have here we find an eldarion-ajax to allow us to go from Idea to Lauch, Faster.


If you have an idea that you'd like help shipping, why don't you give us a shout. We'd love to talk with you about it.