Symfony 可重用 AJAX select / ChoiceType

Symfony reusable AJAX select / ChoiceType

我想使用 Symfony 表单类型创建一个可重复使用的 AJAX-based select (select2),我已经花了很多时间,但无法让它工作如我所愿。

据我所知,您无法在添加表单字段后覆盖它们的选项,因此您必须使用新配置 re-add 它们。 Symfony 文档还提供了一些关于如何使用事件动态添加或修改表单的示例。 https://symfony.com/doc/current/form/dynamic_form_modification.html

我已经成功地在表单中创建了基于 AJAX 的元素,它可以正常工作,但还不能完全重用:

当前设置有效但仅适用于parent形式。我想让我的 AjaxNodeType 完全可重用,这样 parent 表单就不需要关心数据处理和事件。 按照这两个例子,parent 和 child 形成。这里有两个事件侦听器,但当然它们应该只有一个。

在单个元素类型中是否根本不可能替换 parent 形式中的“您自己”,还是我做错了什么?还有其他方法可以动态更改选项吗?


Parent形式 这有效!

class MyResultElementType extends AbstractType
{

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add(
                'fixedNode',
                AjaxNodeType::class,
                [
                    'label' => 'Fixed node',
                    'required' => false,
                ]
            );

        $builder->addEventListener(
            FormEvents::PRE_SET_DATA,
            function (PreSetDataEvent $event) {
                if ($event->getData()) {
                    $fixedNode = $event->getData()->getFixedNode();

                    //this works here but not in child???
                    if ($fixedNode) {
                        $name = 'fixedNode';
                        $parentForm = $event->getForm();
                        $options = $parentForm->get($name)->getConfig()->getOptions();
                        $options['choices'] = [$fixedNode];
                        $parentForm->add($name, AjaxNodeType::class, $options);
                    }
                }
            },
            1000
        );


        $builder->addEventListener(
            FormEvents::PRE_SUBMIT,
            function (PreSubmitEvent $event) {
                $data = $event->getData()['fixedNode'];
                if ($data) {
                    $name = 'fixedNode';
                    $parentForm = $event->getForm();

                    // we have to add the POST-ed data/node here to the choice list
                    // otherwise the submitted value is not valid
                    $node = $this->entityManager->find(Node::class, $data);
                    $options = $parentForm->get($name)->getConfig()->getOptions();
                    $options['choices'] = [$node];
                    $parentForm->add($name, AjaxNodeType::class, $options);
                }
            }
        );
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(
            [
                'data_class' => MyResultElement::class,
                'method' => 'POST',
            ]
        );
    }
}

Child形式/单select这行不通。在 POST 上,fixedNode 字段未设置为 data-entity.

形式
class AjaxNodeType extends AbstractType
{
    /** @var EntityManager */
    private $entityManager;

    public function __construct(
        EntityManager $entityManager
    ) {
        $this->entityManager = $entityManager;
    }

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        //this does not work here but in parent???
        $builder->addEventListener(
            FormEvents::PRE_SET_DATA,
            function (PreSetDataEvent $event) {
                if ($event->getData()) {
                    $fixedNode = $event->getData();

                    $name = $event->getForm()->getName();
                    $parentForm = $event->getForm()->getParent();
                    $options = $parentForm->get($name)->getConfig()->getOptions();
                    $newChoices = [$fixedNode];
                    // check if the choices already match, otherwise we'll end up in an endless loop ???
                    if ($options['choices'] !== $newChoices) {
                        $options['choices'] = $newChoices;
                        $parentForm->add($name, AjaxNodeType::class, $options);
                    }
                }
            },
            1000
        );

        $builder->addEventListener(
            FormEvents::PRE_SUBMIT,
            function (PreSubmitEvent $event) {
                if ($event->getData()) {
                    $name = $event->getForm()->getName();
                    $data = $event->getData();
                    $parentForm = $event->getForm()->getParent();

                    // we have to add the POST-ed data/node here to the choice list
                    // otherwise the submitted value is not valid
                    $node = $this->entityManager->find(Node::class, $data);
                    $options = $parentForm->get($name)->getConfig()->getOptions();
                    $options['choices'] = [$node];
                    $parentForm->add($name, self::class, $options);
                }
            },
            1000
        );
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(
            [
                'class' => Node::class,
                // prevent doctrine from loading ALL nodes
                'choices' => [],
            ]
        );
    }

    public function getParent(): string
    {
        return EntityType::class;
    }
}

再次回答我自己的问题:) 在花了更多时间之后,我得到了一个可行的解决方案。也许它会对某人有所帮助。

  • 我已经从 EntityType 切换到 ChoiceType,因为它只会让事情变得更复杂而且实际上并不需要
  • 多选和单选需要不同的settings/workarrounds(比如by_reference),见下面的行注释
  • re-adding自己给parent form就行了,之前不知道为什么不行...
  • 当 re-adding / re-submitting 值
  • 时注意 endless-loops

执行所有逻辑的主要 re-usable AjaxEntityType 不引用任何特定实体:

<?php

namespace Tool\Form\SingleInputs;

