跳到内容

如何嵌入表单集合

编辑此页

Symfony 表单可以嵌入许多其他表单的集合,这对于在单个表单中编辑相关实体非常有用。在本文中,您将创建一个表单来编辑 Task 类,并且在同一个表单中,您将能够编辑、创建和删除与该 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
// src/Entity/Task.php
namespace App\Entity;

use Doctrine\Common\Collections\Collection;

class Task
{
    protected string $description;
    protected Collection $tags;

    public function __construct()
    {
        $this->tags = new ArrayCollection();
    }

    public function getDescription(): string
    {
        return $this->description;
    }

    public function setDescription(string $description): void
    {
        $this->description = $description;
    }

    public function getTags(): Collection
    {
        return $this->tags;
    }
}

注意

ArrayCollection 是 Doctrine 特有的,类似于 PHP 数组,但提供了许多实用方法。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/Entity/Tag.php
namespace App\Entity;

class Tag
{
    private string $name;

    public function getName(): string
    {
        return $this->name;
    }

    public function setName(string $name): void
    {
        $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/Form/TagType.php
namespace App\Form;

use App\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): void
    {
        $builder->add('name');
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Tag::class,
        ]);
    }
}

接下来,让我们为 Task 实体创建一个表单,使用 CollectionType 字段的 TagType 表单。这将允许我们在 task 表单本身内修改 Task 的所有 Tag 元素

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/Form/TaskType.php
namespace App\Form;

