Дата обновления перевода: 2021-05-31

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

Формы Symfony могут встраивать коллекцию многих других форм, что полезно для редактирования связанных сущностей в одной форме. В этой статье вы создадите форму для редактирования класса Task, и, прямо внутри той же формы, вы сможете редактировать, создавать и удалять многие объекты Tag, связанные с этим классом Task.

Давайте начнем с создания сущности Task:

// 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:

// 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 мог быть изменён пользователем:

// 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 прямо внутри самой формы Задачи:

// 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:

// 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->render('task/new.html.twig', [
            'form' => $form->createView(),
        ]);
    }
}

Теперь в шаблоне вы можете итерировать поверх существующих форм 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:

// 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-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-код в прототипе, смотрите Fragment Naming for Collections.

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
 4
 5
 6
 7
 8
 9
10
11
12
13
jQuery(document).ready(function() {
    // Получите ul, содержащий коллекцию тегов
    var $tagsCollectionHolder = $('ul.tags');
    // посчитайте текущие вводы формы, которые у вас есть (например, 2) и используйте
    // это в качестве нового индекса при вставке нового объекта (например, 2)
    $tagsCollectionHolder.data('index', $tagsCollectionHolder.find('input').length);

    $('body').on('click', '.add_item_link', function(e) {
        var $collectionHolderClass = $(e.currentTarget).data('collectionHolderClass');
        // добавьте новую форму тега (смотрите следующий блок кода)
        addFormToCollection($collectionHolderClass);
    })
});

Работой функции addTagForm() будет использовать атрибут 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
18
19
20
21
22
23
24
25
26
27
28
function addFormToCollection($collectionHolderClass) {
    // Получить ul, содержащий коллекцию тегов
    var $collectionHolder = $('.' + $collectionHolderClass);

    // Получить прототип данных, объяснённый ранее
    var prototype = $collectionHolder.data('prototype');

    // получить новый индекс
    var index = $collectionHolder.data('index');

    var newForm = prototype;
    // Вам нужно это только в случае, если вы не установили 'label', как "false" в вашем поле тегов в TaskType
    // Заменить '__name__label__' в HTML прототипа, чтобы
    // он был числом, основанным на том, сколько объектов у нас есть
    // newForm = newForm.replace(/__name__label__/g, index);

    // Заменить '__name__' в HTML прототипа на
    // номер, основанный на количестве имеющихся объектов
    newForm = newForm.replace(/__name__/g, index);

    // увеличить индекс на единицу для следующего объекта
    $collectionHolder.data('index', index + 1);

    // Отобразить форму на странице в li, до ссылки Li "добавить тег"
    var $newFormLi = $('<li></li>').append(newForm);
    // Добавить новую форму в конце списка
    $collectionHolder.append($newFormLi)
}

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

See also

Вы можете найти рабочий пример тут - JSFiddle.

Чтобы облегчить управление новыми тегами, добавьте методы “adder” и “remover” для тегов в классе Task:

// 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:

// 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()) только для английских слов. Код, написанный на любом другом языке, Не будет работать так, как ожидается.

Разрешение удаления тегов

Следующим шагом является разрешение удаления конкретного предмета в коллекции. Решение схоже с разрешением на добавление тегов.

Начните, добавив опцию allow_delete a форму Type (Тип):

// src/Form/TaskType.php

// ...
public function buildForm(FormBuilderInterface $builder, array $options): void
{
    // ...

    $builder->add('tags', CollectionType::class, [
        // ...
        'allow_delete' => true,
    ]);
}

Теперь, вам нужно поместить некоторый код в метод removeTag() в Task:

// 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
14
15
16
17
18
jQuery(document).ready(function() {
    // Получить ul, которы содержит коллекцию тегов
    $collectionHolder = $('ul.tags');

    // добавить ссылку удаления ко всем существующим элементам li в форме тегов
    $collectionHolder.find('li').each(function() {
        addTagFormDeleteLink($(this));
    });

    // ... оставшийся блок, описанный выше
});

function addFormToCollection() {
    // ...

    // добавить ссылку удаления к новой форме
    addTagFormDeleteLink($newFormLi);
}

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

1
2
3
4
5
6
7
8
9
function addTagFormDeleteLink($tagFormLi) {
    var $removeFormButton = $('<button type="button">Delete this tag</button>');
    $tagFormLi.append($removeFormButton);

    $removeFormButton.on('click', function(e) {
        // удалить li в форме тегов
        $tagFormLi.remove();
    });
}

Когда форма тегов удалена из DOM и отправлена, удалённый объект Tag не будет включён в коллекцию, переданную в setTags(). В зависимости от вашего уровня сохранения, это может быть (не) достаточным для удаления отношения между удалённым Tag и объектом Task.

See also

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

Эта документация является переводом официальной документации Symfony и предоставляется по свободной лицензии CC BY-SA 3.0.