use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManager;
use Symfony\Bridge\Doctrine\Form\DataTransformer\CollectionToArrayTransformer;
use Symfony\Bridge\Doctrine\Form\EventListener\MergeDoctrineCollectionListener;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Event\PreSetDataEvent;
use Symfony\Component\Form\Event\PreSubmitEvent;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;

class AjaxEntityType extends AbstractType
{
    /** @var EntityManager */
    private $entityManager;

    public function __construct(
        EntityManager $entityManager
    ) {
        $this->entityManager = $entityManager;
    }

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        // shamelessly copied from DoctrineType - this is needed to support Doctrine Collections in multi-selects
        if ($options['multiple'] && interface_exists(Collection::class)) {
            $builder
                ->addEventSubscriber(new MergeDoctrineCollectionListener())
                ->addViewTransformer(new CollectionToArrayTransformer(), true);
        }

        // PRE_SET_DATA is the entrypoint on form creation where we need to populate existing choices
        // we process current data and set it as choices so it will be rendered correctly
        $builder->addEventListener(
            FormEvents::PRE_SET_DATA,
            function (PreSetDataEvent $event) use($options) {
                $data = $event->getData();
                $hasData = ($options['multiple'] && count($data) > 0) || (!$options['multiple'] && $data !== null);
                if ($hasData) {
                    $entityOrList = $event->getData();

                    $name = $event->getForm()->getName();
                    $parentForm = $event->getForm()->getParent();
                    $options = $parentForm->get($name)->getConfig()->getOptions();

                    // ONLY do this if the choices are empty, otherwise readding PRE_SUBMIT will not work because this is called again!
                    if(empty($options['choices'])) {
                        if($options['multiple']) {
                            $newChoices = [];
                            foreach ($entityOrList as $item) {
                                $newChoices[$item->getId()] = $item;
                            }
                        } else {
                            $newChoices = [$entityOrList->getId() => $entityOrList];
                        }

                        $options['choices'] = $newChoices;
                        $parentForm->add($name, self::class, $options);
                    }
                }
            },
            1000
        );

        // PRE_SUBMIT is the entrypoint where we need to process the submitted values
        // we have to add the POST-ed choices, otherwise this field won't be valid
        $builder->addEventListener(FormEvents::PRE_SUBMIT, function (PreSubmitEvent $event) use($options)  {
            $entityIdOrList = $event->getData();
            $entityClass = $options['class'];

            // new choices constructed from POST
            $newChoices = [];

            if ($options['multiple']) {
                foreach ($entityIdOrList as $id) {
                    if ($id) {
                        $newChoices[$id] = $this->entityManager->find($entityClass, $id);
                    }
                }
            } elseif ($entityIdOrList) {
                $newChoices = [$entityIdOrList => $this->entityManager->find($entityClass, $entityIdOrList)];
            }

            $name = $event->getForm()->getName();
            $parentform = $event->getForm()->getParent();

            $currentChoices = $event->getForm()->getConfig()->getOptions()['choices'];

            // if the user selected/posted new choices that have not been in the existing list, add them all
            if ($newChoices && count(array_diff($newChoices, $currentChoices)) > 0) {
                $options = $event->getForm()->getParent()->get($name)->getConfig()->getOptions();
                $options['choices'] = $newChoices;

                // re-add ourselves to the parent form with updated / POST-ed options
                $parentform->add($name, self::class, $options);
                if(!$parentform->get($name)->isSubmitted()) {
                    // after re-adding we also need to re-submit ourselves
                    $parentform->get($name)->submit($entityIdOrList);
                }
            }
        }, 1000);
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(
            [
                'mapped' => true,
                'choice_value' => 'id',
                'choice_label' => 'selectLabel',
                'choices' => [],
                'attr' => [
                    'class' => 'select2-ajax',
                ],
                'ajax_url' => null,
            ]
        );

        // AJAX endpoint that is select2 compatible
        // https://select2.org/data-sources/ajax
        $resolver->setRequired('ajax_url');
        $resolver->setAllowedTypes('ajax_url', ['string']);

        // entity class to process
        $resolver->setRequired('class');

        // by_reference needs to be true for single-selects, otherwise our entities will be cloned!
        // by_reference needs to be false for multi-selects, otherwise the setters wont be called for doctrine collections!
        $resolver->setDefault('by_reference', function (Options $options) {
            return !$options['multiple'];
        });

        // adds the ajax_url as attribute
        $resolver->setNormalizer('attr', function (Options $options, $value) {
            $value['data-custom-ajax-url'] = $options['ajax_url'];

            return $value;
        });
    }

    public function getParent(): string
    {
        return ChoiceType::class;
    }
}

特定实体和ajax端点的实际使用情况:

<?php

namespace Tool\Form\SingleInputs;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Tool\Entities\User\Node;

class AjaxNodeType extends AbstractType
{
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(
            [
                'class' => Node::class,
                'ajax_url' => 's/ajax-select/nodes',
            ]
        );
    }

    public function getParent(): string
    {
        return AjaxEntityType::class;
    }
}