Updated: 12 Jun 2024, 16:10+02:00

User interfaces with WTForms and Bootstrap

Table of contents

A code example accompanying this notebook is available on the fswd-app repository.

First, git clone the repository.

Next, run git checkout ui to obtain the source code at tagged location ui.

Alternatively, download a .zip file containing the source code from the GitHub web app.

The package contains the to-do web app example, as changed throughout this notebook with an updated user interface.

Learning objectives

Until now we have created all HTML and CSS manually, as well as the interaction between front-end form input and back-end form handling. This was fine for learning purposes, minimized external dependencies, and gave us great flexibility. But:

  • Creating and maintaining HTML-based web forms and handling of form data in Flask soon becomes a tedious and error-prone task.
  • Achieving high-quality visual results is difficult, in particular across different screen sizes.

With this notebook we will take a cursory (and therefore utterly incomplete) look at the efficient creation of useful form-based user interfaces with two popular frameworks:

WTForms helps you to define HTML-based forms and validate incoming data. Flask-WTF adds some convenient integration with Flask on top, such as automatically populating a form with request.form data if it’s available.

Bootstrap is useful to style responsive interfaces with a clever CSS design system. Bootstrap-Flask adds some time-saving macros for the Jinja templating engine.

WTForms and Bootstrap

WTForms helps with form creation & handling, Bootstrap with interface styling & functionality

You might wonder why we focus on forms. The reason is simple: most web applications with a visual user interface routinely rely on HTML-based forms for user interaction.

By the end of this notebook, you will have cursory knowledge about both WTForms and Bootstrap and their usage with Flask. Having said that, these frameworks are very flexible and powerful:

  • There is often more than one way to solve a specific problem.
  • The feature-set is considerably broader than what we will cover here.

Hence, this notebook is all but a starting point for your own exploration of WTForms and Bootstrap.

1. Prerequisites and installation

1.1. Prerequisites

I assume that as a minimum, you have worked through the full-stack web development and Flask notebooks.

Furthermore, a project folder with a Python Virtual Environment activated and configured as instructed in those earlier notebooks should exist.

1.2. Initial installation and configuration steps

Install the following packages:

pip install flask-wtf bootstrap-flask 

Do not pip install flask-bootstrap! This package has not been maintained in many years (which is the reason why bootstrap-flask exists in the first place).

This command will add Flask-WTF, WTForms, and Bootstrap-Flask to your existing Flask installation.

If you maintain a 📄requirements.txt (which you should): now is the time to also update this file via:

pip freeze > requirements.txt

2. Forms with WTForms and Flask-WTF

The idea behind WTForms is to define a web form (or a part thereof) as a Python class. We use this class to generate an HTML representation and to validate any incoming data.

2.1. A minimal example: form to create a new to-do item

To start with a minimal example, make a new file 📄forms.py. In it, define a form for creating a new to-do item:

from flask_wtf import FlaskForm
from wtforms.fields import StringField, SubmitField
from wtforms.validators import InputRequired, Length

class CreateTodoForm(FlaskForm):  # (1.)
    description = StringField(validators=[InputRequired(), Length(min=5)])  # (2.)
    submit = SubmitField('Create')
  1. Our class CreateTodoForm inherits from FlaskForm.
    • This gives us access to many useful form features, some of which we will explore as we progress with this notebook.
  2. The individual form fields are defined as class attributes: here description and submit.
    • Notice how these attributes are instances of classes that WTForms provides to us. We will take a closer look at fields, as WTForms calls these classes, later on.
    • Furthermore, notice how we defined two validators for description: must not be empty and must be at least 5 characters. (More on validators later.)

You can easily imagine that this is the kind of form we have in mind: create todo form

FlaskForm is the only, but crucial, dependency we maintain to Flask-WTF throughout this notebook. We’ll pull everything else directly from WTForms.

It is important to understand that you can, but don’t have to define all fields of your form in a FlaskForm class. For example, we could have easily omitted the submit attribute.

Next, we instantiate CreateToDoForm in the todos() function of 📄app.py. Rewrite todos() to the following:

import os
from flask import Flask, render_template, redirect, url_for, request, abort, flash
import db, forms

# [...]

@app.route('/todos/', methods=['GET', 'POST'])
def todos():
    db_con = db.get_db_con()
    form = forms.CreateTodoForm()  # (1.)
    if request.method == 'GET':
        sql_query = 'SELECT * FROM todo ORDER BY id;'
        todos = db_con.execute(sql_query).fetchall()
        return render_template('todos.html', todos=todos, form=form)  # (2.)
    else:  # request.method == 'POST'
        if form.validate():  # (3.)
            sql_query = 'INSERT INTO todo (description) VALUES (?);'
            db_con.execute(sql_query, [form.description.data])  # (4.)
            db_con.commit()
            flash('Todo has been created.', 'success')  # (5.)
        else:
            flash('No todo creation: validation error.', 'warning')
        return redirect(url_for('todos'))

# [...]
  1. Variable form holds the class instance.
    • Due to the magic of Flask-WTF, the instance will be populated with data from request.form automatically - i.e., if the HTML form data has been submitted via HTTP POST method.
  2. A call to /todos/ via GET method will gather and render all todos. We pass the form variable to render_template() so it becomes available to the HTML template. (More on that in the next step.)
  3. When we get any form data submitted via POST, we validate whether it meets all defined criteria. (More on validation further down.)
  4. If the data input is valid, we create a new to-do item.
    • Notice how we pull the data from the form object, not from the request.form object as in the previous version of todos() (both would work, by the way).
  5. For added information, we provide some flash messages. (We will use this later in the Bootstrap part.)

If you run the application now, form handling will (almost) work just fine! In our 📄todos.html template there already is a form field of name description, which Flask-WTF will match with the description attribute of our CreateTodoForm class:

<form action="{{ url_for('todos') }}" method="post">
    <input type="text" name="description" />
    <input type="submit" value="Create" />
</form>

Having said that, WTForms offers a default HTML representation of form fields. To access these so-called widgets, simply call the form fields in the template page like so:

<form action="{{ url_for('todos') }}" method="post">
    {{ form.csrf_token }}
    {{ form.description }}
    {{ form.submit }}
</form>

You have certainly noticed the csrf_token attribute. We did not define it ourselves, this is a security feature of WTForms called CSRF protection which Flask-WTF enforces by default.

We won’t discuss CSRF (Cross-Site Request Forgery) protection here. All you need to know is that form.validate() will only return True if your form submits a csrf_token. With {{ form.csrf_token }} we ensure exactly that.

Therefore, if you run the web app with the form in its previous form (i.e. without csrf_token) the validation step will fail, and no to-do item will be created. Knowing that, the following form will validate just fine:

<form action="{{ url_for('todos') }}" method="post">
    {{ form.csrf_token }}
    <input type="text" name="description" />
    <input type="submit" value="Create" />
</form>

This little example illustrates how you can use just the parts of WTForms you like (e.g., data validation) and ignore the others (e.g., widgets).

Crucially, the HTML element generated by form.description includes HTML-native constraint validation, if corresponding validators have been defined in the field instance.

In our example, these are required and minlength="5", which correspond to the InputRequired() and Length(min=5)] validators, respectively.

HTML constraint validation is enforced directly by the browser, with no additional effort to the developer. For instance, when the user doesn’t enter any value, the required validation will kick in, causing a warning to pop up automatically (visuals vary between browsers):

UI HTML constraint validation

Of course, you are free to add such HTML-based validation manually as HTML element attributes. WTForms unburdens you from this task, which saves you some time.

In the next section, we investigate key WTForms concepts more closely, in particular fields and validators.

2.2. WTForms fields and validators

WTForms is built around four key concepts:

  • Field: holds a specified form element, e.g., a text input. WTForms offers various classes that define different field types, e.g., StringField for a text input. A field comes with useful attributes, such as data, label, validators, and widget.
  • Validator: specifies a criterion against which a field’s data is checked, e.g., minimum length. You may provide a list of multiple validators. When all criteria are met, validation returns True and False otherwise.
  • Widget: the HTML representation of a field. Usually, you will use the default widget that comes with a field type (e.g., <input type="text"> for a StringField).
    • You may create a custom widget for a particular field.
  • Form: a class that typically contains at least one field. To access a form field, write either foo_form.bar_field (common approach) or foo_form['bar_field'] (rarely used).
    • We generally use FlaskForm from the Flask-WTF package, which is a sub-class of WTForms Form.

These are the built-in field classes of WTForms and their corresponding widgets:

Field class name Description Default widget type
Selections    
BooleanField Choose True or False with checkbox <input type="checkbox">
RadioField Choose via radio button selection <input type="radio">
SelectField Choose via drop-down menu <select><option></option></select>
SelectMultipleField Choose one or multiple items from a list <select multiple><option></option></select>
Date and time    
MonthField Enter a year and a month (stored as datetime.date, with day=1) <input type="month">
DateField Enter a date (stored as datetime.date) <input type="date">
TimeField Enter a time (stored as datetime.time) <input type="time">
DateTimeLocalField Enter both a date and a time (stored as datetime.datetime) <input type="datetime-local">
DateTimeField Alternative to above - avoid usage, though! <input type="datetime">
Numbers    
DecimalField Enter a decimal number (stored as decimal.Decimal) <input type="number" step="any">
FloatField Enter a decimal number (stored as float) - avoid, use DecimalField instead <input type="text">
IntegerField Enter a number (stored as int) <input type="number">
DecimalRangeField Select a decimal number via a slider <input type="range" step="any">
IntegerRangeField Select a number via a slider <input type="range">
Strings    
EmailField Enter an email address <input type="email">
PasswordField Enter a password <input type="password">
SearchField Enter a search term - functionally same as StringField <input type="search">
StringField Enter some text <input type="text">
TelField Enter a phone number <input type="tel">
TextAreaField Enter some text, input area spans multiple lines <textarea></textarea>
URLField Enter an URL <input type="url">
Files    
FileField Let user select a file for upload <input type="file">
MultipleFileField Let user select multiple files for upload <input type="file" multiple>
Others    
HiddenField Include hidden value to the form, e.g., an ID <input type="hidden">
SubmitField Button to submit the form <input type="submit">

These are the standard WTForms validator classes:

Validator class name Description
AnyOf Validate against a collection of provided values - True if a match
DataRequired Validate if a field’s data is truthy, e.g., number 0 returns False - avoid, use InputRequired instead
Email Validate e-mail address, external dependency: pip install email-validator
EqualTo Compare with data of another field - useful for password confirmation of registration form
InputRequired Validate if field contains some data
IPAdress Validate an IP address (IPv4 format by default)
Length Validate string length (number of characters) - provide min and/or max values
MacAddress Validate a MAC address
NoneOf Validate against a collection of provided values - True if no match
NumberRange Validate for a numeric range - provide min and/or max values
Optional If data in the field is empty, skip all other validators of this field
Regexp Validate against regular expression provided via regex value
URL Validate for URL format
UUID Validate for UUID format

2.3. Another example: form to edit a to-do item

Equipped with this knowledge, define a TodoForm class for editing an individual to-do item. Extend 📄forms.py with:

class TodoForm(FlaskForm):  # (1.)
    method = HiddenField()  # (2.)
    id = HiddenField()
    complete = BooleanField()
    description = StringField(validators=[InputRequired()])
    list_id = SelectField(coerce=int, choices=[], validate_choice=False)  # (3.)
    submit = SubmitField('Update')
  1. The form mostly corresponds to our to-do item data model (shown here), which makes complete sense.
  2. Notice the method attribute, which holds a HiddenField: we leverage this in the template to differentiate between a PATCH and DELETE call, since HTML forms give us just GET and POST.
    • This is a little hack, but not an uncommon one.
  3. The form can refer to at most one to-do list item.
    • This is a limitation compared with our data model, in which we allow a to-do item to belong to more than one list item. (Mostly done to decrease code complexity in this notebook.)
    • SelectMultipleField instead of SelectField would be more true to our data model.
    • The values passed with the constructor are needed in our case, since (1) we want to store an integer value and (2) we will pull the individual choices dynamically from the database.

The function todo(id) in 📄app.py must be reorganized ever so slightly to use our new TodoForm class:

@app.route('/todos/<int:id>', methods=['GET', 'POST'])
def todo(id):
    db_con = db.get_db_con()
    if request.method == 'GET':
        sql_query = (
            'SELECT * FROM '
            '( SELECT * FROM todo '
                'LEFT JOIN todo_list '
                'ON todo.id=? AND todo_id=todo.id ) '
            'WHERE id=?;'
        )
        todo = db_con.execute(sql_query, [id, id]).fetchone()
        if todo:
            form = forms.TodoForm(data=todo)  # (1.)
            sql_query = 'SELECT id, name FROM list ORDER BY name;'
            choices = db_con.execute(sql_query).fetchall()  # (2.)
            form.list_id.choices = [(0, 'List?')] + [(c['id'], c['name']) for c in choices]
            return render_template('todo.html', form=form)  # (3.)
        else:
            abort(404)
    else:  # request.method == 'POST'
        form = forms.TodoForm()
        if form.method.data == 'PATCH':  # (4.)
            if form.validate():  # (5.)
                sql_query = 'UPDATE todo SET complete = ?, description = ? WHERE id = ?;'
                db_con.execute(sql_query, [form.complete.data, form.description.data, id])
                sql_query = 'DELETE FROM todo_list WHERE todo_id = ?;'
                db_con.execute(sql_query, [id])
                if form.list_id.data:
                    sql_query = 'INSERT INTO todo_list (todo_id, list_id) VALUES (?, ?);'
                    db_con.execute(sql_query, [id, form.list_id.data])
                db_con.commit()
                flash('Todo has been updated.', 'success')
            else:
                flash('No todo update: validation error.', 'warning')
            return redirect(url_for('todo', id=id))
        elif form.method.data == 'DELETE':
            sql_query = 'DELETE FROM todo WHERE id = ?;'
            db_con.execute(sql_query, [id])
            db_con.commit()
            flash('Todo has been deleted.', 'success')
            return redirect(url_for('todos'), 303)
        else:
            flash('Nothing happened.', 'info')
            return redirect(url_for('todo', id=id))
  1. In the GET part we fetch the to-do item from the database and store it in todo. Then, we create an instance of TodoForm named form, to which we pass the todo object as data value.
    • WTForms automatically matches the entries of todo with corresponding form fields to populate their data attributes.
  2. Remember the list_id = SelectField() in the form definition? Here we pull all choices from the database, i.e., all list.id values, plus the list.name for user-friendly display.
    • In the next line we process the result with a list comprehension: first, to add an “empty” entry; and second, to create the data structure which SelectField requires.
  3. Notice that we pass just the form object to the template, since it also contains all necessary todo information for display. (We’ll update 📄todo.html in the next paragraph.)
  4. Here we leverage the method hidden field: a form that submitted a PATCH value will pass this if condition. (Again, we’ll need to update 📄todo.html a bit.)
    • In the previous version of todo(id) we relied on a JavaScript workaround that resulted in proper PATCH and DELETE HTTP method calls. To increase compatibility with WTForms we got rid of JavaScript and use this approach instead.
  5. The to-do item will be updated only after successful validation.
    • In our example, the browser already ensures validity. Nevertheless: you never know what happens between browser submission in the front-end and arrival in the back-end, so better re-validate here as well.

Next, rewrite 📄todo.html to adapt to the change of provided object, namely from todo (an sqlite3 class instance) to form (a Flask-WTF class instance):

{% extends 'base.html' %}

{% block HEADER %}
    <h2>{% block TITLE %}To-do "{{ form.description.data }}"{% endblock %}</h2>
{% endblock %}

{% block CONTENT %}
<form action="{{ url_for('todo', id=form.id.data) }}" method="post">  <!-- (1.) -->
    {{ form.method(value='PATCH') }}  <!-- (2.) -->
    {{ form.csrf_token }}
    {{ form.complete }}
    {{ form.description }}
    {{ form.list_id }}
    {{ form.submit }}
</form>
<form action="{{ url_for('todo', id=form.id.data) }}" method="post">  <!-- (3.) -->
{{ form.method(value='DELETE') }}
<input type="submit" value="Delete">
</form>
{% endblock %}
  1. To access to-do item values, use the data argument of the corresponding field, e.g., form.id.data.
    • Previously, we passed the template a todo object, from which we accessed todo.id, todo.description, etc.
  2. WTForms lets us pass arbitrary arguments to a form field, which its widget (i.e., the HTML representation) processes. In most cases the widget will simply add HTML attributes of the same key and value.
    • Hence, with form.method(value='PATCH') we tell the widget to render the HiddenField as <input type="hidden" name="method" value="PATCH">.
    • Likewise, form.method(value='PATCH', class='my_css_class') would render to <input type="hidden" name="method" value="PATCH" class="my_css_class">.
  3. We define a second form with just an <input type="hidden" name="method" value="DELETE"> field and an <input type="submit"> button.
    • On the back-end, our todo(id) control flow will jump into the elif form.method.data == 'DELETE': code block.

This template results into the following form:

UI edit to-do form

While this form is fully functional, it is neither very pleasing visually, nor is it user-friendly. In the following section, we introduce Bootstrap and Bootstrap-Flask to create something like this:

Alt text

3. Styling with Bootstrap and Bootstrap-Flask

Bootstrap is a comprehensive, systematic collection of CSS rules that covers virtually any HTML element. Additional functionality is provided transparently through custom JavaScript functions, such as a dismiss button for alert components.

Due to its popularity, the Bootstrap design system has been ported to common prototyping tools like Figma and Adobe XD.

3.1. A minimal example: alert component

To start using Bootstrap, you simply add its CSS (and optionally JavaScript) to your web application. All HTML elements will then be styled according to the visual design choices of the Bootstrap team. In a sense, Bootstrap is the exact opposite of CSS reset rules.

When you migrate from your own CSS to Bootstrap, it is best to completely get rid of your CSS, since this could (and likely will) interfere with Bootstrap.

While Bootstrap is helpful by default, its true power comes to life with its class-based system for further customization. For example, to style a <div> HTML element as a container for alerts, you could write something like this:

<div class="alert alert-warning">A simple warning alert</div>

Source

…which renders to this:

UI bootstrap warning alert

The same HTML element, but with <div class="alert"> only renders to this:

Alt text

If we wanted to create a dismissible warning alert, an additional <button> element with adequate CSS styling (and a data-bs-dismiss attribute consumed by its accompanying JavaScript code) is needed:

<div class="alert alert-warning alert-dismissible fade show">
    A dismissible warning alert
    <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>

Source

This is a rendering (a click on the x would fade out the alert):

UI bootstrap dismissible warning alert

For completeness, here is an excerpt of the CSS rules that styled the alerts seen above:

.alert {
  --bs-alert-bg: transparent;
  --bs-alert-padding-x: 1rem;
  --bs-alert-padding-y: 1rem;
  --bs-alert-margin-bottom: 1rem;
  --bs-alert-color: inherit;
  --bs-alert-border-color: transparent;
  --bs-alert-border: var(--bs-border-width) solid var(--bs-alert-border-color);
  --bs-alert-border-radius: var(--bs-border-radius);
  --bs-alert-link-color: inherit;
  position: relative;
  padding: var(--bs-alert-padding-y) var(--bs-alert-padding-x);
  margin-bottom: var(--bs-alert-margin-bottom);
  color: var(--bs-alert-color);
  background-color: var(--bs-alert-bg);
  border: var(--bs-alert-border);
  border-radius: var(--bs-alert-border-radius);
}

.alert-warning {
  --bs-alert-color: var(--bs-warning-text-emphasis);
  --bs-alert-bg: var(--bs-warning-bg-subtle);
  --bs-alert-border-color: var(--bs-warning-border-subtle);
  --bs-alert-link-color: var(--bs-warning-text-emphasis);
}

.alert-dismissible {
  padding-right: 3rem;
}

.fade {
  transition: opacity 0.15s linear;
}

.fade:not(.show) {
  opacity: 0;
}

Source

And this is an excerpt of the various CSS rules with which to style grid columns:

.col {
  flex: 1 0 0%;
}

.col-auto {
  flex: 0 0 auto;
  width: auto;
}

.col-1 {
  flex: 0 0 auto;
  width: 8.33333333%;
}

.col-2 {
  flex: 0 0 auto;
  width: 16.66666667%;
}

.col-3 {
  flex: 0 0 auto;
  width: 25%;
}

.col-4 {
  flex: 0 0 auto;
  width: 33.33333333%;
}

Source

We could go on like this for virtually thousands of lines of CSS! As you can see, Bootstrap is mostly a collection of CSS rules - but a very comprehensive and systematic one.

3.2. Apply Bootstrap to web app via Bootstrap-Flask

It is completely viable to apply Bootstrap to your web application manually.

However, you will likely appreciate the helping hand of Bootstrap-Flask once you start using its various Jinja macros in your templates.

To initialize, we add three lines in 📄app.py:

import os
from flask import Flask, render_template, redirect, url_for, request, abort, flash
from flask_bootstrap import Bootstrap5  # (1.)
import db, forms

app = Flask(__name__)

app.config.from_mapping(
    SECRET_KEY = 'secret_key_just_for_dev_environment',
    DATABASE = os.path.join(app.instance_path, 'todos.sqlite'),
    BOOTSTRAP_BOOTSWATCH_THEME = 'pulse'  # (2.)
)
app.cli.add_command(db.init_db)
app.teardown_appcontext(db.close_db_con)

bootstrap = Bootstrap5(app)  # (3.)

@app.route('/index')
@app.route('/')
def index():
	return redirect(url_for('todos'))

# [...]
  1. First, we import the Bootstrap5 class.
  2. Next, we may change the theme (e.g., color codes and fonts) to an alternative - 'pulse' in this example.
    • This is an optional step - click here for more information.
  3. Finally, we instantiate Bootstrap5, which makes the Jinja macros available to us.

This is all we need to change in our back-end business logic. The remainder is done in our templates.

As the very minimum, we should change 📄base.html to resemble the Bootstrap standard:

<!doctype html>
<html lang=en>

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">  <!-- (1.) -->
    <title>{% block TITLE %}{% endblock %} - To-Do App</title>
    {{ bootstrap.load_css() }}  <!-- (2.) -->
</head>

<body>
    <nav>
        <h1>To-Do App</h1>
            <a href="{{ url_for('todos') }}">Todos</a>
            &nbsp;|&nbsp; 
            <a href="{{ url_for('lists') }}">Lists</a>
    </nav>

    <header>{% block HEADER %}{% endblock %}</header>

    <main>
        {% block CONTENT %}{% endblock %}
    </main>

    {{ bootstrap.load_js() }}  <!-- (3.) -->
</body>

</html>
  1. We have extended 📄base.html to the recommendation of Bootstrap, for example by including this <meta> element.
  2. We don’t hard-code a link to the Bootstrap CSS file, but rather let Bootstrap-Flask do this.
  3. The same applies to the Bootstrap JavaScript code, which by convention is placed right before the </body> end tag.

This already has an effect - the web app renders to something similar like this:

UI todo app basic bootstrap

We want to apply more styling. So we experiment with various Bootstrap components until we achieve a satisfactory result, like this one:

UI todo app full bootstrap

For this effect, we used in particular these Bootstrap components:

For reference, here is 📄base.html with all visual tweaks applied:

