Mega Tutorial: How to build a Trello code in Django (Part 5)
Home Django Mega Tutorial: Build a Trello Clone - Part 5

Mega Tutorial: Build a Trello Clone - Part 5

05 February, 2025

In this episode from our "Build a Trello Clone" series, we will be handling the Task editing part. Think of what is happening when you click a Task Card in Trello: a modal opens and you get to edit details about the card. Let's dive in!

Creating the Comment model

In the Second Part of our build a Trello Clone Tutorial we have build a series of models. I forgot to create the Comment model. No matter, let's create it right now:

1
2
3
4
5
6
7
class Comment(TimeStampedModel):
    card = models.ForeignKey(Card, on_delete=models.CASCADE, related_name="comments")
    author = models.ForeignKey(User, on_delete=models.PROTECT, related_name="comments")
    text = models.TextField()

    def __str__(self):
        return f"{self.author} > {self.card} > {self.text}"

Using related_name

Providing the related_name tells Django how we want to access the current model from the related one.

For example, we can access a Card's comments like this: card.comments.all(). We can also access a User's comments like this: user.comments.all().

In case we don't specify a value for related_name, Django will default to {model_name.lower}_set. If we didn't specify, we would need to use: card.comment_set.all() and user.comment_set.all().

As always, remember to create the migration files:

python manage.py makemigrations

... and apply them:

python manage.py migrate

Creating the CardDetailView

Let's now create a DetailView (in boards/views.py) just like we did with BoardDetailView in our previous episode:

1
2
3
4
5
6
7
8
9
@method_decorator(login_required(login_url=reverse_lazy('login')), name='dispatch', )
class CardDetailView(DetailView):
    template_name = "boards/card_detail.html"
    model = Card

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['board'] = self.object.column.board
        return context

Let's now hook it up to our URL schema in boards/urls.py:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from django.urls import path

from .views import (
    BoardListView,
    BoardDetailView,
    CardDetailView
)

urlpatterns = [
    path('', BoardListView.as_view(), name='board_list'),
    path('<int:pk>/', BoardDetailView.as_view(), name='board_detail'),
    path('<int:board_id>/card/<int:pk>/', CardDetailView.as_view(), name='card_detail'),
]

Link view to cards

Since now we have a CardDetailView we need to link our cards from the BoardDetailView's template (boards/partial/board_card.html) to the new view:

Let us thus replace this:

1
2
3
<div class="bg-gray-200 rounded p-4 border border-black flex flex-col justify-between">
    <div>{{ object.title }}</div>
    ...

... with this:

1
2
3
4
5
6
7
<div class="bg-gray-200 rounded p-4 border border-black flex flex-col justify-between">
    <div>
        <a href="{% url 'card_detail' pk=card.pk board_id=card.column.board.pk %}">
            {{ object.title }}
        </a>
    </div>
    ...

Write the base template

Just like in the original Trello application, we want our task modal have the entire board as a background. So let's just copy the template of the BoardDetailView and delete the Column/Card create forms. Let's also add a template for the Card modal:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
{% extends "boards/base.html" %}
{% block content %}
    <div class="overflow-x-scroll">
        <div class="flex min-w-max gap-4">
            {% for column in board.columns.all %}
                <div class="bg-white rounded shadow p-4 w-72 border-2 border-black">
                    <h2 class="font-bold text-lg mb-4">{{ column.title }}</h2>
                    <div class="space-y-4">
                        {% for card in column.cards.all %}
                            {% include "boards/partial/task_card.html" with object=card %}
                        {% endfor %}
                    </div>
                </div>
            {% endfor %}
        </div>
    </div>
    {% include "boards/partial/task_modal.html" %}
{% endblock %}

Edit Card Modal

We just arrived at the main section of this episode. Since it might seem complicated, we are going to break things into 4 parts and then present everything in one easy to copy/paste piece of code.

  1. Alpine.js Application - Here is where we will keep the state and where we will write the code that updates a field of the task. Our convention right now, in order to keep things simple is that we can only update one field at-a-time.
  2. Edit Card Title - Edit inline the title of the card: double click the title, change it and click away.
  3. Edit Card Column - Change the current column. Use an @change event to update the column.
  4. Edit Card Description - Similar to card title, but using a textarea.
  5. Add Comment From - A traditional HTML form for adding a Comment.

