跳到主要內容

如何嵌入表單集合

編輯此頁面

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="&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 metadata。

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,因此表單類型會呼叫 addTag()

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);
    }
}

允許移除標籤

下一步是允許刪除集合中的特定項目。解決方案與允許新增標籤類似。

從在表單類型中新增 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() 的集合中。根據您的持久層,這可能足以實際移除移除的 Tag 和 Task 物件之間的關係,也可能不足以移除關係。

以這種方式移除物件時,您可能需要做更多工作,以確保正確移除 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 授權條款授權。
目錄
    版本