Как динамически модифицировать формы, используя события форм

Зачастую, форма не может быть создана статичной. В этой статье, вы узнаете, как настроить вашу форму, основываясь на трёх распространённых случаях использования:

  1. Настройка вашей формы, основанная на базовых данных

    Пример: у вас есть форма "Продукт" и потребность изменить/добавить/ удалить поле, основываясь на данных основного продукта, который редактируется.

  2. Как динамически генерировать формы, основываясь на пользовательских данных

    Пример: вы создали форму "Сообщение другу" и хотите построить выпадающее меню, которое содержит только пользователей, которые являются друзьями текущего аутентифицированного пользователя.

  3. Динамическое генерирование для отправленной формы

    Пример: в форме регистрации, у вас есть поле "страна" и поле "город", которое должно динамически изменяться, в зависимости от значения в поле "страна".

Если вы хотите узнать больше об основах, стоящих за событиями форм, вы можете посмотреть документацию События форм.

Настройка вашей формы, основанная на базовых данных

Перед тем, как начать генерирование динамической формы, вспомните, как выглядит голый класс формы:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// src/AppBundle/Form/Type/ProductType.php
namespace AppBundle\Form\Type;

use AppBundle\Entity\Product;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class ProductType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->add('name');
        $builder->add('price');
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => Product::class,
        ));
    }
}

Note

Если эта часть кода не выглядит знакомой, то вам скорее всего нужно вернуться на шаг назад и перечитать статью Формы перед тем, как продолжать.

На минуту предположите, что эта форма использует воображаемый класс "Продукт", который имеет только два свойства ("имя" и "цена"). Форма, сгенерированная из этого класса, будетвыглядеть абсолютно одинаково, независимо от того, был ли создан новый Продукт или редактируется старый (например, продукт, полученный из БД).

Теперь предположите, что вы не хотите, чтобы пользователь могу изменять значение name, когда объект уже был создан. Чтобы сделать это, вы можете положиться на систему Symfony компонент Диспетчер событий (EventDispatcher), чтобы проанализировать данные в объекте и изменить форму, основываясь на данных объекта Продукт. В этой статье вы узнаете, как добавлять этот уровень гибкости к вашим формам.

Добавление слушателя событий к классу формы

Итак, вмето того, чтобы напрямую добавить виджет name, ответственность за создание этого конкретного поля делегируется слушателю событий:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// src/AppBundle/Form/Type/ProductType.php
namespace AppBundle\Form\Type;

// ...
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;

class ProductType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->add('price');

        $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
            // ... добавлеие имени поля при необходимости
        });
    }

    // ...
}

Целью является создание поля name только в случае, если базовый объект Product - новый (например, не был сохранён в БД). Основываясь на этом, слушатель событий может выглядеть следующим образом:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// ...
public function buildForm(FormBuilderInterface $builder, array $options)
{
    // ...
    $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
        $product = $event->getData();
        $form = $event->getForm();

        // проверьте, является ли объект Продукт "новым"
        // Если в форму не были переданы данные, то данные - "null".
        // Это должно быть воспринято, как новый "Продукт"
        if (!$product || null === $product->getId()) {
            $form->add('name', TextType::class);
        }
    });
}

Note

Строчка FormEvents::PRE_SET_DATA на самом деле разрешает строку form.pre_set_data. FormEvents служит в целях организации. Это централизованная локация, в которой вы можете найти все доступные виды событий форм. Вы можете просмотреть полный список событий форм, с помощью класса FormEvents.

Добавление подписика событий в класс формы

Для улучшения повторного использования или при наличии тяжелой логики в вашем слушателе событий, вы также можете переместить логику для создания поля name в подписчик событий:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// src/AppBundle/Form/EventListener/AddNameFieldSubscriber.php
namespace AppBundle\Form\EventListener;

use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType;

class AddNameFieldSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents()
    {
        // Сообщает диспетчеру, что вы хотите слушать событие form.pre_set_data
        // и что должен быть вызван метод preSetData.
        return array(FormEvents::PRE_SET_DATA => 'preSetData');
    }

    public function preSetData(FormEvent $event)
    {
        $product = $event->getData();
        $form = $event->getForm();

        if (!$product || null === $product->getId()) {
            $form->add('name', TextType::class);
        }
    }
}

