如何嵌入表单(字段)集合

3.4 版本
维护中的版本

在本文中,你将会学习如何创建一个内嵌多个其他表单集合的表单。这可能会很有用,例如,你有一个Task 任务类(的表单),你想在同一表单之中,编辑/创建/删除和这个Task对象相关联的多个 Tag 标签对象。

Note

在本文中,估且假设你用Doctrine作为数据库存储。但是,如果你用的不是Doctrine(如,Propel或仅仅用了一个数据库连接),其实都差不多。本教程只有极少部分是关乎“Persistence”持久化的。

如果你用了Doctrine,你需要添加Doctrine元数据(memadata,译注:即annotation),在Task(任务)对象的 Tag(标签)上定义 ManyToMany 关联映射。

首先,假设每一个 Task 都有多个 Tag 对象。从创建简单的 Task 类开始:

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
// src/AppBundle/Entity/Task.php
namespace AppBundle\Entity;
 
use Doctrine\Common\Collections\ArrayCollection;
 
class Task
{
    protected $description;
 
    protected $tags;
 
    public function __construct()
    {
        $this->tags = new ArrayCollection();
    }
 
    public function getDescription()
    {
        return $this->description;
    }
 
    public function setDescription($description)
    {
        $this->description = $description;
    }
 
    public function getTags()
    {
        return $this->tags;
    }
}

Note

ArrayCollection 是 Doctrine 特有的(字段类型),基本上等同于使用了一个 array (但是若你使用 Doctrine 就必须是 ArrayCollection)。

现在,创建一个 Tag 类。正如你在上面看到的,一个 Task 可以有很多 Tag 对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/AppBundle/Entity/Tag.php
namespace AppBundle\Entity;
 
class Tag
{
    private $name;
 
    public function getName()
    {
        return $this->name;
    }
 
    public function setName($name)
    {
        $this->name = $name;
    }
}

然后,创建一个表单类,以便用户能够修改 Tag 对象:

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/TagType.php
namespace AppBundle\Form\Type;
 
use AppBundle\Entity\Tag;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
 
class TagType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->add('name');
    }
 
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => Tag::class,
        ));
    }
}

有了这个,就足够该表单自行输出tag字段了。但由于最终目标是要让 Task 的标签(属性),可以在 任务表单 自身中被修改,所以还要为 Task 类创建一个表单。

注意,你使用了 collectionType 字段类型来嵌入 TagType 表单集合:

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/Type/TaskType.php
namespace AppBundle\Form\Type;
 
use AppBundle\Entity\Task;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
 
class TaskType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->add('description');
 
        $builder->add('tags', CollectionType::class, array(
            'entry_type' => TagType::class
        ));
    }
 
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => Task::class,
        ));
    }
}

在控制器中,你要用 TaskType 创建一个新表单:

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
// src/AppBundle/Controller/TaskController.php
namespace AppBundle\Controller;
 
use AppBundle\Entity\Task;
use AppBundle\Entity\Tag;
use AppBundle\Form\Type\TaskType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
 
class TaskController extends Controller
{
    public function newAction(Request $request)
    {
        $task = new Task();
 
        // dummy code - this is here just so that the Task has some tags
        // otherwise, this isn't an interesting example
        // 充数代码 - 仅在这里出现,以便Task能有一些tags
        // 否则,这就不是一个有意义的例子了
        $tag1 = new Tag();
        $tag1->setName('tag1');
        $task->getTags()->add($tag1);
        $tag2 = new Tag();
        $tag2->setName('tag2');
        $task->getTags()->add($tag2);
        // end dummy code / 充数代码结束
 
        $form = $this->createForm(TaskType::class, $task);
 
        $form->handleRequest($request);
 
        if ($form->isSubmitted() && $form->isValid()) {
            // ... maybe do some form processing, like saving the Task and Tag objects
            // ... 不妨进行一些表单处理,像是存住Task和Tag对象
        }
 
        return $this->render('task/new.html.twig', array(
            'form' => $form->createView(),
        ));
    }
}

