Skip to content

Instantly share code, notes, and snippets.

@rmed
Last active April 18, 2024 21:51
Show Gist options
  • Star 21 You must be signed in to star a gist
  • Fork 6 You must be signed in to fork a gist
  • 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>
@JonathanWillitts
Copy link

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
Copy link
Author

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! 😄

@fadebowaley
Copy link

fadebowaley commented Jun 3, 2021

Thanks for this , can the jquery be modified to have calculations in some fields

@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