Одной из распространенных задач в веб-приложениях является создание формы, в которую можно вводить заранее неопределённое количество элементов. Этот подход часто используется при вводе пользовательской информации, например, телефонных номеров или адресов.В примере ниже можно увидеть, как пользователь динамически добавляет дополнительные телефонные номера в форму, нажимая на кнопку "Add another".

Реализация этого во Flask может оказаться довольно сложной, так как требует комбинации техник как на стороне сервера, так и на стороне клиента. В этой статье мы рассмотрим два возможных решения: базовое, использующее только Flask, и более полное, с использованием расширения Flask-WTF для обработки форм.

Использование списков полей в HTML-формах

Хорошая новость заключается в том, что HTML-формы поддерживают списки полей довольно простым способом. По сути, список создается путем определения нескольких полей с одинаковым именем. Ниже приведен пример HTML-формы, содержащей поле для имени и три поля для телефонных номеров:

<form>
  <p>Name: <input type="text" name="name" /></p>
  <p>
    Phone numbers:
    <ul>
      <li>Number 1: <input type="text" name="phone_number" /></li>
      <li>Number 2: <input type="text" name="phone_number" /></li>
      <li>Number 3: <input type="text" name="phone_number" /></li>
    </ul>
  </p>
  <button type="submit">Submit</button>
</form>

Когда эта форма отправляется, сервер получит четыре значения: одно для имени и три для телефонных номеров.

Во Flask данные формы доступны через объект request.form, который работает как словарь в Python. Например, request.form['name'] вернет значение поля name. Однако для полей, которые имеют несколько значений, использование словаря не подходит, так как он возвращает только одно из значений. Для таких полей можно использовать метод request.form.getlist(), который возвращает список всех значений для указанного имени поля:

phone_numbers = request.form.getlist('phone_number')

Реализация списков полей в чистом Flask

Встроенная поддержка списков полей в HTML будет основой первого решения, которое подходит для проектов, где формы обрабатываются вручную, без использования расширения Flask-WTF.

Чтобы сделать пример более интересным, создадим форму, которая включает следующие поля:

  • имя

  • динамический список телефонных номеров, где каждый номер содержит сам номер и тип телефона (домашний, рабочий и т.д.).

Зная о методе request.form.getlist(), реализация на стороне Flask будет довольно простой. Вот полный код Flask-приложения для обработки формы с указанными выше параметрами:

from flask import Flask, render_template, request

app = Flask(__name__)

@app.route('/', methods=['GET', 'POST'])
def index():
    if request.method == 'POST':
        print(f'Name: {request.form["name"]}')
        for number, type_ in zip(
            request.form.getlist('phone_number'),
            request.form.getlist('phone_type')
        ):
            if number:
                print(f'Phone number: {number} ({type_})')
    return render_template('index.html')

Шаблон index.html, который отображается при GET запросе, содержит форму. Поскольку мы не знаем, сколько телефонных номеров понадобится, мы отображаем только один, а затем реализуем логику для динамического создания дополнительных полей с помощью JavaScript. Вот часть шаблона, которая отображает форму с использованием стилей Bootstrap:

 <form action="" method="post">
  <div class="mb-3">
    <label for="name" class="form-label">Name</label>
    <input type="text" class="form-control" name="name" id="name" autofocus />
  </div>
  <div class="mb-3">
    <label for="phone_type" class="form-label">Phone numbers</label>
    <div id="phoneFields">
      <div class="phoneField">
        <div class="row mb-2">
          <div class="col-8">
            <input type="text" class="form-control" name="phone_number" />
          </div>
          <div class="col-4">
            <select class="form-select" name="phone_type">
              <option value="home" selected>Home</option>
              <option value="work">Work</option>
              <option value="mobile">Mobile</option>
              <option value="other">Other</option>
            </select>
          </div>
        </div>
      </div>
    </div>
    <div class="form-text">
      <a href="#" onclick="javascript:return addPhone()">Add another</a>
    </div>
  </div>
  <div class="row form-group">
    <div class="col-3">
      <input type="submit" class="form-control" value="Submit" />
    </div>
  </div>
</form>

На скриншоте показано, как форма выглядела бы в браузере:

