如何通过表单事件动态修改表单

2.8 版本
维护中的版本

通常情况下,表单不能静态被创建。在本章,你将学到如何基于三种常见场景来定制你的表单:

  1. 基于底层数据的定制表单

    例如:你有一个 “Product(产品)” 表单,需要基于正在编辑的产品的相关数据来修改、添加、移除一个字段。

  2. 如何根据用户数据动态生成表单

    例如:创建一个 “Friend Message” 表单,你需要构建一个只包含 “同当前的经过验证的用户是好友关系” 的用户下拉列表。

  3. 配合已提交表单项进行动态生成

    例如:对一个注册表单,你有一个 “country” 字段还有一个 “state” 字段,后者应该随着前者取值的改变而动态加载。

如果你想学习关于表单事件背后更多的基础知识,参考 表单事件 文档。

基于底层数据来定制表单 

在生成动态表单之前,回忆一下原始表单类的样子:

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” 和 “price”) “Product” 类。无论正在创建一个新的产品,还是在编辑(即,产品从数据库中获取)一个已有的产品,根据这个类所生成的表单看起来都是完全一样的。

现在假设,一旦对象被创建,你不希望用户更改 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
21
// 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) {
            // ... adding the name field if needed
            // ... 若需要,添加 name 字段
        });
    }
 
    // ...
}

目标是,仅当底层 Product 对象是新的(如,尚未入库)时,才创建 name 字段。有鉴于此,event listener(事件监听)看上去可能是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ...
public function buildForm(FormBuilderInterface $builder, array $options)
{
    // ...
    $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
        $product = $event->getData();
        $form = $event->getForm();
 
        // check 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"
        // 检查 Product 对象是否 “全新”
        // 如果没有数据传给表单,则 data 会是 “null”
        // 这便可认定,是一个全新的 “Product” 对象
        if (!$product || null === $product->getId()) {
            $form->add('name', TextType::class);
        }
    });
}

Note

FormEvents::PRE_SET_DATA 这一行实际上是解析 form.pre_set_data 字符串。FormEvents 承载了 “组织” 功能。它居于核心位置,以便你可以找到各种不同的表单事件。你可以通过 FormEvents 类来查看完整的表单事件列表。

向表单类添加一个订阅器(Event Subscriber) 

为了更好的复用性,或者你的事件监听逻辑复杂,你可以在 event subscriber 中创建 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
28
29
// 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()
    {
        // Tells the dispatcher that you want to listen on the form.pre_set_data
        // event and that the preSetData method should be called.
        // 告诉dispatcher,你希望对 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
// 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());
    }
 
    // ...

如何根据用户数据动态生成表单 

有时候你想动态生成一些数据,不仅是基于表单数据,还可能基于其他一些内容 - 像是来自当前用户数据等。假设你有一个社交网站,用户只能和被标记为好友互发信息。此时,把消息发给谁的 “下拉列表” 应该仅包含本人好友的那部分用户。

创建Form Type(表单类型) 

使用事件监听后,表单看起来是这样的:

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
// 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) {
            // ... add a choice list of friends of the current application user
            // ... 添加当前用户好友的choice list(选项清单)
        });
    }
}

现在的问题是要获取到当前用户,并创建一个包含用户好友的 choice 字段。

幸运的是,向表单中注入服务十分简单。这可在构造器中完成:

1
2
3
4
5
6
private $tokenStorage;
 
public function __construct(TokenStorageInterface $tokenStorage)
{
    $this->tokenStorage = $tokenStorage;
}

Note

你可能好奇,但现在你已经可以访问到用户(通过 token storage),那为什么不直接在 buildForm() 中使用它并省略监听器呢?这是因为在 buildForm() 方法中这样做的话会导致整个表单类型都被改变,而不仅仅是这一个表单实例。这通常不是问题,但从技术层面来讲,一个单一的表单类型应被用于一个单一的请求来创建多个表单或者字段。

自定义表单类型 

