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

2.8 版本
维护中的版本

在此章,你将会学习到如何创建一个多集合的表单。他可能会很有用,例如,你有一个Task(任务)类并且你还想在一个表单中,编辑/创建/删除任务类下的多个Tag(标签)对象。

Note

在这一章中,假设你使用doctrine在操作数据库存储。但是,如果你不是使用doctrine(如你使用的是 Propel 或者只是使用数据库连接)这些都差不多。persistence仅仅是这个教程的一部分。 如果你正在使用Doctrine,你需要去添加Doctrine元数据,让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
// src/AppBundle/Form/Type/TagType.php
namespace AppBundle\Form\Type;
 
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' => 'AppBundle\Entity\Tag',
        ));
    }
}

有了这个,它就能自己渲染tag表单。但是最终目标是要让 Task 的 tag 可以在 task 表单中自己修改,所以还要创建Task的form类

请注意,你嵌入的TagType表单集合要使用collectionType 字段类型:

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
// src/AppBundle/Form/Type/TaskType.php
namespace AppBundle\Form\Type;
 
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' => 'AppBundle\Entity\Task',
        ));
    }
}

在你的控制器中,你创建一个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
// 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
        $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->isValid()) {
            // ... maybe do some form processing, like saving the Task and Tag objects
        }
 
        return $this->render('AppBundle:Task:new.html.twig', array(
            'form' => $form->createView(),
        ));
    }
}

现在相应的模板能够渲染task(任务)表单下的description字段以及TagType表单下的所有tag(标签)都已经关联到了这个Task。上面的控制器,我添加了一些伪代码所以你能够在action中看到(因为 Task 在最初创建时并没有 tag)。

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.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 #}
        {% 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
<!-- src/AppBundle/Resources/views/Task/new.html.php -->
 
<!-- ... -->
 
<?php echo $view['form']->start($form) ?>
    <!-- render the task's only field: 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集合你就理解为$task->getTags()并保存在数据库中,你随时都可以使用。

到目前为止,这些工作都很重要,但是这些并没有允许你去动态的添加一个新的tag(标签)或者删除现有的tag(标签)。所以,编辑现有tag(标签)是非常好用的,可是你的用户不能添加任何新的tag(标签)。

Caution

在这一章,你仅仅嵌入了一个集合,但你不会局限于此。你也能嵌入你想要的集合。但如果你在开发过程中使用Xdebug,你可能会有一个Maximum function nesting level of '100' reached, aborting!错误。这是由于php设置的xdebug.max_nesting_level默认为100。PHP中当函数调用层数超过限制的时候就会出现。

他直接将嵌套循环限制在100,如果你一次渲染整个表单(如form_widget(form))那么在渲染表单模板时可能不够用。为了避免这样的情况,你可以设置一个较高的值(通过php.ini文件或者在app/autoload.php通过ini_set)或者手动使用form_row去渲染每一个表单。

允许“Prototype”的“新”Tag 

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

你需要做的第一件事就是去让你的表单知道tag(标签)的数量是未知的。到目前为止,你已经添加了两个tag(标签)并且你的表单类型期望就是两个,否则会抛出一个错误:This form should not contain extra fields。为了让他更加灵活,添加allow_add配置选项到你的集合字段:

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”变量。这个“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会在你自定生成的div中添加data-prototype属性,和上面的类似。

Tip

form.tags.vars.prototype 是表单元素,他看起来就像是form_widget(tag)元素里的for循环。这意味着你可以调用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添加一个链接。然后,绑定链接的点击事件,这样你就能添加一个新的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
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
    $collectionHolder = $('ul.tags');
 
    // add the "add a tag" anchor and li to the tags 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)
    $collectionHolder.data('index', $collectionHolder.find(':input').length);
 
    $addTagLink.on('click', function(e) {
        // prevent the link from creating a "#" on the URL
        e.preventDefault();
 
        // add a new tag form (see next code block)
        addTagForm($collectionHolder, $newLinkLi);
    });
});

这个addTagForm函数的工作就是当链接被点击的时候,使用data-prototype属性动态添加一个新的表单。data-prototype包含了名为task[tags][__name__][name]的tag text input 元素,并且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
function addTagForm($collectionHolder, $newLinkLi) {
    // Get the data-prototype explained earlier
    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
    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
    var $newFormLi = $('<li></li>').append(newForm);
    $newLinkLi.before($newFormLi);
}

Note

最好把你的javascript代码分离到javascript文件中去,会比直接写在HTML中要好。

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

Seealso

你可以在JSFiddle中找到这个例子。

Seealso

如果你想自定义prototype中的html代码,请阅读:如何定义一个集合prototype

为了处理这些新的tag(标签)容易些,添加一个tag的“添加”和“移除”方法到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)来内部添加的。这样很好,但强制使用“添加”方法会是处理新的tag对象容易些(特别是如果你使用的是Doctrine,你会在下面学习到)。

Caution

你必须创建addTagremoveTag方法,否则这个表单将继续使用setTag,即便你已经设置了by_referencefalse。你将在后面学习更多关于removeTag的文章。

Doctrine:级联关系并保存“Inverse”边(反向的数据)

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对象关联的tag配置"cascade"为自动持久化操作。为了完成这个效果,你需要在你的ManyToMany配置上添加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”关联“Task”,那么持久化就会很好的工作,因为tag已经正确添加到了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 

下一步就是允许删除集合的特定条目。解决方法类似于允许tag添加的方法。

开始在添加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,
    ));
}

现在,你需要在 Task 的 removeTag 方法中加入一些代码:

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
jQuery(document).ready(function() {
    // Get the ul that holds the collection of tags
    $collectionHolder = $('ul.tags');
 
    // add a delete link to all of the existing tag form li elements
    $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
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
        e.preventDefault();
 
        // remove the li for the tag form
        $tagFormLi.remove();
    });
}

当一个tag表单从DOM中移除,并提交数据,这个移除的Tag对象将不会传递到setTags集合中。根据你的持久层来判断,他可能或者可能不足以删除被移除tagTask对象之间的关系。

Doctrine:确保数据库持久化

以这种方式移除对象之后,你可能需要做一点点工作,以确保Task和被移除的tag关系完全被删除。

在Doctrine中,你有两种关系:拥有方和反向方。在这个案例下,通常有一个多对多的关系,并且被删除的tag将消失,并保持正确性(添加新tag标签也毫不费力地工作)。

但是如果你有一个“一对多”的关系或者在Task实体上有一个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
// 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
    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
        foreach ($originalTags as $tag) {
            if (false === $task->getTags()->contains($tag)) {
                // remove the Task from the Tag
                $tag->getTasks()->removeElement($task);
 
                // if it was a many-to-one relationship, remove the relationship like this
                // $tag->setTask(null);
 
                $em->persist($tag);
 
                // if you wanted to delete the Tag entirely, you can also do that
                // $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对象本身关系的正常更新(不论你是添加还是删除已经存在的 tags)。

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

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