use App\Entity\Task;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class TaskType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('description');

        $builder->add('tags', CollectionType::class, [
            'entry_type' => TagType::class,
            'entry_options' => ['label' => false],
        ]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            '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
// src/Controller/TaskController.php
namespace App\Controller;

use App\Entity\Tag;
use App\Entity\Task;
use App\Form\TaskType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class TaskController extends AbstractController
{
    public function new(Request $request): Response
    {
        $task = new Task();

        // dummy code - add some example tags to the task
        // (otherwise, the template will render an empty list of 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()) {
            // ... do your form processing, like saving the Task and Tag entities
        }

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

在模板中,您现在可以迭代现有的 TagType 表单以渲染它们

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{# templates/task/new.html.twig #}

{# ... #}

{{ form_start(form) }}
    {{ form_row(form.description) }}

    <h3>Tags</h3>
    <ul class="tags">
        {% for tag in form.tags %}
            <li>{{ form_row(tag.name) }}</li>
        {% endfor %}
    </ul>
{{ form_end(form) }}

{# ... #}

当用户提交表单时,为 tags 字段提交的数据用于构建 Tag 对象的 ArrayCollection。然后,该集合被设置在 Tasktag 字段上,并且可以通过 $task->getTags() 访问。

到目前为止,这工作得很好,但仅用于编辑现有标签。它还不允许我们添加新标签或删除现有标签。

警告

您可以根据需要向下嵌入多层嵌套集合。但是,如果您使用 Xdebug,您可能会收到 Maximum function nesting level of '100' reached, aborting! 错误。要解决此问题,请增加 xdebug.max_nesting_level PHP 设置,或使用 form_row() 手动渲染每个表单字段,而不是一次渲染整个表单 (例如 form_widget(form))。

使用“原型”允许“新”标签

之前您在控制器中为您的任务添加了两个标签。现在让用户直接在浏览器中添加他们需要的任意数量的标签表单。这需要一些 JavaScript 代码。

提示

您可以使用 Symfony UX,仅使用 PHP 和 Twig 代码来实现此功能,而无需自己编写所需的 JavaScript 代码。请参阅 Symfony UX 表单集合演示

但首先,您需要让表单集合知道,它将接收未知数量的标签,而不是正好两个。否则,您将看到“此表单不应包含额外的字段”错误。这是通过 allow_add 选项完成的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/Form/TaskType.php

// ...

public function buildForm(FormBuilderInterface $builder, array $options): void
{
    // ...

    $builder->add('tags', CollectionType::class, [
        'entry_type' => TagType::class,
        'entry_options' => ['label' => false],
        'allow_add' => true,
    ]);
}

allow_add 选项还为您提供了一个 prototype 变量。这个“原型”是一个小“模板”,其中包含使用 JavaScript 动态创建任何新的“tag”表单所需的所有 HTML。

让我们从纯 JavaScript (Vanilla JS) 开始 - 如果您正在使用 Stimulus,请参阅下文。

要渲染原型,请将以下 data-prototype 属性添加到模板中现有的 <ul>

1
2
3
4
5
{# the data-index attribute is required for the JavaScript code below #}
<ul class="tags"
    data-index="{{ form.tags|length > 0 ? form.tags|last.vars.name + 1 : 0 }}"
    data-prototype="{{ form_widget(form.tags.vars.prototype)|e('html_attr') }}"
></ul>

在渲染的页面上,结果将如下所示

1
2
3
4
<ul class="tags"
    data-index="0"
    data-prototype="&lt;div&gt;&lt;label class=&quot; required&quot;&gt;__name__&lt;/label&gt;&lt;div id=&quot;task_tags___name__&quot;&gt;&lt;div&gt;&lt;label for=&quot;task_tags___name___name&quot; class=&quot; required&quot;&gt;Name&lt;/label&gt;&lt;input type=&quot;text&quot; id=&quot;task_tags___name___name&quot; name=&quot;task[tags][__name__][name]&quot; required=&quot;required&quot; maxlength=&quot;255&quot; /&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;"
></ul>

现在添加一个按钮来动态添加新标签

1
<button type="button" class="add_item_link" data-collection-holder-class="tags">Add a tag</button>

另请参阅

如果您想自定义原型中的 HTML 代码,请参阅 如何使用表单主题

提示

form.tags.vars.prototype 是一个表单元素,其外观和感觉就像 for 循环内的单个 form_widget(tag.*) 元素。这意味着您可以在其上调用 form_widget()form_row()form_label()。您甚至可以选择仅渲染其字段之一(例如 name 字段)

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

注意

如果您一次渲染整个“tags”子表单(例如 form_row(form.tags)),则 data-prototype 属性会自动添加到包含 div 中,您需要相应地调整以下 JavaScript。

现在添加一些 JavaScript 来读取此属性,并在用户单击“添加标签”链接时动态添加新的标签表单。在您页面上的某个位置添加一个 <script> 标签,以包含使用 JavaScript 所需的功能

1
2
3
4
5
document
  .querySelectorAll('.add_item_link')
  .forEach(btn => {
      btn.addEventListener("click", addFormToCollection)
  });

addFormToCollection() 函数的工作是使用 data-prototype 属性在单击此链接时动态添加新表单。data-prototype HTML 包含标签的文本输入元素,其名称为 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
function addFormToCollection(e) {
  const collectionHolder = document.querySelector('.' + e.currentTarget.dataset.collectionHolderClass);

  const item = document.createElement('li');

  item.innerHTML = collectionHolder
    .dataset
    .prototype
    .replace(
      /__name__/g,
      collectionHolder.dataset.index
    );

  collectionHolder.appendChild(item);

  collectionHolder.dataset.index++;
};

现在,每次用户单击“添加标签”链接时,页面上都会出现一个新的子表单。当表单提交时,任何新的标签表单都将转换为新的 Tag 对象,并添加到 Task 对象的 tags 属性中。

另请参阅

您可以在此 JSFiddle 中找到一个工作示例。

使用 Stimulus 的 JavaScript

如果您正在使用 Stimulus,请将所有内容包装在 <div>

1
2
3
4
5
6
7
<div {{ stimulus_controller('form-collection') }}
    data-form-collection-index-value="{{ form.tags|length > 0 ? form.tags|last.vars.name + 1 : 0 }}"
    data-form-collection-prototype-value="{{ form_widget(form.tags.vars.prototype)|e('html_attr') }}"
>
    <ul {{ stimulus_target('form-collection', 'collectionContainer') }}></ul>
    <button type="button" {{ stimulus_action('form-collection', 'addCollectionElement') }}>Add a tag</button>
</div>

然后创建控制器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// assets/controllers/form-collection_controller.js

import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
    static targets = ["collectionContainer"]

    static values = {
        index    : Number,
        prototype: String,
    }

    addCollectionElement(event)
    {
        const item = document.createElement('li');
        item.innerHTML = this.prototypeValue.replace(/__name__/g, this.indexValue);
        this.collectionContainerTarget.appendChild(item);
        this.indexValue++;
    }
}

在 PHP 中处理新标签

为了更轻松地处理这些新标签,请在 Task 类中为标签添加一个“adder”和一个“remover”方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// src/Entity/Task.php
namespace App\Entity;

// ...
class Task
{
    // ...

    public function addTag(Tag $tag): void
    {
        $this->tags->add($tag);
    }

    public function removeTag(Tag $tag): void
    {
        // ...
    }
}

接下来,将 by_reference 选项添加到 tags 字段并将其设置为 false

1
2
3
4
5
6
7
8
9
10
11
12
// src/Form/TaskType.php

// ...
public function buildForm(FormBuilderInterface $builder, array $options): void
{
    // ...

    $builder->add('tags', CollectionType::class, [
        // ...
        'by_reference' => false,
    ]);
}

通过这两个更改,当表单提交时,每个新的 Tag 对象都通过调用 addTag() 方法添加到 Task 类中。在此更改之前,它们在内部由表单通过调用 $task->getTags()->add($tag) 添加。这很好,但是强制使用“adder”方法使得处理这些新的 Tag 对象更容易(特别是如果您正在使用 Doctrine,您将在接下来了解它!)。

警告

您必须同时创建 addTag()removeTag() 方法,否则即使 by_referencefalse,表单仍将使用 setTag()。您将在本文后面了解有关 removeTag() 方法的更多信息。

警告

Symfony 只能对英语单词进行复数到单数的转换(例如,从 tags 属性到 addTag() 方法)。以任何其他语言编写的代码都将无法按预期工作。

要使用 Doctrine 保存新标签,您需要考虑更多事项。首先,除非您迭代所有新的 Tag 对象并在每个对象上调用 $entityManager->persist($tag),否则您将收到来自 Doctrine 的错误

1
2
3
A new entity was found through the relationship
``App\Entity\Task#tags`` that was not configured to
cascade persist operations for entity...

要解决此问题,您可以选择将持久化操作从 Task 对象自动“级联”到任何相关标签。为此,请将 cascade 选项添加到您的 ManyToMany 元数据

1
2
3
4
5
6
// src/Entity/Task.php

// ...

#[ORM\ManyToMany(targetEntity: Tag::class, cascade: ['persist'])]
protected Collection $tags;

第二个潜在问题涉及 Doctrine 关系的“拥有侧”和“反向侧”。在本示例中,如果关系的“拥有”侧是“Task”,则持久化将正常工作,因为标签已正确添加到 Task。但是,如果拥有侧在“Tag”上,那么您需要做更多工作以确保关系的正确侧被修改。

诀窍是确保在每个“Tag”上都设置了单个“Task”。一种方法是在 addTag() 中添加一些额外的逻辑,由于 by_reference 设置为 false,因此表单类型会调用它

1
2
3
4
5
6
7
8
9
10
11
12
13
// src/Entity/Task.php

// ...
public function addTag(Tag $tag): void
{
    // for a many-to-many association:
    $tag->addTask($this);

    // for a many-to-one association:
    $tag->setTask($this);

    $this->tags->add($tag);
}

如果您要使用 addTask(),请确保您有一个类似这样的适当方法

1
2
3
4
5
6
7
8
9
// src/Entity/Tag.php

// ...
public function addTask(Task $task): void
{
    if (!$this->tasks->contains($task)) {
        $this->tasks->add($task);
    }
}

允许移除标签

下一步是允许删除集合中的特定项目。解决方案与允许添加标签类似。

首先在表单 Type 中添加 allow_delete 选项

1
2
3
4
5
6
7
8
9
10
11
12
// src/Form/TaskType.php

// ...
public function buildForm(FormBuilderInterface $builder, array $options): void
{
    // ...

    $builder->add('tags', CollectionType::class, [
        // ...
        'allow_delete' => true,
    ]);
}

现在,您需要将一些代码放入 TaskremoveTag() 方法中

1
2
3
4
5
6
7
8
9
10
11
12
// src/Entity/Task.php

// ...
class Task
{
    // ...

    public function removeTag(Tag $tag): void
    {
        $this->tags->removeElement($tag);
    }
}

allow_delete 选项意味着,如果在提交时未发送集合的项目,则相关数据将从服务器上的集合中删除。为了使这在 HTML 表单中起作用,您必须在提交表单之前删除要删除的集合项的 DOM 元素。

在 JavaScript 代码中,在页面上的每个现有标签上添加一个“删除”按钮。然后,在添加新标签的函数中追加“添加删除按钮”方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
document
    .querySelectorAll('ul.tags li')
    .forEach((tag) => {
        addTagFormDeleteLink(tag)
    })

// ... the rest of the block from above

function addFormToCollection(e) {
    // ...

    // add a delete link to the new form
    addTagFormDeleteLink(item);
}

addTagFormDeleteLink() 函数将如下所示

1
2
3
4
5
6
7
8
9
10
11
12
function addTagFormDeleteLink(item) {
    const removeFormButton = document.createElement('button');
    removeFormButton.innerText = 'Delete this tag';

    item.append(removeFormButton);

    removeFormButton.addEventListener('click', (e) => {
        e.preventDefault();
        // remove the li for the tag form
        item.remove();
    });
}

当标签表单从 DOM 中删除并提交时,删除的 Tag 对象将不包含在传递给 setTags() 的集合中。根据您的持久层,这可能足以实际删除删除的 TagTask 对象之间的关系,也可能不足以删除。

以这种方式删除对象时,您可能需要做更多工作以确保 Task 和删除的 Tag 之间的关系被正确删除。

在 Doctrine 中,您有关系的两个方面:拥有侧和反向侧。通常在这种情况下,您将具有多对一关系,并且删除的标签将消失并正确持久化(添加新标签也毫不费力)。

但是,如果您具有一对多关系或多对多关系,并且 Task 实体上有一个 mappedBy(意味着 Task 是“反向”侧),则您需要做更多工作才能使删除的标签正确持久化。

在这种情况下,您可以修改控制器以删除已删除标签上的关系。这假设您有一些 edit() 操作正在处理 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
// src/Controller/TaskController.php

// ...
use App\Entity\Task;
use Doctrine\Common\Collections\ArrayCollection;

class TaskController extends AbstractController
{
    public function edit(Task $task, Request $request, EntityManagerInterface $entityManager): Response
    {
        $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->isSubmitted() && $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);

                    $entityManager->persist($tag);

                    // if you wanted to delete the Tag entirely, you can also do that
                    // $entityManager->remove($tag);
                }
            }

            $entityManager->persist($task);
            $entityManager->flush();

            // redirect back to some edit page
            return $this->redirectToRoute('task_edit', ['id' => $id]);
        }

        // ... render some form template
    }
}

如您所见,正确添加和删除元素可能很棘手。除非您具有多对多关系,其中 Task 是“拥有”侧,否则您需要做额外的工作以确保在每个 Tag 对象本身上正确更新关系(无论您是添加新标签还是删除现有标签)。

另请参阅

Symfony 社区创建了一些 JavaScript 包,这些包提供了添加、编辑和删除集合元素所需的功能。请查看适用于现代浏览器的 @a2lix/symfony-collection 包和适用于其余浏览器的基于 jQuery 的 symfony-collection 包。

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