Last active
July 4, 2024 14:05
-
-
Save rmed/def5069419134e9da0713797ccc2cb29 to your computer and use it in GitHub Desktop.
Dynamic Flask-WTF fields
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# -*- 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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{# 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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{# 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 %} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{# 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> |
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.
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
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.