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

Дата обновления перевода 2024-07-19

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

Формы 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
// 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;
    }
}

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 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. Это позволит нам модифицировать все элементы 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 кода.

Tip

Вместо того чтобы самостоятельно писать необходимый JavaScript-код, вы можете использовать Symfony UX для реализации этой возможности, используя только код PHP и Twig. См. статью Демонстрация Symfony UX по коллекциям форм.

Но вначале вам нужно дать коллекции форм знать, что вместо двух тегов она получит неизвестное количество тегов. Иначе вы увидите ошибку "Эта форма не должна содержать дополнительных полей". Это делается с помощью опции 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, чтобы прочитать этот атрбут и динамически добавить новые формы тегов, когда ваш пользователь нажимает на сссылку "Добавить тег". Добавьте тег <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 и вызовете $entityManager->persist($tag) в каждом, ви получите ошибку от Doctrine:

1
2
3
Новая сущность была найдена черрез отношения
``App\Entity\Task#tags``, которая не была сконфигурирована для
каскадных операций персистенции для сущности...

Чтобы исправить это, вы можете выбрать "cascade" стойкую операцию автоматически из объекта Task по отношению к любым связанным тегам. Чтобы сделать это, добавьте опцию cascade к вашим метаданным ManyToMany:

1
2
3
4
5
6
// src/Entity/Task.php

// ...

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

Вторая потенциальная проблема справляется со Стороной владения и стороной инверсииe отношений 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
// 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();

    // Создать 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, для остальных браузеров.