Alpine.js Application

Let's create our tiny Alpine.js application: TaskDetailApp. Here we are going to keep track of the state and also here we will implement the HTTP request for updating a task field:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<script type="text/javascript">
    TaskDetailApp = function() {
        return {
            title: '{{ object.title|escapejs }}',
            description: '{{ object.description|escapejs }}',
            column: {{ object.column.pk }},
            editTitle: false,
            editDescription: false,
            updateTaskField: function(field, value) {
                const data = new URLSearchParams();
                data.append(field, value);
                data.append("csrfmiddlewaretoken", "{{ csrf_token }}");
                data.append("action", "update_task");
                fetch("{% url "card_detail" board_id=object.column.board.pk pk=object.pk  %}", {
                    method: 'post',
                    body: data,
                })
                .then(function(response) {
                    console.log(response)
                }) ;
            }
        }
    }
</script>

Editing Card Title

We will display the title of the card in an <h2>...</h2> tag. When double click we will replace it with an input field. When clicking away, we will save the changes and revert back to displaying the title:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<div x-data="TaskDetailApp()" id="modal-overlay" class="fixed inset-0 bg-gray-800 bg-opacity-50 flex items-center justify-center">
    <div class="bg-white border-4 border-black rounded-lg w-3/4 h-3/4 relative overflow-y-auto">
    <div class="sticky top-0 bg-white z-10 p-4 border-b-2 border-black flex justify-between items-center">
        <h2 class="text-2xl font-bold">
            <span
                @click.prevent
                @dblclick="
                    editTitle = true;
                    $nextTick(() => $refs['edit_title'].focus());"
                @click.away="editTitle = false"
                x-show="!editTitle"
                x-text="title"
                class="select-none cursor-pointer font-lg">
            </span>
            <input
                type="text"
                x-model="title"
                x-show="editTitle"
                @click.away="if (editTitle) {editTitle = false; updateTaskField('title', title)}"
                @keydown.enter="if (editTitle) {editTitle = false; updateTaskField('title', title)}"
                @keydown.window.escape="if (editTitle) {editTitle = false; updateTaskField('title', title)}"
                class="bg-white focus:outline-none focus:shadow-outline border border-gray-300 rounded-lg py-2 px-4 appearance-none leading-normal w-128"
                x-ref="edit_title"
            />
        </h2>
        <a href="{% url "board_detail" pk=board.pk %}" class="text-black font-bold text-2xl">
            <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="3" stroke="currentColor" class="size-6">
              <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
            </svg>
        </a>
    </div>

Notice how we initialized the Alpine.js application: x-data="TaskDetailApp()".

Editing Card Column

This is probably the simplest part. We place the columns in a select and will use the @change event in order to save the changes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<div class="mb-4 p-6">
    <label for="column-select" class="block font-bold text-lg mb-2">In List:</label>
    <select x-model="column" id="column-select"
            class="border-2 border-black rounded p-2"
            @change="updateTaskField('column_id', column)">
        {% for column in board.columns.all %}
            <option value="{{ column.pk }}" x-bind:selected="column == {{ column.pk }}">{{ column.title }}</option>
        {% endfor %}
    </select>
</div>

Editing Card Description

We display the description inside a <div>...</div> tag. When double-clicked, the <div>...</div> will be replaced by a textarea where we can edit the task description. Clicking away or pressing Escape Key will persist changes made and swap out the <textarea></textarea> with the <div>...</div>.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<div class="p-6">
    <label for="description" class="block font-bold text-lg mb-2">Description:</label>
    <div
        @click.prevent
        @dblclick="
            editDescription = true;
            $nextTick(() => $refs['edit_description'].focus());"
        @click.away="editDescription = false"
        x-show="!editDescription"
        x-text="description.trim()?description.trim():'--NO DESCRIPTION--'"
        class="select-none cursor-pointer font-lg whitespace-pre-wrap">
    </div>
    <textarea
        class="w-full border-2 border-black rounded p-2"
        rows="4"
        x-model="description"
        x-show="editDescription"
        @click.away="if (editDescription) {editDescription = false; updateTaskField('description', description)}"
        @keydown.window.escape="if (editDescription) {editDescription = false; updateTaskField('description', description)}"
        x-ref="edit_description"
    ></textarea>
