Как встроить коллекцию форм

Дата обновления перевода: 2022-01-17

Как встроить коллекцию форм

Формы 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. Для отображения прототипа, добавьте следующий атрибут data-prototype к существующему <ul> в вашем шаблоне:

1
<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>

Теперь добавьте кнопку прямо рядом с <ul>, чтобы динамически добавлять новый тег:

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

На отображенной странице, результат будет выглядеть как-то так:

1
<ul class="tags" 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;">

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 для чтения этого атрибута и динамического добавления новых форм тегов, когда пользователь кликает по ссылке "Добавить тег". Этот пример использует jQuery и предполагает, что он включен где-то на вашей странице (например, используя Webpack Encore Symfony).

Добавьте тег<script> где-то на вашей странице, чтобы включить необходимый функционал с помощью JavaScript:

1
2
3
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.

Чтобы облегчить управление новыми тегами, добавьте методы "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 в любst связанные теги. Чтобы сделать это, добавьте опцию cascade в ваши метаданные ManyToMany:

  • Annotations
  • YAML
  • XML
1
2
3
4
5
6
7
8
// src/Entity/Task.php

// ...

/**
 * @ORM\ManyToMany(targetEntity="Tag", 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 a форму 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() {
    // ...

    // add a delete link to the new form
    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 в DB
        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

Сообщество Symfony создало некоторые пакеты JavaScript, которые предоставляют функционал, необходимый для добавления, редактирования и удаления элементов коллекции. Рассмотрите пакет @a2lix/symfony-collection для современных браузеров, и пакет symfony-collection, основанный на jQuery, для остальных браузеров.