ОТлично! Теперь используйте это в вашем классе формы:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// src/AppBundle/Form/Type/ProductType.php
namespace AppBundle\Form\Type;

// ...
use AppBundle\Form\EventListener\AddNameFieldSubscriber;

class ProductType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->add('price');

        $builder->addEventSubscriber(new AddNameFieldSubscriber());
    }

    // ...
}

Как динамически генерировать формы, основываясь на пользовательских данных

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

Создание типа формы

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/AppBundle/Form/Type/FriendMessageFormType.php
namespace AppBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;

class FriendMessageFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('subject', TextType::class)
            ->add('body', TextareaType::class)
        ;
        $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
            // ... добавьте сптсок выбранных друзей текущего пользователя приложения
        });
    }
}

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

К счастью, достаточно просто внедрить сервис внутри формы. Это можно сделать в конструкторе:

1
2
3
4
5
6
private $tokenStorage;

public function __construct(TokenStorageInterface $tokenStorage)
{
    $this->tokenStorage = $tokenStorage;
}

Note

Вам может быть интересно, почему теперь, когда у вас есть доступ к пользователю (через токен хранилища), вы не можете просто использовать его напрямую в buildForm() и опустить слушателя событий? Потому что сделав так в методе buildForm(), вы придёте к тому, что весь тип формы будет изменён, а не только один экземпляр формы. Обычно это может не стать проблемой, но чисто технически, один тип формы можеть быть использован по одному запросу для создания многих форм или полей.

Настройка типа формы

Теперь, когда у вас есть всё основное, вы можете воспользоваться преимуществами TokenStorageInterface и заполнить логику слушателя:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// src/AppBundle/FormType/FriendMessageFormType.php

use AppBundle\Entity\User;
use Doctrine\ORM\EntityRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
// ...

class FriendMessageFormType extends AbstractType
{
    private $tokenStorage;

    public function __construct(TokenStorageInterface $tokenStorage)
    {
        $this->tokenStorage = $tokenStorage;
    }

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('subject', TextType::class)
            ->add('body', TextareaType::class)
        ;

        // возьмите пользователя и проведите быструю проверку на предмет его существовани
        $user = $this->tokenStorage->getToken()->getUser();
        if (!$user) {
            throw new \LogicException(
                'The FriendMessageFormType cannot be used without an authenticated user!'
            );
        }

        $builder->addEventListener(
            FormEvents::PRE_SET_DATA,
            function (FormEvent $event) use ($user) {
                $form = $event->getForm();

                $formOptions = array(
                    'class'         => User::class,
                    'choice_label'  => 'fullName',
                    'query_builder' => function (EntityRepository $er) use ($user) {
                        // построить пользовательский запрос
                        // вернуть $er->createQueryBuilder('u')->addOrderBy('fullName', 'DESC');

                        // или вызвать метод в вашем хранилище, который возвращает разработчик запросов
                        // $er - экземпляр вашего UserRepository
                        // вернуть $er->createOrderByFullNameQueryBuilder();
                    },
                );

                // создать поле, схожее с $builder->add()
                // имя поля, тип поля, данные, опции
                $form->add('friend', EntityType::class, $formOptions);
            }
        );
    }

    // ...
}

Note

Опции формы multiple и expanded будут по умолчанию установлены, как "false", потому что тип поля друзья - EntityType::class.

Использование формы

Если вы используете автомонтирование и автоконфигурацию, то ваша форма готова к использованию!

Tip

Если вы не используете автомонтирование и автоконфигурацию, смотрите How to Access Services or Config from Inside a Form, чтобы узнать, как зарегистрировать ваш тип формы в качестве сервиса.

В контроллере, создайте форму, как обычно:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use Symfony\Bundle\FrameworkBundle\Controller\Controller;

class FriendMessageController extends Controller
{
    public function newAction(Request $request)
    {
        $form = $this->createForm(FriendMessageFormType::class);

        // ...
    }
}

Вы также можете встроить тип формы в другую форму:

