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

Mega Tutorial: Build a Trello Clone - Part 4

26 January, 2025

This is the 4th part of the Build a Trello Clone Tutorial. This time we will implement the Board Detail view. Here the user is able to view/create columns and cards. We are going to use a Django DetailView and make heavy use of Django Templates. In terms of javascript, we are creating interactivity in the app my making forms appear/disappear when clicking certain buttons. Let's dive it, because in this part, stuff really starts coming together nicely!

Creating the DetailView for Boards

Let's head over to the boards/views.py file and add out simple DetailView:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect, get_object_or_404
from django.urls import reverse_lazy
from django.utils.decorators import method_decorator
from django.views.generic import ListView, DetailView

from .models import Board, Column, Card

# ...

@method_decorator(login_required(login_url=reverse_lazy('login')), name='dispatch', )
class BoardDetailView(DetailView):
    template_name = "boards/board_detail.html"
    model = Board

Let's now tie connect it to our existing application. First, boards/urls.py:

1
2
3
4
5
6
7
8
from django.urls import path

from .views import BoardListView, BoardDetailView

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

Create links in the BoardListView template

Now, in order to arrive here, the user should click one of the boards in the BoardListView. Let's head over to the boards/templates/boards/partial/board_card.html subtemplate and add the URL around the board title:

1
2
3
4
5
6
7
...
<h3 class="text-lg font-medium mb-4">
    <a href="{% url 'board_detail' pk=object.pk %}">
        {{ object.title }}
    </a>
</h3>
...

Write templates and AlpineJS code

For today's task, most of our work will be inside the templates. Let's create the boards/templates/boards/board_detail.html:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
{% extends "boards/base.html" %}
{% block content %}
    <div class="overflow-x-scroll">
        <div class="flex min-w-max gap-4">
            {% for column in object.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>
                    {% include "boards/partial/create_card_form.html" with column=column %}
                </div>
            {% endfor %}
            {% include "boards/partial/create_column_form.html" %}
        </div>
    </div>
{% endblock %}

Notice how we created 3 subtemplates:

  1. boards/partial/task_card.html - for displaying a task card
  2. boards/partial/create_card_form.html - for displaying the card creation form
  3. boards/partial/create_column_form.html - for displaying the column creation form

We want our forms to have the same behavior as Trello: display a placeholder, when clicked, replace it with an input.

Task Card Template

This is our simple task card template (boards/partial/task_card.html). I added some placeholder icons, we will figure them out later.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<div class="bg-gray-200 rounded p-4 border border-black flex flex-col justify-between">
    <div>{{ object.title }}</div>
    <div class="flex justify-end space-x-2 mt-2">
        <svg class="h-5 w-5 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" >
            <path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
        </svg>
        <svg class="h-5 w-5 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" >
            <path stroke-linecap="round" stroke-linejoin="round" d="M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 0 1-.923 1.785A5.969 5.969 0 0 0 6 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337Z" />
        </svg>
        <svg class="h-5 w-5 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
          <path stroke-linecap="round" stroke-linejoin="round" d="m20.25 7.5-.625 10.632a2.25 2.25 0 0 1-2.247 2.118H6.622a2.25 2.25 0 0 1-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125Z" />
        </svg>
    </div>
</div>

Create Card Form Template with AlpineJS

For the forms, we will utilize Alpine.js to seamlessly show/hide them as needed. Each form will be thoughtfully designed as a self-contained component, ensuring modularity and ease of use. We want to use the same BoardDetailView for creating both tasks and columns, so I added a action parameter to the forms.

Let's start with boards/partial/create_card_form.html (with action=create_card):

 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
<div class="mt-4" x-data="{ showCardForm: false }">
    <template x-if="!showCardForm">
        <button @click="showCardForm = true"
                class="bg-white text-gray-500 font-bold py-2 px-4 rounded hover:bg-gray-100 transition duration-150 w-full">
            <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
                 stroke-width="1.5" stroke="currentColor" class="inline size-6">
                <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
            </svg>
            Create Card
        </button>
    </template>
    <template x-if="showCardForm">
        <form method="post">
            {% csrf_token %}
            <input type="hidden" name="action" value="create_card"/>
            <input type="hidden" name="column_id" value="{{ column.pk }}"/>
            <textarea name="title" class="p-2 w-full border rounded mt-2"
                      placeholder="Enter card text"></textarea>
            <div class="flex space-x-2 mt-2">
                <button type="submit"
                        class="bg-green-500 text-white font-bold py-2 px-4 rounded hover:bg-green-700 transition duration-150 flex items-center justify-center">
                    <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
                         stroke="currentColor" class="w-6 h-6 mr-1">
                        <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5"/>
                    </svg>
                    Add Card
                </button>
                <button @click="showCardForm = false"
                        class="bg-gray-500 text-white font-bold py-2 px-2 rounded hover:bg-gray-700 transition duration-150 flex items-center justify-center">
                    <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
                         stroke="currentColor" class="w-6 h-6 mr-1">
                        <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
                    </svg>
                </button>
            </div>
        </form>
    </template>
</div>

Create Column Template with AlpineJS

Secondly, here's the boards/partial/create_column_form.html (with action=create_column)

 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
<form method="post" class="w-72" x-data="{ addColumn: false }">
    {% csrf_token %}
    <template x-if="!addColumn">
        <button @click="addColumn = true"
                class="border-2 border-black bg-white text-black font-bold py-2 px-4 rounded hover:bg-gray-100 transition duration-150 w-full">
            <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
                 stroke="currentColor" class="inline size-6">
                <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
            </svg>
            Create Column
        </button>
    </template>
    <template x-if="addColumn">
        <div>
            <input type="hidden" name="action" value="create_column"/>
            <input type="text" name="title" x-model="newColumnName" placeholder="Enter column name"
                   class="p-2 w-full border rounded">
            <button type="submit"
                    class="bg-green-500 text-white font-bold py-2 px-4 rounded hover:bg-green-700 transition duration-150 mt-2">
                <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
                     stroke="currentColor" class="size-6">
                    <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5"/>
                </svg>
            </button>
            <button @click="addColumn = false"
                    class="bg-gray-500 text-white font-bold py-2 px-4 rounded hover:bg-gray-700 transition duration-150 mt-2">
                <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
                     stroke="currentColor" class="size-6">
                    <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
                </svg>
            </button>
        </div>
    </template>
</form>

Handle POST requests

We now have to treat the POST requests in our view. Let's update the BoardDetailView like so:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@method_decorator(login_required(login_url=reverse_lazy('login')), name='dispatch', )
class BoardDetailView(DetailView):
    template_name = "boards/board_detail.html"
    model = Board
    def post(self, request, *args, **kwargs):
        self.object = self.get_object()
        if request.POST.get("action") == "create_column":
            title = request.POST.get("title")
            if title:
                Column.objects.create(
                    board=self.object,
                    title=title
                )
        if request.POST.get("action") == "create_card":
            title = request.POST.get("title")
            column = get_object_or_404(Column, pk=int(request.POST.get("column_id")))
            if title:
                Card.objects.create(title=title, column=column)
        return redirect("board_detail", pk=self.object.pk)

Final Result

Here's what we get by putting everything together:

Create Cards on a Board
Create Cards on a Board

See you in the next part of this tutorial!