现在,相应的模板能够生成task表单的 description 字段以及 TagType 表单下的关联了 Task 的所有tag字段。在上面的控制器中我添加了一些充数代码,以便所以你能在action中看到这些(因为 Task 在最初创建时并没有 tag)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{# app/Resources/views/task/new.html.twig #}
 
{# ... #}
 
{{ form_start(form) }}
    {# render the task's only field: description #}
    {{ form_row(form.description) }}
 
    <h3>Tags</h3>
    <ul class="tags">
        {# iterate over each existing tag and render its only field: name #}
        {# 对已有tag进行遍历,并输出唯一的字段,即: name #}
        {% for tag in form.tags %}
            <li>{{ form_row(tag.name) }}</li>
        {% endfor %}
    </ul>
{{ form_end(form) }}
 
{# ... #}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- src/AppBundle/Resources/views/Task/new.html.php -->
 
<!-- ... -->
 
<?php echo $view['form']->start($form) ?>
    <!-- render the task's only field: description -->
    <!-- 输出 task 的唯一字段: description -->
    <?php echo $view['form']->row($form['description']) ?>
 
    <h3>Tags</h3>
    <ul class="tags">
        <?php foreach($form['tags'] as $tag): ?>
            <li><?php echo $view['form']->row($tag['name']) ?></li>
        <?php endforeach ?>
    </ul>
<?php echo $view['form']->end($form) ?>
 
<!-- ... -->

在用户提交表单时,tags字段所提交的数据将用于构造 Tag 对象的 ArrayCollection,它会被用来设置 Task 实例的 tag 字段。

tags collection可以通过 $task->getTags() 原生访问到,可以被持久化到数据库中或随需使用。

到目前为止,程序运行良好,但却不允许你动态地添加一个新标签或者删除现有标签。所以,在编辑现有标签时是可用的,用户是不能添加任何新标签的。

Caution

本文中,你仅仅嵌入了一个集合,但你不会受限于此。你也可以嵌入你希望的任意多个嵌套集合(nested collection)。但如果你在开发过程中使用了Xdebug,你可能会遇到一个 Maximum function nesting level of '100' reached, aborting! 错误。这是由于PHP配置中的 xdebug.max_nesting_level 默认值是100所导致的。

如果你要一次输出所有表单(如form_widget(form)),在模板生成表单时,这一指令的100次递归调用是不够用的。要解决此问题,你可以把该指令设置为一个较高的值(通过 php.ini 文件,或者通过 ini_set,例如 app/autoload.php),或者使用 form_row 来手动输出每一个表单字段。

使用“Prototype” 来允许“新”标签 

允许用户动态地添加新的tag,意味着你需要使用一些JavaScript。之前你在控制器中把两个tag(标签)添加到了表单。现在要让用户在浏览器中直接添加任意多的标签到表单中。通过一些JavaScript来完成。

你要做的第一件事就是要让你的表单集合知道它将收到不明数量的标签。目前你已经添加了两个tag,而表单类型的预期就是两个,否则会抛出一个错误:This form should not contain extra fields。为了灵活起见,添加 allow_add 选项到你的collection field(集合字段):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/AppBundle/Form/Type/TaskType.php
 
// ...
use Symfony\Component\Form\FormBuilderInterface;
 
public function buildForm(FormBuilderInterface $builder, array $options)
{
    $builder->add('description');
 
    $builder->add('tags', CollectionType::class, array(
        'entry_type'   => TagType::class,
        'allow_add'    => true,
    ));
}

除了要告诉这个字段要接收任意数量的对象之外,allow_add 也令得一个“prototype”变量能够为你所用。这个“原型”是一个小“模板”(template),它包含了输出任意tag表单时所需的的全部html。要生成它,在模板中做出以下修改:

1
2
3
<ul class="tags" data-prototype="{{ form_widget(form.tags.vars.prototype)|e }}">
    ...
</ul>
1
2
3
4
5
<ul class="tags" data-prototype="<?php
    echo $view->escape($view['form']->row($form['tags']->vars['prototype']))
?>">
    ...
</ul>

Note

如果你是一次性地输出“tag”子表单(如 form_row(form.tags)),那么prototype会作为 data-prototype 属性,在外层的 div 上自动可用,就像上面你看到的那样。

Tip

form.tags.vars.prototype 是一个表单元素,可以当成是 for 循环里的个体 form_widget(tag) 元素。这意味着你可以对它调用form_widget, form_rowform_label。甚至可以选择只输出它的一个字段(如 name 字段):

1
   {{ form_widget(form.tags.vars.prototype.name)|e }}

在输出页面,结果如下:

1
2
3
<ul class="tags" data-prototype="{{ form_widget(form.tags.vars.prototype)|e('html_attr') }}">
    ...
</ul>

本文的目的就是使用JavaScript去读取这个属性,并且当用户点击 "Add a tag" 链接时,动态地加载新的tag表单。为了令事情简单,这个例子中使用了jQuery并且假设你页面中的某处已经包含了这个包。

在页面上添加 script 标签,你就可以开始编写一些JavaScript了。

首先,在“tag”列表的底部用JavaScript添加一个链接。然后,绑定该链接的onClick事件,这样你就能添加一个新的tag表单了(addTagForm() 方法会在下一段代码展示)

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
var $collectionHolder;
 
// setup an "add a tag" link / 设置一个“添加一个标签”链接
var $addTagLink = $('<a href="#" class="add_tag_link">Add a tag</a>');
var $newLinkLi = $('<li></li>').append($addTagLink);
 
jQuery(document).ready(function() {
    // Get the ul that holds the collection of tags
    // 得到持有标签集合的ul
    $collectionHolder = $('ul.tags');
 
    // add the "add a tag" anchor and li to the tags ul
    // 添加 "添加一个标签" 锚记到标签的ul元素中
    $collectionHolder.append($newLinkLi);
 
    // count the current form inputs we have (e.g. 2), use that as the new
    // index when inserting a new item (e.g. 2)
    // 计算我们当前持有的表单input数量(如2),并在插入新元素(如2)时把它作为新的索引
    $collectionHolder.data('index', $collectionHolder.find(':input').length);
 
    $addTagLink.on('click', function(e) {
        // prevent the link from creating a "#" on the URL
        // 防止链接在URL中出现一个 "#"
        e.preventDefault();
 
        // add a new tag form (see next code block)
        // 添加一个新的tag表单(参考后面的码段)
        addTagForm($collectionHolder, $newLinkLi);
    });
});

addTagForm() 函数的工作是,当这个链接被点击的时候,使用 data-prototype 属性来动态地添加一个新的表单。data-prototype HTML码段中包含了标签的 text input文本输入框元素,其name名为 task[tags][__name__][name] ,其id名为 task_tags___name___name__name__ 是一个小“占位符”,你可以用一个唯一的、递增的数字来替换它(如 task[tags][3][name])。

令程序运行所需的代码可能会相当的多样化,好在这儿有一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function addTagForm($collectionHolder, $newLinkLi) {
    // Get the data-prototype explained earlier
    // 获取前文解释过的data-prototype
    var prototype = $collectionHolder.data('prototype');
 
    // get the new index
    // 获取新的索引
    var index = $collectionHolder.data('index');
 
    // Replace '__name__' in the prototype's HTML to
    // instead be a number based on how many items we have
    // 在prototype的HTML中替换 '__name__',令其成为一个基于“我们持有多少元素”的数字
    var newForm = prototype.replace(/__name__/g, index);
 
    // increase the index with one for the next item
    // 下一个元素要增加一个索引
    $collectionHolder.data('index', index + 1);
 
    // Display the form in the page in an li, before the "Add a tag" link li
    // 在页面中的 "Add a tag" 链接前面,以li来显示表单 
    var $newFormLi = $('<li></li>').append(newForm);
    $newLinkLi.before($newFormLi);
}

Note

最好把你的JavaScript代码分离到一个真正的JavaScript文件中去,比在这里直接写到HTML中要好。

现在,用户每次点击 Add a tag 链接时,一个新的子表单就会出现在页面上。当表单被提交时,所有的新标签表单都会被转换为一个新的 tag 对象,并被添加到 Task 对象的 tags 属性上。

Seealso

你可以在 JSFiddle 中找到这个例子的可操作版本。

Seealso

如果你想自定义 prototype 中的HTML代码,参考:如何定义Collection Prototype

为了更容易地处理这些新的标签,添加一个tag的“adder” 和 “remover” 方法到 Task 类中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// src/AppBundle/Entity/Task.php
namespace AppBundle\Entity;
 
// ...
class Task
{
    // ...
 
    public function addTag(Tag $tag)
    {
        $this->tags->add($tag);
    }
 
    public function removeTag(Tag $tag)
    {
        // ...
    }
}

接着,把一个 by_reference 选项添加到 tag 字段,并把它设置为 false

1
2
3
4
5
6
7
8
9
10
11
12
// src/AppBundle/Form/Type/TaskType.php
 
// ...
public function buildForm(FormBuilderInterface $builder, array $options)
{
    // ...
 
    $builder->add('tags', CollectionType::class, array(
        // ...
        'by_reference' => false,
    ));
}

有了这两处更改,当表单被提交时,每一个新的 Tag 对象都是通过调用 addTag() 方法添加到 Task 类的。做出这个改变之前,它们是通过调用表单的 $task->getTags()->add($tag) 来从内部进行添加的。这样很好,但是强制使用“adder”方法会可以令处理新的 tag 对象更加容易(特别是当你用Doctrine时,你会在下面学习到!)。

Caution

你不得不同时创建 addTag()removeTag() 方法,否则这个表单将继续使用 setTag,即使你已经把 by_reference 设置为 false。你将在下文中学习到更多关于 removeTag() 方法的内容。

Doctrine:Cascading层级关联 和 存储“Inverse”side(反转侧)

要用Doctrine保存新的 tag,你需要多考虑两个事情。首先,除非你遍历所有的新 tag 对象,并且每一次都调用 $em->persist($tag),否则,你会收到Doctrine给出的一个错误提示:

A new entity was found through the relationship AppBundle\Entity\Task#tags that was not configured to cascade persist operations for entity...

要修复此问题,你可以选择从从 Task 对象到任何其所关联的标签中,自动进行“cascade”(层级化)持久化操作。为了实现这个,在你的 ManyToMany metadata(译注:即annotation配置信息)上面添加 cascade 选项:

1
2
3
4
5
6
7
8
// src/AppBundle/Entity/Task.php
 
// ...
 
/**
 * @ORM\ManyToMany(targetEntity="Tag", cascade={"persist"})
 */
protected $tags;
1
2
3
4
5
6
7
8
# src/AppBundle/Resources/config/doctrine/Task.orm.yml
AppBundle\Entity\Task:
    type: entity
    # ...
    oneToMany:
        tags:
            targetEntity: Tag
            cascade:      [persist]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- src/AppBundle/Resources/config/doctrine/Task.orm.xml -->
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
                    http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
 
    <entity name="AppBundle\Entity\Task">
        <!-- ... -->
        <one-to-many field="tags" target-entity="Tag">
            <cascade>
                <cascade-persist />
            </cascade>
        </one-to-many>
    </entity>
</doctrine-mapping>

第二个潜在的问题是处理Doctrine映射的 Owning Side and Inverse(自有侧和反转侧)。在本例中,如果关联关系中的“Owning”一侧是“Task”,那么持久化将会正常进行,因为标签已经正确地添加到了Task中。然而,如果自有侧是“Tag”的话,那么你需要多做一些工作,来确保正确的关联一侧被修改。

技巧在于,要确保单一的“Task”设置在了每一个“Tag”上。完成这个的一个最简单的方式,就是在 addTag() 中添加额外的逻辑,这个方法能被表单类型所调用,是因为 by_reference 设置的是 false

1
2
3
4
5
6
7
8
9
// src/AppBundle/Entity/Task.php
 
// ...
public function addTag(Tag $tag)
{
    $tag->addTask($this);
 
    $this->tags->add($tag);
}

Tag 中,只要确保有一个 addTask() 方法:

1
2
3
4
5
6
7
8
9
// src/AppBundle/Entity/Tag.php
 
// ...
public function addTask(Task $task)
{
    if (!$this->tasks->contains($task)) {
        $this->tasks->add($task);
    }
}

如果你有一个 one-to-many 的关联,解决方法是类似的,除了你可以直接在 addTag 中调用 setTask之外。

允许删除Tag 

下一步就是允许删除集合中的特定条目。解决方法类似于允许标签被添加进来。

从在form type表单类型中添加 allow_delete 选项开始:

1
2
3
4
5
6
7
8
9
10
11
12
// src/AppBundle/Form/Type/TaskType.php
 
// ...
public function buildForm(FormBuilderInterface $builder, array $options)
{
    // ...
 
    $builder->add('tags', CollectionType::class, array(
        // ...
        'allow_delete' => true,
    ));
}

现在,你需要向 TaskremoveTag() 方法中加入一些代码:

1
2
3
4
5
6
7
8
9
10
11
12
// src/AppBundle/Entity/Task.php
 
// ...
class Task
{
    // ...
 
    public function removeTag(Tag $tag)
    {
        $this->tags->removeElement($tag);
    }
}

模板修改 

allow_delete 选项有一个结果:如果集合中的某个元素没有能够随着提交而送出,那么在服务器端,该元素所对应的数据将从集合中被删除。解决办法是删除DOM中的表单元素。

首先,在每个tag表单中添加一个“delete this tag”链接(删除此标签):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
jQuery(document).ready(function() {
    // Get the ul that holds the collection of tags
    // 获取到持有“标签集合”的ul
    $collectionHolder = $('ul.tags');
 
    // add a delete link to all of the existing tag form li elements
    // 在所有已有的tag表单的li中添加一个删除链接
    $collectionHolder.find('li').each(function() {
        addTagFormDeleteLink($(this));
    });
 
    // ... the rest of the block from above
    // ...上述区块中的其余部分
});
 
function addTagForm() {
    // ...
 
    // add a delete link to the new form
    // 添加一个“删除”链接到新的表单中
    addTagFormDeleteLink($newFormLi);
}

addTagFormDeleteLink()函数可能是下面这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function addTagFormDeleteLink($tagFormLi) {
    var $removeFormA = $('<a href="#">delete this tag</a>');
    $tagFormLi.append($removeFormA);
 
    $removeFormA.on('click', function(e) {
        // prevent the link from creating a "#" on the URL
        // 防止链接在URL中出现一个 "#"
        e.preventDefault();
 
        // remove the li for the tag form
        // 
        $tagFormLi.remove();
    });
}

当一个tag表单从DOM中被移除并且提交时,这个移除的 Tag 对象将不包括在传递到 setTags 的集合之中。根据你的数据库层之不同,这可以,或者不太能够,切实地去除“被移除的 tag 标签和 Task对象”之间的关系。

Doctrine:确保数据库的持久化

以这种方式移除对象之后,你可能需要多做一些处理,来确保 Task 和被删除的 tag 之间的关系被正确地删除。

在Doctrine中,你有关联关系的两个侧面:自有侧和反转侧( the owning side and the inverse side)。通常这时候你会有一个“多对多”(many-to-many)的关系,并且被删除的tag将会消失,并保持正确地入了库(添加新标签时也毫不费力)。

但是如果你有一个“一对多”(one-to-many)的关系或者在Task entity中有一个 mappedBy 的 “多对多”关系(意即Task是“反转”侧),你需要做更多工作来删除tag以正常入库。

在这种情况下,你可以修改控制器来删除“已被删除的tag”的关系。此时,假设你有一个 editAction() 负责处理你的 Task 表单之更新:

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/AppBundle/Controller/TaskController.php
 
use Doctrine\Common\Collections\ArrayCollection;
 
// ...
public function editAction($id, Request $request)
{
    $em = $this->getDoctrine()->getManager();
    $task = $em->getRepository('AppBundle:Task')->find($id);
 
    if (!$task) {
        throw $this->createNotFoundException('No task found for id '.$id);
    }
 
    $originalTags = new ArrayCollection();
 
    // Create an ArrayCollection of the current Tag objects in the database
    // 在数据库中,为当前的 Tag 对象(们)创建一个 ArrayCollection
    foreach ($task->getTags() as $tag) {
        $originalTags->add($tag);
    }
 
    $editForm = $this->createForm(TaskType::class, $task);
 
    $editForm->handleRequest($request);
 
    if ($editForm->isValid()) {
 
        // remove the relationship between the tag and the Task
        // 删除tag和Task之间的关联关系
        foreach ($originalTags as $tag) {
            if (false === $task->getTags()->contains($tag)) {
                // remove the Task from the Tag / 从 Tag 中删除 Task
                $tag->getTasks()->removeElement($task);
 
                // if it was a many-to-one relationship, remove the relationship like this
                // 如果是 many-to-one 多对一关联,按下法删除关联
                // $tag->setTask(null);
 
                $em->persist($tag);
 
                // if you wanted to delete the Tag entirely, you can also do that
                // 若要彻底删除Tag,你也可以这样做
                // $em->remove($tag);
            }
        }
 
        $em->persist($task);
        $em->flush();
 
        // redirect back to some edit page / 重定向回某个编辑页面
        return $this->redirectToRoute('task_edit', array('id' => $id));
    }
 
    // render some form template / 进行模板输出
}

正如你看到的,正确地添加和移除元素非常需要技巧。除非你有一个Task在“自有侧”的多对多关系,否则你需要针对每一个Tag对象自身做一些额外的工作,来确保关联关系被正确会更新(不管你是添加新标签还是删除已经存在的标签)。

Form collection jQuery 插件

jQuery 插件 symfony-collection 专治 collection 表单元素,靠得是添加所需的JavaScript功能代码,来完成添加、编辑、删除“集合中的元素”(elements of collection)。更多高级功能,像是在集合中移动和复制某个元素,或是自定义按键,同样可以实现。

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

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