Trouble with HTMX/Sortable.js integration into Django

Hey. I’m only starting to learn HTMX and I decided to make a project to experiment how much of SPA functionality I can achieve with only HTMX/Alpine.js on my Django templates.
Part of the project is a drag-and-drop re-ordering of a list of objects, which on its HTMX part works fine and is successfully persisted to the db. Thing is, I can’t get the JS to work correctly, as only one drag and drop event happens, and the the functionality is permanently disabled until I refresh the page. Again, all the HTMX part (re-ordering of the list indexes and db alteration) works as intended.

I suspect the problem seems to be related to the instantiation of the Sortable class, but I’m out of my depth at that point. The htmx:afterSwap event never triggers (according to my debugging console.log), unlike the onEnd event which of course does trigger. But the thing is, I tried switching the htmx:afterSwap to an htmx:afterRequest, and it did trigger on the JS script, but nevertheless the drag and drop functionality remained disabled (until I reload the page in which case it works fine).

I know this is a bit off topic with regards to Django, but for the life of my I cannot figure out what is going wrong. Any help would be appreciated.

BTW link to the repository for full code is here: GitHub - nicoferreira90/reading_list_app

The head of the base.html file, which includes the Sortable.js script, which I copied and pasted from the HTMX page:

<head>

    {% comment %} HTMX CDN {% endcomment %}
    <script src="https://unpkg.com/htmx.org@2.0.3" integrity="sha384-0895/pl2MU10Hqc6jd4RvrthNlDiE9U1tWmX7WRESftEDRosgxNsQG/Ze9YMRzHq"
     crossorigin="anonymous"></script>

    {% comment %} Bootstrap CSS CDN {% endcomment %}
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
     rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
      crossorigin="anonymous">
    {% comment %} Bootstrap JS CDN {% endcomment %}
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
       integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
        crossorigin="anonymous"></script>

    {% comment %} sortable.js CDN {% endcomment %}
    <script src=" https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js "></script>

    {% comment %} load Bootstrap theme {% endcomment %}
    <link rel="stylesheet" href="{% static "bootstrap.min.css" %}">

    <style>
        h1, h2 {
            text-align: center;
        }

    </style>

    <script>
      htmx.onLoad(function(content) {
        var sortables = content.querySelectorAll(".sortable");
        for (var i = 0; i < sortables.length; i++) {
          var sortable = sortables[i];
          var sortableInstance = new Sortable(sortable, {
              animation: 150,
              ghostClass: 'blue-background-class',
    
              // Make the `.htmx-indicator` unsortable
              filter: ".htmx-indicator",
              onMove: function (evt) {
                return evt.related.className.indexOf('htmx-indicator') === -1;
              },
    
              // Disable sorting on the `end` event
              onEnd: function (evt) {
                this.option("disabled", true);
                console.log('onEnd has fired!')
              }
          });
    
          // Re-enable sorting on the `htmx:afterSwap` event
          sortable.addEventListener("htmx:afterSwap", function() {
            console.log('afterSwap has fired!')
            sortableInstance.option("disabled", false);
          });
        }
    })
    </script>
</head>

The reading_page which includes the htmx target, in this case the “book_list” div:

{% extends "base.html" %}

{% block content %}
    <div class="container-fluid">
        <div class="row">
            <div class="col-7">
                <h1 class="m-4">My Reading List</h1>

                <div>
                    {% include "reading/partials/search.html" %}
                </div>

                <div class="d-flex justify-content-center align-items-center">
                    <div id="book_list" style="width: 70%;">
                        {% include "reading/partials/book_list.html" %}
                    </div>
                </div>

                
            </div>
            <div class="col-5">

                <h2 class="m-4 text-center">What do you want to read?</h2>

                <div class="d-flex justify-content-center align-items-center">
                    <form action="{% url 'reading_page' %}" method="post" class="d-flex justify-content-center align-items-center" style="width: 90%;">
                        {% csrf_token %}
                        <input type="text" id="book-title" name="book-title" placeholder="title" class="form-control mr-2" required style="max-width: 250px; margin: 2px;">
                        <input type="text" id="book-author" name="book-author" placeholder="author" class="form-control mr-2" required style="max-width: 250px; margin: 2px;">
                        <button hx-post="{% url 'add_book' %}" hx-target="#book_list" type="submit" class="btn btn-success" style="margin: 2px;">
                            Add Book
                        </button>
                    </form>
                </div>

                <h1 class="m-4">Currently Reading...</h1>

                {% include "reading/partials/currently_reading.html" %}
            </div>
        </div>
    </div>
{% endblock content %}

