Image for Django: How to let user re-order/sort table of content with drag and drop

Django: How to let user re-order/sort table of content with drag and drop

I needed to preserver order of table elements and decided to share my solution.

I recently needed to implement a way to let admin users on site order groups freely. Turns out this is quite involved proces relative to other common tasks in Django.

I cannot guarantee that my way shown below is the best but I believe it works quite well and be implemented in a few steps. I am going to be using Group in the example but of course it can be anything.

When starting with bigger pieces of functionality I always try to separate the task into smaller pieces. For example with the ordering, let’s start with the drag and drop of table rows and don’t worry about saving the order just yet.

I started by researching available JavaScript libraries for the re-ordering of elements and after a while decided to use SortableJS. It is fairly robust with tons of options but for the basics can be up and running pretty quickly.

Step One: Add SortableJS to page

{% load static %}
<script src="{% static 'js/external/Sortable.min.js' %}"></script>

Okay, that is done. I always prefer to keep libraries in my project instead of relying on CDN so I have control over it being available and the version I need.

Step Two: Configure <table> for ordering

To configure HTML <table> for SortableJS, we need for it to have <tbody> , ideally with ID to easily access it with JavaScript. For example for the groups: <tbody id="groups">

Step Three: Initialize SortableJS

Now we can initialize SortableJS for our table like so:

const groups = document.getElementById('groups');
   let sortable = Sortable.create(groups, {
});

You should be able to drag and drop table rows and change their positions.

Step Four (optional): Add drag handle

I think it is more intuitive to have handle for dragging than dragging whole rows. So I added new first table cell with <span> element to create handle:

<td>
<span class="handle"></span>
</td>

You can of course use regular image or SVG icon, but if you create your drag handle with CSS it can work for light and dark versions of your site without issues.

Here is the CSS for .handle:

.handle {
    display: inline-block;
    border-top: 3px solid var(--dark);
    width: 30px;
    padding-top: 5px;
    cursor: grab;
}
.handle:after, .handle:before {
    display: block;
    content: "";
    padding-top: 5px;
    border-top: 3px solid var(--dark);
}
.handle:hover, .handle:hover:before, .handle:hover:after {
    border-color: var(--primary);
}
.handle:active, .handle:active:before, .handle:active:after {
    cursor: grabbing;
}

I found initial version on CodePen and then modified it to my liking. Mostly changing sizes and using Bootstrap 4 colors.

Now we just need to tell SortableJS that we have handle it can use:

let sortable = Sortable.create(groups, {
    handle: '.handle',
});

Done!

We are almost done with the front-end stuff.

Configuration tips

You can configure Sortable to add classes to the row that is being dragged. These are specified via additional options like so:

let sortable = Sortable.create(groups, {
    handle: '.handle',
    dragClass: 'dragged',
    chosenClass: 'sortableChosen',
});

For example you can change background of the dragClass so the user can see it more clearly when dragging. I also lowered the opacity of sortableChosen class.

Step Five: Prepare your Django model

We need to save the ordering which means our Django model needs to have order field to save order so we can order by it later. I used IntegerField like so:

class Group(models.Model):
    lookup_id = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True)
    order = models.IntegerField(blank=False, default=100_000)

The rest of Group class is omitted. I am using lookup_id as public-facing identifier for my objects and it has index for fast look-up.

order has big default value so the newly added will naturally appear last in the table. You could try something more sophisticated but I think this is good enough.

Our Django model is ready.

Step Six: How to transfer HTML <table> ordering to database?

We have table with drag handle so user can re-order it as they please. We have also modified our model so it can store the order and we can order_by the results by order property.

Ideally we want to monitor changes in the table order and then sending the new order to Django so it can update order for all groups in the table. You could possibly save after each change or do it periodically say every 10 seconds. I opted for dedicated “Save ordering” button so user can choose when to save or decide not to if they change their mind.

How do we know the order of groups in our table? Well, we know the order of <tr> elements inside the <tbody>. So we can add data attribute to each <tr> and then query the table.

The order of row elements will represent the new order of groups. Let’s modify template and add the attribute like so:

<tr data-lookup="{{ folder.lookup_id }}">

To properly send data to our Django application we are going to need a form:

<form id="orderingForm" method="post">
{% csrf_token %}
<input type="hidden" id="orderingInput" name="ordering">
</form>

And also the button to manually save the new order:

<button id="saveOrdering" class="btn btn-outline-primary btn-lg float-right mr-2">Save ordering</button>

Now our structure is ready and we can move back to JavaScript to react to “Save ordering” click/tap and get the new order from table.

Step Seven: Use JavaScript to submit new ordering via form

We have form ready and we know how to get the new ordering of our groups. First we will create constants for HTML elements we want to work with:

const saveOrderingButton = document.getElementById('saveOrdering');
const orderingForm = document.getElementById('orderingForm');
const formInput = orderingForm.querySelector('#orderingInput');

Next let’s create function that will fill the hidden form input with new ordering and submits it:

function saveOrdering() {
    const rows = document.getElementById("groups").querySelectorAll('tr');
    let ids = [];
    for (let row of rows) {
        ids.push(row.dataset.lookup);
    }
    formInput.value = ids.join(',');
    orderingForm.submit();
}

We need to query the rows inside the function to get current ordering, next we loop over of all rows and extract the lookup-ids into array. Next we will join them with , and lastly submit the form.

Step Eight: React to button click

We are almost done with our JavaScript. However we still need to connect our button with our function.

saveOrderingButton.addEventListener('click', saveOrdering);

As an improvement, you could have the button disabled and only allow it after the user changes order of the items:

let sortable = Sortable.create(groups, {
    handle: '.handle',
    dragClass: 'dragged',
    chosenClass: 'sortable-chosen',
    onChange: () => {
        saveOrderingButton.disabled = false;
    }
});

Also the submit could be handled with AJAX but I don’t want to distract from the important parts here 🙂

Step Nine: Prepare Django view and form

Our front-end is basically ready. Now we need Django logic to extract the new ordering from submitted form and update order properties of our models.

Let’s start by defining simple form in forms.py like so:

class OrderingForm(forms.Form):
    ordering = forms.CharField()

Now we can move to our views file and define the view responsible for saving new ordering:

@require_POST
def save_new_ordering(request):
    pass

Let’s replace pass with full implementation and then I will explain what is going on:

form = OrderingForm(request.POST)

if form.is_valid():
    ordered_ids = form.cleaned_data["ordering"].split(',')

    with transaction.atomic():
        current_order = 1
        for lookup_id in ordered_ids:
            group = Group.objects.get(lookup_id__exact=lookup_id)
            group.order = current_order
            group.save()
            current_order += 1

return redirect('group-list')

First we create new OrderingForm from the request.POST data and then check if it is valid.

If we have valid forms, we create list of ordered lookup-ids from the form field. Next using transaction.atomic() we loop over all the ids, get Group object and then update its order property.

When all is finished we will redirect the user back to the list of groups.

Step Ten: Define url path for new view

I promise, we are almost there. We just need to define url path for our newly created view and update the HTML form definition.

urlpatterns = [
..
path('save-group-ordering', save_new_ordering, name='save-group-oldering'),
..
]

And finally let’s go back to our template and add action to our <form>:

<form id="orderingForm" method="post" action="{% url 'save-group-oldering' %}">

And that is our ordering finished.

I am by no means a Django expert so there may be better solution or easier Python code. Feel free to suggest improvements :-)

Thanks for reading!