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

Дата обновления перевода 2024-07-19

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

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

  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/Form/Type/ProductType.php
namespace App\Form\Type;

use App\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): void
    {
        $builder->add('name');
        $builder->add('price');
    }

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

Note

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

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

Теперь предположите, что вы не хотите, чтобы пользователь могу изменять значение 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/Form/Type/ProductType.php
namespace App\Form\Type;

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

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

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

    // ...
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ...
public function buildForm(FormBuilderInterface $builder, array $options): void
{
    // ...
    $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void {
        $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/Form/EventListener/AddNameFieldSubscriber.php
namespace App\Form\EventListener;

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

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

    public function preSetData(FormEvent $event): void
    {
        $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/Form/Type/ProductType.php
namespace App\Form\Type;

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

class ProductType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $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
// src/Form/Type/FriendMessageFormType.php
namespace App\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;

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

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

1
2
3
4
5
6
7
8
9
10
11
12
use Symfony\Bundle\SecurityBundle\Security;
// ...

class FriendMessageFormType extends AbstractType
{
    public function __construct(
        private Security $security,
    ) {
    }

    // ....
}

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

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

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
// src/Form/Type/FriendMessageFormType.php
namespace App\Form\Type;

use App\Entity\User;
use Doctrine\ORM\EntityRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
// ...

class FriendMessageFormType extends AbstractType
{
    public function __construct(
        private Security $security,
    ) {
    }

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

        // возьмите пользователя и проведите быструю проверку на предмет его существовани
        $user = $this->security->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): void {
            if (null !== $event->getData()->getFriend()) {
                // нам не нужно добавлять поле друга, потому что
                // сообщение будет отправляться зафиксированному другу
                return;
            }

            $form = $event->getForm();

            $formOptions = [
                'class' => User::class,
                'choice_label' => 'fullName',
                'query_builder' => function (UserRepository $userRepository) use ($user): void {
                    // вызовать метод хранилища, который возвращает создатель запросов
                    // return $userRepository->createFriendsQueryBuilder($user);
                },
            ];

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

    // ...
}

Note

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

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

        // ...
    }
}

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

1
2
3
4
5
// внутри какого-то другого класса "тип формы"
public function buildForm(FormBuilderInterface $builder, array $options): void
{
    $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
44
45
// src/Form/Type/SportMeetupType.php
namespace App\Form\Type;

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

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

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

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

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

                $form->add('position', EntityType::class, [
                    'class' => Position::class,
                    '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
56
57
58
59
60
// src/Form/Type/SportMeetupType.php
namespace App\Form\Type;

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

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

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

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

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

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

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

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

        // по умолчанию, действие не появляется в теге <form>
        // вы можете установить это значение, передав маршрут контроллера
        $builder->setAction($options['action']);
    }

    // ...
}

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

Tip

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

Единственное, чего ещё не хватает, это чтобы клиентская сторона обновила вашу форму после выбора спорта. Это нужно сделать, заставив 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
29
// src/Controller/MeetupController.php
namespace App\Controller;

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

class MeetupController extends AbstractController
{
    #[Route('/create', name: 'app_meetup_create', methods: ['GET', 'POST'])]
    public function create(Request $request): Response
    {
        $meetup = new SportMeetup();
        $form = $this->createForm(SportMeetupType::class, $meetup, ['action' => $this->generateUrl('app_meetup_create')]);
        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            // ... сохранить собрание, переадресовать и т.д.
        }

        return $this->renderForm('meetup/create.html.twig', [
            'form' => $form,
        ]);
    }

    // ...
}

Ассоциированый шаблон использует JavaScript, чтобы обновить поле формыposition в соответствии с текущим выбором в поле 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
{# templates/meetup/create.html.twig #}
{{ form_start(form, { attr: { id: 'sport_meetup_form' } }) }}
    {{ form_row(form.sport) }}    {# <select id="meetup_sport" ... #}
    {{ form_row(form.position) }} {# <select id="meetup_position" ... #}
    {# ... #}
{{ form_end(form) }}

<script>
    const form = document.getElementById('sport_meetup_form');
    const form_select_sport = document.getElementById('meetup_sport');
    const form_select_position = document.getElementById('meetup_position');

    const updateForm = async (data, url, method) => {
      const req = await fetch(url, {
        method: method,
        body: data,
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
            'charset': 'utf-8'
        }
      });

      const text = await req.text();

      return text;
    };

    const parseTextToHtml = (text) => {
      const parser = new DOMParser();
      const html = parser.parseFromString(text, 'text/html');

      return html;
    };

    const changeOptions = async (e) => {
      const requestBody = e.target.getAttribute('name') + '=' + e.target.value;
      const updateFormResponse = await updateForm(requestBody, form.getAttribute('action'), form.getAttribute('method'));
      const html = parseTextToHtml(updateFormResponse);

      const new_form_select_position = html.getElementById('meetup_position');
      form_select_position.innerHTML = new_form_select_position.innerHTML;
    };

    form_select_sport.addEventListener('change', (e) => changeOptions(e));
</script>

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