1
2
3
4
5
// внутри какого-то другого класса "тип формы"
public function buildForm(FormBuilderInterface $builder, array $options)
{
    $builder->add('message', FriendMessageFormType::class);
}

Динамическое генерирование для отправленной формы

Ещё один случай, который может возникнуть, это если вы хотите настроить форму конкретно для даных, отправленных пользователем. Например, представьте, что у вас есть форма регистрации на спортивные собрания. Некоторые события позволят вам указывать предпочитаемую позицию на поле. Это будет, например, поле choice. Однако, возможные варианты выбора будут зависеть от каждого вида спорта. Футбол будет иметь атаку, защиту, вратаря и т.д. Бейсбол - питчера, но не вратаря. Вам нужны будут правильные опции, чтобы провести валидацию.

Собрание передаётся форме как сущность поля. Так что мы можем получить доступ к каждому спорту таким образом:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// src/AppBundle/Form/Type/SportMeetupType.php
namespace AppBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
// ...

class SportMeetupType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('sport', EntityType::class, array(
                'class'       => 'AppBundle:Sport',
                'placeholder' => '',
            ))
        ;

        $builder->addEventListener(
            FormEvents::PRE_SET_DATA,
            function (FormEvent $event) {
                $form = $event->getForm();

                // это будет ваша сущность, т.е. SportMeetup
                $data = $event->getData();

                $sport = $data->getSport();
                $positions = null === $sport ? array() : $sport->getAvailablePositions();

                $form->add('position', EntityType::class, array(
                    'class'       => 'AppBundle:Position',
                    'placeholder' => '',
                    'choices'     => $positions,
                ));
            }
        );
    }

    // ...
}

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

Однако, всё становится сложнее, когда вы работаете с отправкой формы. Потому что событие PRE_SET_DATA сообщает нам данные, с которыми вы начинаете (например, пустой объект SportMeetup), а не отправленные данные.

В форме мы обычно можем слушать следующие события:

  • PRE_SET_DATA
  • POST_SET_DATA
  • PRE_SUBMIT
  • SUBMIT
  • POST_SUBMIT

Главное - добавлять слушателя POST_SUBMIT в поле, от которого зависит ваше новое поле. Если вы добавите слушателя POST_SUBMIT в дочернюю форму (например, sport), и добавите еще дочерние формы к родительской, компонент Формы обнаружит новое поле автоматически и свяжет его с отправленными пользовательскими данными.

Тип будет выглядеть так:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// src/AppBundle/Form/Type/SportMeetupType.php
namespace AppBundle\Form\Type;

// ...
use Symfony\Component\Form\FormInterface;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use AppBundle\Entity\Sport;

class SportMeetupType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('sport', EntityType::class, array(
                'class'       => 'AppBundle:Sport',
                'placeholder' => '',
            ));
        ;

        $formModifier = function (FormInterface $form, Sport $sport = null) {
            $positions = null === $sport ? array() : $sport->getAvailablePositions();

            $form->add('position', EntityType::class, array(
                'class'       => 'AppBundle:Position',
                'placeholder' => '',
                'choices'     => $positions,
            ));
        };

        $builder->addEventListener(
            FormEvents::PRE_SET_DATA,
            function (FormEvent $event) use ($formModifier) {
                // Это будет ваша сущность, т.е. SportMeetup
                $data = $event->getData();

                $formModifier($event->getForm(), $data->getSport());
            }
        );

        $builder->get('sport')->addEventListener(
            FormEvents::POST_SUBMIT,
            function (FormEvent $event) use ($formModifier) {
                // Тут важно вызвать $event->getForm()->getData(), так как
                // $event->getData() предоставит вам пользовательские данные (т.е. ID)
                $sport = $event->getForm()->getData();

                // так как мы добавили слушателя в дочернюю форму, нам нужно передать
                // родительской форме функции обратнго вызова!
                $formModifier($event->getForm()->getParent(), $sport);
            }
        );
    }

    // ...
}

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

Единственное, чего ещё не хватает, это чтобы клиентская сторона обновила вашу форму после выбора спорта. Это нужно сделать, заставив AJAX сделать обратный вызов в вашем приложении. Представьте, что у вас есть контроллер создания спортивного собрания:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// src/AppBundle/Controller/MeetupController.php
namespace AppBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use AppBundle\Entity\SportMeetup;
use AppBundle\Form\Type\SportMeetupType;
// ...

