Skip to content
Advertisement

Symfony reusable AJAX select / ChoiceType

I want to create a reusable AJAX-based select (select2) using Symfony form types and I’ve spent quite some time on it but can’t get it to work like I want.

As far as I know you cannot override options of form fields after they have been added, so you have to re-add them with the new config. The Symfony docs also provide some examples on how to dynamically add or modify forms using events. https://symfony.com/doc/current/form/dynamic_form_modification.html

I’ve managed to create my AJAX based elements in the form and it’s working but not completely reusable yet:

  • Form field extends Doctrine EntityType to have full support of data mappers etc
  • Form field is initialized with 'choices' => [], so Doctrine does not load any entities from db
  • Existing choices on edit is added during FormEvents::PRE_SET_DATA
  • Posted choices are added during FormEvents::PRE_SUBMIT

The current setup works but only in the parent form. I want to have my AjaxNodeType completely reusable so the parent form does not need to care about data handling and events. Following the 2 examples, parent and child form. Here with event listeners in both, but of course they should only be in one.

Is it simply not possible in single element types to replace “yourself” in the parent form or am I doing something wrong? Is there any other way to dynamically change the choices?


Parent form This works!

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 form / single select This does NOT work. On POST the fixedNode field is not set to the form 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;
    }
}

Advertisement

Answer

Again, answering my own Questions 🙂 After spending some more time on it I got a working solution. And maybe it will be helpful for someone.

  • I’ve switched from EntityType to ChoiceType as it only made things more complicated and is not actually needed
  • multiple and single selects need different settings/workarrounds (like by_reference), see the line comments below
  • re-adding yourself to the parent form works, I don’t know why it did not before…
  • beware of endless-loops when re-adding / re-submitting values

The main re-usable AjaxEntityType that does all the logic without reference to any specific entity:

<?php

namespace ToolFormSingleInputs;

use DoctrineCommonCollectionsCollection;
use DoctrineORMEntityManager;
use SymfonyBridgeDoctrineFormDataTransformerCollectionToArrayTransformer;
use SymfonyBridgeDoctrineFormEventListenerMergeDoctrineCollectionListener;
use SymfonyComponentFormAbstractType;
use SymfonyComponentFormEventPreSetDataEvent;
use SymfonyComponentFormEventPreSubmitEvent;
use SymfonyComponentFormExtensionCoreTypeChoiceType;
use SymfonyComponentFormFormBuilderInterface;
use SymfonyComponentFormFormEvents;
use SymfonyComponentOptionsResolverOptions;
use SymfonyComponentOptionsResolverOptionsResolver;

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;
    }
}

Actual usage with specific entity and ajax endpoint:

<?php

namespace ToolFormSingleInputs;

use SymfonyComponentFormAbstractType;
use SymfonyComponentOptionsResolverOptionsResolver;
use ToolEntitiesUserNode;

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;
    }
}
User contributions licensed under: CC BY-SA
9 People found this is helpful
Advertisement