Professional Documents
Culture Documents
The Definitive Guide To Hotwire and Django
The Definitive Guide To Hotwire and Django
1 Introduction 1
1.1 What is Hotwire . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
1.2 Objectives . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
1.3 What is included . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2
1.4 Demo . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2
1.5 What if you have problem or suggestions . . . . . . . . . . . . . . . . . . . . . . . . . . . 2
1.6 Changelog . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2
2 Create A project 3
2.1 Objective . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3
2.2 Create Django Project . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3
2.3 Install python-webpack-boilerplate . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4
2.4 Run frontend project . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4
2.5 Install Tailwind . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5
2.6 Write Tailwind CSS . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6
2.7 Test in Django Template . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6
2.8 JIT . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9
2.9 Setup Live Reload . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11
3 Setup Turbo 12
3.1 Objective . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12
3.2 Turbo . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12
3.3 Preparation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12
3.4 Setup Turbo . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13
3.5 Template . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14
3.6 Reference . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
i
6.2 List Page . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27
6.3 Add NavBar . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
6.4 How Turbo Drive Works . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29
6.5 Page Navigation Basics . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29
6.6 Simple Tests . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29
ii
13.4 Form response . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 63
13.5 Install django-turbo-response . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 64
13.6 Return Turbo Response using django-turbo-response . . . . . . . . . . . . . . . . . . . . . 64
13.7 Fix Form on the standard page . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 65
iii
20.5 Content Loader . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 98
iv
28 Full Page Redirect in Modal 137
28.1 Objective . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 137
28.2 Problem . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 137
28.3 Solution 1 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 138
28.4 Solution 2 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 138
v
35.6 View . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 180
vi
Chapter 1
Introduction
Hotwire (HTML OVER THE WIRE) is an alternative approach to building modern web applications without
using much JavaScript by sending HTML instead of JSON over the wire.
1. It is built by Basecamp (it powers the HEY email service).
2. It has a healthy ecosystem, and there are many relevant projects, tutorials about it.
3. It has become the default frontend solutions in Rails.
4. It is also very popular in the PHP community (Laravel, Symfony).
1.2 Objectives
This book will help you to learn Hotwire with Django in systematic way.
By the end of this book, you will be able to:
1. Learn Howtwire includes Turbo and Stimulus, and what problem they can help solve.
2. Jump start frontend project bundled by Webpack.
3. Setup Turbo and Stimulus.
4. Learn how page navigation works in Turbo Drive
5. Understand what is cache in Turbo Drive and how preview works.
6. Learn what is Turbo Frame and how it works.
7. Build Inline Editing feature with Turbo Frame.
8. Learn what is Stimulus and how Stimulus Controller work.
9. What is Stimulus Values, Targes, and Actions
10. Use Stimulus to build a Datetime picker and improve form submission process.
11. Understand how to use events to do communication among Stimulus controllers
12. Build Type as search feature with Stimulus, Turbo and Django.
13. Learn what is Turbo Stream and how it works.
14. How to build Collaborative Editing based on Websocket and Turbo Stream
With Hotwire, we can bring SPA-like experience to our Django web app:
1
Definitive Guide to Hotwire and Django, Release 1.0.0
1.4 Demo
If you meet problem, please check FAQ first (you can find it at the end of the book)
If you want to talk with me, please send email to
michaelyin@accordbox.com
1.6 Changelog
1.6.1 1.0.0
• 2022-05-27: Published
• 2022-05-25: Review finished
• 2022-02-15: Draft version finished
• 2021-12-23: Started writing
2 Chapter 1. Introduction
Chapter 2
Create A project
2.1 Objective
# create virtualenv
$ python3 -m venv venv
$ source venv/bin/activate
django==3.2
.
├── hotwire_django_app
├── env
├── manage.py
└── requirements.txt
3
Definitive Guide to Hotwire and Django, Release 1.0.0
# create db tables
(venv)$ python manage.py migrate
(venv)$ python manage.py runserver
Check on http://127.0.0.1:8000/, and you should be able to see the Django welcome page
python-webpack-boilerplate can helps you jump start frontend project bundled by Webpack
Add python-webpack-boilerplate to the requirements.txt
django==3.2
python-webpack-boilerplate==1.0.0
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'webpack_boilerplate', # new
]
Let’s run Django command to create frontend project from the python-webpack-boilerplate
project_slug [frontend]:
run_npm_command_at_root [n]: y
[SUCCESS]: Frontend app 'frontend' has been created.
Here we set run_npm_command_at_root to y so we can run npm command at the root of the Django project
.
├── frontend # new
├── hotwire_django_app
├── manage.py
├── package-lock.json
├── package.json
└── requirements.txt
Notes:
1. Now a new frontend directory is created which contains pre-defined files for our frontend project.
2. package.json and some other config files are placed at the root directory.
If you have no nodejs installed, please install it first by using below links
1. On nodejs homepage3
2. Using nvm4 I recommend this way.
$ node -v
v16.13.1
$ npm -v
8.1.2
If the command run without error, that means the setup works, let’s terminate the npm run start by
pressing Ctrl + C
By default Python Webpack Boilerplate does not contains Tailwind CSS (it is just a boilerplate), let’s
add it.
# install packages
$ npm install -D tailwindcss@latest postcss-import
"postcss-import": "^14.1.0",
"tailwindcss": "^3.0.24",
// https://tailwindcss.com/docs/using-with-preprocessors
module.exports = {
plugins: [
require('postcss-import'),
require('tailwindcss/nesting')(require('postcss-nesting')),
require('tailwindcss'),
require('postcss-preset-env')({
features: { 'nesting-rules': false }
}),
]
};
Next, generate a config file for your frontend project using the Tailwind CLI utility included when you
install the tailwindcss npm package
module.exports = {
content: [],
theme: {
extend: {},
3 https://nodejs.org/en/download/
4 https://github.com/nvm-sh/nvm
},
plugins: [],
}
Update src/application/app.js
window.document.addEventListener("DOMContentLoaded", function () {
window.console.log("dom ready 1");
});
Update src/styles/index.scss
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
.jumbotron {
// should be relative path of the entry scss file
background-image: url("../../vendors/images/sample.jpg");
background-size: cover;
}
.btn-blue {
@apply inline-flex items-center;
@apply px-4 py-2;
@apply font-semibold rounded-lg shadow-md;
@apply text-white bg-blue-500;
@apply hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-opacity-
,→75;
Now the tailwindcss can be compiled successfully, let’s test in Django template.
STATICFILES_DIRS = [
str(BASE_DIR / "frontend/build"),
]
WEBPACK_LOADER = {
'MANIFEST_FILE': str(BASE_DIR / "frontend/build/manifest.json"),
}
1. We add the above frontend/build to STATICFILES_DIRS so Django can find the static assets (img,
font and others)
2. We add MANIFEST_FILE location to the WEBPACK_LOADER so our custom loader can help us load JS
and CSS.
Update hotwire_django_app/urls.py
from django.contrib import admin
from django.urls import path
from django.views.generic import TemplateView
urlpatterns = [
path('', TemplateView.as_view(template_name="index.html")), # new
path('admin/', admin.site.urls),
]
├── hotwire_django_app
│ ├── __init__.py
│ ├── asgi.py
│ ├── settings.py
│ ├── templates # new
│ ├── urls.py
│ └── wsgi.py
Update TEMPLATES in hotwire_django_app/settings.py, so Django can know where to find the templates
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': ['hotwire_django_app/templates'], # new
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
<!DOCTYPE html>
<html>
<head>
<title>Index</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% stylesheet_pack 'app' %}
</head>
<body>
{% javascript_pack 'app' %}
</body>
</html>
Now check on http://127.0.0.1:8000/ and you should be able to see a welcome page.
2.8 JIT
2.8. JIT 9
Definitive Guide to Hotwire and Django, Release 1.0.0
module.exports = {
content: contentPaths,
theme: {
extend: {},
},
plugins: [],
}
Notes:
1. Here we add Django templates path to the projectPaths
2. And then we pass the contentPaths to the content
3. The final built css file will contain css classes used in the Django templates
# restart webpack
$ npm run start
With webpack-dev-server, we can use it to auto reload web page when the code of the project changed.
Update frontend/webpack/webpack.config.dev.js
devServer: {
// add this
watchFiles: [
Path.join(__dirname, '../../hotwire_django_app/**/*.py'),
Path.join(__dirname, '../../hotwire_django_app/**/*.html'),
],
},
1. Here we tell webpack-dev-server to watch all .py and .html files under the hotwire_django_app
directory.
2. Now if we change code in the editor, the web page will auto reload automatically, which is awe-
some!
More details can be found on Python Webpack Boilerplate Doc5
5 https://python-webpack-boilerplate.readthedocs.io/en/latest/live_reload/
Setup Turbo
3.1 Objective
3.2 Turbo
3.3 Preparation
frontend/src
├── application
│ └── turbo_drive.js
└── styles
└── turbo_drive.scss
Edit frontend/src/application/turbo_drive.js
12
Definitive Guide to Hotwire and Django, Release 1.0.0
window.document.addEventListener("DOMContentLoaded", function () {
window.console.log("dom ready");
});
Update hotwire_django_app/templates/index.html
<!DOCTYPE html>
<html>
<head>
<title>Index</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% stylesheet_pack 'turbo_drive' %}
</head>
<body>
jumbotron and three supporting pieces of content. Use it as a starting point to create�
,→something more unique.</p>
{% javascript_pack 'turbo_drive' %}
</body>
</html>
Here we install hotwired/turbo 7.1.0, we use --save-exact to pin the version here to make the readers
easy to troubleshoot in some cases.
If we check package.json
"dependencies": {
"@hotwired/turbo": "7.1.0",
}
window.document.addEventListener("DOMContentLoaded", function () {
window.console.log("dom ready 1");
});
Notes:
1. We import "@hotwired/turbo"; in our js application file.
2. And we add event listener to the turbo:load event, which can let us check if Turbo is installed
successfully.
3.5 Template
<!DOCTYPE html>
<html>
<head>
<title>Index</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% stylesheet_pack 'turbo_drive' %}
{% javascript_pack 'turbo_drive' attrs='defer' %} <! --- new --->
</head>
<body>
jumbotron and three supporting pieces of content. Use it as a starting point to create�
,→something more unique.</p>
</body>
</html>
Notes:
1. Please make sure to move the JS from the bottom to the head, and set defer attribute, I will talk
about this later.
dom ready
turbo:load
3.6 Reference
6 https://turbo.hotwired.dev/handbook/installing
3.6. Reference 15
Chapter 4
4.1 Objective
├── hotwire_django_app
│ ├── __init__.py
│ ├── asgi.py
│ ├── settings.py
│ ├── tasks # new
│ │ ├── __init__.py
│ │ ├── admin.py
│ │ ├── apps.py
│ │ ├── migrations
│ │ ├── models.py
│ │ ├── tests.py
│ │ └── views.py
│ ├── templates
│ │ └── index.html
│ ├── urls.py
│ └── wsgi.py
class TasksConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'hotwire_django_app.tasks' # update
16
Definitive Guide to Hotwire and Django, Release 1.0.0
INSTALLED_APPS = [
...
'hotwire_django_app.tasks', # new
]
4.3 Model
Update hotwire_django_app/tasks/models.py
class Task(models.Model):
title = models.CharField(
max_length=250,
validators=[MinLengthValidator(limit_value=3)]
)
due_date = models.DateField(default=timezone.now)
Migrate the db
4.4 Form
Create tasks/forms.py
class TaskForm(forms.ModelForm):
class Meta:
model = Task
fields = ("title", "due_date")
Now the Django tasks app is created, we will use it in later chapters.
4.3. Model 17
Chapter 5
5.1 Objectives
Let’s create turbo_drive app, we will learn how Turbo Drive works with this Django app.
(venv)$ mkdir -p ./hotwire_django_app/turbo_drive
(venv)$ python manage.py startapp turbo_drive ./hotwire_django_app/turbo_drive
class TurboDriveConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'hotwire_django_app.turbo_drive' # update
18
Definitive Guide to Hotwire and Django, Release 1.0.0
INSTALLED_APPS = [
...
'hotwire_django_app.turbo_drive', # new
]
5.3 View
Create hotwire_django_app/turbo_drive/views.py
def create_view(request):
if request.method == 'POST':
form = TaskForm(request.POST)
if form.is_valid():
form.save()
return redirect('/')
else:
form = TaskForm()
Here we create a simple Django FBV, user can create task on the form page.
5.4 Template
Create hotwire_django_app/templates/turbo_drive/base.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% stylesheet_pack 'turbo_drive' %}
{% javascript_pack 'turbo_drive' attrs='defer' %}
</head>
<body>
{% block content %}
{% endblock content %}
</body>
</html>
5.3. View 19
Definitive Guide to Hotwire and Django, Release 1.0.0
Create hotwire_django_app/templates/turbo_drive/create.html
{% extends "turbo_drive/base.html" %}
{% block content %}
<form method="post">
{% csrf_token %}
{{ form.as_p }}
</div>
{% endblock %}
5.5 URL
Create hotwire_django_app/turbo_drive/urls.py
app_name = 'turbo-drive'
urlpatterns = [
path('create/', create_view, name='task-create'),
]
Notes:
1. Here we use app_name to set the namespace of the Django app.
Update hotwire_django_app/urls.py
urlpatterns = [
path('', TemplateView.as_view(template_name="index.html")),
path('turbo-drive/', include('hotwire_django_app.turbo_drive.urls')),
path('admin/', admin.site.urls),
]
If we check on http://127.0.0.1:8000/turbo-drive/create/
As you can see, even we import Tailwind to our Django project, the default form style still look ugly.
Next, let’s start improving the form style.
5.7 tailwindcss-forms
tailwindcss/forms is a plugin that provides a basic reset for form styles that makes form
elements easy to override with utilities.
"@tailwindcss/forms": "^0.5.2",
module.exports = {
//
plugins: [
require('@tailwindcss/forms'), // new
],
}
5.7. tailwindcss-forms 21
Definitive Guide to Hotwire and Django, Release 1.0.0
Hmm, the form style looks much better, let’s keep improving it.
5.8 crispy-tailwind
django-crispy-forms==1.14.0 # new
crispy-tailwind==0.5.0 # new
Update hotwire_django_app/settings.py
INSTALLED_APPS = [
...
'crispy_forms', # new
'crispy_tailwind', # new
]
Update hotwire_django_app/templates/turbo_drive/create.html
{% extends "turbo_drive/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<form method="post">
{% csrf_token %}
{{ form|crispy }}
</div>
{% endblock %}
Notes:
1. We load crispy_forms_tags at the top
2. {{ form|crispy }} will render the form using Tailwind template pack. (set by
CRISPY_TEMPLATE_PACK)
5.9 JIT
5.9. JIT 23
Definitive Guide to Hotwire and Django, Release 1.0.0
pyPackagesPaths = [
Path.join(pySitePackages, "./crispy_tailwind/**/*.html"),
Path.join(pySitePackages, "./crispy_tailwind/**/*.py"),
Path.join(pySitePackages, "./crispy_tailwind/**/*.js"),
];
}
module.exports = {
content: contentPaths,
theme: {
extend: {},
},
plugins: [
require('@tailwindcss/forms'),
],
};
If we set pySitePackages env variable, the Tailwind can know the path of the crispy_tailwind package,
and will scan the code to detect what css class names are used.
# check
(venv)$ env | grep pySitePackages
# restart webpack
(venv)$ npm run start
5.9. JIT 25
Definitive Guide to Hotwire and Django, Release 1.0.0
As you can see, now the form works with Tailwind smoothly
Now, please try to create some tasks on the form page, and you will be redirected to the home page.
If the title is very short, you might see Error: Form responses must redirect to another location in
the console of the web devtool, do not worry, and I will talk about it in the next chapter.
6.1 Objective
Update hotwire_django_app/turbo_drive/views.py
from django.shortcuts import render, redirect
def list_view(request):
object_list = Task.objects.all().order_by('-pk')
context = {
"object_list": object_list,
}
Notes:
1. We add a simple list view using Django FBV
6.2.1 Template
Create hotwire_django_app/templates/turbo_drive/list.html
{% extends "turbo_drive/base.html" %}
{% block content %}
27
Definitive Guide to Hotwire and Django, Release 1.0.0
{% endblock %}
6.2.2 URL
Update hotwire_django_app/turbo_drive/urls.py
app_name = 'turbo-drive'
urlpatterns = [
path('list/', list_view, name='task-list'), # new
path('create/', create_view, name='task-create'),
]
Now test on the http://127.0.0.1:8000/turbo-drive/list/ and we should see the task list page.
Next, let’s put some navigation links at the top of the page.
Create hotwire_django_app/templates/turbo_drive/navbar.html
List
</a>
<a href="{% url 'turbo-drive:task-create' %}" class="inline-block mt-0 text-teal-200 hover:text-
,→white mr-4">
Create
</a>
</div>
</nav>
Update hotwire_django_app/templates/turbo_drive/base.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% stylesheet_pack 'turbo_drive' %}
{% javascript_pack 'turbo_drive' attrs='defer' %}
</head>
<body>
{% block content %}
{% endblock content %}
</body>
</html>
Now we can click links on the top navbar to jump between the list and create page.
Turbo Drive models page navigation as a visit to a location (URL) with an action.
There are two types of visit in Turbo Drive:
Turbo Drive automatically initiates a restoration visit when you navigate with the browser’s
Back or Forward buttons
In Restoration Visits, Turbo Drive will restore the page from cache without loading a fresh copy from
the network, if possible
6.6.1 Test 1
2. Not submit the form, but click the top List link.
3. Now click the browser back button, Turbo will do Restoration Visits, restore the page from the
cache (without sending request)
4. The title field already has value we just typed.
6.6.2 Test 2
def create_view(request):
import time # new
time.sleep(1)
if request.method == 'POST':
form = TaskForm(request.POST)
if form.is_valid():
form.save()
def list_view(request):
import time
time.sleep(1) # new
object_list = Task.objects.all().order_by('-pk')
context = {
"object_list": object_list,
}
Notes:
1. Try to visit http://127.0.0.1:8000/turbo-drive/create/ in a new browser tab
2. You will see browser blank page for about 2 seconds, and then the page will render.
3. Add some text in the title field, do NOT submit the form.
4. Then click List link, you will see a blue progress bar on the top, after 2 seconds, the list page
will render.
5. Then click Create link, Turbo will restore the page from the cache while sending HTTP request.
So you can see the form page immediately, after 2 seconds, the Turbo receive the response, it will
then use the response to update the page, and the text in the title field will disappear.
During Turbo Drive navigation, the browser will not display its native progress indicator. Turbo
Drive installs a CSS-based progress bar to provide feedback while issuing a request.
The progress bar is enabled by default. It appears automatically for any page that takes
longer than 500ms to load.
7.1 Objective
7.2 turbo:before-cache
In the previous chapter, we learned Turbo save the page to cache and use it when we visit the page.
Turbo Drive saves a copy of the current page to its cache just before rendering a new page
Update frontend/src/application/turbo_drive.js
document.addEventListener("turbo:before-cache", function () {
console.log('turbo:before-cache');
const form = document.querySelector('form');
if (form) {
form.reset();
}
});
Notes:
1. We add an event handler to listen to the turbo:before-cache, which fires before Turbo saves the
current page to cache.
2. In the event handler, we use js to reset the form.
Let’s do a test
1. Force reload http://127.0.0.1:8000/turbo-drive/create/
2. Add some text to the title field.
3. Click the top List link.
4. Then click browser Back button, so Turbo will render cached content. (restoration visit)
5. We can see the title field has empty value.
As you can see, we can hook turbo:before-cache to do some cleanup work (close Modal, close Drop-
down list) and make the page content ready to be cached.
This can improve the user experience.
31
Definitive Guide to Hotwire and Django, Release 1.0.0
Now, please comment out the event handler since I will talk about another solution soon.
7.3 data-turbo-cache
Annotate elements with data-turbo-cache="false" if you do not want Turbo to cache them.
This is helpful for temporary elements, like flash notices.
7.3.1 View
def create_view(request):
import time
time.sleep(1)
if request.method == 'POST':
form = TaskForm(request.POST)
if form.is_valid():
form.save()
Notes:
1. After the form.save() method, we call messages.success to display success message on the next
page.
2. And we redirect user to the task-list page.
7.3.2 Template
Create hotwire_django_app/templates/turbo_drive/messages.html
{{ message|safe }}
</div>
{% endfor %}
We have set data-turbo-cache="false" because we wish Turbo to ignore the flash messages when
caching.
Update hotwire_django_app/templates/turbo_drive/base.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% stylesheet_pack 'turbo_drive' %}
{% javascript_pack 'turbo_drive' attrs='defer' %}
</head>
<body>
{% include 'turbo_drive/navbar.html' %}
{% block content %}
{% endblock content %}
</body>
</html>
7.3.3 Test
7.4 turbo-cache-control
We can also control the cache behavior at page level using <meta name="turbo-cache-control"> in
<head>
7.4.1 no-preview
<head>
<meta name="turbo-cache-control" content="no-preview">
</head>
1. Cached version of the page should not be shown as a preview during an application visit
2. Cached version will only be used for restoration visits.
7.4.2 no-cache
7.4. turbo-cache-control 33
Definitive Guide to Hotwire and Django, Release 1.0.0
<head>
<meta name="turbo-cache-control" content="no-cache">
</head>
7.5.1 data-turbo-preview
Turbo Drive adds a data-turbo-preview attribute to the element when it displays a preview
from cache.
Let’s add code below to the frontend/src/styles/turbo_drive.scss
[data-turbo-preview] body {
opacity: 0.5;
}
This makes the page content little transparent, which tell user the page content is not truly ready.
7.5.2 turbo-progress-bar
The progress bar is a element with the class name turbo-progress-bar. Its default styles
appear first in the document and can be overridden by rules that come later.
Let’s add code below to the frontend/src/styles/turbo_drive.scss
.turbo-progress-bar {
@apply bg-blue-500;
height: 5px;
}
8.1 Objective
From https://turbo.hotwired.dev/handbook/building#loading-your-application%E2%80%
99s-javascript-bundle
Always make sure to load your application’s JavaScript bundle using elements in the of your
document. Otherwise, Turbo Drive will reload the bundle with every page change.
We already do that in our base.html
<head>
{% stylesheet_pack 'app' %}
{% javascript_pack 'app' attrs='defer' %}
</head>
8.3 defer
When browser detects script which has attribute defer, it downloads the file and keep rendering the
rest of the page. (Which is non-blocking)
After the page is ready, the defer Javascript will run in order.
With defer attribute, we do not need to put JS links at the bottom of our page
8.4 Templates
Update hotwire_django_app/templates/turbo_drive/base.html
{% load webpack_loader static %}
<!DOCTYPE html>
<html>
35
Definitive Guide to Hotwire and Django, Release 1.0.0
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% stylesheet_pack 'turbo_drive' %}
{% javascript_pack 'turbo_drive' attrs='defer' %}
</head>
<body>
{% include 'turbo_drive/navbar.html' %}
{% include 'turbo_drive/messages.html' %}
{% block content %}
{% endblock content %}
</body>
</html>
Notes:
1. We add extra_top_js and extra_bottom_js block.
Update hotwire_django_app/templates/turbo_drive/create.html
{% extends "turbo_drive/base.html" %}
{% load crispy_forms_tags %}
{% block extra_top_js %}
<script>
console.log('top script');
</script>
<script>
console.log('create page top script');
</script>
{% endblock %}
{% block content %}
<form method="post">
{% csrf_token %}
{{ form|crispy }}
</div>
{% endblock %}
{% block extra_bottom_js %}
<script>
console.log('create page extra_bottom_js')
</script>
{% endblock %}
Notes:
1. In the extra_top_js, block, we add two script tag
2. In the extra_bottom_js block, we add one script tag
Update hotwire_django_app/templates/turbo_drive/list.html
{% extends "turbo_drive/base.html" %}
{% block extra_top_js %}
<script>
console.log('top script');
</script>
<script>
console.log('list page top script');
</script>
{% endblock %}
{% block content %}
{% endblock %}
{% block extra_bottom_js %}
<script>
console.log('list page extra_bottom_js')
</script>
{% endblock %}
Notes:
1. In the extra_top_js, block, we add two script tag
2. In the extra_bottom_js block, we add one script tag
Fully reload http://127.0.0.1:8000/turbo-drive/list/, we will see logs in the console of the web devtool.
top script
list page top script
list page extra_bottom_js
dom ready
turbo:load
As you can see, there are only three logs, I will explain why this happen in the next sections.
8.6 DOMContentLoaded
In frontend/src/application/turbo_drive.js, we have
window.document.addEventListener("DOMContentLoaded", function () {
window.console.log("dom ready");
});
In Turbo Drive, window.onload and DOMContentLoaded will fire ONLY in response to the initial page load,
not after subsequent page navigation.
That is why we did not see dom ready in the second page visit.
We should move code to the turbo:load event handler instead
For example, to make Google Analytics work with Turbo Drive, we should put the tracking code in
turbo:load event handler, then it will work on every page visits, instead of the initial visit.
When you navigate to a new page, Turbo Drive looks for any elements in the new page’s
which aren’t present on the current page. Then it appends them to the current where they’re
loaded and evaluated by the browser. You can use this to load additional JavaScript files
on-demand.
In the create.html, we have
<script>
console.log('top script');
</script>
<script>
console.log('create page top script');
</script>
Since the first script already exists in the of the page, it will not be evaluated.
That is why we only see create page top script in the second visit during the above test.
Turbo Drive evaluates elements in a page’s each time it renders the page. You can use inline
body scripts to set up per-page JavaScript state or bootstrap client-side models. To install
behavior, or to perform more complex operations when the page changes, avoid script ele-
ments and use the turbo:load event instead.
Everytime Turbo drive render the page, the in the will be evaluated, that is why we see create page
extra_bottom_js and list page extra_bottom_js each time.
If you have used Django before, you should know if we run collectstatic, a hash string will be added
to the filename of the assets.
We can know if the file content has changed from the filename.
Turbo Drive can track the URLs of asset elements in and automatically issue a full reload if they change.
Update hotwire_django_app/templates/turbo_drive/base.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% block extra_top_js %}
{% endblock %}
</head>
<body>
{% block content %}
{% endblock content %}
{% block extra_bottom_js %}
{% endblock %}
</body>
</html>
<body>
<script src="app.js"></script>
<script >
App.Setup();
</script>
</body>
You might have trouble migrating Javascript to work with Turbo Drive, and you will get Uncaught
ReferenceError: App is not defined in the web console.
Because after you put app.js in the with defer attribute, there is no good way to make inline script run
after the defer script. (App.Setup is evaluated before app.js)
The ideal solution is to use Stimulus, I will talk about it in later chapter.
9.1 Objective
9.2 Problem
<script>
!function(d,s,id){var js,fjs=d.getElementsByTagName(s)[0];if(!d.getElementById(id)){js=d.
,→createElement(s);js.id=id;js.src='https://weatherwidget.io/js/widget.min.js';fjs.parentNode.
,→insertBefore(js,fjs);}}(document,'script','weatherwidget-io-js');
</script>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% block extra_top_js %}
{% endblock %}
</head>
<body>
{% include 'turbo_drive/navbar.html' %}
{% include 'turbo_drive/messages.html' %}
41
Definitive Guide to Hotwire and Django, Release 1.0.0
{% block content %}
{% endblock content %}
{% block extra_bottom_js %}
{% endblock %}
</body>
</html>
<head>
<script id="weatherwidget-io-js" src="https://weatherwidget.io/js/widget.min.js"></script>
</head>
During the second visit, since the head already has <script id="weatherwidget-io-js", the script
https://weatherwidget.io/js/widget.min.js will not be evaluated, which caused the widget not run
on the second visit and the third visit.
9.4 Solution
document.addEventListener('turbo:before-render', () => {
document.querySelector('#weatherwidget-io-js').remove();
});
Before rendering new page, the #weatherwidget-io-js script will be removed from the page head.
If we test again, it should work like a charm.
Before checking the next chapter, please remove the 3-party script from the
hotwire_django_app/templates/turbo_drive/base.html and the turbo:before-render event handler.
10.1 Objective
Please go to http://127.0.0.1:8000/turbo-drive/create/
And type only one character in the title field, then submit the form, which will cause the form validation
fail.
We hope to see the Django form validation error.
However, it does not work, and we see Form responses must redirect to another location in the con-
sole of the devtool.
In Turbo, we need to return 422 Unprocessable Entity status code so Turbo can display the form error
message.
when the response is rendered with either a 4xx or 5xx status code. This allows form valida-
tion errors to be rendered by having the server respond with “422 Unprocessable Entity” and
a broken server to display a “Something Went Wrong” screen on a 500 Internal Server Error.
More details can be found on https://turbo.hotwired.dev/handbook/drive#
redirecting-after-a-form-submission
10.3 View
43
Definitive Guide to Hotwire and Django, Release 1.0.0
Notes:
1. If form validation fail, we return 422 status code.
def create_view(request):
import time
time.sleep(1)
if request.method == 'POST':
form = TaskForm(request.POST)
if form.is_valid():
form.save()
else:
status = http.HTTPStatus.OK # update
form = TaskForm()
Even Turbo Doc say it expects an HTTP 303 redirect response, it seems Turbo can also work with 302
response.
10.5 Workflow
Turbo Drive handles form submissions in a manner similar to link clicks. The key difference
is that form submissions can issue stateful requests using the HTTP POST method, while
link clicks only ever issue stateless HTTP GET requests.
1. When we submit the form, Turbo start handling the form submission.
2. It sends POST request to the backend server.
3. If the response status code is 422, it knows the form validation fail and render the page content.
4. If the response status code is 302 or 303, Turbo Drive will visit the URL like normal application visit.
From https://turbo.hotwired.dev/reference/events, turbo:submit-start fires during a form submission
Update frontend/src/application/turbo_drive.js
When we plan to import Turbo to our Django project, we should make sure Django return 422 when form
validation fail.
Things might be a little hard for some 3-party packages, which still return 200 when form validation fail.
Below are some solutions:
1. Add data-turbo="false" to the specific form to disable Turbo Drive
2. Patch Django FormMixin.form_invalid to return 422 when form validation fail, this can make all
Django CBV work with Turbo Drive. I use this approach in SaaS Hammer7
7 https://saashammer.com/
11.1 Objective
Let’s create turbo_frame app, we will learn how Turbo Frame works with this Django app.
./hotwire_django_app
├── __init__.py
├── asgi.py
├── settings.py
├── tasks
├── templates
├── turbo_drive
├── turbo_frame # new
├── urls.py
└── wsgi.py
class TurboFrameConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'hotwire_django_app.turbo_frame' # update
INSTALLED_APPS = [
...
'hotwire_django_app.turbo_frame', # new
]
47
Definitive Guide to Hotwire and Django, Release 1.0.0
$ ./manage.py check
11.3 View
Create hotwire_django_app/turbo_frame/views.py
import http
def list_view(request):
object_list = Task.objects.all().order_by('-pk')
context = {
"object_list": object_list,
}
def create_view(request):
if request.method == 'POST':
form = TaskForm(request.POST)
if form.is_valid():
instance = form.save()
status = http.HTTPStatus.UNPROCESSABLE_ENTITY
else:
status = http.HTTPStatus.OK
form = TaskForm()
status = http.HTTPStatus.UNPROCESSABLE_ENTITY
else:
status = http.HTTPStatus.OK
form = TaskForm(instance=instance)
Notes:
1. Here we create 4 FBV, which are for CRUD work.
2. If task is created or updated successfully, user would be redirected to the task detail page.
3. If task is deleted successfully, user would be redirected to the task list page.
11.4 Template
11.4.1 base.html
create hotwire_django_app/templates/turbo_frame/base.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
{% include 'turbo_frame/navbar.html' %}
{% include 'turbo_frame/messages.html' %}
{% block content %}
{% endblock content %}
</body>
</html>
Notes:
1. Please note in this Django app, we will use turbo_frame entry file ({% javascript_pack
'turbo_frame'). we will create it in a bit.
11.4. Template 49
Definitive Guide to Hotwire and Django, Release 1.0.0
Create hotwire_django_app/templates/turbo_frame/messages.html
{{ message|safe }}
</div>
{% endfor %}
Create hotwire_django_app/templates/turbo_frame/navbar.html
List
</a>
<a href="{% url 'turbo-frame:task-create' %}" class="inline-block mt-0 text-teal-200 hover:text-
,→white mr-4">
Create
</a>
</div>
</nav>
11.4.2 create_page.html
Create hotwire_django_app/templates/turbo_frame/create_page.html
{% extends "turbo_frame/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<form method="post">
{% csrf_token %}
{{ form|crispy }}
</div>
{% endblock %}
11.4.3 update_page.html
create hotwire_django_app/templates/turbo_frame/update_page.html
{% extends "turbo_frame/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="w-full max-w-7xl mx-auto px-4">
<form method="post">
{% csrf_token %}
{{ form|crispy }}
</form>
</div>
{% endblock %}
11.4.4 delete_page.html
Create hotwire_django_app/templates/turbo_frame/delete_page.html
{% extends "turbo_frame/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="w-full max-w-7xl mx-auto px-4">
<h1 class="text-4xl sm:text-6xl lg:text-7xl mb-6">Delete Task</h1>
<form method="post">
{% csrf_token %}
{{ form|crispy }}
</form>
</div>
{% endblock %}
11.4.5 list_page.html
Create hotwire_django_app/templates/turbo_frame/list_page.html
{% extends "turbo_frame/base.html" %}
{% block content %}
11.4. Template 51
Definitive Guide to Hotwire and Django, Release 1.0.0
</li>
{% endfor %}
</ul>
</div>
</div>
{% endblock %}
11.4.6 detail_page.html
Create hotwire_django_app/templates/turbo_frame/detail_page.html
{% extends "turbo_frame/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="w-full max-w-7xl mx-auto px-4">
<h1 class="text-4xl sm:text-6xl lg:text-7xl mb-6">Task Detail</h1>
<div>
<a href="{% url 'turbo-frame:task-list'%}" class="btn-blue">Go to list</a>
</div>
</div>
{% endblock %}
11.4.7 task_detail.html
Create hotwire_django_app/templates/turbo_frame/task_detail.html
11.5 Frontend
Create frontend/src/styles/turbo_frame.scss
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
.btn-blue {
@apply inline-flex items-center;
@apply px-4 py-2;
@apply font-semibold rounded-lg shadow-md;
@apply text-white bg-blue-500;
@apply hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-opacity-
,→75;
.btn-red {
@apply inline-flex items-center;
@apply px-4 py-2;
@apply font-semibold rounded-lg shadow-md;
@apply text-white bg-red-500;
@apply hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-opacity-75;
}
Create frontend/src/application/turbo_frame.js
import "@hotwired/turbo";
Notes:
1. We import turbo_frame.scss we just created
2. And this JS entry file would be used by the above turbo_frame/base.html
11.6 URL
Create hotwire_django_app/turbo_frame/urls.py
app_name = 'turbo-frame'
urlpatterns = [
path('list/', list_view, name='task-list'),
path('create/', create_view, name='task-create'),
path('<int:pk>/', detail_view, name='task-detail'),
path('<int:pk>/update/', update_view, name='task-update'),
path('<int:pk>/delete/', delete_view, name='task-delete'),
]
Notes:
1. Here we use app_name to set the namespace of the Django app.
Update hotwire_django_app/urls.py
11.5. Frontend 53
Definitive Guide to Hotwire and Django, Release 1.0.0
urlpatterns = [
path('', TemplateView.as_view(template_name="index.html")),
path('turbo-drive/', include('hotwire_django_app.turbo_drive.urls')),
path('turbo-frame/', include('hotwire_django_app.turbo_frame.urls')), # new
path('admin/', admin.site.urls),
]
12.1 Objective
Turbo Frame can help us treat part of our page like HTML iframe element.
Turbo Frames allow predefined parts of a page to be updated on request. Any links and forms
inside a frame are captured, and the frame contents automatically updated after receiving a
response.
With Turbo Frame, we can update segment of our page without reloading the whole page.
12.3.1 View
Update hotwire_django_app/turbo_frame/views.py
def index_view(request):
return render(request, 'turbo_frame/index.html')
We added a index_view
12.3.2 Template
Create hotwire_django_app/templates/turbo_frame/index.html
55
Definitive Guide to Hotwire and Django, Release 1.0.0
{% extends "turbo_frame/base.html" %}
{% block content %}
</div>
{% endblock %}
List
</a>
<a href="{% url 'turbo-frame:task-create' %}" class="inline-block mt-0 text-teal-200 hover:text-
,→white mr-4">
Create
</a>
<a href="{% url 'turbo-frame:index' %}" class="inline-block mt-0 text-teal-200 hover:text-white�
,→mr-4">
12.3.3 URL
Update hotwire_django_app/turbo_frame/urls.py
app_name = 'turbo-frame'
urlpatterns = [
path('', index_view, name='index'), # new
path('list/', list_view, name='task-list'),
path('create/', create_view, name='task-create'),
path('<int:pk>/', detail_view, name='task-detail'),
path('<int:pk>/update/', update_view, name='task-update'),
path('<int:pk>/delete/', delete_view, name='task-delete'),
]
12.3.4 Test
Update hotwire_django_app/templates/turbo_frame/index.html
{% extends "turbo_frame/base.html" %}
{% block content %}
</div>
{% endblock %}
Notes:
1. We add turbo-frame element to the index page.
2. It contains a link, if we click the link, Turbo will capture the event, and load the url to update the
content of the frame
If we click the link on http://127.0.0.1:8000/turbo-frame/
In the Chrome devtool, we can see http request has been sent to visit http://127.0.0.1:8000/
turbo-frame/list/, but the content of the turbo-frame is not updated.
We can also see Response has no matching <turbo-frame id="task-list"> element from the console
of the devtool.
Each turbo frame must have a unique ID, which is used to match the content being replaced
when requesting new pages from the server
Let’s update hotwire_django_app/templates/turbo_frame/list_page.html
{% extends "turbo_frame/base.html" %}
{% block content %}
</li>
{% endfor %}
</ul>
</div>
</turbo-frame>
</div>
{% endblock %}
12.5 Notes
Actually, this feature can also be done using Javascript, but turbo-frame helps us get things done in a
more clean way.
12.5. Notes 59
Chapter 13
13.1 Objective
Update hotwire_django_app/templates/turbo_frame/index.html
{% extends "turbo_frame/base.html" %}
{% block content %}
<div class="mb-4">
<turbo-frame id="task-create" src="{% url 'turbo-frame:task-create' %}">
Loading...
</turbo-frame>
</div>
</div>
{% endblock %}
Notes:
1. We add task-create frame to load the form from the turbo-frame:task-create
2. We set src to the turbo frame to do eager loading, so we do not need to click the button to load
content.
Update hotwire_django_app/templates/turbo_frame/create_page.html to add the <turbo-frame
id="task-create">
{% extends "turbo_frame/base.html" %}
{% load crispy_forms_tags %}
60
Definitive Guide to Hotwire and Django, Release 1.0.0
{% block content %}
{{ form|crispy }}
</div>
{% endblock %}
Now if we check http://127.0.0.1:8000/turbo-frame/, we should see the task create form on the top of
the page.
{% extends "turbo_frame/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<turbo-frame id="task-create">
<form method="post" action="{% url 'turbo-frame:task-create' %}"> <!-- update -->
{% csrf_token %}
{{ form|crispy }}
</div>
{% endblock %}
1. If we type one letter in the title field and submit the form.
2. The form validation would fail, and we can see the form error on the index page successfully.
3. If the form validation succeed, it would return 302 response, and Turbo will visit the list page,
since no Turbo frame task-create is found, it will raise Response has no matching <turbo-frame
id="task-create"> element error in the console of the devtool. We will fix it soon.
Update hotwire_django_app/turbo_frame/views.py
def create_view(request):
if request.method == 'POST':
form = TaskForm(request.POST)
if form.is_valid():
instance = form.save()
status = http.HTTPStatus.UNPROCESSABLE_ENTITY
else:
status = http.HTTPStatus.OK
form = TaskForm()
Any request triggered by an interaction within the Turbo Frame will include a “Turbo-Frame”
header
So in Django view, we can check the request header, and then decide to return normal HTTP response
or Turbo Frame response.
If the form is valid, we return HTML which contains turbo-frame tag.
Let’s submit the form on the index page, we can see the Task created successfully message.
It is tedious to write raw HTML in Django view, let’s use some tool to help us.
django-turbo-response==0.0.52
Update hotwire_django_app/settings.py
INSTALLED_APPS = [
'turbo_response', # new
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'turbo_response.middleware.TurboMiddleware', # new
...
]
Update hotwire_django_app/turbo_frame/views.py
def create_view(request):
if request.method == 'POST':
form = TaskForm(request.POST)
if form.is_valid():
instance = form.save()
8 https://github.com/hotwire-django/django-turbo-response
status = http.HTTPStatus.UNPROCESSABLE_ENTITY
else:
status = http.HTTPStatus.OK
form = TaskForm()
13.7.1 Template
Create hotwire_django_app/templates/turbo_frame/form/create.html
{% load crispy_forms_tags %}
{% csrf_token %}
{{ form|crispy }}
{% extends "turbo_frame/base.html" %}
{% block content %}
{% if request.turbo.frame %}
<turbo-frame id="{{ request.turbo.frame }}">
{% include 'turbo_frame/form/create.html' %}
</turbo-frame>
{% else %}
{% include 'turbo_frame/form/create.html' %}
{% endif %}
</div>
{% endblock %}
The logic is simple, we only render turbo-frame element, if the HTTP request has Turbo-Frame header.
1. If we create task on http://127.0.0.1:8000/turbo-frame/, the Django view will return Turbo re-
sponse.
2. If we create task on http://127.0.0.1:8000/turbo-frame/create/ the Django view will return 302
response.
14.1 Objective
14.2 Detail
14.2.1 Template
Update hotwire_django_app/templates/turbo_frame/task_detail.html
<turbo-frame id="task-detail-{{ instance.pk }}" class="flex-1"> <! --- new --->
</turbo-frame>
14.3 Edit
14.3.1 View
Update hotwire_django_app/turbo_frame/views.py
def update_view(request, pk):
instance = get_object_or_404(Task, pk=pk)
if request.method == 'POST':
form = TaskForm(request.POST, instance=instance)
if form.is_valid():
form.save()
67
Definitive Guide to Hotwire and Django, Release 1.0.0
if request.turbo.frame:
# if request come from Turbo Frame
return redirect(reverse('turbo-frame:task-detail', kwargs={'pk': instance.pk}))
else:
# if request come from standard page
messages.success(request, 'Task update successfully')
return redirect(reverse('turbo-frame:task-detail', kwargs={'pk': instance.pk}))
status = http.HTTPStatus.UNPROCESSABLE_ENTITY
else:
status = http.HTTPStatus.OK
form = TaskForm(instance=instance)
14.3.2 Template
Create hotwire_django_app/templates/turbo_frame/form/update.html
{% load crispy_forms_tags %}
{% csrf_token %}
{{ form|crispy }}
{% if request.turbo.frame %}
<a href="{% url 'turbo-frame:task-detail' form.instance.pk %}" class="btn-red">Cancel</a>
{% else %}
<a href="{% url 'turbo-frame:task-list' %}" class="btn-red">Cancel</a>
{% endif %}
</form>
If the request has Turbo Frame header, the Cancel points to the task detail URL.
Update hotwire_django_app/templates/turbo_frame/update_page.html
{% extends "turbo_frame/base.html" %}
{% block content %}
<div class="w-full max-w-7xl mx-auto px-4">
{% if request.turbo.frame %}
<turbo-frame id="{{ request.turbo.frame }}">
{% include 'turbo_frame/form/update.html' %}
</turbo-frame>
{% else %}
{% include 'turbo_frame/form/update.html' %}
{% endif %}
</div>
{% endblock %}
14.4 Delete
14.4.1 View
Update hotwire_django_app/turbo_frame/views.py
if request.turbo.frame:
# if request come from Turbo Frame
response = TurboFrame(f"task-detail-{pk}").render('') # new
return response
else:
# if request come from standard page
messages.success(request, 'Task deleted successfully')
return redirect('turbo-frame:task-list')
14.4.2 Template
Create hotwire_django_app/templates/turbo_frame/form/delete.html
{% load crispy_forms_tags %}
{{ form|crispy }}
{% if request.turbo.frame %}
<a href="{% url 'turbo-frame:task-detail' instance.pk %}" class="btn-red">Cancel</a>
{% else %}
<a href="{% url 'turbo-frame:task-list' %}" class="btn-red">Cancel</a>
{% endif %}
</form>
Update hotwire_django_app/templates/turbo_frame/delete_page.html
{% extends "turbo_frame/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="w-full max-w-7xl mx-auto px-4">
<h1 class="text-4xl sm:text-6xl lg:text-7xl mb-6">Delete Task</h1>
{% if request.turbo.frame %}
14.4. Delete 69
Definitive Guide to Hotwire and Django, Release 1.0.0
</div>
{% endblock %}
If we click the Delete button, the content in the turbo-frame will be empty, but the li element still exists.
We should find a way to remove the li element, I will talk about the solution Turbo Stream in later chapter.
15.1 Objective
./hotwire_django_app
├── __init__.py
├── asgi.py
├── settings.py
├── stimulus_basic # new
├── tasks
├── templates
├── turbo_drive
├── turbo_frame
├── urls.py
└── wsgi.py
class StimulusBasicConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'hotwire_django_app.stimulus_basic' # update
INSTALLED_APPS = [
...
71
Definitive Guide to Hotwire and Django, Release 1.0.0
'hotwire_django_app.stimulus_basic', # new
]
$ ./manage.py check
15.3 View
Update hotwire_django_app/stimulus_basic/views.py
from django.shortcuts import render
def counter_view(request):
return render(request, 'stimulus_basic/counter.html')
15.4 Template
Create hotwire_django_app/templates/stimulus_basic/base.html
{% load webpack_loader static %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
{% include 'stimulus_basic/navbar.html' %}
{% block content %}
{% endblock content %}
</body>
</html>
Notes:
1. Please note in this Django app, we will use stimulus_basic entry file. We will create it in a bit.
Create hotwire_django_app/templates/stimulus_basic/navbar.html
<nav class="flex items-center justify-between flex-wrap bg-teal-500 p-6 mb-4">
<div class="w-full">
<a href="{% url 'stimulus-basic:counter' %}" class="inline-block mt-0 text-teal-200 hover:text-
,→white mr-4">
Counter
</a>
</div>
</nav>
{% extends "stimulus_basic/base.html" %}
{% block content %}
</div>
{% endblock %}
15.5 Frontend
Create frontend/src/styles/stimulus_basic.scss
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
Create frontend/src/application/stimulus_basic.js
import "@hotwired/turbo";
Notes:
1. We import stimulus_basic.scss we just created
2. And this JS entry file would be used by the stimulus_basic/base.html
15.6 URL
Create hotwire_django_app/stimulus_basic/urls.py
app_name = 'stimulus-basic'
urlpatterns = [
path('counter/', counter_view, name='counter'),
]
Notes:
1. Here we use app_name to set the namespace of the Django app.
Update hotwire_django_app/urls.py
15.5. Frontend 73
Definitive Guide to Hotwire and Django, Release 1.0.0
urlpatterns = [
path('', TemplateView.as_view(template_name="index.html")),
path('turbo-drive/', include('hotwire_django_app.turbo_drive.urls')),
path('turbo-frame/', include('hotwire_django_app.turbo_frame.urls')),
path('stimulus-basic/', include('hotwire_django_app.stimulus_basic.urls')), # new
path('admin/', admin.site.urls),
]
16.1 Objective
16.2 Introduction
Stimulus is a JavaScript framework with modest ambitions. Unlike other front-end frame-
works, Stimulus is designed to enhance static or server-rendered HTML—the “HTML you al-
ready have”—by connecting JavaScript objects to elements on the page using simple anno-
tations.
We can:
1. Use the classic dev frameworks such as Django, Rails to render HTML.
2. Use Stimulus to attach JS to the DOM elements (handle events, do DOM manipulation).
Here we install @hotwired/stimulus@3.0.1 (which is the latest version when I write this book), we use
--save-exact to pin the version here to make the readers easy to troubleshoot in some cases.
If we check package.json
"dependencies": {
"@hotwired/stimulus": "3.0.1",
}
75
Definitive Guide to Hotwire and Django, Release 1.0.0
Update frontend/src/application/stimulus_basic.js
import "@hotwired/turbo";
import { Application } from "@hotwired/stimulus";
window.Stimulus = Application.start();
window.Stimulus.register("counter", CounterController);
Notes:
1. We import CounterController and then register it with Stimulus.register method.
Update hotwire_django_app/templates/stimulus_basic/counter.html
{% extends "stimulus_basic/base.html" %}
{% block content %}
</div>
{% endblock %}
Notes:
1. The key is <div data-controller="counter"></div>, we attach counter controller to the div ele-
ment using data-controller="counter"
16.7 Workflow
Stimulus continuously monitors the page waiting for HTML data-controller attributes to ap-
pear. For each attribute, Stimulus looks at the attribute’s value to find a corresponding con-
troller class, creates a new instance of that class, and connects it to the element.
1. Stimulus detects elements which have data-controller attribute.
2. Then it creates a new instance of the controller class (counter controller), and connect it to the
DOM element.
3. The controller’s connect method will run.
4. this.element is reference to the connected DOM element, the DOM innerHTML is set to Hello
World in this case.
16.7. Workflow 77
Definitive Guide to Hotwire and Django, Release 1.0.0
It is tedious to register all the Stimulus controllers manually, let’s make it automatic
Update frontend/src/application/stimulus_basic.js
import "@hotwired/turbo";
import { Application } from "@hotwired/stimulus";
import { definitionsFromContext } from "@hotwired/stimulus-webpack-helpers";
window.Stimulus = Application.start();
const context = require.context("../controllers", true, /\.js$/);
window.Stimulus.load(definitionsFromContext(context));
With require.context (brought by Webpack), we pass the root directory of the controllers, and
definitionsFromContext helps us generate controller names from the path and register them.
Please rerun npm run start, and then check on http://127.0.0.1:8000/stimulus-basic/counter/
17.1 Objective
Update frontend/src/controllers/counter_controller.js
Notes:
1. Here this.count is something like a private variable of the controller instance.
2. We use this.element.addEventListener to register event handler to the DOM element, if it is
clicked, then the this.count will increase and the innderHTML will display the value.
Let’s update hotwire_django_app/templates/stimulus_basic/counter.html
{% extends "stimulus_basic/base.html" %}
{% block content %}
79
Definitive Guide to Hotwire and Django, Release 1.0.0
</div>
{% endblock %}
Notes:
1. We have button, h1 and div elements on the page, all of them have data-controller="counter"
2. Stimulus will create three controller instances and connect the instances to the respective ele-
ment.
Visit http://127.0.0.1:8000/stimulus-basic/counter/
We can click the elements and all of them should work as expected.
Update frontend/src/controllers/counter_controller.js
import { Controller } from '@hotwired/stimulus';
get count() {
return parseInt(this.element.dataset.count);
}
set count(value) {
this.element.dataset.count = value;
}
Notes:
1. We add getter and setter to the class, you can check https://javascript.info/class#
getters-setters to learn more.
2. If we set value using this.count = 0, the value would be set to the dataset of the DOM element.
Now if we check the element in the devtool, we can see data-count attributes.
Update frontend/src/controllers/counter_controller.js
import {Controller} from '@hotwired/stimulus';
connect() {
// set initial state
this.element.innerHTML = 'Click me';
countValueChanged(value, previousValue) {
Notes:
1. At the top, we defined static values, which has the value name, value type, and default value.
2. We can get or set the value using this.countValue and Stimulus will help us read, do type conver-
sion, and write HTML data attributes.
3. With Stimulus Value, we can remove the annoying getter and setter and the code is cleaner.
4. What is more, we can use countValueChanged as value change callback, this is very useful in some
cases.
Now if we check the element in the devtool, we can see data-counter-count-value attribute, the con-
troller name has been added as prefix to avoid potential conflict.
You can check https://stimulus.hotwired.dev/reference/values to learn more.
17.5 Actions
connect() {
this.element.innerHTML = 'Click me';
}
countValueChanged(value, previousValue) {
console.log(`${previousValue} changed to ${value}`);
}
increment(){
this.countValue++;
this.element.innerHTML = `You clicked ${this.countValue} times`;
}
}
{% block content %}
<button
</div>
{% endblock %}
Notes:
1. We add data-action="click->counter#increment" to the HTML elements.
2. click is the event we want to listen.
3. counter is the controller name.
4. increment is the controller method we want to fire.
5. The data-action value means, the click event will fire counter controller increment method.
Now you can test on http://127.0.0.1:8000/stimulus-basic/counter/ and it should still work.
And the code is easy to understand and maintain.
Notes:
1. It seems addEventListener and data-action can do the same thing here, so which one is recom-
mended?
2. When we use addEventListener, we should run removeEventListener in the disconnect method
(which run when the controller is disconnected from the DOM). If we forget do so, some bugs
might happen in some cases.
3. So data-action is better option in most cases.
You can check https://stimulus.hotwired.dev/reference/actions to learn more.
17.5. Actions 83
Chapter 18
18.1 Objective
{% extends "stimulus_basic/base.html" %}
{% block content %}
<div class="w-full max-w-7xl mx-auto px-4">
<button
class="px-4 py-2 bg-blue-500 hover:bg-blue-700 text-white font-semibold rounded-lg"
data-controller="counter"
data-action="click->counter#increment"
>
You clicked <span class="font-bold"></span> times!
</button>
</div>
{% endblock %}
84
Definitive Guide to Hotwire and Django, Release 1.0.0
Notes:
1. We add You clicked <span class="font-bold"></span> times! element as child of our con-
troller.
Update frontend/src/controllers/counter_controller.js
connect() {
this.element.querySelector('span').innerText = this.countValue;
}
countValueChanged(value, previousValue) {
console.log(`${previousValue} changed to ${value}`);
}
increment(){
this.countValue++;
this.element.querySelector('span').innerText = this.countValue;
}
}
Notes:
1. We use this.element.querySelector('span') to find the specific child element, and change
innerText to make it work.
2. It seems tedious to use this.element.querySelector('span') and is there better way to do this?
18.3 Target
{% extends "stimulus_basic/base.html" %}
{% block content %}
<button
class="px-4 py-2 bg-blue-500 hover:bg-blue-700 text-white font-semibold rounded-lg"
data-controller="counter"
data-action="click->counter#increment"
>
You clicked <span class="font-bold" data-counter-target="count"></span> times!
</button>
18.3. Target 85
Definitive Guide to Hotwire and Django, Release 1.0.0
</div>
{% endblock %}
connect() {
this.countTarget.innerText = this.countValue;
}
countValueChanged(value, previousValue) {
console.log(`${previousValue} changed to ${value}`);
}
increment(){
this.countValue++;
this.countTarget.innerText = this.countValue;
}
}
Notes:
1. We add static targets to the controller class.
2. Now we can use countTarget to access the span element without DOM selection.
3. We can even use countTargets to access all elements and use hasCountTarget to check if there
is a matching target in scope. https://stimulus.hotwired.dev/reference/targets#properties
With Stimulus Targets, we can make our Controller code more readable, since we do not need to use the
DOM Selectors API
You can check https://stimulus.hotwired.dev/reference/targets to learn more.
Update hotwire_django_app/templates/stimulus_basic/counter.html
{% extends "stimulus_basic/base.html" %}
{% block content %}
<button
class="px-4 py-2 bg-blue-500 hover:bg-blue-700 text-white font-semibold rounded-lg"
data-controller="counter"
data-action="click->counter#increment"
>
<span data-counter-target="initialDiv">
Click Me
</span>
<span data-counter-target="progressDiv" class="hidden">
You clicked <span class="font-bold" data-counter-target="count"></span> times!
</span>
</button>
</div>
{% endblock %}
Notes:
1. We add initialDiv and progressDiv targets.
2. The initialDiv is display and the progressDiv is hidden by default.
3. Once user click the counter, we hide the initialDiv and display the progressDiv
Update frontend/src/controllers/counter_controller.js
connect() {
this.countTarget.innerText = this.countValue;
}
countValueChanged(value, previousValue) {
console.log(`${previousValue} changed to ${value}`);
increment(){
this.countValue++;
this.countTarget.innerText = this.countValue;
}
}
In countValueChanged method, if the value increase to 1, we hide the initialDiv and show the
progressDiv
The css name here is hidden, what if we want it configurable.
Update hotwire_django_app/templates/stimulus_basic/counter.html
{% extends "stimulus_basic/base.html" %}
{% block content %}
<button
class="px-4 py-2 bg-blue-500 hover:bg-blue-700 text-white font-semibold rounded-lg"
data-controller="counter"
data-action="click->counter#increment"
data-counter-hidden-class="hidden"
>
<span data-counter-target="initialDiv">
Click Me
</span>
<span data-counter-target="progressDiv" class="hidden">
You clicked <span class="font-bold" data-counter-target="count"></span> times!
</span>
</button>
</span>
</div>
</div>
{% endblock %}
Notes:
1. We add data-counter-hidden-class="hidden" to the controller DOM element.
Update frontend/src/controllers/counter_controller.js
connect() {
this.countTarget.innerText = this.countValue;
}
countValueChanged(value, previousValue) {
console.log(`${previousValue} changed to ${value}`);
if (value === 1){
this.initialDivTarget.classList.add(this.hiddenClass);
this.progressDivTarget.classList.remove(this.hiddenClass);
}
}
increment(){
this.countValue++;
this.countTarget.innerText = this.countValue;
}
}
Notes:
1. We defined static classes = ['hidden']
2. We use this.initialDivTarget.classList.add(this.hiddenClass) to add hidden class to the div.
3. This makes our controller code decoupled with the css. Controller only care about the behavior,
and we can pass css name from the HTML.
More details can be found on https://stimulus.hotwired.dev/reference/css-classes
18.6 Controller
Stimulus’s use of data attributes helps separate content from behavior in the same way CSS
separates content from presentation
Stimulus helps you build small, reusable controllers, giving you just enough structure to keep
your code from devolving into “JavaScript soup.”
18.6. Controller 89
Chapter 19
19.1 Objective
19.2.1 View
Update hotwire_django_app/stimulus_basic/views.py
def turbo_frame_load_view(request): # new
return render(request, 'stimulus_basic/turbo_frame_load.html')
19.2.2 Template
Create hotwire_django_app/templates/stimulus_basic/turbo_frame_load.html
{% extends "stimulus_basic/base.html" %}
{% block content %}
<turbo-frame id="page_content">
<a class="px-4 py-2 bg-blue-500 hover:bg-blue-700 text-white font-semibold rounded-lg"
href="{% url 'stimulus-basic:counter' %}">Click to load content with Turbo Frame</a>
</turbo-frame>
</div>
{% endblock %}
Update hotwire_django_app/templates/stimulus_basic/navbar.html
<nav class="flex items-center justify-between flex-wrap bg-teal-500 p-6 mb-4">
<div class="w-full">
<a href="{% url 'stimulus-basic:counter' %}" class="inline-block mt-0 text-teal-200 hover:text-
,→white mr-4">
90
Definitive Guide to Hotwire and Django, Release 1.0.0
Counter
</a>
</div>
</nav>
{% extends "stimulus_basic/base.html" %}
{% block content %}
<turbo-frame id="page_content">
</turbo-frame>
</div>
{% endblock %}
19.2.3 URL
Update hotwire_django_app/stimulus_basic/urls.py
app_name = 'stimulus-basic'
urlpatterns = [
path('counter/', counter_view, name='counter'),
path('turbo_frame_load/', turbo_frame_load_view, name='turbo_frame_load'), # new
]
Update frontend/src/controllers/counter_controller.js
initialize(){
console.log('initialize fired');
}
connect() {
console.log('connect fired');
this.countTarget.innerText = this.countValue;
}
disconnect() {
console.log('disconnect fired');
}
countValueChanged(value, previousValue) {
console.log(`${previousValue} changed to ${value}`);
if (value === 1){
this.initialDivTarget.classList.add(this.hiddenClass);
this.progressDivTarget.classList.remove(this.hiddenClass);
}
}
increment(){
this.countValue++;
this.countTarget.innerText = this.countValue;
}
}
Notes:
1. We add console.log code in the initialize, connect and disconnect methods.
Let’s visit http://127.0.0.1:8000/stimulus-basic/counter/ and check the console of the web devlool
undefined changed to 0
initialize fired
connect fired
undefined changed to 0
initialize fired
connect fired
undefined changed to 0
initialize fired
connect fired
Notes:
1. Please note the countValueChanged fire even before the initialize method.
And then if we click the top Turbo Frame, we see
disconnect fired
disconnect fired
disconnect fired
The DOM elements have been removed from the document, so the Stimulus controller’s disconnect
method get fired, which means we can do some cleanup work in this method.
The disconnect method of the controller will run, and you can see disconnect fired
Then if we add the element back to document again.
document.body.appendChild(element)
The connect method of the controller will run, but the initialize method will not run.
{% extends "stimulus_basic/base.html" %}
{% block content %}
<turbo-frame id="page_content">
<button
<h1
data-controller="counter"
data-action="click->counter#increment turbo:before-cache@window->counter#reset"
data-counter-hidden-class="hidden"
class="text-4xl sm:text-6xl lg:text-7xl mb-6">
<span data-counter-target="initialDiv">
Click Me
</span>
<span data-counter-target="progressDiv" class="hidden">
You clicked <span class="font-bold" data-counter-target="count"></span> times!
</span>
</h1>
<div
data-controller="counter"
data-action="click->counter#increment turbo:before-cache@window->counter#reset"
data-counter-hidden-class="hidden"
class="text-4xl"
>
<span data-counter-target="initialDiv">
Click Me
</span>
<span data-counter-target="progressDiv" class="hidden">
You clicked <span class="font-bold" data-counter-target="count"></span> times!
</span>
</div>
</turbo-frame>
</div>
{% endblock %}
Notes:
1. The data-action attribute’s value is a space-separated list of action descriptors
2. turbo:before-cache@window means we listen for turbo:before-cache on the global window ob-
ject.
3. The reset method is to reset the controller to initial state.
Update frontend/src/controllers/counter_controller.js
'count',
'initialDiv',
'progressDiv'
];
static classes = ['hidden'];
initialize(){
console.log('initialize fired');
}
connect() {
console.log('connect fired');
this.countTarget.innerText = this.countValue;
}
disconnect() {
console.log('disconnect fired');
}
countValueChanged(value, previousValue) {
console.log(`${previousValue} changed to ${value}`);
if (value === 1){
this.initialDivTarget.classList.add(this.hiddenClass);
this.progressDivTarget.classList.remove(this.hiddenClass);
}
}
increment(){
this.countValue++;
this.countTarget.innerText = this.countValue;
}
reset(){
// cleanup for page cache
this.countValue = 0;
this.initialDivTarget.classList.remove(this.hiddenClass);
this.progressDivTarget.classList.add(this.hiddenClass);
}
}
Now if we test again, the controller would behave better with Turbo page cache.
20.1 Objective
20.2 Background
In the previous chapter, we import https://weatherwidget.io/ by adding the code below to the Django
template
<script>
!function(d,s,id){var js,fjs=d.getElementsByTagName(s)[0];if(!d.getElementById(id)){js=d.
,→createElement(s);js.id=id;js.src='https://weatherwidget.io/js/widget.min.js';fjs.parentNode.
,→insertBefore(js,fjs);}}(document,'script','weatherwidget-io-js');
</script>
Create frontend/src/controllers/weather_widget_controller.js
disconnect() {
this.cleanUp();
}
insertScript(d, s, id) {
var js, fjs = d.getElementsByTagName(s)[0];
96
Definitive Guide to Hotwire and Django, Release 1.0.0
if (!d.getElementById(id)) {
js = d.createElement(s);
js.id = id;
js.src = 'https://weatherwidget.io/js/widget.min.js';
fjs.parentNode.insertBefore(js, fjs);
}
}
cleanUp() {
document.querySelector('#weatherwidget-io-js').remove();
}
}
Notes:
1. In the connect method, we call insertScript method to insert the weatherwidget.io script
2. The code in insertScript is reformatted version of the above weatherwidget inline script
3. In the cleanUp method, we remove the weatherwidget.io script.
4. In the disconnect method, we run cleanUp to do cleanup work.
Create hotwire_django_app/templates/stimulus_basic/weather_widget.html
<a data-controller="weather-widget"
class="weatherwidget-io" href="https://forecast7.com/en/40d71n74d01/new-york/"
data-label_1="NEW YORK"
data-label_2="WEATHER"
data-theme="original">NEW YORK WEATHER
</a>
Notes:
1. With data-controller="weather-widget", the weather_widget_controller will connect to the
DOM element.
Update hotwire_django_app/templates/stimulus_basic/base.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
{% include 'stimulus_basic/navbar.html' %}
{% block content %}
{% endblock content %}
<div class="my-4">
{% include 'stimulus_basic/weather_widget.html' %}
</div>
</body>
</html>
Notes:
1. We import the weather widget using {% include 'stimulus_basic/weather_widget.html' %}
We can use Stimulus Controller to develop Content Loader (something like turbo-frame)
Below is the code you can reference:
connect() {
this.load()
}
load() {
fetch(this.urlValue)
.then(response => response.text())
.then(html => this.element.innerHTML = html)
}
}
And you can take a look at this interesting example Refreshing Automatically With a Timer9
9 https://stimulus.hotwired.dev/handbook/working-with-external-resources#refreshing-automatically-with-a-timer
21.1 Objective
21.2 Preparation
21.2.1 View
Update hotwire_django_app/stimulus_basic/views.py
import http
from django.shortcuts import render, redirect, reverse
from django.contrib import messages
def create_view(request):
if request.method == 'POST':
form = TaskForm(request.POST)
if form.is_valid():
form.save()
status = http.HTTPStatus.UNPROCESSABLE_ENTITY
else:
status = http.HTTPStatus.OK
form = TaskForm()
def list_view(request):
object_list = Task.objects.all().order_by('-pk')
context = {
"object_list": object_list,
}
99
Definitive Guide to Hotwire and Django, Release 1.0.0
Notes:
1. We add create_view and list_view, which we will use in a bit.
21.2.2 Template
Create hotwire_django_app/templates/stimulus_basic/create_page.html
{% extends "stimulus_basic/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<form method="post">
{% csrf_token %}
{{ form|crispy }}
</div>
{% endblock %}
Create hotwire_django_app/templates/stimulus_basic/list_page.html
{% extends "stimulus_basic/base.html" %}
{% block content %}
{% endblock %}
Create hotwire_django_app/templates/stimulus_basic/messages.html
{{ message|safe }}
</div>
{% endfor %}
Update hotwire_django_app/templates/stimulus_basic/navbar.html
<nav class="flex items-center justify-between flex-wrap bg-teal-500 p-6 mb-4">
<div class="w-full">
<a href="{% url 'stimulus-basic:counter' %}" class="inline-block mt-0 text-teal-200 hover:text-
,→white mr-4">
Counter
</a>
Turbo Frame
</a>
List
</a>
Create
</a>
</div>
</nav>
Notes:
1. We add Create and List link to the top navbar.
21.2.3 URL
Update hotwire_django_app/stimulus_basic/urls.py
from django.urls import path
from .views import counter_view, turbo_frame_load_view, create_view, list_view
app_name = 'stimulus-basic'
urlpatterns = [
path('counter/', counter_view, name='counter'),
path('turbo_frame_load/', turbo_frame_load_view, name='turbo_frame_load'),
path('list/', list_view, name='task-list'), # new
path('create/', create_view, name='task-create'), # new
]
21.3 Frontend
Update frontend/src/styles/stimulus_basic.scss
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
.btn-blue {
@apply inline-flex items-center;
@apply px-4 py-2;
@apply font-semibold rounded-lg shadow-md;
.btn-red {
@apply inline-flex items-center;
@apply px-4 py-2;
@apply font-semibold rounded-lg shadow-md;
@apply text-white bg-red-500;
@apply hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-opacity-75;
}
Update hotwire_django_app/settings.py
INSTALLED_APPS = [
'django.forms', # new
]
Create hotwire_django_app/templates/form/flatpickr_date.html
{% include "django/forms/widgets/input.html" %}
Update hotwire_django_app/tasks/forms.py
class CustomDate(forms.widgets.DateInput):
template_name = 'form/flatpickr_date.html'
class TaskForm(forms.ModelForm):
class Meta:
model = Task
fields = ("title", "due_date")
widgets = {
'due_date': CustomDate(),
}
Notes:
1. We created a CustomDate Django form widget, and set the template form/flatpickr_date.html
21.6 Flatpickr
"flatpickr": "^4.6.13",
"stimulus-flatpickr": "^3.0.0-0"
Create frontend/src/controllers/flatpickr_controller.js
Notes:
1. We defined controller class which inherit from stimulus-flatpickr Flatpickr, so we can extend
the behavior if we like.
2. Do not forget to import the css to make the theme work.
Update hotwire_django_app/templates/form/flatpickr_date.html
<div>
<input
data-controller="flatpickr"
data-flatpickr-enable-time="false"
type="text"
name="{{ widget.name }}"
class="textinput py-2 leading-normal text-gray-700 bg-white px-4 appearance-none
focus:outline-none rounded-lg w-full border-gray-300 block border"
{% if widget.value != None %}value="{{ widget.value }}"{% endif %}
{% include "django/forms/widgets/attrs.html" %}
>
</div>
Notes:
1. data-controller="flatpickr" means flatpickr controller will connect to the input element in a
bit.
2. data-flatpickr-enable-time can control if enable the time or not, which means we can create
CustomDateTime widget which has data-flatpickr-enable-time="true"
3. We add some CSS class to make the widget work better with Tailwind.
21.7 Notes:
Stimulus is like Glue, we can use it to write wrapper for 3-party NPM package or online resource.
For example, we can use it to improve file upload, rich text editor widgets on our form page.
22.1 Objective
22.2 Workflow
22.3 Spinner
Create hotwire_django_app/templates/stimulus_basic/spinner.html
</svg>
Create frontend/src/controllers/form_controller.js
105
Definitive Guide to Hotwire and Django, Release 1.0.0
disconnect() {
this.enableSubmits();
this.element.toggleAttribute("data-submitting", false);
}
disableSubmits() {
this.submitTargets.forEach(
function (submitTarget) {
submitTarget.disabled = true;
}
);
}
enableSubmits() {
this.submitTargets.forEach(
function (submitTarget) {
submitTarget.disabled = false;
}
);
}
submitStart() {
const form = this.element;
if (form) {
form.toggleAttribute("data-submitting", true);
this.disableSubmits();
}
}
submitEnd() {
const form = this.element;
if (form) {
form.toggleAttribute("data-submitting", false);
this.enableSubmits();
}
}
Notes:
1. The form controller has submit target, which is the submit button.
2. In submitStart, we add attribute data-submitting to the form element and set disabled=true at-
tribute to the submit target, which can avoid duplicate submissions.
3. In submitEnd, we remove data-submitting to the form element and set disabled=false attribute
to the submit target.
22.5 CSS
22.5.1 tailwind.config.js
Update tailwind.config.js
module.exports = {
content: contentPaths,
theme: {
extend: {},
},
variants: {
extend: {
opacity: ['disabled'], // new
}
},
plugins: [
require('@tailwindcss/forms'),
],
};
22.5.2 CSS
Update frontend/src/styles/stimulus_basic.scss
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
.btn-blue {
@apply inline-flex items-center;
@apply px-4 py-2;
@apply font-semibold rounded-lg shadow-md;
@apply text-white bg-blue-500;
@apply hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-opacity-
,→75;
.btn-red {
@apply inline-flex items-center;
@apply px-4 py-2;
@apply font-semibold rounded-lg shadow-md;
@apply text-white bg-red-500;
@apply hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-opacity-75;
@apply disabled:opacity-50; // new
}
form[data-controller="form"] {
button[data-form-target="submit"] {
svg {
@apply hidden;
}
}
&[data-submitting] {
button[data-form-target="submit"] {
@apply cursor-not-allowed;
svg {
@apply inline-block;
}
}
}
}
Notes:
1. We add disabled:opacity-50 to btn-blue and btn-red, so if the button is disabled, the button color
will change.
2. For the form which has data-controller="form", by default, the svg in button is hidden, if the
data-submitting=true, the svg icon will display.
22.6 Template
Create hotwire_django_app/templates/stimulus_basic/create_page.html
{% extends "stimulus_basic/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<form
method="post"
data-controller="form"
data-action="turbo:submit-start->form#submitStart turbo:submit-end->form#submitEnd"
>
{% csrf_token %}
{{ form|crispy }}
</div>
{% endblock %}
Notes:
1. with data-controller="form", form controller will connect to the DOM element.
2. With data-action="turbo:submit-start->form#submitStart turbo:submit-end->form#submitEnd",
the controller will listen to the turbo:submit-start and turbo:submit-end events.
3. We add data-form-target="submit" to the submit button, so the controller can access it without
DOM searching.
Remember to restart webpack to load the new controller, and then test on http://127.0.0.1:8000/
stimulus-basic/create/
When we submit the form, we should see the spinner in the button.
Note:
To embed SVG in Django, please check https://saashammer.com/docs/frontend/svg.html
23.1 Objective
class StimulusAdvancedConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'hotwire_django_app.stimulus_advanced' # update
INSTALLED_APPS = [
...
'hotwire_django_app.stimulus_advanced', # new
]
23.3 View
Create hotwire_django_app/stimulus_advanced/views.py
110
Definitive Guide to Hotwire and Django, Release 1.0.0
import http
def list_view(request):
object_list = Task.objects.all().order_by('-pk')
context = {
"object_list": object_list,
}
def create_view(request):
if request.method == 'POST':
form = TaskForm(request.POST)
if form.is_valid():
instance = form.save()
status = http.HTTPStatus.UNPROCESSABLE_ENTITY
else:
status = http.HTTPStatus.OK
form = TaskForm()
status = http.HTTPStatus.UNPROCESSABLE_ENTITY
else:
status = http.HTTPStatus.OK
form = TaskForm(instance=instance)
Notes:
1. Here we create 4 FBV, which are for CRUD work.
23.4 Template
23.4.1 base.html
create hotwire_django_app/templates/stimulus_advanced/base.html
{% load webpack_loader static %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
{% include 'stimulus_advanced/navbar.html' %}
{% include 'stimulus_advanced/messages.html' %}
{% block content %}
{% endblock content %}
</body>
</html>
Notes:
1. Please note in this Django app, we will use stimulus_advanced entry file. We will create it in a bit.
Create hotwire_django_app/templates/stimulus_advanced/messages.html
{% for message in messages %}
{# data-turbo-cache="false" will tell Turbo to not cache the element #}
<div data-turbo-cache="false" class="p-4 mb-4 text-sm text-green-700 bg-green-100 rounded-lg�
,→dark:bg-green-200 dark:text-green-800" role="alert">
{{ message|safe }}
</div>
{% endfor %}
Create hotwire_django_app/templates/stimulus_advanced/navbar.html
<nav class="flex items-center justify-between flex-wrap bg-teal-500 p-6 mb-4">
<div class="w-full">
<a href="{% url 'stimulus-advanced:task-list' %}" class="inline-block mt-0 text-teal-200�
,→hover:text-white mr-4">
112 Chapter 23. Prepare Django app to learn Stimulus and Turbo Frame
Definitive Guide to Hotwire and Django, Release 1.0.0
List
</a>
<a href="{% url 'stimulus-advanced:task-create' %}" class="inline-block mt-0 text-teal-200�
,→hover:text-white mr-4">
Create
</a>
</div>
</nav>
23.4.2 create_page.html
Create hotwire_django_app/templates/stimulus_advanced/create_page.html
{% extends "stimulus_advanced/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<form method="post">
{% csrf_token %}
{{ form|crispy }}
</div>
{% endblock %}
23.4.3 update_page.html
create hotwire_django_app/templates/stimulus_advanced/update_page.html
{% extends "stimulus_advanced/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="w-full max-w-7xl mx-auto px-4">
<form method="post">
{% csrf_token %}
{{ form|crispy }}
</form>
</div>
{% endblock %}
23.4.4 delete_page.html
Create hotwire_django_app/templates/stimulus_advanced/delete_page.html
{% extends "stimulus_advanced/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="w-full max-w-7xl mx-auto px-4">
<h1 class="text-4xl sm:text-6xl lg:text-7xl mb-6">Delete Task</h1>
<form method="post">
{% csrf_token %}
{{ form|crispy }}
</form>
</div>
{% endblock %}
23.4.5 list_page.html
Create hotwire_django_app/templates/stimulus_advanced/list_page.html
{% extends "stimulus_advanced/base.html" %}
{% block content %}
</div>
{% endblock %}
114 Chapter 23. Prepare Django app to learn Stimulus and Turbo Frame
Definitive Guide to Hotwire and Django, Release 1.0.0
23.4.6 detail_page.html
Create hotwire_django_app/templates/stimulus_advanced/detail_page.html
{% extends "stimulus_advanced/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="w-full max-w-7xl mx-auto px-4">
<h1 class="text-4xl sm:text-6xl lg:text-7xl mb-6">Task Detail</h1>
<div>
<a href="{% url 'stimulus-advanced:task-list'%}" class="btn-blue">Go to list</a>
</div>
</div>
{% endblock %}
23.4.7 task_detail.html
Create hotwire_django_app/templates/stimulus_advanced/task_detail.html
<a class="btn-blue mr-3" href="{% url 'stimulus-advanced:task-update' instance.pk %}">
Edit
</a>
<a class="btn-red mr-3" href="{% url 'stimulus-advanced:task-delete' instance.pk %}">
Delete
</a>
23.5 Frontend
Create frontend/src/styles/stimulus_advanced.scss
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
.btn-blue {
@apply inline-flex items-center;
@apply px-4 py-2;
@apply font-semibold rounded-lg shadow-md;
@apply text-white bg-blue-500;
@apply hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-opacity-
,→75;
@apply disabled:opacity-50;
}
.btn-red {
@apply inline-flex items-center;
@apply px-4 py-2;
@apply font-semibold rounded-lg shadow-md;
@apply text-white bg-red-500;
form[data-controller="form"] {
button[data-form-target="submit"] {
svg {
@apply hidden;
}
}
&[data-submitting] {
button[data-form-target="submit"] {
@apply cursor-not-allowed;
svg {
@apply inline-block;
}
}
}
}
Create frontend/src/application/stimulus_advanced.js
import "@hotwired/turbo";
import { Application } from "@hotwired/stimulus";
import { definitionsFromContext } from "@hotwired/stimulus-webpack-helpers";
window.Stimulus = Application.start();
const context = require.context("../controllers", true, /\.js$/);
window.Stimulus.load(definitionsFromContext(context));
Notes:
1. We import stimulus_advanced.scss we just created
2. And this JS entry file would be used by the stimulus_advanced/base.html
3. The Stimulus controllers under the controllers directory would also work because of the
stimulus-webpack-helpers
23.6 URL
Create hotwire_django_app/stimulus_advanced/urls.py
app_name = 'stimulus-advanced'
urlpatterns = [
path('list/', list_view, name='task-list'),
path('create/', create_view, name='task-create'),
path('<int:pk>/', detail_view, name='task-detail'),
path('<int:pk>/update/', update_view, name='task-update'),
path('<int:pk>/delete/', delete_view, name='task-delete'),
]
116 Chapter 23. Prepare Django app to learn Stimulus and Turbo Frame
Definitive Guide to Hotwire and Django, Release 1.0.0
Notes:
1. Here we use app_name to set the namespace of the Django app.
Update hotwire_django_app/urls.py
urlpatterns = [
...
path('stimulus-advanced/', include('hotwire_django_app.stimulus_advanced.urls')), # new
]
24.1 Objective
24.2 tailwindcss-stimulus-components
Update frontend/src/application/stimulus_advanced.js
import "@hotwired/turbo";
import { Application } from "@hotwired/stimulus";
import { definitionsFromContext } from "@hotwired/stimulus-webpack-helpers";
import { Alert } from "tailwindcss-stimulus-components"; // new
window.Stimulus = Application.start();
const context = require.context("../controllers", true, /\.js$/);
window.Stimulus.load(definitionsFromContext(context));
Notes:
1. Here we register the alert controller from the tailwindcss-stimulus-components
24.3 View
Update hotwire_django_app/stimulus_advanced/views.py
def flash_message_demo_view(request):
messages.info(request, 'Info Message')
118
Definitive Guide to Hotwire and Django, Release 1.0.0
24.4 MESSAGE_TAGS
Update hotwire_django_app/settings.py
MESSAGE_TAGS = {
messages.INFO: 'bg-sky-500',
messages.SUCCESS: 'bg-emerald-500',
messages.WARNING: 'bg-amber-500',
messages.ERROR: 'bg-red-500',
}
1. The message tags will be inserted to the template based on the message level.
24.5 Template
Create hotwire_django_app/templates/stimulus_advanced/flash_message_demo.html
{% extends "stimulus_advanced/base.html" %}
{% block content %}
</div>
{% endblock %}
Update hotwire_django_app/templates/stimulus_advanced/navbar.html
List
</a>
<a href="{% url 'stimulus-advanced:task-create' %}" class="inline-block mt-0 text-teal-200�
,→hover:text-white mr-4">
Create
</a>
<a href="{% url 'stimulus-advanced:flash-message-demo' %}" class="inline-block mt-0 text-teal-
,→200 hover:text-white mr-4">
Notes:
1. We add Flash Message Demo link to the top navbar.
Update hotwire_django_app/templates/stimulus_advanced/messages.html
{% comment %}
MESSAGE_TAGS = {
messages.INFO: 'bg-sky-500',
messages.SUCCESS: 'bg-emerald-500',
messages.WARNING: 'bg-amber-500',
messages.ERROR: 'bg-red-500',
}
{% endcomment %}
<div class="w-full">
{% for message in messages %}
<span>×</span>
</button>
</div>
{% endfor %}
</div>
1. The comment block can make Tailwind JIT work without issue
2. data-controller="alert" would make alert controller connect to the DOM element.
3. data-action="alert#close" make the button click event fire close method of the alert controller.
The Alert HTML come from https://www.creative-tim.com/learning-lab/tailwind-starter-
kit/documentation/css/alerts 10 , but you can create it on your own
Update hotwire_django_app/templates/stimulus_advanced/base.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
{% include 'stimulus_advanced/messages.html' %}
{% include 'stimulus_advanced/navbar.html' %}
{% block content %}
{% endblock content %}
10 https://www.creative-tim.com/learning-lab/tailwind-starter-kit/documentation/css/alerts
</body>
</html>
24.6 URL
Update hotwire_django_app/stimulus_advanced/urls.py
app_name = 'stimulus-advanced'
urlpatterns = [
path('list/', list_view, name='task-list'),
path('create/', create_view, name='task-create'),
path('<int:pk>/', detail_view, name='task-detail'),
path('<int:pk>/update/', update_view, name='task-update'),
path('<int:pk>/delete/', delete_view, name='task-delete'),
24.7 Test
If we test on http://127.0.0.1:8000/stimulus-advanced/flash-message-demo/
24.8 Notes:
1. If we want to change the JS behavior, we can create a alert_controller which inherit from the
tailwindcss-stimulus-components Alert
2. The controller supports custom values, you can check the source code11 to learn more.
11 https://github.com/excid3/tailwindcss-stimulus-components/blob/v3.0.3/src/alert.js
25.1 Objective
25.2 View
Update hotwire_django_app/stimulus_advanced/urls.py
app_name = 'stimulus-advanced'
urlpatterns = [
path('list/', list_view, name='task-list'),
path('create/', create_view, name='task-create'),
path('<int:pk>/', detail_view, name='task-detail'),
path('<int:pk>/update/', update_view, name='task-update'),
path('<int:pk>/delete/', delete_view, name='task-delete'),
Notes:
1. Here we add modal-demo view which render stimulus_advanced/modal_demo.html template.
25.3 Template
Create hotwire_django_app/templates/stimulus_advanced/modal_demo.html
123
Definitive Guide to Hotwire and Django, Release 1.0.0
{% extends "stimulus_advanced/base.html" %}
{% block content %}
</div>
{% endblock %}
Notes:
1. We add a modal container div, the code come from https://github.com/excid3/
tailwindcss-stimulus-components
Update hotwire_django_app/templates/stimulus_advanced/navbar.html
List
</a>
<a href="{% url 'stimulus-advanced:task-create' %}" class="inline-block mt-0 text-teal-200�
,→hover:text-white mr-4">
Create
</a>
<a href="{% url 'stimulus-advanced:flash-message-demo' %}" class="inline-block mt-0 text-teal-
,→200 hover:text-white mr-4">
</div>
</nav>
25.4 Frontend
Update frontend/src/application/stimulus_advanced.js
import "@hotwired/turbo";
import { Application } from "@hotwired/stimulus";
import { definitionsFromContext } from "@hotwired/stimulus-webpack-helpers";
import { Alert, Modal } from "tailwindcss-stimulus-components";
window.Stimulus = Application.start();
const context = require.context("../controllers", true, /\.js$/);
window.Stimulus.load(definitionsFromContext(context));
window.Stimulus.register('alert', Alert);
window.Stimulus.register('modal', Modal); // new
If we check the modal-background element in the browser devtool, we can find some Tailwind css classes
style such as h-full is missing in the css file.
Because h-full come from tailwindcss-stimulus-components npm package and Tailwind does not
know it is been used, so it is not included in the final css file.
To fix the issue, we should tell Tailwind to scan the tailwindcss-stimulus-components components.
Update tailwind.config.js
Now if we restart npm run start command, the modal can work as expected.
25.6. Make Tailwind JIT work with 3-party npm package 127
Chapter 26
26.1 Objective
In the previous chapter, we have learned to use the Modal controller of the
tailwindcss-stimulus-components.
In this chapter, we will learn how to extend the modal component to make it load form.
Update frontend/src/application/stimulus_advanced.js
import "@hotwired/turbo";
import { Application } from "@hotwired/stimulus";
import { definitionsFromContext } from "@hotwired/stimulus-webpack-helpers";
import { Alert } from "tailwindcss-stimulus-components";
window.Stimulus = Application.start();
const context = require.context("../controllers", true, /\.js$/);
window.Stimulus.load(definitionsFromContext(context));
window.Stimulus.register('alert', Alert);
Notes:
1. We removed the code registering the modal, because we will create one under our controller
directory.
Create frontend/src/controllers/modal_controller.js, which inherit from the
tailwindcss-stimulus-components Modal
128
Definitive Guide to Hotwire and Django, Release 1.0.0
26.3 Template
Update hotwire_django_app/templates/stimulus_advanced/modal_demo.html
{% extends "stimulus_advanced/base.html" %}
{% block content %}
>
<!-- Modal Inner Container -->
<div class="max-h-screen w-full max-w-lg relative">
<!-- Modal Card -->
<div class="m-1 bg-white rounded shadow">
<div class="p-8">
</turbo-frame>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
Notes:
1. In the Modal body, we add a turbo-frame element, the src attribute is the URL of the task-create
page.
Update hotwire_django_app/templates/stimulus_advanced/create_page.html
{% extends "stimulus_advanced/base.html" %}
{% block content %}
{% if request.turbo.frame %}
<turbo-frame id="{{ request.turbo.frame }}">
{% include 'stimulus_advanced/form/create.html' %}
</turbo-frame>
{% else %}
{% include 'stimulus_advanced/form/create.html' %}
{% endif %}
</div>
{% endblock %}
Create hotwire_django_app/templates/stimulus_advanced/form/create.html
{% load crispy_forms_tags %}
<form
data-controller="form"
data-action="turbo:submit-start->form#submitStart turbo:submit-end->form#submitEnd"
method="post"
action="{% url 'stimulus-advanced:task-create' %}"
>
{% csrf_token %}
{{ form|crispy }}
Let’s add loading="lazy" to the <turbo-frame id="modal-content", so it will load the form content only
when we open the modal
def create_view(request):
if request.method == 'POST':
form = TaskForm(request.POST)
if form.is_valid():
instance = form.save()
request.turbo.frame
).template('stimulus_advanced/messages.html', {}).response(request)
return response
else:
return redirect(reverse('stimulus-advanced:task-detail', kwargs={'pk': instance.pk}
))
,→
status = http.HTTPStatus.UNPROCESSABLE_ENTITY
else:
status = http.HTTPStatus.OK
form = TaskForm()
Notes:
1. If the response has Turbo Frame header in the request, we return Turbo Frame element to display
the success message.
Test on http://127.0.0.1:8000/stimulus-advanced/modal-demo/
27.1 Objective
1. After we submit the form in modal, if we reopen the modal, we wish a reset form for us to use.
Update frontend/src/controllers/modal_controller.js
open(e) {
this.loadContent();
super.open(e);
}
close(e) {
if (this.hasModalContentTarget) {
const frame = this.modalContentTarget;
frame.innerHTML = '';
}
super.close(e);
}
loadContent() {
if (this.hasModalContentTarget && this.hasUrlValue) {
const frame = this.modalContentTarget;
133
Definitive Guide to Hotwire and Django, Release 1.0.0
// https://turbo.hotwired.dev/reference/frames#functions
frame.reload();
}
}
}
Notes:
1. The triple dot is Javascript Spread syntax, you can check https://developer.mozilla.org/en-
US/docs/Web/JavaScript/Reference/Operators/Spread_syntax to learn more.
2. Here we use Spread syntax to add some new targets and values to our controller
3. We add loadContent method to reload the modal content, which is called when the modal is open.
27.1.2 Template
Update hotwire_django_app/templates/stimulus_advanced/modal_demo.html
{% extends "stimulus_advanced/base.html" %}
{% block content %}
<div data-controller="modal"
data-modal-allow-background-close="true"
data-modal-url-value="{% url 'stimulus-advanced:task-create' %}"
> <!-- update -->
<a href="#" data-action="click->modal#open" class="btn-blue">
<span>Open Modal</span>
</a>
>
<!-- Modal Inner Container -->
<div class="max-h-screen w-full max-w-lg relative">
<!-- Modal Card -->
<div class="m-1 bg-white rounded shadow">
<div class="p-8">
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
Update frontend/src/controllers/modal_controller.js
closeOnSuccessSubmit(event) {
if (event.detail.success) {
setTimeout(() => {
this.close(event);
}, 2000);
}
}
}
Notes:
1. We add closeOnSuccessSubmit method, which will call after form submission.
2. If form submission is successful, we will close the modal after 2 second.
Let’s update hotwire_django_app/templates/stimulus_advanced/modal_demo.html to listen to the
turbo:submit-end event.
<div data-modal-target="container"
data-action="click->modal#closeBackground keyup@window->modal#closeWithKeyboard turbo:submit-
,→end->modal#closeOnSuccessSubmit"
>
...
</div>
In the above closeOnSuccessSubmit, we use setTimeout to make some code run after some delay.
Let’s rewrite the code with async/await pattern.
function sleep(ms) {
return new window.Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async closeOnSuccessSubmit(event) {
if (event.detail.success) {
await sleep(2000);
this.close(event);
}
}
}
Notes:
1. We convert closeOnSuccessSubmit to async method.
2. The modal will close after await sleep(2000).
3. You can check async function12 if you want to know more about async function
12 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function
28.1 Objective
28.2 Problem
28.2.1 View
Update hotwire_django_app/stimulus_advanced/views.py
def create_view(request):
if request.method == 'POST':
form = TaskForm(request.POST)
if form.is_valid():
instance = form.save()
else:
return redirect(reverse('stimulus-advanced:task-detail', kwargs={'pk': instance.pk}
,→))
status = http.HTTPStatus.UNPROCESSABLE_ENTITY
else:
status = http.HTTPStatus.OK
form = TaskForm()
Notes:
1. If form submission succeed, we return redirect response instead of Turbo Frame response.
28.2.2 Controller
Update frontend/src/controllers/modal_controller.js
137
Definitive Guide to Hotwire and Django, Release 1.0.0
async closeOnSuccessSubmit(event) {
if (event.detail.success) {
await sleep(2000);
// do nothing
}
}
Notes:
1. We remove the this.close(event); and do nothing here.
If we test on http://127.0.0.1:8000/stimulus-advanced/modal-demo/
We notice the redirect seems work ONLY in the turbo frame instead of the whole page. since
the detail page has no <turbo-frame id="modal-content">, we also get Response has no matching
<turbo-frame id="modal-content"> element error in the console.
28.3 Solution 1
Update frontend/src/controllers/modal_controller.js
async closeOnSuccessSubmit(event) {
if (event.detail.success) {
const fetchResponse = event.detail.fetchResponse;
if (fetchResponse.succeeded && fetchResponse.redirected) {
Turbo.visit(fetchResponse.location); // this work on whole page
} else {
this.close(event);
}
}
}
}
Notes:
1. In this solution, in the Django view, we return 301 redirect response.
2. Turbo will follow the 301 redirect response.
3. If fetchResponse.succeeded and fetchResponse.redirected are both true, we can use Turbo.
visit(fetchResponse.location) to do page visit.
4. However, this solution also has one drawback, the target page is visited twice (One by Turbo work-
flow, one by Turbo.visit). If we check carefully, we can not see the flash messages.
28.4 Solution 2
In this solution:
1. We do not return 301 redirect response in Django view, but a turbo frame which contains some
text.
2. We add a special header to the response, and use JS to extract the URL to do redirect.
28.4.1 View
Update hotwire_django_app/stimulus_advanced/views.py
def create_view(request):
if request.method == 'POST':
form = TaskForm(request.POST)
if form.is_valid():
instance = form.save()
status = http.HTTPStatus.UNPROCESSABLE_ENTITY
else:
status = http.HTTPStatus.OK
form = TaskForm()
Notes:
1. We add the url to the custom X-Redirect header of the response.
Update frontend/src/controllers/modal_controller.js
async closeOnSuccessSubmit(event) {
if (event.detail.success) {
await sleep(2000);
}
}
Notes:
1. We check if the response header has X-Redirect, if it has value, we use it to do full page redirect.
Now you should be able to see the flash message after creating task.
There are many discussions about this topic, and you can check https://github.com/hotwired/turbo/
issues/138 to know more.
29.1 Objective
29.2 Basics
29.2.1 CustomEvent
Please run code below in the console of the web devtool if you have no idea what is CustomEvent
140
Definitive Guide to Hotwire and Django, Release 1.0.0
29.2.2 Bubble
When an event happens on an element, it first runs the handlers on it, then on its parent, then
all the way up on other ancestors.
<form onclick="alert('form')">FORM
<div onclick="alert('div')">DIV
<p onclick="alert('p')">P</p>
</div>
</form>
29.3 Preparation
29.3.1 URL
Update hotwire_django_app/stimulus_advanced/urls.py
urlpatterns = [
...
path(
'event-demo/',
TemplateView.as_view(template_name='stimulus_advanced/event_demo.html'),
name='event-demo'
),
]
29.3.2 Template
Create hotwire_django_app/templates/stimulus_advanced/event_demo.html
14 https://javascript.info/bubbling-and-capturing
15 https://javascript.info/event-delegation
16 https://javascript.info/dispatch-events
{% extends "stimulus_advanced/base.html" %}
{% block content %}
</div>
{% endblock %}
List
</a>
<a href="{% url 'stimulus-advanced:task-create' %}" class="inline-block mt-0 text-teal-200�
,→hover:text-white mr-4">
Create
</a>
<a href="{% url 'stimulus-advanced:flash-message-demo' %}" class="inline-block mt-0 text-teal-
,→200 hover:text-white mr-4">
Modal Demo
</a>
<a href="{% url 'stimulus-advanced:event-demo' %}" class="inline-block mt-0 text-teal-200�
,→hover:text-white mr-4">
Event Demo
</a>
</div>
</nav>
29.4 Controller
Create frontend/src/controllers/child_controller.js
sendMsg() {
this.dispatch(
"sendMsg", { detail: { content: 'hello' }});
}
displayMsg(event) {
this.messageTarget.innerHTML += event.detail.content + '<br/>';
}
}
Notes:
1. sendMsg method is to dispatch the custom event sendMsg, the detail contains a simple message.
2. The displayMsg method is to handle the sendMsg event, it displays the event message in the
messageTarget
Create frontend/src/controllers/parent_controller.js
import {Controller} from '@hotwired/stimulus';
sendMsg() {
this.dispatch(
"sendMsg", { detail: { content: 'hello' }});
}
displayMsg(event) {
this.messageTarget.innerHTML += event.detail.content + '<br/>';
}
}
{% block content %}
<h4>Child 2</h4>
<span data-child-target="message"></span>
<button data-action="child#sendMsg" class="btn-blue">
Send Message
</button>
</div>
</div>
</div>
{% endblock %}
Notes:
1. We have one parent div, which contains two child div
2. If we click button in child div, it will call child#sendMsg, which sends child:sendMsg event.
3. The event will bubble from child div, up to parent div. (this is the default behavior)
4. On the child div, we use child:sendMsg->child#displayMsg to listen to child:sendMsg event, if it
receives the event, child#displayMsg will display the message.
5. On the parent div, we use child:sendMsg->parent#displayMsg to listen to child:sendMsg event, if
it receives the event, parent#displayMsg will display the message.
We can click button on http://127.0.0.1:8000/stimulus-advanced/event-demo/
1. If we click button in the child div, it will display a new hello message.
2. The parent div will also display a new hello message.
3. The other child div does NOT display the new message.
1. We can tell Child controller to listen events on the global document object
2. When the event bubble from Parent up to the document, the child controller’s method can process
it.
Update hotwire_django_app/templates/stimulus_advanced/event_demo.html
{% extends "stimulus_advanced/base.html" %}
{% block content %}
<h4>Parent</h4>
<span data-parent-target="message"></span>
<button data-action="parent#sendMsg" class="btn-blue">
Send Message
</button>
</div>
</div>
{% endblock %}
Notes:
1. ON the child div, we use parent:sendMsg@document->child#displayMsg to listen to parent:sendMsg
on the document object.
2. When event bubble up to document, child#displayMsg method will run.
On http://127.0.0.1:8000/stimulus-advanced/event-demo/
If we click button in the parent div, both child divs will display the hello message.
30.1 Objective
class TurboStreamConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'hotwire_django_app.turbo_stream' # update
INSTALLED_APPS = [
...
'hotwire_django_app.turbo_stream', # new
]
30.3 View
Create hotwire_django_app/turbo_stream/views.py
148
Definitive Guide to Hotwire and Django, Release 1.0.0
import http
def list_view(request):
object_list = Task.objects.all().order_by('-pk')
context = {
"object_list": object_list,
}
def create_view(request):
if request.method == 'POST':
form = TaskForm(request.POST)
if form.is_valid():
instance = form.save()
status = http.HTTPStatus.UNPROCESSABLE_ENTITY
else:
status = http.HTTPStatus.OK
form = TaskForm()
status = http.HTTPStatus.UNPROCESSABLE_ENTITY
else:
status = http.HTTPStatus.OK
form = TaskForm(instance=instance)
Notes:
1. Here we create 4 FBV, which are for CRUD work.
30.4 Template
30.4.1 base.html
create hotwire_django_app/templates/turbo_stream/base.html
{% load webpack_loader static %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
{% include 'turbo_stream/messages.html' %}
{% include 'turbo_stream/navbar.html' %}
{% block content %}
{% endblock content %}
</body>
</html>
Notes:
1. Please note in this Django app, we will use turbo_stream entry file. We will create it in a bit.
Create hotwire_django_app/templates/turbo_stream/messages.html
{% comment %}
MESSAGE_TAGS = {
messages.INFO: 'bg-sky-500',
messages.SUCCESS: 'bg-emerald-500',
messages.WARNING: 'bg-amber-500',
messages.ERROR: 'bg-red-500',
}
{% endcomment %}
<div class="w-full">
{% for message in messages %}
<span>×</span>
</button>
</div>
{% endfor %}
</div>
Create hotwire_django_app/templates/turbo_stream/navbar.html
List
</a>
<a href="{% url 'turbo-stream:task-create' %}" class="inline-block mt-0 text-teal-200�
,→hover:text-white mr-4">
Create
</a>
</div>
</nav>
30.4.2 create.html
Create hotwire_django_app/templates/turbo_stream/create_page.html
{% extends "turbo_stream/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<form method="post">
{% csrf_token %}
{{ form|crispy }}
</div>
{% endblock %}
30.4.3 update_page.html
create hotwire_django_app/templates/turbo_stream/update.html
{% extends "turbo_stream/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="w-full max-w-7xl mx-auto px-4">
<form method="post">
{% csrf_token %}
{{ form|crispy }}
</form>
</div>
{% endblock %}
30.4.4 delete_page.html
Create hotwire_django_app/templates/turbo_stream/delete_page.html
{% extends "turbo_stream/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="w-full max-w-7xl mx-auto px-4">
<h1 class="text-4xl sm:text-6xl lg:text-7xl mb-6">Delete Task</h1>
<form method="post">
{% csrf_token %}
{{ form|crispy }}
</form>
</div>
{% endblock %}
30.4.5 list_page.html
Create hotwire_django_app/templates/turbo_stream/list_page.html
{% extends "turbo_stream/base.html" %}
{% block content %}
</div>
{% endblock %}
30.4.6 detail_page.html
Create hotwire_django_app/templates/turbo_stream/detail_page.html
{% extends "turbo_stream/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="w-full max-w-7xl mx-auto px-4">
<h1 class="text-4xl sm:text-6xl lg:text-7xl mb-6">Task Detail</h1>
<div>
<a href="{% url 'turbo-stream:task-list'%}" class="btn-blue">Go to list</a>
</div>
</div>
{% endblock %}
30.4.7 task_detail.html
Create hotwire_django_app/templates/turbo_stream/task_detail.html
30.5 Frontend
Create frontend/src/styles/turbo_stream.scss
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
.btn-blue {
@apply inline-flex items-center;
@apply px-4 py-2;
@apply font-semibold rounded-lg shadow-md;
@apply text-white bg-blue-500;
@apply hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-opacity-
,→75;
@apply disabled:opacity-50;
}
.btn-red {
@apply inline-flex items-center;
@apply px-4 py-2;
@apply font-semibold rounded-lg shadow-md;
@apply text-white bg-red-500;
@apply hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-opacity-75;
@apply disabled:opacity-50;
}
form[data-controller="form"] {
button[data-form-target="submit"] {
svg {
@apply hidden;
}
}
&[data-submitting] {
button[data-form-target="submit"] {
@apply cursor-not-allowed;
svg {
@apply inline-block;
}
}
}
}
Create frontend/src/application/turbo_stream.js
import "@hotwired/turbo";
import { Application } from "@hotwired/stimulus";
import { definitionsFromContext } from "@hotwired/stimulus-webpack-helpers";
import { Alert } from "tailwindcss-stimulus-components";
window.Stimulus = Application.start();
const context = require.context("../controllers", true, /\.js$/);
window.Stimulus.load(definitionsFromContext(context));
window.Stimulus.register('alert', Alert);
Notes:
30.6 URL
Create hotwire_django_app/turbo_stream/urls.py
app_name = 'turbo-stream'
urlpatterns = [
path('list/', list_view, name='task-list'),
path('create/', create_view, name='task-create'),
path('<int:pk>/', detail_view, name='task-detail'),
path('<int:pk>/update/', update_view, name='task-update'),
path('<int:pk>/delete/', delete_view, name='task-delete'),
]
Notes:
1. Here we use app_name to set the namespace of the Django app.
Update hotwire_django_app/urls.py
urlpatterns = [
...
path('turbo-stream/', include('hotwire_django_app.turbo_stream.urls')), # new
]
31.1 Objective
31.2.1 URL
Update hotwire_django_app/turbo_stream/urls.py
app_name = 'turbo-stream'
urlpatterns = [
path(
'',
TemplateView.as_view(template_name='turbo_stream/index.html'), # new
name='index'
),
path('list/', list_view, name='task-list'),
path('create/', create_view, name='task-create'),
path('<int:pk>/update/', update_view, name='task-update'),
path('<int:pk>/delete/', delete_view, name='task-delete'),
]
Create hotwire_django_app/templates/turbo_stream/index.html
{% extends "turbo_stream/base.html" %}
{% block content %}
</div>
{% endblock %}
156
Definitive Guide to Hotwire and Django, Release 1.0.0
List
</a>
<a href="{% url 'turbo-stream:task-create' %}" class="inline-block mt-0 text-teal-200�
,→hover:text-white mr-4">
Create
</a>
<a href="{% url 'turbo-stream:index' %}" class="inline-block mt-0 text-teal-200 hover:text-
,→white mr-4">
31.2.2 Test
Update hotwire_django_app/templates/turbo_stream/index.html
{% extends "turbo_stream/base.html" %}
{% block content %}
<div class="mb-4">
<turbo-frame id="task-create" src="{% url 'turbo-stream:task-create' %}">
Loading...
</turbo-frame>
</div>
</div>
{% endblock %}
Notes:
1. We add <turbo-frame id="task-create" to load the form from the turbo-stream:task-create
Update hotwire_django_app/templates/turbo_stream/create_page.html
{% extends "turbo_stream/base.html" %}
{% block content %}
{% if request.turbo.frame %}
<turbo-frame id="{{ request.turbo.frame }}">
{% include 'turbo_stream/form/create.html' %}
</turbo-frame>
{% else %}
{% include 'turbo_stream/form/create.html' %}
{% endif %}
</div>
{% endblock %}
Create hotwire_django_app/templates/turbo_stream/form/create.html
{% load crispy_forms_tags %}
<form
data-controller="form"
data-action="turbo:submit-start->form#submitStart turbo:submit-end->form#submitEnd"
method="post"
action="{% url 'turbo-stream:task-create' %}"
>
{% csrf_token %}
{{ form|crispy }}
Update hotwire_django_app/turbo_stream/views.py
from turbo_response import TurboFrame
def create_view(request):
if request.method == 'POST':
form = TaskForm(request.POST)
if form.is_valid():
instance = form.save()
status = http.HTTPStatus.UNPROCESSABLE_ENTITY
else:
status = http.HTTPStatus.OK
form = TaskForm()
Notes:
1. If the request come from Turbo Frame, we return Turbo Frame to display the success message.
If we submit in the form, we should be able to see:
<turbo-frame id="task-create">
<div class="w-full">
...
</div>
</turbo-frame>
31.5 Question
Actually, we have done similar work in the previous chapter, and I have a question here:
What if we want to keep creating new task?
32.1 Objective
Turbo Streams deliver page changes as fragments of HTML wrapped in self-executing ele-
ments. Each stream element specifies an action together with a target ID to declare what
should happen to the HTML inside it.
With Turbo Stream, we can use HTML snippet from web server to append, update, or remove the target
DOM elements.
Please read Stream Messages and Actions17 first, and then check the next sections.
Update hotwire_django_app/turbo_stream/views.py
def create_view(request):
if request.method == 'POST':
form = TaskForm(request.POST)
if form.is_valid():
instance = form.save()
17 https://turbo.hotwired.dev/handbook/streams#stream-messages-and-actions
161
Definitive Guide to Hotwire and Django, Release 1.0.0
"request": request,
},
).response(request).rendered_content,
]) # new
else:
return redirect(reverse('turbo-stream:task-detail', kwargs={'pk': instance.pk}))
status = http.HTTPStatus.UNPROCESSABLE_ENTITY
else:
status = http.HTTPStatus.OK
form = TaskForm()
Notes:
1. We return a TurboStreamResponse instance, which wrap TurboStream("task-create")
2. The update is the action, which means the HTML returned from the server, will update innerHTML
of the id=task-create element.
If we check on http://127.0.0.1:8000/turbo-stream/, the HTML from the server will look like
<turbo-stream action="update" target="task-create">
<template>
<form>
...
</form>
</template>
</turbo-stream>
Now the form will reset after successful form submission, next, let’s work on the flash message part.
32.4 Message
Create hotwire_django_app/templates/turbo_stream/messages_inner.html
{% for message in messages %}
<span>×</span>
</button>
</div>
{% endfor %}
Notes:
1. data-alert-dismiss-after-value="1000" means the alert will dismiss after 1 second.
Update hotwire_django_app/templates/turbo_stream/messages.html
{% comment %}
MESSAGE_TAGS = {
messages.INFO: 'bg-sky-500',
messages.SUCCESS: 'bg-emerald-500',
messages.WARNING: 'bg-amber-500',
messages.ERROR: 'bg-red-500',
}
{% endcomment %}
Notes:
1. We refactored the message templates, all the messages will be put in <div id="messages">
2. The messages_inner.html can be reused by both turbo_stream/messages.html and the Django
view.
Update hotwire_django_app/turbo_stream/views.py
def create_view(request):
if request.method == 'POST':
form = TaskForm(request.POST)
if form.is_valid():
instance = form.save()
TurboStream("task-create").update.template(
"turbo_stream/form/create.html",
{
"form": TaskForm(),
"request": request,
},
).response(request).rendered_content,
])
else:
return redirect(reverse('turbo-stream:task-detail', kwargs={'pk': instance.pk}))
status = http.HTTPStatus.UNPROCESSABLE_ENTITY
else:
status = http.HTTPStatus.OK
form = TaskForm()
Notes:
1. We add TurboStream("messages").append to the TurboStreamResponse, so we can update multiple
parts of the page in one TurboStreamResponse.
If we submit form on http://127.0.0.1:8000/turbo-stream/, the HTML from the server will look like
<template>
<form>
...
</form>
</template>
</turbo-stream>
32.5 text/vnd.turbo-stream.html
If we check the requests in the network tab, we can see the POST request sent by Turbo has custom
http header:
When submitting a element whose method attribute is set to POST, PUT, PATCH, or DELETE,
Turbo injects text/vnd.turbo-stream.html into the set of response formats in the request’s
Accept header
text/vnd.turbo-stream.html means the client can accept the Turbo Stream response.
Please note the GET request has no text/vnd.turbo-stream.html, which means by default we should
not return TurboStreamResponse if request.method == 'GET'
32.6 Conclusion
With Turbo Stream, we can reuse Django template to partially update our page without touching JS code
or JSON, we can use it to build many powerful UI interactions with less code.
33.1 Objective
33.2.1 Template
Update hotwire_django_app/templates/turbo_stream/list_page.html
{% extends "turbo_stream/base.html" %}
{% block content %}
</li>
{% endfor %}
</ul>
</div>
</div>
{% endblock %}
Notes:
1. We add id="task-detail-li-{{ instance.pk }}" to the li element, so we can update the li ele-
ment using Turbo Stream in a bit.
166
Definitive Guide to Hotwire and Django, Release 1.0.0
33.3.1 Template
Update hotwire_django_app/templates/turbo_stream/task_detail.html
{% if use_turbo_frame %}
<turbo-frame id="task-detail-{{ instance.pk }}" class="flex-1">
</turbo-frame>
{% else %}
{% endif %}
Notes:
1. If use_turbo_frame is True, we render turbo-frame tag,
2. You can put the code to a new template to reuse it.
33.4.1 View
Update hotwire_django_app/turbo_stream/views.py
def update_view(request, pk):
instance = get_object_or_404(Task, pk=pk)
if request.method == 'POST':
form = TaskForm(request.POST, instance=instance)
if form.is_valid():
form.save()
return TurboStreamResponse([
TurboStream("messages").append.template(
"turbo_stream/messages_inner.html",
).response(request).rendered_content,
TurboStream(f"task-detail-{instance.pk}").update.template(
"turbo_stream/task_detail.html",
{
"instance": instance,
},
).response(request).rendered_content,
])
else:
# if request come from standard page
return redirect(reverse('turbo-stream:task-detail', kwargs={'pk': instance.pk}))
status = http.HTTPStatus.UNPROCESSABLE_ENTITY
else:
status = http.HTTPStatus.OK
form = TaskForm(instance=instance)
Notes:
1. In the TurboStreamResponse, we append success message HTML and update the
task-detail-{instance.pk} element with new task detail.
33.4.2 Template
Create hotwire_django_app/templates/turbo_stream/form/update.html
{% load crispy_forms_tags %}
{% csrf_token %}
{{ form|crispy }}
{% if request.turbo.frame %}
<a href="{% url 'turbo-stream:task-detail' form.instance.pk %}" class="btn-red">Cancel</a>
{% else %}
<a href="{% url 'turbo-stream:task-list' %}" class="btn-red">Cancel</a>
{% endif %}
</form>
Update hotwire_django_app/templates/turbo_stream/update_page.html
{% extends "turbo_stream/base.html" %}
{% block content %}
<div class="w-full max-w-7xl mx-auto px-4">
{% if request.turbo.frame %}
<turbo-frame id="{{ request.turbo.frame }}">
{% include 'turbo_stream/form/update.html' %}
</turbo-frame>
{% else %}
{% include 'turbo_stream/form/update.html' %}
{% endif %}
</div>
{% endblock %}
33.5.1 View
Update hotwire_django_app/turbo_stream/views.py
def delete_view(request, pk):
instance = get_object_or_404(Task, pk=pk)
if request.method == 'POST':
instance.delete()
TurboStream(f"task-detail-li-{pk}").remove.render()
])
else:
# if request come from standard page
return redirect('turbo-stream:task-list')
Notes:
1. In the TurboStreamResponse, we append success message HTML and remove the
task-detail-li-{pk} element from the ul.
2. Please note we can not remove the li element while using Turbo Frame in this case, but Turbo
Stream can do!
33.5.2 Template
Create hotwire_django_app/templates/turbo_stream/form/delete.html
{% load crispy_forms_tags %}
{{ form|crispy }}
{% if request.turbo.frame %}
<a href="{% url 'turbo-stream:task-detail' instance.pk %}" class="btn-red">Cancel</a>
{% else %}
<a href="{% url 'turbo-stream:task-list' %}" class="btn-red">Cancel</a>
{% endif %}
</form>
Update hotwire_django_app/templates/turbo_stream/delete_page.html
{% extends "turbo_stream/base.html" %}
{% block content %}
<div class="w-full max-w-7xl mx-auto px-4">
<h1 class="text-4xl sm:text-6xl lg:text-7xl mb-6">Delete Task</h1>
{% if request.turbo.frame %}
<turbo-frame id="{{ request.turbo.frame }}">
{% include 'turbo_stream/form/delete.html' %}
</turbo-frame>
{% else %}
{% include 'turbo_stream/form/delete.html' %}
{% endif %}
</div>
{% endblock %}
34.1 Objective
34.2 Filter
34.2.1 django-filter
Django-filter is a generic, reusable application to alleviate writing some of the more mundane
bits of view code
Add django-filter==21.1 to the requirements.txt
django-filter==21.1
34.2.2 TaskFilter
Create hotwire_django_app/tasks/filters.py
import django_filters
class TaskFilter(django_filters.FilterSet):
title = django_filters.CharFilter(lookup_expr="icontains")
class Meta:
model = Task
fields = ["title"]
171
Definitive Guide to Hotwire and Django, Release 1.0.0
34.2.3 View
Update hotwire_django_app/turbo_stream/views.py
def list_view(request):
task_filter = TaskFilter(request.GET, queryset=Task.objects.all())
object_list = task_filter.qs.order_by('-pk')
context = {
"object_list": object_list,
}
Notes:
1. In the list_view, we use TaskFilter to read filter parameters from request.GET
2. The TaskFilter will help us filter the data automatically.
Let’s test with the URL below
1. http://127.0.0.1:8000/turbo-stream/list/?title=test
2. http://127.0.0.1:8000/turbo-stream/list/?title=hello
We should see the filter function is working on the list page.
34.3 Pagination
34.3.1 url_replace
Create hotwire_django_app/tasks/utils.py
This function can help us update or add querystring in the URL. You can also create a custom template
tag based on it.
34.3.2 View
Update hotwire_django_app/turbo_stream/views.py
import http
def list_view(request):
task_filter = TaskFilter(request.GET, queryset=Task.objects.all())
object_list = task_filter.qs.order_by('-pk')
# pagination
paginator = Paginator(object_list, 10)
page = request.GET.get("page")
try:
page_obj = paginator.page(page)
except PageNotAnInteger:
page_obj = paginator.page(1)
except EmptyPage:
page_obj = paginator.object_list.none()
next_url = None
if page_obj and page_obj.has_next():
next_page = page_obj.next_page_number()
next_url = url_replace(request, page=next_page)
pre_url = None
if page_obj and page_obj.has_previous():
pre_page = page_obj.previous_page_number()
pre_url = url_replace(request, page=pre_page)
context = {
"object_list": page_obj.object_list if page_obj else [],
"pre_url": pre_url,
"next_url": next_url,
}
Notes:
1. After filtering the queryset based on URL, then we paginate the queryset.
34.3.3 Template
Create hotwire_django_app/templates/turbo_stream/list_pagination.html
<li>
{% if pre_url %}
<a href="{{ pre_url }}" class="btn-blue mr-3">Previous</a>
{% else %}
<button class="btn-blue mr-3 cursor-not-allowed" disabled>Previous</button>
{% endif %}
</li>
<li>
{% if next_url %}
<a href="{{ next_url }}" class="btn-blue mr-3">Next</a>
{% else %}
<button class="btn-blue mr-3 cursor-not-allowed" disabled>Next</button>
{% endif %}
</li>
</ul>
</nav>
Update hotwire_django_app/templates/turbo_stream/list_page.html
{% extends "turbo_stream/base.html" %}
{% block content %}
</div>
{% endblock %}
Create hotwire_django_app/templates/turbo_stream/task_list_with_pagination.html
<div class="md:w-2/3 bg-white rounded-lg border mb-4">
<ul class="divide-y-2 divide-gray-100" id="task-list-ul">
{% for instance in object_list %}
<li class="p-3 flex items-center" id="task-detail-li-{{ instance.pk }}">
{% include 'turbo_stream/task_detail.html' with instance=instance use_turbo_frame=True only %}
</li>
{% endfor %}
</ul>
</div>
{% include 'turbo_stream/list_pagination.html' %}
Update hotwire_django_app/templates/turbo_stream/list.html
{% extends "turbo_stream/base.html" %}
{% block content %}
<div id="task-list-with-pagination">
{% include 'turbo_stream/task_list_with_pagination.html' %} <!-- update -->
</div>
</turbo-frame>
</div>
{% endblock %}
1. We use <turbo-frame id="task-list"> to wrap the task list and pagination component.
Update hotwire_django_app/templates/turbo_stream/index.html
{% extends "turbo_stream/base.html" %}
{% block content %}
<div class="mb-4">
<turbo-frame id="task-create" src="{% url 'turbo-stream:task-create' %}">
Loading...
</turbo-frame>
</div>
<turbo-frame id="task-list" src="{% url 'turbo-stream:task-list' %}"> <! --- new --->
Loading...
</turbo-frame>
</div>
{% endblock %}
Notes:
1. Try to edit, delete task on the http://127.0.0.1:8000/turbo-stream/
2. Try to click pagination link.
Considering there might be multiple Turbo Frames on one page, so Turbo frame loading would not dis-
play a progress bar on the top, but we can find another way to improve the experience.
From the https://turbo.hotwired.dev/reference/frames#html-attributes
busy is a boolean attribute toggled to be present when a initiated request starts, and toggled
false when the request ends
Let’s update frontend/src/styles/turbo_stream.scss
turbo-frame {
display: block;
transition: opacity 500ms;
}
turbo-frame[busy] {
opacity: 0.5;
}
Notes:
1. By default, turbo-frame is a inline element, here we set it to block
2. If busy attribute is set on the element, we change the opacity to 0.8
3. transition: opacity 500ms; is to prevent flicker on fast network.
In the dev tool, we can change the network to Slow 3G to check the effect.
And this solution can also inspire people to implement more complex solution based on loading SVG.
Auto Search
35.1 Objective
35.2 Controller
Create frontend/src/helpers/index.js
return () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(fn, delay);
};
};
connect() {
if (this.hasInputTarget) {
this.inputTarget.addEventListener("input", this.onInputChange);
}
}
disconnect() {
if (this.hasInputTarget) {
this.inputTarget.removeEventListener("input", this.onInputChange);
}
}
177
Definitive Guide to Hotwire and Django, Release 1.0.0
loadResults() {
this.element.requestSubmit();
}
}
Notes:
1. In the connect method, we use addEventListener to attach event handler to the inputTarget
2. Do not forget to removeEventListener in disconnect method.
3. To submit form in Turbo, please use this.element.requestSubmit instead of form.submit(), more
details can be found https://github.com/hotwired/turbo/issues/97#issuecomment-757224391
35.3 Template
Create hotwire_django_app/templates/turbo_stream/list_search_form.html
Update hotwire_django_app/templates/turbo_stream/list_page.html
{% extends "turbo_stream/base.html" %}
{% block content %}
<turbo-frame id="task-list">
<div id="task-list-with-pagination">
{% include 'turbo_stream/task_list_with_pagination.html' %}
</div>
</turbo-frame>
</div>
{% endblock %}
Notes:
1. We add a search form above the task list.
2. The form has title input, and method="get", if we submit the form, the form data will be added to
the URL as querystring.
Now visit http://127.0.0.1:8000/turbo-stream/list/ and type random text to the form input, we can see
the task list is filtered, that is great!
35.4 Problem
We also notice a problem, we will lose focus on the title input after Turbo Frame render the content.
So we can NOT keep typing in the search box.
The solution is:
We use Turbo Stream to ONLY UPDATE the task list and the pagination component, excluding the search
form
As I said in the previous chapter, Turbo will not add text/vnd.turbo-stream.html to the GET request, let’s
fix it.
Update frontend/src/controllers/autocomplete_controller.js
fetchRequest(event) {
// https://turbo.hotwired.dev/handbook/drive#pausing-requests
event.preventDefault();
event.detail.fetchOptions.headers['Accept'] = 'text/vnd.turbo-stream.html';
event.detail.resume();
}
Notes:
1. In the Django view, we can check the Accept header to know if the request come from Stimulus
controller or normal browser.
Update hotwire_django_app/templates/turbo_stream/list_search_form.html
Notes:
1. We add data-action="turbo:before-fetch-request->autocomplete#fetchRequest" to the form.
2. Before Turbo send request, autocomplete#fetchRequest will run and modify the http header, and
then, it will call event.detail.resume() so the request will be sent out.
35.6 View
Update hotwire_django_app/turbo_stream/views.py
def list_view(request):
task_filter = TaskFilter(request.GET, queryset=Task.objects.all())
object_list = task_filter.qs.order_by('-pk')
# pagination
paginator = Paginator(object_list, 10)
page = request.GET.get("page")
try:
page_obj = paginator.page(page)
except PageNotAnInteger:
page_obj = paginator.page(1)
except EmptyPage:
page_obj = paginator.object_list.none()
next_url = None
if page_obj and page_obj.has_next():
next_page = page_obj.next_page_number()
next_url = url_replace(request, page=next_page)
pre_url = None
if page_obj and page_obj.has_previous():
pre_page = page_obj.previous_page_number()
pre_url = url_replace(request, page=pre_page)
context = {
"object_list": page_obj.object_list if page_obj else [],
"pre_url": pre_url,
"next_url": next_url,
}
Notes:
1. If request.turbo.has_turbo_header, we return Turbo Stream response to update the
task-list-with-pagination
2. The search box will not be updated, so we can keep typing in the search box.
Let’s test again on the http://127.0.0.1:8000/turbo-stream/, the issue should be fixed now.
36.1 Websocket
36.2 Redis
Start by installing Docker19 if you haven’t already done so. Then, open your terminal and run the following
command:
This downloads the official Redis Docker image from Docker Hub and runs it on port 6379 in the back-
ground.
To test if Redis is up and running, run:
PONG
18 https://caniuse.com/websockets
19 https://docs.docker.com/get-docker/
181
Definitive Guide to Hotwire and Django, Release 1.0.0
Either download Redis from source20 or via a package manager (like APT, YUM, Homebrew, or Choco-
latey) and then start the Redis server via:
$ redis-server
$ redis-cli ping
PONG
36.3 django-channels
Update requirements.txt
channels==3.0.4 # new
channels-redis==3.3.1 # new
# WSGI_APPLICATION = 'hotwire_django_app.wsgi.application'
ASGI_APPLICATION = 'hotwire_django_app.asgi.application'
Update hotwire_django_app/settings.py
import os
INSTALLED_APPS = [
...
'channels', # new
]
CHANNEL_LAYERS = { # new
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
"hosts": [(os.environ.get("REDIS_URL", "redis://127.0.0.1:6379/0"))],
},
},
}
36.4 Routing
import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'hotwire_django_app.settings')
application = ProtocolTypeRouter({
"http": get_asgi_application(),
'websocket': URLRouter(
routing.urlpatterns
)
})
Notes:
1. hotwire_django_app/asgi.py is the entry point into the app.
2. By default, we do not need to config the HTTP router
3. We added the websocket router along with routing.urlpatterns to point to the
hotwire_django_app.tasks app.
Create hotwire_django_app/tasks/routing.py
urlpatterns = [
path('ws/turbo_stream/<group_name>/', consumers.HTMLConsumer.as_asgi()),
]
class HTMLConsumer(AsyncWebsocketConsumer):
async def connect(self):
group_name = self.scope['url_route']['kwargs']['group_name']
self.group_name = f'turbo_stream.{group_name}'
await self.channel_layer.group_add(
self.group_name,
self.channel_name
)
await self.accept()
Notes:
1. In HTMLConsumer.connect, we get the group_name from self.scope, and then add the current chan-
nel to the self.group_name group.
2. In HTMLConsumer.html_message, if the consumer receives an event which has a type of html
message, it will send the html back to client.
3. For example, we can send the Turbo Stream HTML to the consumer, and then the consumer will
send the HTML to the browser to update the web page in async way.
Update frontend/src/controllers/websocket_controller.js
import {Controller} from '@hotwired/stimulus';
import { connectStreamSource, disconnectStreamSource } from '@hotwired/turbo';
connect() {
const ws_url = this.socketUrlValue;
this.source = new WebSocket((location.protocol === 'https:' ? 'wss' : 'ws') + '://' + window.
,→location.host + ws_url);
connectStreamSource(this.source);
}
disconnect() {
if (this.source) {
disconnectStreamSource(this.source);
this.source.close();
this.source = null;
}
}
}
Notes:
1. In the connect method, we create a WebSocket connection based on the socketUrlValue, and use
connectStreamSource to connect Turbo with the websocket.
2. If the browser receive HTML (for example, Turbo Stream) via the websocket, Turbo will process it
to update the page.
3. In the disconnect method, do not forget to run disconnectStreamSource and close the websocket
to do cleanup work.
Update hotwire_django_app/templates/turbo_stream/index.html
{% extends "turbo_stream/base.html" %}
{% block content %}
<div data-controller="websocket"
data-websocket-socket-url-value="/ws/turbo_stream/task/"
></div> <!-- new -->
<div class="mb-4">
<turbo-frame id="task-create" src="{% url 'turbo-stream:task-create' %}">
Loading...
</turbo-frame>
</div>
</div>
{% endblock %}
Notes:
1. We attach websocket controller to a div, and we set the websocket url value to the DOM attribute.
36.6 Test
channel_layer = get_channel_layer()
async_to_sync(channel_layer.group_send)(
'turbo_stream.tasks',
{'type': 'html_message', 'html': html}
)
Notes:
1. We send the Turbo Stream HTML to the turbo_stream.tasks group via the Redis.
2. After consumer receive it, html_message will send the html to the web browser.
3. The Turbo will process the HTML via the websocket and update the page in realtime.
4. We can see the task list and pagination component are already removed from the page.
Create hotwire_django_app/tasks/receivers.py
@receiver(post_save, sender=Task)
def update_task_detail(sender, instance, created, **kwargs):
html = TurboStream(f"task-detail-{instance.pk}").update.template(
"turbo_stream/task_detail.html",
{
"instance": instance,
},
).render()
channel_layer = get_channel_layer()
group_name = 'turbo_stream.tasks'
async_to_sync(channel_layer.group_send)(
group_name,
{'type': 'html_message', 'html': html}
)
Notes:
1. Here we defined a Django signal receiver, after the task instance is saved, it will send Turbo Stream
HTML to the channel group.
2. The channel consumer will then send the HTMl to the browser to update the page.
Update hotwire_django_app/tasks/apps.py
class TasksConfig(AppConfig):
188
Definitive Guide to Hotwire and Django, Release 1.0.0
default_auto_field = 'django.db.models.BigAutoField'
name = 'hotwire_django_app.tasks'
def ready(self):
from hotwire_django_app.tasks import receivers # noqa
Please note the above signal receiver will not work until we import it in the ready method.
You can check https://docs.djangoproject.com/en/4.0/topics/signals/ to learn more.
37.2 Test
37.3 ReconnectingWebSocket
Update frontend/src/controllers/websocket_controller.js
connect() {
const ws_url = this.socketUrlValue;
this.source = new ReconnectingWebSocket((location.protocol === 'https:' ? 'wss' : 'ws') + '://'�
,→+ window.location.host + ws_url); // update
connectStreamSource(this.source);
}
disconnect() {
if (this.source) {
disconnectStreamSource(this.source);
this.source.close();
this.source = null;
}
}
}
37.4 Notes:
1. Turbo Stream + Websocket is powerful, we can implement many features based on them
2. If you already understand how it works, you can also check https://github.com/hotwire-django/
turbo-django, which do the similar work.
Next Steps
Congratulations on making it through. I hope you have learned a lot in this book.
Below are some great learning resources I wish you can check to better learn the techs in this book.
38.1.1 Stimulus
1. better-stimulus21
2. tailwindcss-stimulus-components22
3. Stimulus components23
4. stimulus-use24
And this tutorial series is worth to take a look (from perspective of PHP developer) https://symfonycasts.
com/screencast/stimulus
You can find other good Stimulus Resources at https://github.com/skatkov/awesome-stimulusjs
38.1.2 Turbo
As we know, Turbo project derive from turbolinks, so you can check https://github.com/turbolinks/
turbolinks if you are new to Turbo.
This tutorial series is worth to take a look (from perspective of PHP developer) https://symfonycasts.
com/screencast/turbo/intro
After publishing this book, I will spend more time on a Django SaaS template.
SaaS Hammer25
21 https://github.com/julianrubisch/better-stimulus
22 https://github.com/excid3/tailwindcss-stimulus-components
23 https://www.stimulus-components.com/
24 https://github.com/stimulus-use/stimulus-use
25 https://saashammer.com/
191
Definitive Guide to Hotwire and Django, Release 1.0.0