class MeetupController extends Controller
{
    public function createAction(Request $request)
    {
        $meetup = new SportMeetup();
        $form = $this->createForm(SportMeetupType::class, $meetup);
        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            // ... сохранить собрание, переадресовать и т.д.
        }

        return $this->render(
            'AppBundle:Meetup:create.html.twig',
            array('form' => $form->createView())
        );
    }

    // ...
}

Ассоциированый шаблон использует JavaScript, чтобы обновить поле формы``position`` в соответствии с текущим выбором в поле sport:

  • Twig
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    {# app/Resources/views/Meetup/create.html.twig #}
    {{ form_start(form) }}
        {{ form_row(form.sport) }}    {# <select id="meetup_sport" ... #}
        {{ form_row(form.position) }} {# <select id="meetup_position" ... #}
        {# ... #}
    {{ form_end(form) }}
    
    <script>
    var $sport = $('#meetup_sport');
    // Когда выбран спорт ...
    $sport.change(function() {
      // ... вызвать соответствуюущую форму.
      var $form = $(this).closest('form');
      // Симулировать данные формы, но включать только значение выбранного спорта.
      var data = {};
      data[$sport.attr('name')] = $sport.val();
      // Отправить данные через AJAX по пути действия формы.
      $.ajax({
        url : $form.attr('action'),
        type: $form.attr('method'),
        data : data,
        success: function(html) {
          // Заменить текущую позицию на поле ...
          $('#meetup_position').replaceWith(
            // ... той, что вернулась из ответа AJAX.
            $(html).find('#meetup_position')
          );
          // Теперь поле позиций отображает правильные позиции.
        }
      });
    });
    </script>
    
  • PHP
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    <!-- app/Resources/views/Meetup/create.html.php -->
    <?php echo $view['form']->start($form) ?>
        <?php echo $view['form']->row($form['sport']) ?>    <!-- <select id="meetup_sport" ... -->
        <?php echo $view['form']->row($form['position']) ?> <!-- <select id="meetup_position" ... -->
        <!-- ... -->
    <?php echo $view['form']->end($form) ?>
    
    <script>
    var $sport = $('#meetup_sport');
    // Когда выбран спорт ...
    $sport.change(function() {
      // ... вызвать соответствуюущую форму.
      var $form = $(this).closest('form');
      // Симулировать данные формы, но включать только значение выбранного спорта.
      var data = {};
      data[$sport.attr('name')] = $sport.val();
      // Отправить данные через AJAX по пути действия формы.
      $.ajax({
        url : $form.attr('action'),
        type: $form.attr('method'),
        data : data,
        success: function(html) {
          //  Заменить текущую позицию на поле ...
          $('#meetup_position').replaceWith(
            // ... той, что вернулась из ответа AJAX.
            $(html).find('#meetup_position')
          );
          // Теперь поле позиций отображает правильные позиции.
        }
      });
    });
    </script>
    

Главным преимуществом отправки формы целиком, вместо простого извлечения обновлённого поля position, является то, что не требуется дополнительного кода серверской стороны; весь код использованный для генерирования отправленной формы выше, можно использовать повторно.

Подавление валидации формы

Чтобы подавить валидацию формы, вы можете использовать событие POST_SUBMIT и предотвратить вызов ValidationListener.

Это может понадобиться потому, что даже если вы установите validation_groups в значение false, всё равно проводятся некоторые проверки целостности. Например, загруженный файл всё ещё будет проверен на счёт превышения допустимого размера, а форма всё ещё будет проверять, не было ли отправлено несуществующих полей. Чтобы отключить всё это, используйте слушатель:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormEvent;

public function buildForm(FormBuilderInterface $builder, array $options)
{
    $builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) {
        $event->stopPropagation();
    }, 900); // Всегда устанавливайте более высокий приоритете, чем у ValidationListener

    // ...
}

Caution

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

Эта документация является переводом официальной документации Symfony и предоставляется по свободной лицензии CC BY-SA 3.0.