Skip to content

Instantly share code, notes, and snippets.

@rmed

rmed/app.py

Last active Feb 26, 2021
Embed
What would you like to do?
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>
@JonathanWillitts

This comment has been minimized.

Copy link

@JonathanWillitts JonathanWillitts commented Nov 10, 2020

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:

  1. The if (newIndex > 20) statement in the JS should be if (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.

  2. 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 becomes lap0details-0-form instead of lap-details-0-form. I fixed this by using -__- (double underscore) and updating the find/replace code to reflect this.

@rmed

This comment has been minimized.

Copy link
Owner Author

@rmed rmed commented Nov 23, 2020

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