Обратите внимание на <div> с идентификатором phoneFields. Это родительский элемент для телефонных номеров, который изначально содержит один дочерний элемент с полями для номера и типа телефона. Верхний <div> этого дочернего элемента имеет класс phoneField. Мы будем использовать этот класс для поиска элемента и создания его копий.

Кнопка "Add another" имеет обработчик onclick, который вызывает функцию addPhone(), создающую дополнительные поля для телефонов при каждом нажатии.

Вот определение функции addPhone():

function addPhone() {
  const firstPhone = document.querySelector('.phoneField');
  const newPhone = document.createElement('div');
  newPhone.classList.add('phoneField');
  newPhone.innerHTML = firstPhone.innerHTML;
  document.getElementById('phoneFields').appendChild(newPhone);
  document.querySelector('.phoneField:last-child input').focus();
  return false;
}

Функция addPhone() начинает с использования функции querySelector() из DOM API браузера для поиска верхнего <div> первого набора полей для телефонов.

Затем функция создает новый <div> с помощью createElement(), добавляет ему класс phoneField и устанавливает его свойство innerHTML равным значению того же свойства у первого элемента, создавая его точную копию. Новый элемент добавляется как последний дочерний элемент phoneFields. Наконец, для удобства, мы находим только что добавленное поле <input> и устанавливаем на него фокус.

Функция addPhone() завершается возвратом false. Обратите внимание, что элемент <a> определяет свой обработчик onclick как return addPhone(). Это отправляет false обратно в браузер, чтобы сообщить, что событие клика обработано и браузеру не нужно беспокоиться о нем. Без этого браузер выполнит переход по ссылке, что нам не нужно.

Использование форм Flask-WTF

Решение, представленное выше, является базовым. Обычно веб-приложение, обрабатывающее формы, должно обеспечивать валидацию всех полей, защиту от CSRF-атак, а также возможность предварительной загрузки данных в форму для редактирования. Хотя эти функции можно добавить в приведенный выше пример, большинство проектов на Flask полагаются на расширение Flask-WTF и его использование пакета WTForms для реализации надежной обработки форм без необходимости изобретать велосипед.

Однако при попытке адаптировать базовое решение Flask к Flask-WTF возникает важная проблема: проект WTForms, от которого зависит Flask-WTF, не распознает поля с повторяющимися именами.

При использовании WTForms список полей должен быть реализован с помощью контейнера FieldList. И если каждый элемент списка содержит несколько полей, как в нашем примере, то он должен быть реализован как подформа и включен в список с использованием оболочки FormField.

К сожалению, решение, которое отлично работало для чистого Flask, требует некоторых доработок.

Ниже показано, как нужно определить эту форму, чтобы она соответствовала требованиям Flask-WTF:

class PhoneForm(FlaskForm):
    class Meta:
        csrf = False  # CSRF не нужен здесь, так как это подформаrm
    number = StringField('Phone number', validators=[
        DataRequired(),
        Regexp(r'^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4,6}#x27;,
               message='Enter a valid phone number.')
    ])
    type_ = SelectField('Phone type', choices=[
        ('home', 'Home'),
        ('work', 'Work'),
        ('mobile', 'Mobile'),
        ('other', 'Other')
    ])

class MyForm(FlaskForm):
    name = StringField('Name', validators=[DataRequired()])
    phone_numbers = FieldList(FormField(PhoneForm), min_entries=1)
    submit = SubmitField('Submit')

В этом примере класс MyForm представляет основную форму. Его поле phone_numbers является FieldList, и каждый элемент этого списка — это FormField, ссылающийся на подформу, реализованную как класс PhoneForm, с полями number и type_ (подчеркивание добавлено, чтобы избежать использования зарезервированного слова type). FieldList позволяет настроить количество элементов в форме, и здесь мы указываем минимальное количество записей, равное одному, чтобы форма всегда начиналась с одного телефона.

Обратите внимание, что были добавлены валидаторы для номера телефона через регулярки, чтобы убедиться, что пользователь вводит корректный номер.

Теперь рассмотрим, как серверная часть может обрабатывать форму:

@app.route('/', methods=['GET', 'POST'])
def index():
    form = MyForm()
    if form.validate_on_submit():
        print(f'Name: {form.name.data}')
        for phone in form.phone_numbers.data:
            print(f'Phone: {phone["number"]} ({phone["type_"]})')
    return render_template('index.html', form=form)

