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

3.4 版本
维护中的版本

正在修复中: 阅读时请注意,本文档以下部分正待修复。我们将尽快完成校对。

很多时候,一个表单不能静态被创建。在这一章节,你将会学习如何去定义你的表单-基于三种常见的例子:

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

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

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

例子:你创建一个“Friend Message”表单,并且你需要去构建一个只包含用户好友的下列列表,这些好友都是当前验证过的用户。

3.提交表单动态生成

例子:在一个注册表单,你有一个“country”字段还有一个“state”字段,这个“country”字段的值应该跟着“state”字段的改变而改变。

如果你想要学习更多关于表单事件后面的基础知识,你可以查看表单事件文档。

---基于底层数据的定制表单---

在进入动态表单生成之前,等一等,回忆一下一个赤裸的表单类是什么样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// src/AppBundle/Form/Type/ProductType.php
namespace AppBundle\Form\Type;
 
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' => 'AppBundle\Entity\Product'
        ));
    }
}

Note

如果这段特定的代码你已经不熟悉了,你可能要回到 表单章节 进行一下复习。

暂时假定这个表单利用一个假想的“Product”类,这个类仅有两个属性(“name”和“price”)。无论是否正在创建一个新的产品或者现有产品正在编辑(如,产品从数据库中获取)这个类生成的表单看起来都是完全一样的。

假设,一旦对象被创建,你不希望用户更改name值。要做到这一点,你可以依靠symfony的 EventDispatcher component 系统去分析对象上的数据并根据产品对象的数据修改表单。在这里,你需要学会如何增加表单的灵活性。

---添加一个事件监听到一个表单类---1

因此,它直接添加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) {
            // ... 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)
{
    // ...
    $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"
        if (!$product || null === $product->getId()) {
            $form->add('name', TextType::class);
        }
    });
}

Note

FormEvents::PRE_SET_DATA这一行实际上是解析字符串form.pre_set_dataFormEvents服务是具有组织功能的。他有着非常重要的位置,在这里你可以找到很多不同的表单事件。你可以通过 FormEvents 类来查看完整的表单事件列表。

---添加一个事件订阅器(Event Subscriber)到表单类---2

为了有更好的可重用性或者如果你在事件监听器里有复杂的逻辑,你可以通过在event subscriber中添加name字段来转移逻辑:

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());
    }
 
    // ...
}

现在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()
    {
        // Tells the dispatcher that you want to listen on the form.pre_set_data
        // event and that the preSetData method should be called.
        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

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

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) {
            // ... add a choice list of friends of the current application user
        });
    }
}

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

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

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

Note

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

---自定义表单类型---2

现在您已经基本到位了,你可以利用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 Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Doctrine\ORM\EntityRepository;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
 
// ...
 
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)
        ;
 
        // grab the user, do a quick sanity check that one exists
        $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' => 'AppBundle\Entity\User',
                    'property' => '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();
                    },
                );
 
                // 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

---使用这个表单---2

我们的表单现在就可以使用了。但第一步,因为他有一个__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 类的控制器中,你就可以很容易的调用:

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 字段。这些选择可能会依赖于选择的运动。拿足球来说,就会有前锋,后卫,守门员等等.... 棒球就会有投手,但不会出现守门员。你需要选择正确的选项来验证通过。

活动将作为一个实体字段传入到表单。所以我们能够访问每一个运动,如下:

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();
 
                // this would be your entity, i.e. 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) {
                // 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) {
                // 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 functions!
                $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->isValid()) {
            // ... save the meetup, redirect etc.
        }
 
        return $this->render(
            'AppBundle:Meetup:create.html.twig',
            array('form' => $form->createView())
        );
    }
 
    // ...
}

相关的模板会根据表单sport字段当前的选择来更新position,使用一些javascript:

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');
// 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>
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
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
 
    // ...
}

Caution

通过这样做,你可以关闭很多别的东西,不仅仅是表单验证,因为POST_SUBMIT事件可能有其他的监听。

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

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