</div>

Comment Form

For adding comments we took a traditional HTML form approach. We add the comment in a textarea and we POST it to our Django view, refreshing the page.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<div class="p-6">
    <form class="mt-4" method="post">
        {% csrf_token %}
        <input type="hidden" name="action" value="create_comment" />
        <label for="new-comment" class="block font-bold text-lg mb-2">Add a Comment:</label>
        <textarea id="new-comment" class="w-full border-2 border-black rounded p-2" rows="2"
                  placeholder="Write your comment..." name="text"></textarea>
        <div class="flex justify-end">
            <button type="submit" id="add-comment" class="mt-2 px-4 py-2 bg-blue-500 text-white font-bold rounded">Add
                Comment
            </button>
        </div>
    </form>
    {% if object.comments.all %}
        <h3 class="text-xl font-bold mb-4">Comments:</h3>
        <div id="comments" class="space-y-4">
            {% for comment in object.comments.all %}
            <div class="p-4 border-2 border-gray-300 rounded">
                <p class="text-sm font-bold">{{ comment.author }}:</p>
                <p class="text-sm">{{ comment.text }}</p>
            </div>
            {% endfor %}
        </div>
    {% endif %}
</div>
</div>
</div>

Everything together

That was quite the journey. In case you have any trouble piecing everything together, here's the full version of the boards/templates/boards/partial/task_modal.html:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
<script type="text/javascript">
    TaskDetailApp = function() {
        return {
            title: '{{ object.title|escapejs }}',
            description: '{{ object.description|escapejs }}',
            column: {{ object.column.pk }},
            editTitle: false,
            editDescription: false,
            updateTaskField: function(field, value) {
                const data = new URLSearchParams();
                data.append(field, value);
                data.append("csrfmiddlewaretoken", "{{ csrf_token }}");
                data.append("action", "update_task");
                fetch("{% url "card_detail" board_id=object.column.board.pk pk=object.pk  %}", {
                    method: 'post',
                    body: data,
                })
                .then(function(response) {
                    console.log(response)
                }) ;
            }
        }
    }
