如何嵌入表單集合
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 表單集合示範。
但首先,您需要讓表單集合知道,它將接收未知數量的標籤,而不是精確的兩個。否則,您會看到「This form should not contain extra fields
」錯誤。這是透過 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
變數。此「原型」是一個小「範本」,其中包含動態建立任何新的「標籤」表單所需的 HTML(使用 JavaScript)。
讓我們從純 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()
方法)。以任何其他語言編寫的程式碼將無法如預期般運作。
允許移除標籤
下一步是允許刪除集合中的特定項目。解決方案與允許新增標籤類似。
從在表單類型中新增 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 套件。