Как встроить коллекцию форм
Дата обновления перевода 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="<div><label class=" required">__name__</label><div id="task_tags___name__"><div><label for="task_tags___name___name" class=" required">Name</label><input type="text" id="task_tags___name___name" name="task[tags][__name__][name]" required="required" maxlength="255" /></div></div></div>"
></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()
) для английских слов. Код, написанный
на любом другом языке, не будет работать так, как ожидается.
Разрешение удаления тегов
Следующим шагом является разрешение удаления конкретного предмета в коллекции. Решение схоже с разрешением на добавление тегов.
Начните, добавив опцию 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
.
See also
Сообщество Symfony создало некоторые пакеты JavaScript, которые предоставляют функционал, необходимый для добавления, редактирования и удаления элементов коллекции. Рассмотрите пакет @a2lix/symfony-collection для современных браузеров, и пакет symfony-collection, основанный на jQuery, для остальных браузеров.