DEV Community

rodbv
rodbv

Posted on

Creating a To-do app with HTMX and Django, part 8: inline edit, and using Iconify

Welcome back to the series of posts in which I'm documenting my learning process of HTMX with Django. So far we have implemented the ability to add todo items, delete them, toggle their completion status, and infinite scroll.

To see the whole series or articles, you can check dev.to/rodbv. The repository of the code so far is at github.com/rodbv/todo-mx.

The next goal is to implement inline edit of the todo-item: when we click on an "edit" button somewhere in the list item, it would show an input text with the task title, and save/cancel buttons.

As usual, there are a few possibilities here; one is to render both the <span> with the title, and a form with <input> for each row, and use plain Javascript (or AlpineJS) to toggle edit mode.

I'd rather keep following the hypermedia/server-oriented approach and return the form for the row when a request to edit and item is made to the server; in other words, add an "edit" button on the row that sends a request to tasks/:id/edit which in turn returns the form we want, replacing the whole line.

First, let's add a new button, "Edit", near the existing "Delete" button, configured with the GET request we want:

... previous code

{% for todo in todos %} 
  <li class="list-row">
    <input type="checkbox" class="checkbox checkbox-lg checkbox-info mr-4"
      hx-put="{% url 'toggle_todo' todo.id %}"
      hx-swap="outerHTML"
      hx-target="closest li"
      hx-on:click="this.setAttribute('disabled', 'disabled')"
      {% if todo.is_completed %} checked {% endif %}
    />

    <span class="flex-1 text-lg 
      {% if todo.is_completed %}text-gray-500{% endif %}"
    >{{ todo.title }}</span>

    <!-- THIS BUTTON IS NEW -->
    <button hx-get="{% url 'edit_task' todo.id %}" 
      class="btn btn-circle btn-sm btn-info btn-outline"
      hx-swap="innerHTML"
      hx-target="closest li"
      >Edit</button>

      <button hx-delete="{% url 'task_details' todo.id %}" 
        class="btn btn-circle btn-sm btn-error btn-outline"
        hx-on:todo-deleted="this.closest('li').remove()"
        hx-confirm="Delete this item?">x</button>
    </li>
  {% endfor %}

... previous code
Enter fullscreen mode Exit fullscreen mode

This is how the button will look like:

Todo items with edit button

Later, we will use Iconify to add some nice icons to the new buttons, but let's focus on the task now.

Having the button to request the edit form, it's time to add the new url and view. First, in core/urls.py:

# core/urls.py

from django.urls import path
from . import views

urlpatterns = [
    path("", views.index, name="index"),
    path("tasks/", views.tasks, name="tasks"),
    path("tasks/<int:task_id>/toggle/", views.toggle_todo, name="toggle_todo"),
    path("tasks/<int:task_id>/", views.task_details, name="task_details"),
    # NEW
    path("tasks/<int:task_id>/edit/", views.edit_task, name="edit_task"),
]
Enter fullscreen mode Exit fullscreen mode

Then on core/views.py:


# core/views.py

...previous code

# NEW
@login_required
@require_http_methods(["GET"])
def edit_task(request, task_id):
    todo = request.user.todos.get(id=task_id)
    return render(
        request,
        "tasks.html#todo-item-edit",
        {"todo": todo},
    )
Enter fullscreen mode Exit fullscreen mode

Note that the new url/view is returning a new partial name, tasks.html#todo-item-edit. It's time for us to define this partial, in tasks.html. Since this partial only rendered when requested by this view, we will add it to the bottom of the file, and do not set the inline attribute, which would cause it to be rendered by default.

In core/templates/tasks.html, at this to the very end of the file:


...previous code

{% partialdef todo-item-edit %}
<input type="checkbox" form="edit-form" name="is_completed" class="checkbox checkbox-lg checkbox-info mr-2"
  {% if todo.is_completed %} checked {% endif %}/>

