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> |
This comment has been minimized.
This comment has been minimized.
Just updated the code (and the post!) fixing several overlooks on my part and with major improvements related to the rendering and handling of the form template. Thanks! |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This comment has been minimized.
Your article/code has been a massive help.
I just posted the following in the comments of your article, but in case anyone comes here directly:
The
if (newIndex > 20)
statement in the JS should beif (newIndex >= 20)
to limit to 20 entries, else it'll allow 21.In fact, using:
if (newIndex >= {{ form.laps.max_entries }})
is probably more elegant, as that pulls the max value direct from the form definition.You have to be careful if using a form/model with an underscore in the name, as the javascript to add replaces that too, so
lap_details-_-form
becomeslap0details-0-form
instead oflap-details-0-form
. I fixed this by using-__-
(double underscore) and updating the find/replace code to reflect this.