Здесь использование Flask-WTF всё упрощает. Мы просто создаем экземпляр MyForm и вызываем его метод validate_on_submit(), чтобы определить, есть ли в форме валидные данные. Этот метод выполняет все проверки и возвращает True, только если всё прошло успешно. В этом случае данные выводятся в консоль. В этом решении нет необходимости использовать функцию zip(), так как Flask-WTF возвращает данные для каждой подформы в виде словаря.

Шаблон index.html отображает форму, как и раньше, но в этом случае мы передаем объект формы в вызов render_template(), так как нам нужен доступ к нему при рендеринге формы, чтобы получить поля и сообщения об ошибках валидации.

При работе с Bootstrap и Flask-WTF удобно создавать макросы для рендеринга каждого поля формы, так как Bootstrap добавляет некоторый шаблонный код, который нужно повторять для каждого поля. Для этого приложения мы создали два макроса: text() для текстовых полей и select() для выпадающего списка типов телефонов. Вот их определения в Jinja:

{% macro text(field, label, autofocus) %}
  {% if label %}
    {{ field.label(class='form-label') }}
  {% endif %}
  {% if autofocus %}
    {{ field(class='form-control' + (' is-invalid' if field.errors else ''), autofocus="true") }}
  {% else %}
    {{ field(class='form-control' + (' is-invalid' if field.errors else '')) }}
  {% endif %}
  {% if field.errors %}
  <div class="invalid-feedback">{{ field.errors|join(', ') }}</div>
  {% endif %}
{% endmacro %}

{% macro select(field) %}
  {{ field(class="form-select") }}
{% endmacro %}

Макросы принимают объект поля Flask-WTF в качестве первого аргумента. В случае text() также есть два логических аргумента для выбора, нужно ли отображать метку и нужно ли устанавливать фокус на поле.

Одним из приятных аспектов работы с Flask-WTF и WTForms является то, что поля знают, как отображать себя. Макросы используют это, когда вызывают {{ field(...) }} или {{ field.label(...) }} для рендеринга поля или метки. Объекты полей также содержат сообщения об ошибках валидации из предыдущей отправки в field.errors, что также используется в макросе text().

Шаблон index.html импортирует эти макросы как fields.text() и fields.select(). Ниже приведена часть шаблона, которая отображает форму:

<form action="" method="post">
  {{ form.csrf_token }}
  <div class="mb-3">
    {{ fields.text(form.name, true, true) }}
  </div>
  <div class="mb-3">
    <label for="phone_type" class="form-label">Phone numbers:</label>
    <div id="phoneFields">
      {% for phone in form.phone_numbers.entries %}
      <div class="phoneField">
        <div class="row">
          <div class="col-8 mb-2">
            {{ fields.text(phone.form.number, false, false) }}
          </div>
          <div class="col-4">
            {{ fields.select(phone.form.type_) }}
          </div>
        </div>
      </div>
      {% endfor %}
    </div>
    <div class="form-text">
      <a href="#" onclick="javascript:return addPhone()">Add another</a>
    </div>
  </div>
  <div class="row form-group">
    <div class="col-3">
      <button type="submit" class="btn btn-primary">Submit</button>
    </div>
  </div>
</form>

Обратите внимание, что в контексте этого шаблона переменная form представляет форму, которую нужно отобразить. Выражение form.csrf_token должно быть включено в форму для генерации скрытого поля, реализующего защиту от CSRF. Отдельные поля формы доступны как атрибуты, например, form.name ссылается на поле для имени, которое отображается сразу после CSRF-токена.

Атрибут form.phone_numbers ссылается на контейнер FieldList со списком телефонных номеров. Шаблон перебирает элементы этого списка, чтобы отобразить каждую подформу с ее двумя полями, снова используя макросы для рендеринга полей. Поскольку список был настроен с минимальным количеством записей, равным одному, мы гарантированно получим одну запись при первом рендеринге формы.

Наконец, кнопка "Add another" отображается аналогично базовому примеру. Версия функции addPhone(), использованная в том примере, была довольно короткой, так как ей нужно было только дублировать первое поле и добавлять его как последний дочерний элемент родительского <div>.

В этой версии addPhone() нужно внести некоторые изменения, так как вместо использования одного и того же имени поля, которые являются частью списка, должны следовать соглашению об именах, используемому классами FieldList и FormField WTForms.

function addPhone() {
  const firstPhone = document.querySelector('.phoneField');
  const newPhone = document.createElement('div');
  newPhone.classList.add('phoneField');
  newPhone.innerHTML = firstPhone.innerHTML;
  const newIndex = document.querySelectorAll('.phoneField').length;
  const number = newPhone.querySelector('#phone_numbers-0-number');
  number.id = `phone_numbers-${newIndex}-number`;
  number.name = number.id;
  number.value = '';
  const type = newPhone.querySelector('#phone_numbers-0-type_');
  type.id = `phone_numbers-${newIndex}-type_`;
  type.name = type.id;
  type.value = 'home';
  document.getElementById('phoneFields').appendChild(newPhone);
  document.querySelector('.phoneField:last-child input').focus();
  return false;
}

Первые четыре строки этой функции дублируют набор полей, связанных с первым телефонным номером, и идентичны базовому примеру. После создания копии мы должны внести некоторые изменения в имена и идентификаторы двух полей, которые должны следовать шаблону {имя-списка-полей}-{индекс}-{имя-поля-подформы}, чтобы быть распознанными Flask-WTF.

Для генерации правильных имен и идентификаторов переменная newIndex инициализируется индексом, который должны иметь новые поля. Это значение рассчитывается путем подсчета количества элементов с классом phoneField на странице. Затем для двух полей в каждой подформе имена и идентификаторы (которые были скопированы из первой записи телефона) заменяются на правильные имена для индекса добавляемой записи. В этом примере имена имеют вид phone_numbers-{newIndex}-number и phone_numbers-{newIndex}-type_, где newIndex начинается с нуля. Скопированные поля могут также содержать предварительно заполненные значения, если форма редактируется, поэтому на всякий случай значения полей также сбрасываются.

Последние три строки функции вставляют элемент newPhone на страницу как последний дочерний элемент phoneFields, как это делалось в базовом примере.

В принципе, вот и всё. Этот пример содержит все преимущества Flask-WTF, включая защиту от CSRF, полную валидацию даже для динамически вставленных полей и возможность предварительного заполнения формы данными для редактирования. Ниже вы можете увидеть скриншот, демонстрирующий работу валидаторов DataRequired и Regexp на недействительных телефонных номерах.

Удаление записей

Два примера, которые мы рассмотрели, показывают, как добавлять новые элементы. Однако этого может быть недостаточно для некоторых приложений, где также нужно предоставить пользователю возможность удалять элементы из формы. Давайте обсудим, как это можно сделать, если вы захотите добавить эту функциональность самостоятельно.

При использовании решения только на Flask удаление элемента может быть выполнено просто путем удаления полей формы со страницы. Это делается через поиск элемента или элементов, которые нужно удалить, и вызова метода remove() на них. Поскольку в этом решении все поля имеют одинаковое имя, удаление элементов не влияет на оставшиеся поля формы.

Однако при использовании Flask-WTF удаление элементов сложнее, так как имена полей должны удовлетворять строгой последовательной нумерации. То есть любые поля, которые идут после удаленных, должны быть перенумерованы для сохранения строгой последовательности. Чтобы избежать этой проблемы, можно скрыть удаленные элементы вместо их удаления, что визуально будет иметь тот же эффект, но сохранит все поля в последовательности. Чтобы сервер игнорировал скрытые элементы, в них можно установить заранее определенное значение, которое сервер будет определять как удаленную запись.

Дело за вами ?

?Если тебе интересны и другие полезные материалы по IT и Python, то можешь подписаться на мой канал PythonTalk или посетить сайт OlegTalks?

Комментарии (1)


  1. dopusteam
    09.02.2025 14:32

    Функция addPhone() завершается возвратом false. Обратите внимание, что элемент <a> определяет свой обработчик onclick как return addPhone(). Это отправляет false обратно в браузер, чтобы сообщить, что событие клика обработано и браузеру не нужно беспокоиться о нем. Без этого браузер выполнит переход по ссылке, что нам не нужно.

    Почему бы не сделать addPhone кнопкой, а не ссылкой?