<div class="flex space-x-4">

  <form id="edit-form" hx-post="{% url 'task_details' todo.id %}" hx-swap="outerHTML" hx-target="closest li" class="w-full flex items-center space-x-2">
    <input type="text"  name="title" required class="input input-bordered flex-1 text-lg" value="{{ todo.title }}" autofocus />
  </form>

  <button form="edit-form" class="btn btn-circle btn-sm btn-success" 
    type="submit"
  >Save</button>

  <button class="btn btn-circle btn-sm btn-error btn-outline" 
    hx-get="{% url 'task_details' todo.id %}" 
    hx-swap="outerHTML" 
    hx-target="closest li"
  >Cancel</button>

</div>
{% endpartialdef %}

Enter fullscreen mode Exit fullscreen mode

We're adding the is_completed checkbox, to show the status of the todo, which will also be sent with the form, in case the user decides to change it. Note that we removed its hx-put trigger. We also have an input text, set as required, for the title, and two buttons:

  • The Save button will submit the form to /tasks/:id/details, with the new value of the title;
  • The Cancel button will send a GET request to the same route, which will simply return the todo item using the usual partial for todos, tasks.html#todo-items-partial. We'll get to this code soon, first let's see it in action to show what we just described.

Edit todo item inline

One nice side-effect of using a form to edit the todo item is that the Enter/Return key also works.

We already have the url/view being used by both the save and cancel routes - they're both calling task_details, with the methods POST and GET respectively, so let's just extend the view function.

# core/views.py

... previous code

@login_required
@require_http_methods(["DELETE", "POST", "GET"])
def task_details(request, task_id):
    todo = request.user.todos.get(id=task_id)

    if request.method == "DELETE":
        todo.delete()
        response = HttpResponse(status=HTTPStatus.NO_CONTENT)
        response["HX-Trigger"] = "todo-deleted"
        return response

    if request.method == "POST":
        title = request.POST.get("title")
        if not title:
            raise ValueError("Title is required")

        todo.title = title
        todo.is_completed = bool(request.POST.get("is_completed"))
        todo.save()

    # both POST and GET will just return the todo details
    return render(
        request,
        "tasks.html#todo-items-partial",
        {"todos": [todo]},
    )
Enter fullscreen mode Exit fullscreen mode

That's it for the changes on in the view.

Ideally, we should be using th PUT method, not POST. While this is possible, it would require much more code, we would have to drop the form and send hx-put straight from the Save button, using hx-vals to collect the values of is_completed and title. We would also lose two benefits of the form, which is the native required validation of the input, and autofocus for the title input, so I think it's a reasonable tradeoff from REST-ful purity.

Field validation - required title in action

Using Iconify for the icons

The last thing we want to do is to make the buttons nicer-looking. I've decided to use Iconify, an open-source, free alternative; specifically, their "Ming Cute - Line" set.

How to use Iconify

We can just copy the CSS for the icons we want, in put in a new file in our project, /core/static/css/styles.css (click here to see the code). I'm adding a second class name to each style, to make it simpler to refer to the class names. It's a good idea to keep the original names for reference in Iconify.

/* 
To get more icons, go to 
https://icon-sets.iconify.design/mingcute/page-3.html?suffixes=Fill 
*/

.icon-delete,
.mingcute--folder-delete-line {
    display: inline-block;
    width: 18px;
    height: 18px;

...rest of CSS
Enter fullscreen mode Exit fullscreen mode

In core/templates/tasks.html we can add a reference to the CSS file and use them in each button, for example:

  <button class="btn btn-circle btn-sm btn-error btn-outline" 
    hx-get="{% url 'task_details' todo.id %}" 
    hx-swap="outerHTML" 
    hx-target="closest li"
  ><span class="icon-back"></span></button>
Enter fullscreen mode Exit fullscreen mode

This is how it looks like. The whole code can be found in the part 8 branch. Feel free to ask for what you want in the next posts!

Edit form in action

Top comments (0)