跳到内容

如何使用表单事件动态修改表单

编辑此页

通常,表单无法静态创建。在本文中,您将学习如何基于三个常见的用例自定义表单

  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,
        ]);
    }
}

注意

如果您还不熟悉这部分代码,您可能需要退后一步,先查看 表单 文章,然后再继续。

假设这个表单使用一个虚构的“产品”类,该类只有两个属性(“名称”和“价格”)。从这个类生成的表单看起来完全相同,无论是在创建新产品还是在编辑现有产品(例如,从数据库中获取的产品)时。

现在假设,您不希望用户在对象创建后能够更改 name 值。为此,您可以依赖 Symfony 的 EventDispatcher 组件系统来分析对象上的数据,并根据 Product 对象的数据修改表单。在本文中,您将学习如何为表单添加这种级别的灵活性。

向表单类添加事件监听器

因此,不是直接添加 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 {
            // ... adding the name field if needed
        });
    }

    // ...
}

目标是仅在底层 Product 对象是新对象(例如,尚未持久化到数据库)时才创建 name 字段。基于此,事件监听器可能如下所示

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();

        // checks if the Product object is "new"
        // If no data is passed to the form, the data is "null".
        // This should be considered a new "Product"
        if (!$product || null === $product->getId()) {
            $form->add('name', TextType::class);
        }
    });
}

注意

FormEvents::PRE_SET_DATA 行实际上解析为字符串 form.pre_set_dataFormEvents 服务于组织目的。它是一个中心位置,您可以在其中找到所有可用的表单事件。您可以通过 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
    {
        // Tells the dispatcher that you want to listen on the form.pre_set_data
        // event and that the preSetData method should be called.
        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 {
            // ... add a choice list of friends of the current application user
        });
    }
}

现在的问题是如何获取当前用户并创建一个仅包含该用户好友的选择字段。这可以通过将 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)
        ;

        // grab the user, do a quick sanity check that one exists
        $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()) {
                // we don't need to add the friend field because
                // the message will be addressed to a fixed friend
                return;
            }

            $form = $event->getForm();

            $formOptions = [
                'class' => User::class,
                'choice_label' => 'fullName',
                'query_builder' => function (UserRepository $userRepository) use ($user): void {
                    // call a method on your repository that returns the query builder
                    // return $userRepository->createFriendsQueryBuilder($user);
                },
            ];

            // create the field, this is similar the $builder->add()
            // field name, field type, field options
            $form->add('friend', EntityType::class, $formOptions);
        });
    }

    // ...
}

注意

您可能想知道,既然您已经可以访问 User 对象,为什么不直接在 buildForm() 中使用它并省略事件监听器呢?这是因为在 buildForm() 方法中这样做会导致整个表单类型被修改,而不仅仅是这一个表单实例。这通常可能不是问题,但从技术上讲,单个表单类型可以在单个请求中用于创建多个表单或字段。

使用表单

如果您正在使用默认的 services.yaml 配置,则由于 autowireautoconfigure,您的表单已准备好使用。否则,将表单类注册为服务,并使用 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
// inside some other "form type" class
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();

                // this would be your entity, i.e. 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 监听器。如果您向表单子项(例如,sport)添加 POST_SUBMIT 监听器,并向父表单添加新的子项,则 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
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 {
                // this would be your entity, i.e. SportMeetup
                $data = $event->getData();

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

        $builder->get('sport')->addEventListener(
            FormEvents::POST_SUBMIT,
            function (FormEvent $event) use ($formModifier): void {
                // It's important here to fetch $event->getForm()->getData(), as
                // $event->getData() will get you the client data (that is, the ID)
                $sport = $event->getForm()->getData();

                // since we've added the listener to the child, we'll have to pass on
                // the parent to the callback function!
                $formModifier($event->getForm()->getParent(), $sport);
            }
        );

        // by default, action does not appear in the <form> tag
        // you can set this value by passing the controller route
        $builder->setAction($options['action']);
    }

    // ...
}

您可以看到,您需要监听这两个事件并具有不同的回调,仅仅是因为在两种不同的场景中,您可以使用的数据在不同的事件中可用。除此之外,监听器在给定的表单上始终执行完全相同的操作。

提示

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()) {
            // ... save the meetup, redirect etc.
        }

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

    // ...
}

关联的模板使用一些 JavaScript 来根据 sport 字段中的当前选择更新 position 表单字段

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 字段的主要好处是,不需要额外的服务器端代码;上面生成提交表单的所有代码都可以重用。

这项工作,包括代码示例,均根据 Creative Commons BY-SA 3.0 许可获得许可。
目录
    版本