Samstag, 30. Januar 2016

Python, WTForms, JavaScript: Formulare dynamisch erweitern

Folgender Fall: in einer Webanwendung sollen Daten über ein Formular eingegeben werden. Die Felder des Formulars sind klar definiert, aber wie viele Daten eingegeben werden sollen, ist offen.

Ein Beispiel hierfür wäre z.B. eine Webanwendung für Rezepte. Es gibt Rezepte, die haben eine handvoll Zutaten. Aber es gibt auch Rezepte, die haben mehrere Dutzend Zutaten. Hier macht es also wenig Sinn, die Anzahl der Eingabefelder für das Formular fix festzulegen. Vielmehr gilt es, die Möglichkeit für den Nutzer zu schaffen, das Formular dynamisch zu erweitern.

Die Zutaten hier dafür: Python 3.4, Bottle 0.12, WTForms 2.1 , JavaScript und jQuery 2.2. Bottle als Webframework ist hier übrigens nur Mittel zum Zweck, das Beispiel lässt sich genau so mit anderen Python Webframeworks umsetzen.

Das Beispiel besteht aus vier Codeteilen:
  • der Bottle Hauptanwendung, die das Routing etc. festlegt
  • den Formularklassen, erstellt mit WTForms
  • den beiden Templates zur Dateneingabe und zur Datenausgabe
Wer das Beispiel nachstellen möchte: wie bei Bottle üblich wird erwartet, dass die Templates im Verzeichnis views liegen. Das benötigte jQuery wird im Verzeichnis static erwartet. Alternativ könnte man natürlich auch jQuery von einem der vielen CDN-Servern online laden.

Die Hauptdatei sieht so aus:

import os
from bottle import route, run, template, debug, static_file, request
from my_forms import InputForm

BASE_DIR = os.path.dirname(os.path.dirname(__file__))

@route('/ingredients')
def index():
    form = InputForm()
    return template('dyn_form_input_wt.html', form=form)

@route('/ingredients', method='POST')
def post_data():
    form_data =  request.forms
    form = InputForm(form_data)
    if not form.validate():
        errors = form.errors['ingredients']
    else:
        errors = None
    ingredients = []
    for i in range(0, int(len(form_data)/3)):
        ingredients.append((form_data['ingredients-'+str(i)+'-description'],
                            form_data['ingredients-'+str(i)+'-unit'],
                            form_data['ingredients-'+str(i)+'-quantity']))
    return template('output.html',
                    ingredients=ingredients,
                    errors=errors)

@route('/static/<filename>')
def server_static(filename):
    return static_file(filename, root=os.path.join(BASE_DIR, 'static'))

debug=True
run(host='localhost', port=8080, reloader=True)

Hier gibt es eigentlich nicht viel zu sagen. Eine Route liefert das Formular aus, die Route mit method='POST' nimmt die Formulardaten auf und sortiert diese zurück in eine Liste. In einer realen Anwendung würde man an dieser Stelle die Daten z.B. in eine Datenbank schreiben.
Wichtig ist hierbei aber, dass davon ausgegangen wird, dass die name-Attribute der Formularfelder a) der Namensgebung von WTForms FieldList entsprechen - also Name des Formulars - Zähler - Name des Felds - , b) der Zähler bei Null beginnt und c) die Zählung lückenlos ist (also 0, 1, 2, 3 usw.). Dass das tatsächlich auch so ist, dafür sorgt der JavaScript in der weiter unten gezeigten Template-Datei dyn_form_input_wt.html.

Die Datei my_forms.py, welche die Formularklassen enthält, sieht so aus:

from wtforms.form import Form
from wtforms.fields import StringField, SelectField, DecimalField, FormField, \
    FieldList
from wtforms.validators import NumberRange, InputRequired

UNITS = [('kg', 'kg'),
         ('g', 'g'),
         ('TL', 'TL'),
         ('EL', 'EL'),
         ('St', 'St')] 

class IngredientForm(Form):
    description = StringField('description',
                              [InputRequired()])                                  
    unit = SelectField('unit', choices=UNITS)
    quantity = DecimalField('quantity',
                            [InputRequired(), NumberRange(min=0)],
                             places=3)
                            
class InputForm(Form):
    ingredients = FieldList(FormField(IngredientForm), min_entries=1)

Das ist soweit alles WTForms Standard ohne Tricks und Kniffe. In der Klasse InputForm wird die IngredientForm per FormField zu einem Formular-Feld zusammengefasst, FieldList wiederum fasst mehrere Formular-Felder zusammen. Mehr Infos dazu sind in der Dokumentation von WTForms zu finden.

Das Template dyn_form_input_wt.html ist für die Eingabe der Zutaten sowie das dynamische Hinzufügen und Entfernen von Eingabefeldern zuständig. Das Template sieht so aus:

