Mega Tutorial: Build a Trello Clone - Part 3
13 December, 2024
This is the 3nd part of the Build a Trello Clone Tutorial.
In this part we are going to focus on implementing the basic functionality of the user dashboard,
where the user can view and create boards.
We are going to see what a Django ClassBasedView
how to use a GenericView
.
We are also going to write out first javascript code to make the dashboard interactive.
Planning
Go into your Trello account and have a look at the main page, the one you get redirected to after authenticating.
As you can see the user dashboard is listing the existing boards. Also, from here the user can create a new board. This 3rd part of this tutorial will be exactly about this.
We need to create a view that lists all the available boards.
By available boards I'm referring to the boards the user created, plus the boards the user was invited to.
We're going to make use of the generic implementations from Django's Class Based Views.
In this case, it's going to be the ListView
.
We also need to create a section on top with all the user's favorite boards.
After, we are going to add the Create Board functionality to this view.
Let's get to it!
Creating the Django app
As always, let's use the Django command to init the boards
application:
1 | python manage.py startapp boards |
Add the boards
application to INSTALLED_APPS
:
1 2 3 4 5 6 7 8 9 10 | INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'accounts', 'boards', # <-- ] |
Enrich Board model
It's a good idea to write common queries in the model as methods so that:
- You don't repeat yourself
- You don't get it wrong
Let's enrich the Board
model with:
-
card_count
- to know how many tasks there are in a board (we're going to write this as a property) -
get_available
- To get all the available boards to a user (we're going to write this as a static method) -
get_favorites
- To get all the favorite available boards for a user (also static method)
Here's how that looks like:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | class Board(TimeStampedModel): title = models.CharField(max_length=128) owner = models.ForeignKey(User, on_delete=models.CASCADE) @property def card_count(self): return Card.objects.filter(column__board=self).count() @staticmethod def get_available(user): return Board.objects.prefetch_related( Prefetch('invites', queryset=Invite.objects.filter(user=user)) ).filter(Q(invites__user=user) | Q(owner=user)).distinct() @staticmethod def get_favorites(user): return Board.get_available(user).prefetch_related( Prefetch('favorited_by', queryset=Favorite.objects.filter(user=user)) ).filter(favorited_by__user=user) def __str__(self): return f"{self.owner} > {self.title}" |
Writing Tests in Django
Even though this tutorial is not focused on writing tests, we can a short detour to show how to check the behaviour of these new methods we wrote.
Here's how to think about writing these tests. We want to create a few scenarios and see if the result matches out expectations. Let's create a few users and assign them to some boards, either as owners or as invitees.
We also want to check some edge cases, like:
- What happens a user has no favorite boards
- What happens when a user has marked all boards as favorite
- What happens if a user marks as favorite a board he doesn't have access to
- What happends if a user invites himself to a board he owns
If you don't already have it, go and create a tests.py
file inside the boards
Django application.
Here are the tests I wrote. If you find a corner case interesting, add it to the test scenarios.
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 | from django.contrib.auth.models import User from django.test import TestCase from boards.models import Board, Invite, Favorite class BoardsTestCase(TestCase): def setUp(self): # Create users self.john = User.objects.create_user( username="john", email="john@example.com", password="j0hn" ) self.mindy = User.objects.create_user( username="mindy", email="mindy@example.com", password="m1ndy" ) self.kylie = User.objects.create_user( username="kylie", email="kylie@example.com", password="kyli3" ) self.board1 = Board.objects.create(title="Board 1", owner=self.john) self.board2 = Board.objects.create(title="Board 2", owner=self.john) self.board3 = Board.objects.create(title="Board 3", owner=self.mindy) Invite.objects.create(user=self.john, board=self.board3) Invite.objects.create(user=self.mindy, board=self.board1) # Invite mindy to her own board Invite.objects.create(user=self.mindy, board=self.board3) Invite.objects.create(user=self.kylie, board=self.board2) Invite.objects.create(user=self.kylie, board=self.board3) # john favorited all the boards he has access to Favorite.objects.create(user=self.john, board=self.board1) Favorite.objects.create(user=self.john, board=self.board2) Favorite.objects.create(user=self.john, board=self.board3) # mindy favorited none # kylie favorited 1 (not available) and 2 Favorite.objects.create(user=self.kylie, board=self.board1) Favorite.objects.create(user=self.kylie, board=self.board2) def test_get_available_boards(self): # - john - available_boards_for_john = Board.get_available(self.john) expected_available_boards_for_john = Board.objects.filter( pk__in=[self.board1.pk, self.board2.pk, self.board3.pk]) self.assertQuerysetEqual( available_boards_for_john, expected_available_boards_for_john, ordered=False, ) # - mindy - available_boards_for_mindy = Board.get_available(self.mindy) expected_available_boards_for_mindy = Board.objects.filter( pk__in=[self.board1.pk, self.board3.pk]) self.assertQuerysetEqual( available_boards_for_mindy, expected_available_boards_for_mindy, ordered=False, ) # - kylie - available_boards_for_kylie = Board.get_available(self.kylie) expected_available_boards_for_kylie = Board.objects.filter( pk__in=[self.board2.pk, self.board3.pk]) self.assertQuerysetEqual( available_boards_for_kylie, expected_available_boards_for_kylie, ordered=False, ) def test_get_favorite_boards(self): # - john - favorite_boards_for_john = Board.get_favorites(self.john) expected_favorite_boards_for_john = Board.objects.filter( pk__in=[self.board1.pk, self.board2.pk, self.board3.pk]) self.assertQuerysetEqual( favorite_boards_for_john, expected_favorite_boards_for_john, ordered=False, ) # - mindy - favorite_boards_for_mindy = Board.get_favorites(self.mindy) expected_favorite_boards_for_mindy = Board.objects.none() self.assertQuerysetEqual( favorite_boards_for_mindy, expected_favorite_boards_for_mindy, ordered=False, ) # - kylie - favorite_boards_for_kylie = Board.get_favorites(self.kylie) expected_favorite_boards_for_kylie = Board.objects.filter( pk__in=[self.board2.pk]) self.assertQuerysetEqual( favorite_boards_for_kylie, expected_favorite_boards_for_kylie, ordered=False, ) |
In order to run the tests, Django provides a simple command that discovers all the tests in the registered applications and runs them:
python manage.py test
Here's the expected output:
python manage.py test
Found 2 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 0.826s
OK
Destroying test database for alias 'default'...
Creating the Boards View
Let's go to boards/views.py
and write our view code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | from django.contrib.auth.decorators import login_required from django.shortcuts import redirect from django.urls import reverse_lazy from django.utils.decorators import method_decorator from django.views.generic import ListView from .models import Board @method_decorator(login_required(login_url=reverse_lazy('login')), name='dispatch', ) class BoardListView(ListView): template_name = "boards/board_list.html" def get_queryset(self): return Board.get_available(self.request.user) def get_favorites_queryset(self): return Board.get_favorites(self.request.user) def get_context_data(self, *, object_list=None, **kwargs): context = super().get_context_data() context["favorite_object_list"] = self.get_favorites_queryset() return context |
Let's quickly go over this code. Like we said, Django offers several abstract class-based views (CBV).
One of these is ListView
.
The purpose of this type of view is to list objects belonging to a certain model.
In order to customize the query that selects what subset of objects we are interested in displaying,
we need to override the get_queryset
method.
The ListView
will assign the result of this method to the object_list
inside the template.
Notice how we also added a get_favorite_queryset
method.
This is because we also want the list of favorite boards.
We add this list in the context that is being passed to the template
(this is done by overriding the get_context_data
method).
Add URL scheme
Head over to boards/urls.py
and add our BoardListView
to urlpatterns
:
1 2 3 4 5 6 7 | from django.urls import path from .views import BoardListView urlpatterns = [ path('', BoardListView.as_view(), name='boards'), ] |
Let's update taskleap/urls.py
by hooking up the boards url scheme:
1 2 3 4 5 6 7 8 9 10 11 | from django.contrib import admin from django.urls import path, include from accounts import urls as accounts_urls from boards import urls as boards_urls urlpatterns = [ path('admin/', admin.site.urls), path('accounts/', include(accounts_urls)), path('boards/', include(boards_urls)), ] |
Creating the Boards Template
Let's continue our pattern and create a base template for the boards
application.
Base Template
We need all pages to have a nav bar, a main container and a footer.
Let's write the base template in boards/templates/boards/base.html
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | {% extends "master.html" %} {% block body %} {% include "boards/partial/nav.html" %} <!-- Main Content --> <main class="flex-grow container mx-auto px-4 py-8 bg-white"> {% block content %}{% endblock %} </main> <!-- Footer --> <footer class="bg-black py-4"> <div class="container mx-auto px-4 text-center text-white"> <p>© 2024 Your Company. All rights reserved.</p> </div> </footer> </body> {% endblock %} |
Notice how we included a boards/partial/nav.html
template. Let's write it now.
Nav Bar Template
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | <nav class="bg-black py-4"> <div class="container mx-auto px-4 flex justify-between items-center"> <h1 class="text-2xl font-bold text-white">TaskLeap</h1> <div class="flex items-center space-x-4"> <ul class="flex space-x-4"> <li><a href="#" class="text-white hover:text-gray-400">Home</a></li> <li><a href="#" class="text-white hover:text-gray-400">Boards</a></li> <li><a href="#" class="text-white hover:text-gray-400">Settings</a></li> </ul> <!-- Logout Button --> <a href="{% url "logout" %}" class="flex items-center bg-gray-700 hover:bg-gray-800 text-white font-semibold py-2 px-4 rounded"> <!-- Heroicons Logout Icon --> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 mr-2"> <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M18 12h-9m0 0l3-3m-3 3l3 3"/> </svg> Logout </a> </div> </div> </nav> |
Notice how the used {% url "logout" %}
template tag.
This translates the view name logout
(check /accounts/urls.py
) to the actual URL.
Board List Template
Ok, we're finally here! Let's write the boards/template/boards/board_list.html
template.
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 | {% extends "boards/base.html" %} {% block content %} <div> {% if favorite_object_list %} <h2 class="text-xl font-semibold mb-6">Your Favorite Boards</h2> <div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6"> {% for object in favorite_object_list %} {% include "boards/partial/board_card.html" %} {% endfor %} </div> {% endif %} <h2 class="text-xl font-semibold mb-6">Your Boards</h2> <div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6"> {% for object in object_list %} {% include "boards/partial/board_card.html" %} {% endfor %} <!-- Create Board Card --> <div class="bg-gray-50 border border-gray-300 rounded-lg p-4 flex flex-col items-center justify-center shadow hover:bg-gray-100 cursor-pointer"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-10 h-10 text-gray-500 mb-2"> <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/> </svg> <p class="text-gray-500 font-medium">Create New Board</p> </div> </div> </div> {% endblock %} |
Notice how we created a sub template for displaying board cards.
Here's that template (boards/template/partial/board_card.html
):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | <div class="relative bg-gray-100 border border-gray-300 rounded-lg p-4 shadow"> <!-- Star Icon --> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="absolute top-2 right-2 size-6 text-yellow-500"> <path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z"/> </svg> <h3 class="text-lg font-medium mb-4">{{ object.title }}</h3> <div class="flex items-center justify-between text-gray-700"> <!-- Number of Tasks --> <p>Tasks: <span class="font-semibold">{{ object.card_count }}</span></p> <!-- Creation Date --> <p class="font-mono text-sm text-gray-500">Created: {{ object.created|date:'Y-m-d' }}</p> </div> </div> |
Testing in browser
Open your browser and go in the admin area. Create some Board
objects.
Also, go and create some Favorite
objects.
Let's open the browser and see how our board list looks like:
Writing a template filter
Let's start enhancing our web application. Let's display a solid star for the favorite boards and only the outline of a star for the others.
Let's create boards/templatetags/__init__.py
and boards/templatetags/board_tags.py
.
Inside boards/templatetags/board_tags.py
let's create the is_favorite
filter:
1 2 3 4 5 6 7 8 9 10 11 | from django import template from boards.models import Favorite register = template.Library() @register.filter def is_favorite(board, user): if user.is_authenticated: return Favorite.objects.filter(board=board, user=user).exists() return False |
We're going to use this filter to check if the current user favorited a board.
Let's update the boards/template/partial/board_card.html
template:
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 | {% load board_tags %} <div class="relative bg-gray-100 border border-gray-300 rounded-lg p-4 shadow"> {% if object|is_favorite:request.user %} <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="absolute top-2 right-2 size-6 text-yellow-500"> <path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z" clip-rule="evenodd" /> </svg> {% else %} <!-- Star Icon --> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="absolute top-2 right-2 size-6 text-yellow-500"> <path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z"/> </svg> {% endif %} <h3 class="text-lg font-medium mb-4">{{ object.title }}</h3> <div class="flex items-center justify-between text-gray-700"> <!-- Number of Tasks --> <p>Tasks: <span class="font-semibold">{{ object.card_count }}</span></p> <!-- Creation Date --> <p class="font-mono text-sm text-gray-500">Created: {{ object.created|date:'Y-m-d' }}</p> </div> </div> |
Here's our new updated board list:
Tweaking the URL schema
Now that we've created the boards
page, let's adjust the URL schema so that after logging in we get redirected here.
We need to change the next_page
param of the LoginView
to point to boards
.
It's also a good idea to add a RedirectView
in the place of the existing home
URL
to point us to the boards
section.
Let's edit accounts/urls.py
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | from django.urls import path from django.views.generic import RedirectView from django.contrib.auth.views import LoginView, LogoutView from accounts.views import RegisterView, dashboard urlpatterns = [ path('login', LoginView.as_view( template_name="accounts/login.html", redirect_authenticated_user=True, next_page="boards", ), name='login'), path('register', RegisterView.as_view(), name='register'), path('logout', LogoutView.as_view( next_page="login", ), name='logout'), path('', RedirectView.as_view(url="boards"), name='home' ), ] |
Feel free to also delete the dashboard
view in accounts/views.py
.
Creating Boards
Let's implement the create boards functionality. This is what we need to do:
- We need to create an Alpine.js application
- Create a modal (not model!) containing a simple form
- Hide the modal by default but have it shown when we click on the "Add Board" button
- Write the create board functionality somewhere in the backend
Here's how I modified the boards/templates/boards/board_list.html
template:
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 | {% extends "boards/base.html" %} {% block content %} <!-- Create Alpine.js Application --> <div x-data="{ openModal: false }"> {% if favorite_object_list %} <h2 class="text-xl font-semibold mb-6">Your Favorite Boards</h2> <div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6"> {% for object in favorite_object_list %} {% include "boards/partial/board_card.html" %} {% endfor %} </div> {% endif %} <h2 class="text-xl font-semibold mb-6 mt-10">Your Boards</h2> <div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6"> {% for object in object_list %} {% include "boards/partial/board_card.html" %} {% endfor %} <!-- Create Board Card --> <!-- Open Modal on Click --> <div @click="openModal = true" class="bg-gray-50 border border-gray-300 rounded-lg p-4 flex flex-col items-center justify-center shadow hover:bg-gray-100 cursor-pointer"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-10 h-10 text-gray-500 mb-2"> <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/> </svg> <p class="text-gray-500 font-medium">Create New Board</p> </div> </div> <!-- Include the Modal --> {% include "boards/partial/create_board_modal.html" %} </div> {% endblock %} |
Notice how I add the Alpine.js application inside the div
container. It is a very simple application.
It only has one state attribute: openModal
, that is defaulting to false
.
Next we added an on @click
functionality to the "Create Board" button.
We just set openModal
to true
.
Lastly, we included the boards/partial/create_board_modal.html
template we haven't coded, yet. Let's do that:
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 | <!-- Create Modal --> <div> <!-- Background --> <div x-show="openModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-100" x-transition.opacity> <!-- Modal Content --> <div class="bg-white rounded-lg shadow-lg w-1/2 p-6" @click.away="openModal = false"> <h2 class="text-xl font-semibold mb-4"> Create New Board </h2> <form method="post"> {% csrf_token %} <div class="mb-4"> <label for="title" class="block text-gray-700 font-medium mb-2"> Board Title </label> <input type="text" id="title" name="title" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring focus:ring-gray-200" placeholder="Enter board title"/> </div> <div class="flex justify-end space-x-2"> <button @click="openModal = false" type="button" class="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400"> Cancel </button> <button type="submit" class="px-4 py-2 bg-black text-white rounded-lg hover:bg-gray-800"> Create </button> </div> </form> </div> </div> </div> |
Using x-show="openModal"
makes the modal be visible only when the openModal
attribute on the Alpine.js
application is set to true
.
Clicking outside the modal hides it: @click.away="openModal = false"
.
Also, clicking on the Cancel does the same: @click="openModal = false"
.
We've set the form method to post
. We need to write some code on the current view that processes this
POST data and creates a Board
model.
There might be better ways to do this, but I found that the easiest is to just add post
method to the view.
Another way would have been to create another view altogether.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | @method_decorator(login_required(login_url=reverse_lazy('login')), name='dispatch', ) class BoardListView(ListView): template_name = "boards/board_list.html" def post(self, request, *args, **kwargs): # <-- title = request.POST.get("title") if title: Board.objects.create(title=title, owner=self.request.user) return redirect("boards") def get_queryset(self): return Board.get_available(self.request.user) def get_favorites_queryset(self): return Board.get_favorites(self.request.user) def get_context_data(self, *, object_list=None, **kwargs): context = super().get_context_data() context["favorite_object_list"] = self.get_favorites_queryset() return context |
Here's how everything works together: