Skip to content

Instantly share code, notes, and snippets.

@rmed
Last active June 29, 2024 12:20
Show Gist options
  • Save rmed/def5069419134e9da0713797ccc2cb29 to your computer and use it in GitHub Desktop.
Save rmed/def5069419134e9da0713797ccc2cb29 to your computer and use it in GitHub Desktop.
Dynamic Flask-WTF fields
# -*- coding: utf-8 -*-
# app.py
from flask import Flask, render_template
from flask_sqlalchemy import SQLAlchemy
from flask_wtf import FlaskForm
from wtforms import Form, FieldList, FormField, IntegerField, SelectField, \
StringField, TextAreaField, SubmitField
from wtforms import validators
class LapForm(Form):
"""Subform.
CSRF is disabled for this subform (using `Form` as parent class) because
it is never used by itself.
"""
runner_name = StringField(
'Runner name',
validators=[validators.InputRequired(), validators.Length(max=100)]
)
lap_time = IntegerField(
'Lap time',
validators=[validators.InputRequired(), validators.NumberRange(min=1)]
)
category = SelectField(
'Category',
choices=[('cat1', 'Category 1'), ('cat2', 'Category 2')]
)
notes = TextAreaField(
'Notes',
validators=[validators.Length(max=255)]
)
class MainForm(FlaskForm):
"""Parent form."""
laps = FieldList(
FormField(LapForm),
min_entries=1,
max_entries=20
)
# Create models
db = SQLAlchemy()
class Race(db.Model):
"""Stores races."""
__tablename__ = 'races'
id = db.Column(db.Integer, primary_key=True)
class Lap(db.Model):
"""Stores laps of a race."""
__tablename__ = 'laps'
id = db.Column(db.Integer, primary_key=True)
race_id = db.Column(db.Integer, db.ForeignKey('races.id'))
runner_name = db.Column(db.String(100))
lap_time = db.Column(db.Integer)
category = db.Column(db.String(4))
notes = db.Column(db.String(255))
# Relationship
race = db.relationship(
'Race',
backref=db.backref('laps', lazy='dynamic', collection_class=list)
)
# Initialize app
app = Flask(__name__)
app.config['SECRET_KEY'] = 'sosecret'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///test.db'
db.init_app(app)
db.create_all(app=app)
@app.route('/', methods=['GET', 'POST'])
def index():
form = MainForm()
template_form = LapForm(prefix='laps-_-')
if form.validate_on_submit():
# Create race
new_race = Race()
db.session.add(new_race)
for lap in form.laps.data:
new_lap = Lap(**lap)
# Add to race
new_race.laps.append(new_lap)
db.session.commit()
races = Race.query
return render_template(
'index.html',
form=form,
races=races,
_template=template_form
)
@app.route('/<race_id>', methods=['GET'])
def show_race(race_id):
"""Show the details of a race."""
race = Race.query.filter_by(id=race_id).first()
return render_template(
'show.html',
race=race
)
if __name__ == '__main__':
app.run()
{# templates/index.html #}
{% import "macros.html" as macros %}
<html>
<head>
<title>Lap logging</title>
{# Import JQuery #}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script>
const ID_RE = /(-)_(-)/;
/**
* Replace the template index of an element (-_-) with the
* given index.
*/
function replaceTemplateIndex(value, index) {
return value.replace(ID_RE, '$1'+index+'$2');
}
/**
* Adjust the indices of form fields when removing items.
*/
function adjustIndices(removedIndex) {
var $forms = $('.subform');
$forms.each(function(i) {
var $form = $(this);
var index = parseInt($form.data('index'));
var newIndex = index - 1;
if (index < removedIndex) {
// Skip
return true;
}
// This will replace the original index with the new one
// only if it is found in the format -num-, preventing
// accidental replacing of fields that may have numbers
// intheir names.
var regex = new RegExp('(-)'+index+'(-)');
var repVal = '$1'+newIndex+'$2';
// Change ID in form itself
$form.attr('id', $form.attr('id').replace(index, newIndex));
$form.data('index', newIndex);
// Change IDs in form fields
$form.find('label, input, select, textarea').each(function(j) {
var $item = $(this);
if ($item.is('label')) {
// Update labels
$item.attr('for', $item.attr('for').replace(regex, repVal));
return;
}
// Update other fields
$item.attr('id', $item.attr('id').replace(regex, repVal));
$item.attr('name', $item.attr('name').replace(regex, repVal));
});
});
}
/**
* Remove a form.
*/
function removeForm() {
var $removedForm = $(this).closest('.subform');
var removedIndex = parseInt($removedForm.data('index'));
$removedForm.remove();
// Update indices
adjustIndices(removedIndex);
}
/**
* Add a new form.
*/
function addForm() {
var $templateForm = $('#lap-_-form');
if ($templateForm.length === 0) {
console.log('[ERROR] Cannot find template');
return;
}
// Get Last index
var $lastForm = $('.subform').last();
var newIndex = 0;
if ($lastForm.length > 0) {
newIndex = parseInt($lastForm.data('index')) + 1;
}
// Maximum of 20 subforms
if (newIndex >= 20) {
console.log('[WARNING] Reached maximum number of elements');
return;
}
// Add elements
var $newForm = $templateForm.clone();
$newForm.attr('id', replaceTemplateIndex($newForm.attr('id'), newIndex));
$newForm.data('index', newIndex);
$newForm.find('label, input, select, textarea').each(function(idx) {
var $item = $(this);
if ($item.is('label')) {
// Update labels
$item.attr('for', replaceTemplateIndex($item.attr('for'), newIndex));
return;
}
// Update other fields
$item.attr('id', replaceTemplateIndex($item.attr('id'), newIndex));
$item.attr('name', replaceTemplateIndex($item.attr('name'), newIndex));
});
// Append
$('#subforms-container').append($newForm);
$newForm.addClass('subform');
$newForm.removeClass('is-hidden');
$newForm.find('.remove').click(removeForm);
}
$(document).ready(function() {
$('#add').click(addForm);
$('.remove').click(removeForm);
});
</script>
<style>
.is-hidden {
display: none;
}
</style>
</head>
<body>
<a id="add" href="#">Add Lap</a>
<hr/>
{# Show all subforms #}
<form id="lap-form" action="" method="POST" role="form">
{{ form.hidden_tag() }}
<div id="subforms-container">
{% for subform in form.laps %}
{{ macros.render_lap_form(subform, loop.index0) }}
{% endfor %}
</div>
<button type="submit">Send</button>
</form>
{% if form.errors %}
{{ form.errors }}
{% endif %}
{# Form template #}
{{ macros.render_lap_form(_template, '_') }}
{# Show races #}
{% for race in races %}
<p><a href="{{ url_for('show_race', race_id=race.id) }}">Race {{ race.id }}</a></p>
{% endfor %}
</body>
</html>
{# templates/macros.html #}
{# Render lap form.
This macro is intended to render both regular lap subforms (received from the
server) and the template form used to dynamically add more forms.
Arguments:
- subform: Form object to render
- index: Index of the form. For proper subforms rendered in the form loop,
this should match `loop.index0`, and for the template it should be
'_'
#}
{%- macro render_lap_form(subform, index) %}
<div id="lap-{{ index }}-form" class="{% if index != '_' %}subform{% else %}is-hidden{% endif %}" data-index="{{ index }}">
<div>
{{ subform.runner_name.label }}
{{ subform.runner_name }}
</div>
<div>
{{ subform.lap_time.label }}
{{ subform.lap_time}}
</div>
<div>
{{ subform.category.label }}
{{ subform.category }}
</div>
<div>
{{ subform.notes.label }}
{{ subform.notes }}
</div>
<a class="remove" href="#">Remove</a>
<hr/>
</div>
{%- endmacro %}
{# templates/show.html #}
<html>
<head>
<title>Race details</title>
</head>
<body>
<a href="{{ url_for('index') }}">Back to index</a>
{% if not race %}
<p>Could not find race details</p>
{% else %}
<table>
<thead>
<tr>
<th>Runner name</th>
<th>Lap time</th>
<th>Category</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{% for lap in race.laps %}
<tr>
<td>{{ lap.runner_name }}</td>
<td>{{ lap.lap_time }}</td>
<td>
{%- if lap.category == 'cat1' %}
Category 1
{%- elif lap.category == 'cat2' %}
Category 2
{%- else %}
Unknown
{%- endif %}
</td>
<td>{{ lap.notes }}</td>
</tr>
{% endfor%}
</tbody>
</table>
{% endif %}
</body>
</html>
@rmed
Copy link
Author

rmed commented Jun 7, 2021

I reckon it would be pretty straightforward to extend the JS code to perform calculations as part of the add/remove flow, or even have it separate.

@cd-eha
Copy link

cd-eha commented Aug 17, 2021

Thanks for the tutorial. Help me so much to think differently the aproach of this problem.

Here is my suggestion:

{{ subform.lap_time.label(for="lap-_-lap_time") }}
{{ subform.lap_time(id="lap-_-lap_time")  }}

If the id is not specified, it will generate many subform with the same id that cloned from the template.

@kevinlinxc
Copy link

kevinlinxc commented Nov 23, 2022

A mistake I made that cost me a lot of time was using wtf.quick_form in the macro. This made my submit button do nothing. Switching to wtf.form_field was permissible however, which let me control the columns and have the nice bootstrap css.

Just in case it helps someone else, I replaced https://gist.github.com/rmed/def5069419134e9da0713797ccc2cb29#file-macros-html-L15-L30
with

<div>
            {{ wtf.form_field(subform.location, form_type='horizontal', horizontal_columns=('lg',2,5)) }}
            {{ wtf.form_field(subform.time, form_type='horizontal', horizontal_columns=('lg',2,5)) }}
</div>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment