symfony4 Form -- Форма с вложенной коллекцией форм. Создание, сохранение. Вложенные сущности, "документы", иерархия

В Symfony существует компонент Forms, предназначенный для создания форм, основанных на сущностях (Entity). Если корректно написать класс формы, то можно максимально упростить рендеринг, сохранение и автозаполнение формы. Но чем сложнее поставленная задача, тем более неоднозначно решение.

Здесь, например, можно посмотреть, как сохранять non-mapped поля при автоматическом сохранении формы.

Задача

В данной статье мы рассмотрим, как АВТОМАТИЧЕСКИ (не каждое поле отдельно, а сущность целиком) вывести форму для создания сущности (Entity), а также вложенной в неё коллекции (ArrayCollection), и сохранить её.

Решение

Итак, у нас есть 2 связанные сущности: Product и ProductField. Связаны они Один-ко-Многим.

  • Часть сущностей (нас интересуют в основном поля связи). Для синхронного сохранения вложенной сущности, добавим полю productFields сущности Product опцию cascade={"persist", "remove"}:
    // Product.php:
    
    // ... здесь могут быть и другие поля
    /**
         * @var Collection 
         * 
         * @ORM\OneToMany(targetEntity="ProductField", mappedBy="product", cascade={"persist", "remove"})
         */
        protected $productFields;
        
        public function __construct()
        {
            $this->productFields = new ArrayCollection();
        }
    // ProductField.php:
    
    // ... здесь могут быть и другие поля
        /**
         * @var Product
         * 
         * @ORM\ManyToOne(targetEntity="Product", inversedBy="productFields")
         * @ORM\JoinColumn(name="product_id", referencedColumnName="id", onDelete="CASCADE")
         */
        protected $product;

    И для сохранения внешнего ключа в таблице product_field, добавим установку связи с продуктом (обратная связь: ProductField->setProduct) при создании связи Product->addProductField.
    Внимание! Это обязательно, иначе ProductField добавится в коллекцию, но не будет установлен внешний ключ в таблице БД!

    // Product.php(методы добавления, удаления и получения коллекции $productFields):
    
    /**
         * @param ProductField $productField
         *
         * @return self
         */
        public function addProductField(ProductField $productField): self
        {
            if (!$this->productFields->contains($productField)) {
                $productField->setProduct($this); //вот эта строка важна
                
                $this->productFields[] = $productField;
            }
            
            return $this;
        }
    
        /**
         * @param ProductField $productField
         * 
         * @return self
         */
        public function removeProductField(ProductField $productField): self
        {
            if ($this->productFields->contains($productField)) {
                $this->productFields->removeElement($productField);
            }
            
            return $this;
        }
    
        /**
         * @return Collection | ProductField[]
         */
        public function getProductFields(): Collection
        {
            return $this->productFields;
        }
  • В контроллере будет всего лишь:
       
        // ProductController.php
    
        // создаём форму
        $product = new Product();
        $form = $this->createForm(ProductType::class, $product); 
        $form->handleRequest($request);
        
        // если не нажата кнопка сохранить, печатаем форму на экран
        if (!$form->isSubmitted()) {
            return $this->renderProductUpdateView($request);
        }
            
        // если данные невалидны, бросаем исключение
        if (!$form->isValid()) {
            throw new ValidatorException('Введённые данные некорректны');
        }
            
        // сохраняем сущность вместе со вложенной
        $em = $this->getDoctrine()->getManager();
        $em->persist($product);
        $em->flush();
  • Формы сущностей. Чтобы Symfony понимал, в какую сущность сохранять данные, добавляем метод configureOptions(). Чтобы поля для вложенной сущности подгружались в форму, добавляем атрибуты CollectionType-поля: by_reference, prototype, allow_add и allow_delete. Взято из оф. документации Симфони:
        // ProductType.php:
    
        /**
         * @param FormBuilderInterface $builder
         * @param array $options
         */
        public function buildForm(FormBuilderInterface $builder, array $options): void
        {
            $builder->add('name', TextType::class, [
                    'label' => 'Название',
                    'constraints' => [
                        new Type([
                            'type' => 'string',
                        ]),
                    ],
                ])
                ->add('productFields', CollectionType::class, [
                    'label' => false,
                    'entry_type' => ProductFieldType::class,
                    'by_reference' => false,
                    'prototype' => true,
                    'allow_add' => true,
                    'allow_delete' => true,
                ])
                ->add('submit', SubmitType::class, [
                    'label' => 'Сохранить изменения',
                ]);
        }
        
        /**
         * @param OptionsResolver $resolver
         */
        public function configureOptions(OptionsResolver $resolver): void
        {
            $resolver->setDefaults([
                'data_class' => Product::class,
            ]);
        }
        // ProductFieldType.php:
    
        /**
         * @param FormBuilderInterface $builder
         * @param array $options
         */
        public function buildForm(FormBuilderInterface $builder, array $options): void
        {
            $builder->add('name', TextType::class, [
                    'label' => 'Свойство',
                    'constraints' => [
                        new Type([
                            'type' => 'string',
                        ]),
                    ],
                ])
                ->add('value', TextType::class, [
                    'label' => 'Значение',
                    'constraints' => [
                        new Type([
                            'type' => 'string',
                        ]),
                    ],
                ]);
        }
        
        /**
         * @param OptionsResolver $resolver
         */
        public function configureOptions(OptionsResolver $resolver): void
        {
            $resolver->setDefaults([
                'data_class' => ProductField::class,
            ]);
        }
  • В шаблоне. Выводим вложенные сущности в виде списка, а также добавляем атрибуты для подгрузки неограниченного количества пунктов списка:
    //product_update.html.twig:
        {{ form_start(productForm, {'attr': {'method': 'post'}}) }}
                
                {{ form_row(productForm.submit, {'attr': {'formnovalidate': 'true'}}) }}
                    
                {{ form_row(productForm.name) }}
    
                <ul id="email-fields-list"
                    data-prototype="{{ form_widget(productForm.productFields.vars.prototype)|e }}"
                    data-widget-tags="{{ '<li></li>'|e }}">
                        
                    {% for productField in productForm.productFields %}
                        <li>
                            {{ form_row(productField.name) }}
                            {{ form_row(productField.value) }}
                        </li>
                    {% endfor %}
                </ul>
                    
                <a href="#"
                    class="add-another-collection-widget"
                    data-list="#email-fields-list">Добавить свойство товара</a>
                    
            {{ form_end(productForm) }}
  • js-код для подгрузки неограниченного количества вложенных форм (не забудьте подключить его в своём шаблоне):
    // add-collection-widget.js:
    
    jQuery(document).ready(function () {
        jQuery('.add-another-collection-widget').click(function (e) {
            e.preventDefault();
            var list = jQuery(jQuery(this).attr('data-list'));
            // Try to find the counter of the list
            var counter = list.data('widget-counter') | list.children().length;
            // If the counter does not exist, use the length of the list
            if (!counter) { counter = list.children().length; }
    
            // grab the prototype template
            var newWidget = list.attr('data-prototype');
            // replace the "__name__" used in the id and name of the prototype
            // with a number that's unique to your emails
            // end name attribute looks like name="contact[emails][2]"
            newWidget = newWidget.replace(/__name__/g, counter);
            // Increase the counter
            counter++;
            // And store it, the length cannot be used if deleting widgets is allowed
            list.data(' widget-counter', counter);
    
            // create a new list element and add it to the list
            var newElem = jQuery(list.attr('data-widget-tags')).html(newWidget);
            newElem.appendTo(list);
        });
    });

Чтобы сделать из формы создания форму редактирования, просто передайте вторым аргументом в метод createForm() не пустой объект сущности, а тот, который надо изменить.

Вот и всё) Спасибо всем, кто писал хоть что-то на просторах интернета на эту тему) Без вас было бы слишком тяжело использовать "лёгкий" способ сохранения сущностей в Symfony :))

Источники