</script>
<div x-data="TaskDetailApp()" id="modal-overlay" class="fixed inset-0 bg-gray-800 bg-opacity-50 flex items-center justify-center">
    <div class="bg-white border-4 border-black rounded-lg w-3/4 h-3/4 relative overflow-y-auto">
        <!-- Sticky Header with Close Button -->
            <div class="sticky top-0 bg-white z-10 p-4 border-b-2 border-black flex justify-between items-center">
                <h2 class="text-2xl font-bold">
                    <span
                        @click.prevent
                        @dblclick="
                            editTitle = true;
                            $nextTick(() => $refs['edit_title'].focus());"
                        @click.away="editTitle = false"
                        x-show="!editTitle"
                        x-text="title"
                        class="
                            select-none
                            cursor-pointer
                            font-lg
                        "></span>
                    <input
                        type="text"
                        x-model="title"
                        x-show="editTitle"
                        @click.away="if (editTitle) {editTitle = false; updateTaskField('title', title)}"
                        @keydown.enter="if (editTitle) {editTitle = false; updateTaskField('title', title)}"
                        @keydown.window.escape="if (editTitle) {editTitle = false; updateTaskField('title', title)}"
                        class="
                            bg-white
                            focus:outline-none focus:shadow-outline
                            border border-gray-300
                            rounded-lg
                            py-2
                            px-4
                            appearance-none
                            leading-normal
                            w-128
                        "
                        x-ref="edit_title"
                    />
                </h2>
                <a href="{% url "board_detail" pk=board.pk %}" class="text-black font-bold text-2xl">
                    <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="3" stroke="currentColor" class="size-6">
                      <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
                    </svg>
                </a>
            </div>
        <div class="mb-4 p-6">
            <label for="column-select" class="block font-bold text-lg mb-2">In List:</label>
            <select x-model="column" id="column-select"
                    class="border-2 border-black rounded p-2"
                    @change="updateTaskField('column_id', column)">
                {% for column in board.columns.all %}
                    <option value="{{ column.pk }}" x-bind:selected="column == {{ column.pk }}">{{ column.title }}</option>
                {% endfor %}
            </select>
        </div>
        <div class="p-6">
            <label for="description" class="block font-bold text-lg mb-2">Description:</label>
            <div
                @click.prevent
                @dblclick="
                    editDescription = true;
                    $nextTick(() => $refs['edit_description'].focus());"
                @click.away="editDescription = false"
                x-show="!editDescription"
                x-text="description.trim()?description.trim():'--NO DESCRIPTION--'"
                class="select-none cursor-pointer font-lg whitespace-pre-wrap">
            </div>
            <textarea
                class="w-full border-2 border-black rounded p-2"
                rows="4"
                x-model="description"
                x-show="editDescription"
                @click.away="if (editDescription) {editDescription = false; updateTaskField('description', description)}"
                @keydown.window.escape="if (editDescription) {editDescription = false; updateTaskField('description', description)}"
                x-ref="edit_description"
            ></textarea>
        </div>
        <div class="p-6">
            <form class="mt-4" method="post">
                {% csrf_token %}
                <input type="hidden" name="action" value="create_comment" />
                <label for="new-comment" class="block font-bold text-lg mb-2">Add a Comment:</label>
                <textarea id="new-comment" class="w-full border-2 border-black rounded p-2" rows="2"
                          placeholder="Write your comment..." name="text"></textarea>
                <div class="flex justify-end">
                    <button type="submit" id="add-comment" class="mt-2 px-4 py-2 bg-blue-500 text-white font-bold rounded">Add
                        Comment
                    </button>
                </div>
            </form>
            {% if object.comments.all %}
                <h3 class="text-xl font-bold mb-4">Comments:</h3>
                <div id="comments" class="space-y-4">
                    {% for comment in object.comments.all %}
                    <div class="p-4 border-2 border-gray-300 rounded">
                        <p class="text-sm font-bold">{{ comment.author }}:</p>
                        <p class="text-sm">{{ comment.text }}</p>
                    </div>
                    {% endfor %}
                </div>
            {% endif %}
        </div>
    </div>
</div>

Saving Changes in the CardDetailView

You might be confused because you don't see the edits get persisted. That's because we didn't write the backend code for this. Let's add the POST handler on our CardDetailView:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@method_decorator(login_required(login_url=reverse_lazy('login')), name='dispatch', )
class CardDetailView(DetailView):
    template_name = "boards/card_detail.html"
    model = Card

    def post(self, request, *args, **kwargs):
        self.object = self.get_object()

        if request.POST.get("action") == "create_comment":
            text = request.POST.get("text")
            if text:
                Comment.objects.create(
                    author=self.request.user,
                    card=self.object,
                    text=text
                )
            return redirect("card_detail", pk=self.object.pk, board_id=self.object.column.board.pk)

        if request.POST.get("action") == "update_task":
            for field_name in ("title", "description"):
                if request.POST.get(field_name, None) is not None:
                    setattr(self.object, field_name, request.POST[field_name])

            if request.POST.get("column_id", None) is not None:
                column = get_object_or_404(Column, pk=int(request.POST.get("column_id")))
                self.object.column = column

            self.object.save()

            return HttpResponse("ok", status=200)
        return redirect("card_detail", pk=self.object.pk, board_id=self.object.column.board.pk)

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['board'] = self.object.column.board
        return context

Final Result

Here's what we get by putting everything together:

Edit Task Card
Edit Task Card

See you in the next part of this tutorial!