-
-
Save rmed/def5069419134e9da0713797ccc2cb29 to your computer and use it in GitHub Desktop.
# -*- 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> |
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! 😄
Thanks for this , can the jquery be modified to have calculations in some fields
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.
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>
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.