Drag-and-drop AJAX Lists with Django
— Django, Cookiecutter, Python — 3 min read
A shorter one for today. I had a proof of concept to put together a few weeks ago that required an order list of items which I needed to be able to reorder. Naturally, drag and drop was the obvious choice for the user experience, over for example numerical order dropdowns (YUCK)! This seems simple to start with, but breaking it down you need;
- A way to persist in Django the order of a list of items,
- A way to allow a user to reorder an HTML list via drag and drop,
- A way to detect changes and save the new order into Django.
This tutorial assumes you know the basics of Django, including;
- A basic project layout,
- How to add a model to Django Admin,
- Create and apply a database migration.
Persistence
My proof of concept was to create an exam paper builder, allowing a user to add and order questions to a paper. Therefore my data model roughly looks like;
One question can belong to a paper (but doesn't have too) and a question can belong to many papers. This is a many-to-many relationship. In the simplest of situations Django allows the use of models.ManyToManyField
on its own, however, this doesn't support explicit ordering of items. To achieve this, we need a 'through' model with the ManyToManyField.
1from django.db import models2
3class Paper(models.Model):4 title = models.CharField(max_length=255)5
6class Question(models.Model):7 description = models.TextField()8 answer = models.TextField()9 papers = models.ManyToManyField(Paper, through='PaperQuestion')10
11class PaperQuestion(models.Model):12 question = models.ForeignKey(Question, on_delete=models.PROTECT)13 paper = models.ForeignKey(Paper, on_delete=models.PROTECT)14 active = models.BooleanField()15 order = models.IntegerField()16 17 class Meta():18 ordering = ['order',]
PaperQuestion
explicitly defines the join between Paper
and Question
, and importantly, defines an order attribute. If you're following along, create a similar model structure for your needs, and create + apply the migration to your database.
Creating the Drag and Drop interface
I'm assuming you're using django-cookiecutter as the basis of your project (you really should). As a result, you already have a good basis to start off with. You have various options for your drag and drop functionality, some of which will be driven by your UI framework (if you're using one). I wanted something that played nice with Bootstrap 4 (which is bundled with recent django-cookiecutter invocations) and seemed relatively simple. I'm also very lazy at times and ended up going with the first option from Google.
So .... roll up, roll up, marvel in ..... jQuery Sortable. It isn't the flashiest, but it has good browser support, is customisable and the repo has frequent updates. There are plenty of options out there including Shopify's Draggable and SortableJS. Fundamentally, what you choose doesn't particularly matter as long as there is a way to serialize the order of list and send an AJAX request to Django.
1{% extends "base.html" %}2
3{% load static i18n %}4
5{% block title %}Paper Builder{% endblock %}6
7{% block extracss %}8<style>9body.dragging, body.dragging * {10 cursor: move !important;11}12
13.dragged {14 position: absolute;15 opacity: 0.5;16 z-index: 2000;17}18
19ol.exmaple li:hover {20 cursor: move !important;21}22
23ol.example li.placeholder {24 position: relative;25 /** More li styles **/26}27ol.example li.placeholder:before {28 position: absolute;29 /** Define arrowhead **/30}31
32.list-group-item {33 display: list-item;34 list-style-position: inside35}36
37</style>38{% endblock %}39
40{% block content %}41<ol class='example list-group'>42 {% for question in questions %}43 <li class="list-group-item" data-id={{question.id}}>{{ question.description }}</li>44 {% endfor %}45</ol>46
47{% endblock %}48
49
50{% block extrajavascript %}51
52<!-- required for CSRF cookie parsing //-->53<script src="{% static 'js/jquery-sortable.js' %}"></script>54<script src="https://cdn.jsdelivr.net/npm/js-cookie@2/src/js.cookie.min.js"></script>55<script>56// get the Django CSRF Cookie57$(function() {58 var csrftoken = Cookies.get('csrftoken');59
60function csrfSafeMethod(method) {61 // these HTTP methods do not require CSRF protection62 return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method));63}64
65// Ensure jQuery AJAX calls set the CSRF header to prevent security errors66$.ajaxSetup({67 beforeSend: function(xhr, settings) {68 if (!csrfSafeMethod(settings.type) && !this.crossDomain) {69 xhr.setRequestHeader("X-CSRFToken", csrftoken);70 }71 }72});73
74// Make our ordered list with a class of example sortable.75// onDrop (reorder item) make a JSON representation of the list and POST the JSON to the current page76var group = $("ol.example").sortable({77 delay: 500,78 onDrop: function ($item, container, _super) {79 var data = group.sortable("serialize").get();80 var jsonString = JSON.stringify(data, null, ' ');81 _super($item, container);82 $.ajax({83 type: "POST",84 data: jsonString,85 url: ""86 });87 },88 });89});90</script>91
92{% endblock %}
We have a simple template loop to print our questions in an <ol>
block. The data-id
is what will be serialized into our AJAX request. Our JavaScript is pretty simple. It is pretty much the example code from the author's site. It;
- Parses the Django CSRF cookie (CSRF protection is on by default and I don't want to disable it),
- Sets up the AJAX handler for jQuery,
- Sets up our draggable list and event listener for
onDrop
Processing the AJAX request
The final piece of the puzzle is the Django View which will both display the question list on GET
and process the AJAX on POST
.
1import json2from django.shortcuts import render3from django.http import JsonResponse, HttpResponse4from django.views import View5from .models import Question, PaperQuestion, Paper6from django.views.decorators.csrf import ensure_csrf_cookie7from django.utils.decorators import method_decorator8
9class PaperQuestionReorder(View):10 template_name = "paper_question_reorder.tmpl"11
12 # Ensure we have a CSRF cooke set13 @method_decorator(ensure_csrf_cookie)14 def get(self, request, pk):15 return render(self.request, self.template_name, {'pk': pk, 'questions': Question.objects.filter(paperquestion__paper_id=pk).order_by('paperquestion__order'), 'paper': Paper.objects.get(id=pk)})16
17 # Process POST AJAX Request18 def post(self, request, pk):19 if request.method == "POST" and request.is_ajax():20 try:21 # Parse the JSON payload22 data = json.loads(request.body)[0]23 # Loop over our list order. The id equals the question id. Update the order and save24 for idx,question in enumerate(data):25 pq = PaperQuestion.objects.get(paper=pk, question=question['id']) 26 pq.order = idx + 127 pq.save()28
29 except KeyError:30 HttpResponseServerError("Malformed data!")31
32 return JsonResponse({"success": True}, status=200)33 else:34 return JsonResponse({"success": False}, status=400)
This is a basic Django View to handle the display and updates of the list. The comments should be self-explanatory of the flow. Our Question
order processing loop is the most complex, so let's focus on that. An example list of questions has an HTML representation as so;
1<ol class="example list-group">2 <li class="list-group-item" data-id="2">4 divided by 2</li>3 <li class="list-group-item" data-id="1">1+1</li>4 <li class="list-group-item" data-id="3">10 * 10</li>5</ol>
When the list is reordered, an AJAX request is raised and sent to Django with the following JSON payload;
1[2 [3 {4 "id": 15 },6 {7 "id": 38 },9 {10 "id": 211 }12 ]13]:
Processing of the JSON is as follows;
- Loop over the question IDs in the JSON list,
- Retrieve the question that equals the current ID,
- Update the
order
attribute of the Question's through model to the current loop count + 1.
These 3 pieces should give you a working list. Want to test it's working? Refresh the page or inspect your data through Django Admin or the database directly.
How could this be optimised? The biggest bottleneck I see here is the heavy SQL writes involved here which will scale linearly with the number of items in the list. If the time came, I would buffer changes client side to only save say every 15 seconds, or make the user explicitly save to cut down on overlapping write operations.
Full code for this tutorial is on my GitLab repo - https://gitlab.com/fluffy-clouds-and-lines/drag-and-drop-ajax-lists-with-django
Comment and questions below as ever!