And the book_list partial which has all the html code:

{% if book_list %}

    <form class="sortable" hx-trigger="end" hx-post="{% url "book_sort" %}" hx-target="#book_list">

    {% for book in book_list %}

    <div>
        <input type='hidden' name='book_order' value="{{ book.pk }}"/>
        <div class="card bg-light mb-2" style="width: 100%;">
            <div class="card-body">
                <div class="row">
                    <!-- Text Section (Book Title and Author) -->
                    <div class="col-10">
                        <p class="h3"><b>{{book.title}}</b></p>
                        <p class="lead">{{book.author}}</p>
                    </div>
                    <!-- Button Section (Delete Button) -->
                    <div class="col-2 text-end">
                        <div class="row">
                            <h4><b>#{{book.order}}</b></h4>
                        </div>
                        <div class="row">
                            <button hx-delete="{% url 'delete_book' book.pk %}" hx-target="#book_list" hx-confirm="Are you sure you wish to delete this book?"
                                type="submit" class="btn btn-danger">
                                Delete
                            </button>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>


    {% endfor %}

    </form>

{% else %}

    <div class="card bg-light mb-3" style="width: 100%; padding: 2%;">
        <h3>Your book list is empty.</h3>
    </div>

{% endif %}

Instead of permanently disabling the Sortable instance after each drag, I would:

  • Keep it enabled by default
  • Temporarily disable during HTMX requests
  • Re-enable after HTMX requests complete
  • Use htmx:beforeRequest and htmx:afterRequest events to manage the sorting state during HTMX updates

Replace the script in your base.html file with this:

// Store Sortable instances globally
const sortableInstances = new WeakMap();

htmx.onLoad(function(content) {
  const sortables = content.querySelectorAll(".sortable");
  
  sortables.forEach(sortable => {
    // Clean up any existing instance
    if (sortableInstances.has(sortable)) {
      sortableInstances.get(sortable).destroy();
      sortableInstances.delete(sortable);
    }
    
    // Create new Sortable instance
    const sortableInstance = new Sortable(sortable, {
      animation: 150,
      ghostClass: 'blue-background-class',
      
      // Make the `.htmx-indicator` unsortable
      filter: ".htmx-indicator",
      onMove: function (evt) {
        return evt.related.className.indexOf('htmx-indicator') === -1;
      },
      
      // Temporarily disable sorting on the `end` event
      onEnd: function (evt) {
        // Instead of disabling, we'll let HTMX handle the request
        console.log('Drag ended - sending HTMX request');
      }
    });
    
    // Store the instance
    sortableInstances.set(sortable, sortableInstance);
    
    // Add HTMX event listeners
    sortable.addEventListener("htmx:beforeRequest", function() {
      console.log('HTMX request starting');
      if (sortableInstances.has(sortable)) {
        // Temporarily disable during the request
        sortableInstances.get(sortable).option("disabled", true);
      }
    });
    
    sortable.addEventListener("htmx:afterRequest", function() {
      console.log('HTMX request completed');
      if (sortableInstances.has(sortable)) {
        // Re-enable after the request
        sortableInstances.get(sortable).option("disabled", false);
      }
    });
  });
});

// Clean up on HTMX before swap
htmx.on("htmx:beforeSwap", function(evt) {
  const sortables = evt.detail.target.querySelectorAll(".sortable");
  sortables.forEach(sortable => {
    if (sortableInstances.has(sortable)) {
      sortableInstances.get(sortable).destroy();
      sortableInstances.delete(sortable);
    }
  });
});

I haven’t tested the code but feel free to post the console.log output here :slight_smile: