Как вбудувати колекцію форм

Дата оновлення перекладу 2022-12-14

Как вбудувати колекцію форм

Форми Symfony можуть вбудовувати колекцію множини іншиї форм, що корисно для редагування пов'язаних сутностей в одній формі. В цій статті ви створите форму для редагування класу Task, і прямо всередині тієї ж форми ви зможете редагувати, створювати та видаляти багато об'єктів Tag, пов'язаних з цим класом Task.

Давайте почнемо зі створення сутності 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/Entity/Task.php
namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;

class Task
{
    protected $description;
    protected $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;
    }
}

Note

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 $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. Це дозволить нам модифікувати всі елементи 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
// 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();

        // фіктивний код - він тут просто для того, щоб Task мав якісь
        // інашке це не буде цікавим прикладом
        $tag1 = new Tag();
        $tag1->setName('tag1');
        $task->getTags()->add($tag1);
        $tag2 = new Tag();
        $tag2->setName('tag2');
        $task->getTags()->add($tag2);
        // кінець фіктивного коду

        $form = $this->createForm(TaskType::class, $task);

        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            // ... проведіть обробку форми, на кшталт збереження сутностей Task і Tag
        }

        return $this->renderForm('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 використовуються для створення ArrayCollection об'єктів Tag. Потім колекція встановлюється в поле tag Task і до неї можна отримати доступ через $task->getTags().

Поки все працює чудово, але лише для редагування існуючих тегів. Ми ще не можемо додавати нові або видаляти вже існуючі теги.

Caution

Ви можете вбудувати вкладену колекцію на стільки рівнів нижче, наскільки вам цього захочеться. Але якщо ви використовуєте Xdebug, то ви можете отримати помилку Досягнено максимального рівня функціонування вкладеності '100', переривання!. Щоб виправити це, збільшіть PHP-налаштування xdebug.max_nesting_level, або відобразіть кожне поле форми вручну, використовуючи form_row() замість відображення всієї форми одразу (наприклад, form_widget(form))

Дозвіл на "нові" теги за допомогою "прототипу"

Раніше ви додали два теги до вашого завдання в контролері. Тепер дозвольте користувача додавати стільки форм тегів, скільки їм необхідно, прямо у браузері. Це потребує трохи коду JavaScript.

Але спочатку вам потрібно дати колекції форми знати, що замість двох тегів вона отримає невідому кількість тегів. Інашке ви побачите помилку "Ця форма не має містити додаткових полів". Це робиться за допомогою опції 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, необхідний для динамічного створення будь-яких нових форм "tag" за допомогою JavaScript.

Давайте почнемо з простого JavaScript (Vanilla JS) – якщо ви використовуєте Stimulus, див. нижче.

Щоб відобразити прототип, додайте наступний атрибут data-prototype до існуючого <ul> у вашому шаблоні:

1
2
3
4
5
{# атрибут data-index обовʼязковий для коду JavaScript нижче #}
<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>

See also

Якщо ви хочете налаштувати HTML-код в прототипі, дивіться .

Tip

form.tags.vars.prototype - це елемент форми, який виглядає та веде себе так само, як і індивідуальні елементи form_widget(tag) всередині вашого циклу for. Це означає, що ви можете викликати form_widget(), form_row() або form_label(). Ви навіть можете обирати відображення лише одного з його полів (наприклад, поля name):

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

Note

Якщо ви одразу відобразите всю суб-форму "tags" (наприклад, form_row(form.tags)), атрибут data-prototype буде автоматично доданий до div, і вам необхідно відповідно налаштувати наступний JavaScript.

Тепер додайте трохи JavaScript, щоб прочитати цей атрибут та динамічно додати нові форми тегів, коли користувач натискає на посилання "Add a tag". Додайте тег <script> десь на вашій сторінці, щоб включити обовʼязкову функціональність для JavaScript:

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

Роботою функції addFormToCollection() буде використовувати атрибут data-prototype, щоб динамічно додавати нову форму, коли переходять за її посиланням. HTML data-prototype містить елемент введення тегу text з іменем 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
const 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++;
};

Тепер, кожний раз, коли користувач натискає на посилання Add a tag, на сторінці з'являтиметься нова підформа. Коли форма буде відправлена, будь-які нові форми тегів будуть конвертовані у нові об'єкти Tag і додані у властивість tags об'єкта Task.

See also

Ви можете знайти робочий приклад тут - JSFiddle.

JavaScript з Stimulus

Якщо ви використовуєте 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

Щоб полегшити управління новими тегами, додайте методи "adder" та "remover" для тегів у класі Task:

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 буде додаватися в клас Task, шляхом виклику методу addTag(). До цього вони додавалися формою внутрішньо, шляхом виклику $task->getTags()->add($tag). Це було нормально, використання методу "adder" робить керування новими об'єктами Tag легшим (особливо, якщо ви використовуєте Doctrine, про що ви дізнаєтеся далі!).

Caution

Вам потрібно створити обидва методи: addTag() та removeTag(), інакше форма всеодно буде використовувати setTag() навіть якщо by_reference у значенні false. Ви дізнаєтеся більше про метод removeTag() далі в цій статті.

Caution

Symfony може робити перетворення багато-до-одного (наприклад, із властивості tags у метод addTag()) тільки для англійських слів. Код, написаний будь-якою іншою мовою, не працюватиме так, як очікується.

Щоб зберегти в Doctrine нові теги, вам необхідно врахувати ще декілька речей. Для початку, якщо ви не зробите перебір всіх нових об'єктів Tag і не викличете в кожному $em->persist($tag), ви отримаєте помилку від Doctrine:

Знайдена нова сутність у відносинах AppBundle\Entity\Task#tags, яка не була сконфігурована для каскадного збереження для сутності...

Щоб виправити це, ви можете обрати автоматичну операцію "каскадного" збереження з об'єкта Task у будь-які пов'язані теги. Щоб зробити це, додайте опцію cascade у ваші метадані ManyToMany:

  • Attributes
  • YAML
  • XML
1
2
3
4
5
6
// src/Entity/Task.php

// ...

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

Друга можлива проблема стосується Сторони володіння та сторони інверсії відносин Doctrine. У цьому прикладі, якщо сторона "володіння" у відносинах - це "Task", тобі збереження буде нормально працювати, так як в Task правильно додані теги. Однак, якщо сторони володіння у "Tag", тоді вам знадобиться зробити деяку роботу, щоб переконатися, що змінена правильна сторона відносин.

Секрет в тому, щоб переконатися, що один "Task" встановлено в кожному "Tag". Простий спосіб зробити це - додати додаткову логіку до 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
{
    // для асоціації багато-до-багатьох:
    $tag->addTask($this);

    // для асоціації багато-до-одного:
    $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 у форму Type:

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

Тепер вам потрібно розмістити деякий код в методі removeTag() у Task:

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 у видаленому об'єкті колекції до відправки форми.

Спочатку додайте посилання "видалити цей тег" до кожного тегу форми:

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

    // ... залишок блоку, описаного вище

function addFormToCollection() {
    // ...

    // додати посилання на видалення до нової форми
    addTagFormDeleteLink(item);
}

Функція addTagFormDeleteLink() виглядатиме приблизно так:

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

    tagFormLi.append(removeFormButton);

    removeFormButton.addEventListener('click', (e) => {
        e.preventDefault()
        // видалити li у формах тегів
        tagFormLi.remove();
    });
}

Коли форма тегів видалена з DOM і відправлена, видалений об'єкт Tag не буде додано в колекцію, передану в setTags(). В залежності від вашого рівня зберігання, це може бути (не)достатнім для видалення відносин між видаленим Tag та об'єктом Task.

При видаленні об'єктів таким чином, вам може знадобитися зробити трохи більше роботи, щоб гарантувати правильне видалення відносин між Task та видаленим Tag.

В Doctrine, у вас є дві сторони відносин: сторона володіння та сторона інверсії. Зазвичай в цьому випадку у вас будуть відносини багато-до-багатьох і видалені теги зникнуть та будуть правильно збережені (додавання нових тегів також працює без зусиль).

Але якщо у вас відносини один-до-багатьох, або відносини багато-до-багатьох з mappedBy в сутності Task (що означає, що 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
49
50
51
// src/Controller/TaskController.php

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

class TaskController extends AbstractController
{
    public function edit($id, Request $request, EntityManagerInterface $entityManager): Response
    {
        if (null === $task = $entityManager->getRepository(Task::class)->find($id)) {
            throw $this->createNotFoundException('No task found for id '.$id);
        }

        $originalTags = new ArrayCollection();

    // Створити ArrayCollection поточних об'єктів Tag в базі даних
        foreach ($task->getTags() as $tag) {
            $originalTags->add($tag);
        }

        $editForm = $this->createForm(TaskType::class, $task);

        $editForm->handleRequest($request);

        if ($editForm->isSubmitted() && $editForm->isValid()) {
        // видалити відносини між тегом та Task
            foreach ($originalTags as $tag) {
                if (false === $task->getTags()->contains($tag)) {
                // видалити Task з Tag
                $tag->getTasks()->removeElement($task);

                // якщо це були відносини багато-до-одного, видалити відносини, як ці
                // $tag->setTask(null);

                $entityManager->persist($tag);

                // якщо ви хочете видалити Tag повністю, ви також можете це зробити
                // $em->remove($tag);
            }
        }

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

        // перенаправлення на ту ж сторінку редагування
        return $this->redirectToRoute('task_edit', ['id' => $id]);
    }

    // ... відобразити якийсь шаблон форми
}

Як ви бачите, правильне додавання та видалення елементів може бути піступним. Окрім випадків, коли у вас відносини багато-до-багатьох, де Task - сторона "володіння", вам знадобиться робити додаткову працю, щоб переконаися в тому, що відносини правильно оновлені (незалежно від того, чи додаєте ви нові теги, чи видаляєте вже існуючі) в кожному об'єкті Tag.

See also

Cпільнота Symfony створило деякі пакети JavaScript, які надають функціонал, необхідний для додавання, редагування та видалення елементів колекції. Розгляньте пакет @a2lix/symfony-collection для сучасних браузерів та пакет symfony-collection, заснований на jQuery, для решти браузерів.