
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.
- 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.
- Edit Card Title - Edit inline the title of the card: double click the title, change it and click away.
-
Edit Card Column - Change the current column. Use an
@change
event to update the column. -
Edit Card Description - Similar to card title, but using a
textarea
. -
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:

See you in the next part of this tutorial!