{% from 'bootstrap5/nav.html' import render_nav_item %}
{% from 'bootstrap5/utils.html' import render_messages %}

<!doctype html>
<html lang=en>

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{% block TITLE %}{% endblock %} - To-Do App</title>
    {{ bootstrap.load_css() }}
</head>

<body>
<div class="container border shadow w-50 pb-3 my-3">

    <nav class="navbar navbar-expand-xl border-bottom mb-3">
        <div class="container-fluid px-0">
            <a class="navbar-brand" href="{{ url_for('index') }}">To-Do App</a>
            <button class="navbar-toggler" type="button" data-bs-toggle="collapse"
                    data-bs-target="#navbarSupportedContent" 
                    aria-controls="navbarSupportedContent"
                    aria-expanded="false" aria-label="Toggle navigation">
                <span class="navbar-toggler-icon"></span>
            </button>
            <div class="collapse navbar-collapse" id="navbarSupportedContent">
                <ul class="navbar-nav me-auto mb-2 mb-lg-0">
                    {{ render_nav_item('todos', 'Todos') }}
                    {{ render_nav_item('lists', 'Lists') }}
                </ul>
            </div>
        </div>
    </nav>

    <header>{% block HEADER %}{% endblock %}</header>

    <main>
        {{ render_messages(dismissible=True, dismiss_animate=True) }}
        {% block CONTENT %}{% endblock %}
    </main>

</div>
{{ bootstrap.load_js() }}
</body>

</html>

All other templates can do with some visual tweaks as well. For example, here is the updated 📄todo.html:

{% extends 'base.html' %}

{% block HEADER %}
    <h2>{% block TITLE %}To-do "{{ form.description.data }}"{% endblock %}</h2>
{% endblock %}

{% block CONTENT %}
<div class="row row-cols-auto g-3">
    <form action="{{ url_for('todo', id=form.id.data) }}" method="post" class="col">
    <div class="row row-cols-auto g-2">
        {{ form.method(value='PATCH') }}
        {{ form.csrf_token }}
        <div class="col">{{ form.complete(class='form-check-input align-middle') }}</div>
        <div class="col">{{ form.description(class='form-control') }}</div>
        <div class="col">{{ form.list_id(class='form-select') }}</div>
        <div class="col">{{ form.submit(class='btn btn-primary') }}</div>
    </div>
    </form>
    <form action="{{ url_for('todo', id=form.id.data) }}" method="post" class="col">
    <div class="row row-cols-auto">
        {{ form.method(value='DELETE') }}
        <div class="col gx-2"><input type="submit" value="Delete" class="btn btn-outline-danger"></div>
    </div>
    </form>
</div>
{% endblock %}

3.3. Working with Bootstrap: a matter of trial and error

At this point you are likely wondering how to come up with an intricate <button> definition like:

<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
        data-bs-target="#navbarSupportedContent" 
        aria-controls="navbarSupportedContent"
        aria-expanded="false" aria-label="Toggle navigation">
    <span class="navbar-toggler-icon"></span>
</button>

The answer is straightforward: working with Bootstrap is very much repeated trial and error, plus accumulated experience.

You will consult a combination of:

This last resource is very powerful: for example, you can easily change class assignments on-the-fly and see the effect instantaneously:

UI browser dev tools


Annex: Follow-up recommendations

Congratulations, you have another tool in the full-stack arsenal under your belt!

At this point it is time for you to dive into more practical experiments with WTForms and Bootstrap:

  • Start by further investigating the fswd-app repository, in particular the templates 📄base.html, 📄todos.html, and 📄todo.html.
  • Take a look at more tutorials (preferably of a recent date), like this one about Flask-WTF on DigitalOcean.
  • Browse through the Bootstrap documentation with activated browser developer tools (Shift+Ctrl+I / ++I) and manipulate the reference examples shown on the documentation.
  • Similarly, investigate the Bootstrap-Flask demo application to learn more ways to leverage this package. (Unfortunately, the links were down at the time of writing.)

Copyright © 2024 Prof. Dr. Alexander Eck. All rights reserved.