现已准备就绪,你可以使用security helper的相关功能来填充监听相关逻辑了:

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
62
63
64
65
66
67
68
// src/AppBundle/Form/Type/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\Security;
// ...
 
class FriendMessageFormType extends AbstractType
{
    private $security;
 
    public function __construct(Security $security)
    {
        $this->security = $security;
    }
 
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $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) {
                $form = $event->getForm();
 
                $formOptions = array(
                    'class'         => User::class,
                    'choice_label'  => 'fullName',
                    'query_builder' => function (EntityRepository $er) use ($user) {
                        // build a custom query
                        // return $er->createQueryBuilder('u')->addOrderBy('fullName', 'DESC');
 
                        // or call a method on your repository that returns the query builder
                        // the $er is an instance of your UserRepository
                        // return $er->createOrderByFullNameQueryBuilder();
                        // 构建一个自定义查询
                        // return $er->createQueryBuilder('u')->addOrderBy('fullName', 'DESC');
 
                        // 或者调用repository方法来返回qb
                        // $er 对象是你的 UserRepository 实例
                        // 返回 $er->createOrderByFullNameQueryBuilder();
                    },
                );
 
                // create the field, this is similar the $builder->add()
                // field name, field type, data, options
                $form->add('friend', EntityType::class, $formOptions);
            }
        );
    }
 
    // ...
}

Note

multipleexpanded 表单选项,默认值都是 false,这是因为好友的字段类型是 EntityType::class

使用表单 

我们的表单现在可以使用了。但首先,因为有 __construct() 方法,你需要把表单注册为一个服务,并为它添加 form.type 标签:

1
2
3
4
5
6
7
# app/config/config.yml
services:
    app.form.friend_message:
        class: AppBundle\Form\Type\FriendMessageFormType
        arguments: ['@security.token_storage']
        tags:
            - { name: form.type }
1
2
3
4
5
6
7
<!-- app/config/config.xml -->
<services>
    <service id="app.form.friend_message" class="AppBundle\Form\Type\FriendMessageFormType">
        <argument type="service" id="security.token_storage" />
        <tag name="form.type" />
    </service>
</services>
1
2
3
4
5
6
7
8
9
10
// app/config/config.php
use Symfony\Component\DependencyInjection\Reference;
 
$definition = new Definition(
    'AppBundle\Form\Type\FriendMessageFormType',
    array(new Reference('security.token_storage'))
);
$definition->addTag('form.type');
 
$container->setDefinition('app.form.friend_message', $definition);

在继承了 Controller 基类的 controller 中,你可以直接调用:

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
// inside some other "form type" class
public function buildForm(FormBuilderInterface $builder, array $options)
{
    $builder->add('message', FriendMessageFormType::class);
}

配合已提交表单项进行动态生成 

可能会出现的另一种可能是,你希望根据用户提交的特定数据来自定义表单。例如,你有一个运动会的报名登记表。有些活动会允许你在字段上去指定你的偏好位置。不妨假定这是一个 choice 字段。只是,这些选择项,将依赖于不同的运动项目。拿足球来说,就会有前锋,后卫,守门员等等...棒球就会有投手,但不会有守门员。你需要选择正确的选项以便验证通过。

报名(meetup)将作为一个 entity 字段而传入表单。所以我们像下面这样能够访问到每一个运动:

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
// 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();
 
                // this would be your entity, i.e. SportMeetup
                // 这个应当时你的 entity, 即 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 监听添加到你的新字段所依赖的字段中去。如果你对一个子表单(即,sport)添加了 POST_SUBMIT 监听,同时向父表单添加一个新的子表单,表单组件就会自动侦测出新的字段,并把它映射为客户端提交的数据。

