Simplify Your Django App with HTMX

Last year I created a gradebook project using Django for both the frontend and backend. I had studied React for a few weeks but it simply seemed like it would be too much for me to work on, when combined with what I anticipated to be a reasonably large project. I decided to stick with the Django templating knowing that I would have to write a bit of javascript and maybe use a bit of jquery.

This post isn’t so much of a tutorial on using HTMX, as there are many very good tutorials and videos that do a better job than I can. This post is more of an example showing how a bit of htmx can greatly simplify the code needed to create, display and edit objects in Django.

Perhaps the most difficult frontend page for me to code in my project was where a user would enter in the assessment grades for a classroom of students. A standard form would not suffice because the number of learning objectives (grades) in each assessment would vary, as would the number of students. It’s possible that a formset inside a formset could have worked but I thought I could do it easier with some javascript. My view would pass an array to the template which contained all the grades information. My js script would then go through and build an n x m grid for the students and grades.

First was the code for my js script gradebook.js that creates a dynamic form:

var down = document.getElementById("GFG_DOWN");
var scale_mode = nameArray.shift();
var numberObj = nameArray.shift();

var obj = []
// get the names of each learning objective
for (var g = 0; g < numberObj; g++) {
    obj[g] = nameArray.shift();
}
var numberStudents = nameArray.length;

// set attributes for the form
var form = document.createElement("form");
form.setAttribute("method", "post");
form.setAttribute("action", "submit.php");

// grades will be put into a table
let table = document.createElement('table');
table.className = "table table-bordered";
let thead = document.createElement('thead');
let tbody = document.createElement('tbody');

table.appendChild(thead);
table.appendChild(tbody);

//add the table to the body tag
document.getElementById('grade-form').appendChild(table);

//fill out first row of table
// first column will be the student name
let row_1 = document.createElement('tr');
let heading_1 = document.createElement('th');
heading_1.scope = "col";
heading_1.innerHTML = "Student";

// next columns will be names of the learning objective(s)
var headingName = [];
for (var i = 0; i < numberObj; i++) {
    headingName[i] = "heading" + (i + 2);
    headingName[i] = document.createElement('th');
    headingName[i].scope = "col";
    headingName[i].innerHTML = obj[i]
}

headingName[numberObj] = "heading" + (numberObj + 2);
headingName[numberObj] = document.createElement('th');
headingName[numberObj].scope = "col";
headingName[numberObj].innerHTML = "Comments";

row_1.appendChild(heading_1);
for (var i = 0; i < numberObj; i++) {
    row_1.appendChild(headingName[i]);
}
row_1.appendChild(headingName[numberObj]);
thead.appendChild(row_1);

// each row will print the student name in 1st column
// next column will be the objective grades
var m = 0;
var rowName = [];
var studentname = [];

for (var n = 0; n < numberStudents; n++) {
    rowName[n] = "rowname" + (n + 2);
    rowName[n] = document.createElement('tr');
    studentname[n] = document.createElement('td');
    studentname[n].innerHTML = nameArray[n];
    rowName[n].appendChild(studentname[n]);
    var objective = [];
    // two different scale modes to choose from
    switch (scale_mode) {
    
        case 'MOE':
            console.log("3");
            var option_array = {
                '---': '---',
                'I': 'I',
                'EMG': 'EMG',
                'DEV': 'DEV',
                'PRF': 'PRF',
                'EXT': 'EXT'
            };
            break;
        case 'MOEPLUS':
            console.log("4");
            var option_array = {
                '---': '---',
                'I': 'I',
                'EMG': 'EMG',
                'DEV': 'DEV',
                'PRF': 'PRF',
                'PRF+': 'PRF+',
                'EXT': 'EXT'
            };
            break;
    };

    // create the table sell for each grade
    for (k = 0; k < numberObj; k++) {
        objective = document.createElement('td');
        GR = document.createElement("select");
        for (index in option_array) {
            GR.options[GR.options.length] = new Option(option_array[index], index);
        }
        GRName = "row-" + n + "-col-" + k;
        GR.setAttribute("name", GRName);
        rowName[n].appendChild(objective);
        objective.appendChild(GR);
    }

    commentCell = document.createElement('td');
    comment = "row-" + n + "-comment";
    commentText = document.createElement("textarea");
    commentText.setAttribute("name", comment);
    rowName[n].appendChild(commentCell);
    commentCell.appendChild(commentText);
    tbody.appendChild(rowName[n]);
}

The table from the javascript above is placed into an element with id “grade-form”. This shows up in the django template:

{% block content %}
<div class="container">
  <div class="row">
    <div class="col">
      <p></p>
    </div>
  </div>
  <div class="row">
    <div class="col-md-8">
      <div class="form-group">
        <form action="" method="post">{% csrf_token %}
          <div id="grade-form"></div>
          <div class="form-group">
            <button type="submit" class="btn btn-primary">Submit</button>
          </div>
        </form>
      </div>
    </div>

  </div>
{% endblock content %}
<div>
  {% block extra_js %}

    <script>
      var nameArray = {{ data|safe }};
    </script>
    <script type="text/javascript" src="{% static 'gradebook/js/gradeform.js' %}"></script>
  {% endblock extra_js %}
 
</div> 

So this worked but I felt it was a bit messy. The assessment UI wasn’t just this page to enter new grades though. I also needed a separate page to show existing grades, and then I had an UpdateView where the user could edit any one grade. All of this required three separate templates and 3 different looking interfaces. I managed how these three pages would be shown to the user by some logic: if the grades existed, the user would be shown the page for existing grades; if there weren’t any grades yet, a user would be taken to the page to enter new grades. This system also carried some risk with the user possibly entering in grades twice.

HTMX to the rescue

What I really wanted was just one page that showed the assessment info and table to the user. If no grades were entered yet, they would just click in each cell to enter a grade. If there was an existing grade, the user could click on the cell to change the grade. That’s it. One page, one url. Prior to using HTMX, the grades would be shown in the template with:

{% for g in grade_list %}
	{% if g.student.id == student.id %}
		<td><a  href="{% url 'gradebook:updatesinglegrade' g.assessment.pk g.cblock.pk g.pk %}"></a>
		  <input type="text" class="form-control score" title={{ g.score }} name="score" id="input-{{ forloop.counter0 }}" placeholder={{ g.score }} required>
		</td>
	{% endif %}
{% endfor %}

I replaced the element with this:

<input type="text" hx-post="{% url 'gradebook:grade-change' g.pk %}" hx-swap="outerHTML" hx-trigger="keyup delay:1200ms" class="form-control score" title={{ g.score }} name="score" id="input-{{ forloop.counter0 }}" placeholder={{ g.score }} required>

And that was it. No javascript form needed, no separate page for grade entry or grade edit. The hx-post tells Django which view to use, hx-swap tells Django what to replace this element with and hx-trigger tells Django what causes a change. The <id> is used when a bit of js to colour each cell when the template is first loaded. The view for the HTMX is :

class GradeChange(SingleObjectMixin, View):
    """ view to handle htmx grade change"""
    model = Grade

    def post(self, request, *args, **kwargs):
        grade = self.get_object()
        user = grade.user
        sm = GradeBookSetup.objects.get(user=user).scale_mode
        ns = request.POST.get('score')
		new_score = ns.upper()
        if new_score == "RRR":
            new_score = "PRF"
        if new_score == "RRF":
            new_score = "PRF+"

        def get_color(grade):  # map background color to the score
            if grade == "EXT":
                convert_code = "rgba(153,102,255,0.4)"
            elif grade == "PRF+":
                convert_code = "rgba(75, 192, 192, 0.7)"
            elif grade == "PRF":
                convert_code = "rgba(75, 192, 192, 0.3)"
            elif grade == "DEV":
                convert_code = "rgba(255, 205, 86, 0.4)"
            elif grade == "EMG":
                convert_code = "rgba(225, 99, 132, 0.4)"
            else:
                convert_code = "rgba(0, 0, 0, 0.1)"
            return (convert_code)

        elif sm == 'MOEPLUS':
            score_list = ["EXT", "PRF+", "PRF", "DEV", "EMG", "I", "---"]
        elif sm == 'MOE':
            score_list = ["EXT", "PRF", "DEV", "EMG", "I", "---"]

        if new_score in score_list:
            grade.score = new_score
            grade.save()
            grade_score = str(grade.score)
            bgcode = get_color(grade.score)
            input_string = f'<input type="text" hx-post="{reverse("gradebook:grade-change", args=[grade.pk])}" hx-swap="outerHTML" hx-trigger="keyup delay:1200ms" class="form-control score" style="background-color:{ bgcode }" title="{ grade_score }" name="score" placeholder="{ grade.score }" required>'
        else:
            bgcode = get_color(grade.score)
            input_string = f'<input type="text" hx-post="{reverse("gradebook:grade-change", args=[grade.pk])}" hx-swap="outerHTML" hx-trigger="keyup delay:1200ms" class="form-control score" style="background-color:{ bgcode }; border:solid rgb(255, 0, 0,.5);" title="xxx" name="score" placeholder="{ new_score }" required>'

        return HttpResponse(input_string)

This view does a couple of more things than just post a new grade. I have a few “shortcuts” for the user. Instead of typing “PRF” or “PRF+” (which are the grades I’m using), they can type “RRR” and “RRF” for a shortcut. This allows all grades to be entered using only a left hand. The user can also enter in lowercase letters and they’re changed to uppercase. As well, I’m colour coding each cell based on the score entered. This view was very easy to write, is easy to read and understand, and provides a very clean way of entering in assessment grades.