如何嵌入表单集合
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
。然后,该集合被设置在 Task
的 tag
字段上,并且可以通过 $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="<div><label class=" required">__name__</label><div id="task_tags___name__"><div><label for="task_tags___name___name" class=" required">Name</label><input type="text" id="task_tags___name___name" name="task[tags][__name__][name]" required="required" maxlength="255" /></div></div></div>"
></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_reference
为 false
,表单仍将使用 setTag()
。您将在本文后面了解有关 removeTag()
方法的更多信息。
警告
Symfony 只能对英语单词进行复数到单数的转换(例如,从 tags
属性到 addTag()
方法)。以任何其他语言编写的代码都将无法按预期工作。
允许移除标签
下一步是允许删除集合中的特定项目。解决方案与允许添加标签类似。
首先在表单 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,
]);
}
现在,您需要将一些代码放入 Task
的 removeTag()
方法中
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()
的集合中。根据您的持久层,这可能足以实际删除删除的 Tag
和 Task
对象之间的关系,也可能不足以删除。
另请参阅
Symfony 社区创建了一些 JavaScript 包,这些包提供了添加、编辑和删除集合元素所需的功能。请查看适用于现代浏览器的 @a2lix/symfony-collection 包和适用于其余浏览器的基于 jQuery 的 symfony-collection 包。