此一表单类型如下所示:

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/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) {
                // this would be your entity, i.e. SportMeetup
                // 这应该是你的 entity, 即 SportMeetup
                $data = $event->getData();
 
                $formModifier($event->getForm(), $data->getSport());
            }
        );
 
        $builder->get('sport')->addEventListener(
            FormEvents::POST_SUBMIT,
            function (FormEvent $event) use ($formModifier) {
                // It's important here to fetch $event->getForm()->getData(), as
                // $event->getData() will get you the client data (that is, the ID)
                // 此处取数据非常重要的一点是 $event->getForm()->getData(), 因为
                // $event->getData() 只会给你客户端的数据(也就是,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 functions!
                // 由于我们对子表单项添加了监听,我们就必须把父表单传给回调函数!
                $formModifier($event->getForm()->getParent(), $sport);
            }
        );
    }
 
    // ...
}

你可以看到,你要监听这两个事件,并且需要不同的回调,这是因为在两个不同的场景中,你可以在不同的事件中使用数据。此外,监听器在给定的表单中总是会精确执行同一件事情。

还差一件事就是客户端在运动项目被选中之后的更新。这是由 ajax 回调来处理的。假设你有一个运动会报名的controller:

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/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->isValid()) {
            // ... save the meetup, redirect etc.
            // ... 存储报名信息、重定向等
        }
 
        return $this->render(
            'AppBundle:Meetup:create.html.twig',
            array('form' => $form->createView())
        );
    }
 
    // ...
}

相关的模板会根据表单 sport 字段当前的选择项,通过使用一些javascript来更新 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
{# 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');
// When sport gets selected ...
// 当运动项目被选择时 ...
$sport.change(function() {
  // ... retrieve the corresponding form.
  // ... 取出对应的表单项
  var $form = $(this).closest('form');
  // Simulate form data, but only include the selected sport value.
  // 模拟表单数据,但是只包括被选择了的运动项目值
  var data = {};
  data[$sport.attr('name')] = $sport.val();
  // Submit data via AJAX to the form's action path.
  // 通过 AJAX 把数据提交到表单的action路径
  $.ajax({
    url : $form.attr('action'),
    type: $form.attr('method'),
    data : data,
    success: function(html) {
      // Replace current position field ...
      // 替换掉当前的 position 字段 ...
      $('#meetup_position').replaceWith(
        // ... with the returned one from the AJAX response.
        // ... 用 AJAX 响应返回来的值(来替换)
        $(html).find('#meetup_position')
      );
      // Position field now displays the appropriate positions.
      // Position 字段现在显示的是正确的“位置”
    }
  });
});
</script>
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');
// When sport gets selected ...
$sport.change(function() {
  // ... retrieve the corresponding form.
  var $form = $(this).closest('form');
  // Simulate form data, but only include the selected sport value.
  var data = {};
  data[$sport.attr('name')] = $sport.val();
  // Submit data via AJAX to the form's action path.
  $.ajax({
    url : $form.attr('action'),
    type: $form.attr('method'),
    data : data,
    success: function(html) {
      // Replace current position field ...
      $('#meetup_position').replaceWith(
        // ... with the returned one from the AJAX response.
        $(html).find('#meetup_position')
      );
      // Position field now displays the appropriate positions.
    }
  });
});
</script>

这样提交的主要好处是表单仅仅提取更新的position字段,不需要额外的服务器端代码;上面生成表单提交的所有的代码都可以重用。

抑制表单验证 

使用 POST_SUBMIT 事件即可禁用表单验证并阻止 ValidationListener 被调用。

这样做的原因是,即使你设置 validation_groupsfalse,也会有一些完整性的检查被执行。例如,检查上传的文件是否过大,是否有不存在的字段被提交。使用监听器来彻底禁用之:

1
2
3
4
5
6
7
8
9
10
11
12
13
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); // Always set a higher priority than ValidationListener
             // 设置一个始终比 ValidationListener 高的优先级
 
    // ...
}

Caution

这样一来,你就突然关闭了很多别的东西,不仅仅是表单验证,因为 POST_SUBMIT 事件可能拥有其他的监听。

本文,包括例程代码在内,采用的是 Creative Commons BY-SA 3.0 创作共用授权。

登录symfonychina 发表评论或留下问题(我们会尽量回复)