<!DOCTYPE html>
<html>
<head>
    <title>Dynamic Forms</title>
    <script src="static/jquery-2.2.0.min.js"></script>
</head>
<body>
<form action="/ingredients" method="POST">
    <div class="input_fields_wrap">
    <!-- here goes the form fields -->
    </div>
    <button class="add_field_button">Add field</button>
    <button class="remove_field_button">Remove field</button>
    <input type="submit">
</form>
<script>
$(document).ready(function() {
    var max_fields = 20; //maximum input boxes allowed
    var wrapper = $(".input_fields_wrap"); //Fields wrapper
    var addButton = $(".add_field_button"); //Add button ID
    var removeButton = $(".remove_field_button"); //Add button ID
    var htmlString = '<div id="input_fields_0">{{ !form.ingredients }}</div>';
    var field_counter = 0;
    $(wrapper).append(makeString()); //add the first form
    $(addButton).click(function(e){ //on add input button click
        e.preventDefault();
        if(field_counter < max_fields){ //max input box allowed
            field_counter++;
            $(wrapper).append(makeString());}
        else { window.alert('max number of ingredients reached!')}
    });
    $(removeButton).click(function(e){ //on remove input button click
        e.preventDefault();
        if(field_counter > 0){ //make sure at least one field is there
            $('#input_fields_'+field_counter).remove();
            field_counter--;}
        else { window.alert('Cannot delete, one input has to remain.')}
    });
    function makeString() {
        var myString = htmlString;
        return myString.replace(/0/g, field_counter);
    };
});
</script>
</body>
</html>

Wie zu sehen ist, besteht das Template aus zwei Sektionen: dem HTML-Teil und dem JavaScript Teil. Erster ist ziemlich "straight forward" und bedarf wohl keiner weiterer Erklärung, letzter macht die Hauptarbeit und liefert die Dynamik.

Im HTML-Teil wird nur der "Rahmen" für das Formular angelegt, es werden aber keine Formularfelder erzeugt. Dies geschieht ausschließlich per JavaScript / jQuery.

Den Ausgangspunkt bildet die Definition der Variablen htmlString, in der auch das WTForms Formular gerendert wird, über {{ !form.ingredients }}

Die Zeile $(wrapper).append(makeString()); erzeugt den ersten Satz Formularfelder, der Zähler field_counter steht hier noch auf Null.

Zum Hinzufügen weitere Felder ist eine JavaScript-Funktion an den Button "Add field" gebunden, welche a) checkt, ob nicht die per max_fields festgelegte Anzahl an Feldern schon erreicht ist, b) den Feldzähler field_counter um eins erhöht und dann c) einen Satz Formularfelder hinzufügt.

Zum Löschen, welches über eine an den Button "Remove Fields" gebundene Funktion erfolgt, ist der Ablauf ähnlich. Hier wird zuerst geprüft, ob mehr als ein Satz Formularfelder vorhanden ist. Wenn ja wird das letzte entfernt und der field_counter Zähler um eins dekrementiert.

Die Funktion makeString ist dafür verantwortlich, dass die Nummerierung der Formularfelder (bzw. genau genommen deren Attribute wie id, name etc. gem. dem aktuellen Stand von field_counter angepasst werden. Dazu wird einfach die String-Methode replace() mit den entsprechenden Werten auf den Ausgangsstring angewendet und als neuer String zurück geliefert.

Wenn das Formular abgesendet wurde, wird in diesem Beispiel hier das Template output.html aufgerufen, welches die eine Liste der Zutaten mit Menge und Einheit sowie mögliche Fehler ausgibt. Das Template sieht so aus:

<!DOCTYPE html>
<html>
<head>
<title>Dynamic Forms Output</title>
</head>
<body>
<p>Received ingredients from form data:</p>
<ul>
% for ingredient in ingredients:
<li>{{ ingredient[0] }}: {{ ingredient[2] }} {{ ingredient[1] }}
% end
</ul>
% if errors:
<p>The form has the following errors:
<ul>
% for error in errors:
<li>{{ error }}
% end
</ul>
% end
</body>
</html>

Das Template ist hier auch nur Mittel zum Zweck, bei Produktiv-Code würde man hier sicherlich anders vorgehen.

Wie hier gezeigt wurde, ist es nicht so schwierig und aufwendig, dynamisch erweiterbare Formular zu erzeugen, auch wenn diese in den ansonsten "starren" Klassen eines Python Formularframeworks definiert sind.
Dazu benötigt werden aus WTForms insbesonders die Klassen FormField und FieldList sowie auf der Client-Seite ein bisschen JavaScript mit jQuery.