Як динамічно модифікувати форми, використовуючи події форм

Дата оновлення перекладу 2022-12-14

Як динамічно модифікувати форми, використовуючи події форм

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

  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

Якщо цей відрізок коду не виглядає знайомим, то вам скоріш за все необхідно повернутися на крок назад та перечитати статтю Форми перед тим, як продовжувати.

На хвилину припустіть, що ця форма використовує уявний клас "Продукт", який має лише дві властивості ("ім'я" та "ціна"). Форма, згенерована з цього класу, буде виглядати абсолютно однаково, незалежно від того, чи було створено новий Продукт, чи редагується старий (наприклад, продукт, отриманий з бази даних).

Тепер припустіть, що ви не хочете, щоб користувач міг змінюватися значення 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) {
            // ... додавання імені поля за необхідності
        });
    }

    // ...
}

Ціллю є створення поля 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): void
{
    // ...
    $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/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) {
            // ... додайте список обраних друзів поточного користувача додатку
        });
    }
}

Тепер проблема в тому, щоб отримати поточного користувача та створити поле вибору, яке містить лише друзів користувача. Це можна зробити впровадивши сервіс Security у тип форми, щоб ви могли отримати поточний об'єкт користувача:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use Symfony\Component\Security\Core\Security;
// ...

class FriendMessageFormType extends AbstractType
{
    private $security;

    public function __construct(Security $security)
    {
        $this->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
60
61
// 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\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Security\Core\Security;
// ...

class FriendMessageFormType extends AbstractType
{
    private $security;

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

            $form = $event->getForm();

            $formOptions = [
                'class' => User::class,
                'choice_label' => 'fullName',
                'query_builder' => function (UserRepository $userRepository) use ($user) {
                    // викликати метод сховища, який повертає створювач запитів
                    // повернути $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
// 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) {
                $form = $event->getForm();

                // це буде ваша сутність, тобто SportMeetup
                $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), та додасте ще дочірні форми до батьківської, компонент Form виявить нове поле автоматично та пов'яже його з відправленими користувацькими даними.

Тип виглядатиме так:

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
// 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) {
            $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) {
                // Це буде ваша сутність, тобто 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);
            }
        );
    }

    // ...
}

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

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
// 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
{
    public function create(Request $request): Response
    {
        $meetup = new SportMeetup();
        $form = $this->createForm(SportMeetupType::class, $meetup);
        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            // ... зберегти збори, переадресувати і т.д.
        }

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

    // ...
}

Асоційований шаблон використовує 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
{# templates/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,
    complete: function(html) {
      // Замінити поточну позицію на полі ...
      $('#meetup_position').replaceWith(
        // ... тією, що повернулася з відповіді AJAX.
        $(html.responseText).find('#meetup_position')
      );
      // Тепер поле позицій відображає правильні позиції.
    }
  });
});
</script>

Головною перевагою відправки форми цілком, замість простого отримання оновленного поля position, є те, що немає необхідності у додатковому коді серверної сторони; весь код, використаний для генерування відправленої форми вище, можна використовувати повторно.