# Render a WTForms form with Jinja and HTML
TIP
List of all code changes made in this lecture: https://diff-store.com/diff/section14__07_render_wtform_with_jinja_macros (opens new window)
# Create a new Flask route for adding movies
We want to allow users to create new movies using our form, so the first thing to do is create a Flask route that will display our form, so our users can access it.
In routes.py
, let's import MovieForm
:
from movie_library.forms import MovieForm
And create our route:
@pages.route("/add", methods=["GET", "POST"])
def add_movie():
form = MovieForm()
if request.method == "POST":
pass
return render_template(
"new_movie.html", title="Movies Watchlist - Add Movie", form=form
)
Note that I've made it be able to receive GET
and POST
requests. The template will be rendered in response to a GET
request, and the form data will be received and validated in response to a POST
request.
For now, the POST
request does nothing. Let's render our form first, and handle receiving the data in the next lecture.
# How to render a WTForms field
First things first, let's create our templates/new_movie.html
file. This is where our form will live:
{% extends "layout.html" %}
{% block main_content %}
<form name="add_movie" method="post" novalidate class="form">
<div class="form__container">
</div>
</form>
{% endblock %}
Here I've added a form
element, which contains div
. The inner div
will be used purely for styling.
Let's render our first field:
{% extends "layout.html" %}
{% block main_content %}
<form name="add_movie" method="post" novalidate class="form">
<div class="form__container">
<div class="form__group">
{{ field.label(class_="form__label") }}
{{ field(class_="form__field")}}
{%- for error in field.errors %}
<span class="form__error">{{ error }}</span>
{% endfor %}
</div>
</div>
</form>
{% endblock %}
This is what we've added:
<div class="form__group">
{{ field.label(class_="form__label") }}
{{ field(class_="form__field")}}
{%- for error in field.errors %}
<span class="form__error">{{ error }}</span>
{% endfor %}
</div>
A field consists of three parts:
- The label, whose HTML is generated by WTForms when we do
{{ field.label() }}
- The field input itself, generated by
{{ field() }}
- The errors, which are stored in
field.errors
. We iterate over them and display aspan
element for each validation error (if any). If there are no errors, then this won't show.
You can pass CSS classes to the label and field with the class_
keyword argument.
# Extract the rendering of a field into a macro
We have three fields in our form: title
, director
, and year
. All three fields will be rendered in exactly the same way: with field.label()
, field()
, and a list of errors.
Therefore this is a perfect candidate for a Jinja macro!
Let's create templates/macros/fields.html
and place this inside it:
{% macro render_text_field(field) %}
<div class="form__group">
{{ field.label(class_="form__label") }}
{{ field(class_="form__field")}}
{%- for error in field.errors %}
<span class="form__error">{{ error }}</span>
{% endfor %}
</div>
{% endmacro %}
Now we can just import that from templates/new_movie.html
and use it three times, once per field:
{% from "macros/fields.html" import render_text_field %}
{% extends "layout.html" %}
{% block main_content %}
<form name="add_movie" method="post" novalidate class="form">
<div class="form__container">
{{ render_text_field(form.title) }}
{{ render_text_field(form.director) }}
{{ render_text_field(form.year) }}
</div>
</form>
{% endblock %}
# Render all the form fields (including CSRF field)
There are two more fields we need to include in our form: the CSRF protection field, and the submit button.
Fortunately with Flask-WTF, including CSRF protection is super easy. Every form class already has a field defined, which we just need to render:
{% from "macros/fields.html" import render_text_field %}
{% extends "layout.html" %}
{% block main_content %}
<form name="add_movie" method="post" novalidate class="form">
<div class="form__container">
{{ form.hidden_tag() }}
{{ render_text_field(form.title) }}
{{ render_text_field(form.director) }}
{{ render_text_field(form.year) }}
</div>
</form>
{% endblock %}
The submit button is a label-less field, so we will render it just as we have done the input fields: using {{ form.submit() }}
. Remember we can pass a class to it:
{% from "macros/fields.html" import render_text_field %}
{% extends "layout.html" %}
{% block main_content %}
<form name="add_movie" method="post" novalidate class="form">
<div class="form__container">
{{ form.hidden_tag() }}
{{ render_text_field(form.title) }}
{{ render_text_field(form.director) }}
{{ render_text_field(form.year) }}
<div>
{{ form.submit(class_="button button--form") }}
</div>
</div>
</form>
{% endblock %}
# Style the form using CSS
The last bit to do is our form styling! To do that, let's create a CSS file in movie_library/static/css/forms.css
, and link it to our template:
{% from "macros/fields.html" import render_text_field %}
{% extends "layout.html" %}
{%- block head_content %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/forms.css') }}" />
{% endblock %}
{% block main_content %}
<form name="add_movie" method="post" novalidate class="form">
<div class="form__container">
{{ form.hidden_tag() }}
{{ render_text_field(form.title) }}
{{ render_text_field(form.director) }}
{{ render_text_field(form.year) }}
<div>
{{ form.submit(class_="button button--form") }}
</div>
</div>
</form>
{% endblock %}
There's quite a bit of styling to add to make it look like the design!
Let's work on the form container first. We want to limit its width, center it on the page, and add some border:
.form {
margin: 0 auto;
max-width: 30rem;
border: var(--border);
font-size: 1.2rem;
/* An explicit background is required here, as it's actually transparent by default, and we
don't want to see the shadow element behind */
background: var(--background-color);
}
Adding the sharp shadow is a bit trickier! We need to play with the box-shadow
property a bit to make it to what we want:
.form {
margin: 0 auto;
max-width: 30rem;
border: var(--border);
font-size: 1.2rem;
/* An explicit background is required here, as it's actually transparent by default, and we
don't want to see the shadow element behind */
background: var(--background-color);
/* This shadow is separated from the edges by 0.75rem, and shrunk by 0.2rem on the
top and bottom. */
box-shadow: 0.75rem 0.75rem 0 -0.2rem var(--accent-colour);
}
Then, let's add padding into our form__container
so that there's room around the fields up to the border:
.form__container {
padding: 2.5rem 1.5rem 1.5rem 1.5rem;
}
/* The following media queries allow for more padding inside the form as the window
size increases */
@media screen and (min-width: 24.75em) {
.form__container {
padding-left: 2rem;
}
}
@media screen and (min-width: 30em) {
.form__container {
padding-left: 2.5rem;
}
}
Next up, let's start styling the .form__group
and .form__label
classes. By displaying the group using flex
, we can make it a column so the label will appear on top of the field, and the errors below the field:
.form__group {
/* Surrounds the label and input fields, placing the label above the input */
display: flex;
flex-direction: column;
margin-bottom: 1.5rem;
}
.form__label {
margin-bottom: 0.5rem;
}
We've got some styling to do in the fields also! Nothing much new here:
.form__field {
/* Removes the border and the outline highlight when the text field is in focus */
outline: none;
border: none;
/* Re-adds a bottom border which will be replaces when the field is in focus. This
is going to prevent any jumping when we add the border later */
border-bottom: 3px solid #fff;
/* We have to be explicit about our text fields inheriting font properties */
font-size: inherit;
font-family: inherit;
padding: 0.75rem 0.5rem;
background: var(--background-color);
}
/* When the field is in focus, we change the border colour at the bottom to the accent colour */
.form__field:focus {
border-bottom: var(--border);
}
I will also add a few other pieces of styling that we will use in other forms in the application. To make life a bit easier for you, I'll add them now:
.form__small {
font-size: 0.83rem;
color: var(--text-muted);
}
.form__link {
text-decoration: none;
color: var(--accent-colour);
}
.form__link:hover {
color: #d05656;
}
.form__error {
margin-top: 0.5rem;
}
.form__error,
.form__flash {
display: block;
padding: 0.5rem;
color: var(--text);
}
.form__error,
.form__flash--danger {
background: var(--accent-colour);
}
.form__flash {
margin: 0.5rem;
}
.form__flash--success {
background: var(--accent-colour-2);
}
Most of these are to do with flashed messages that we will be using in our authentication forms.
Finally, we need to add button styling:
/* Styles specific to the form buttons */
.button--form {
margin: 2rem 0 0 auto;
padding: 0.75rem 3rem;
border: none;
background: var(--background-color);
}
.button--form:hover {
background: var(--background-color-hover);
}
And with that, we're done! Quite a lot of CSS, but not many new properties that you haven't seen before.
As usual, I recommend you code along with me and play around with the CSS properties we're using. Make the design your own by changing certain parts, and truly understand what each property is doing!