How could my custom Symfony ChoiceType allow extra items?

Viewed 64

I'm working on a TagField for EasyAdmin 4 (and Symfony 6) that will rely on a TagType. This TagType will have the native ChoiceType as a parent.

This field will be rendered as a multiple select, with these attributes to allow adding tags on the fly:

[ 'data-ea-widget' => 'ea-autocomplete', 'data-ea-autocomplete-allow-item-create' => 'true' ]

To do so, I created a TagListener. Its main goal is to prefill the options with the already existing tags (on other entities) to support tag suggestion. After reading the docs and many articles, I chose to listen to the FormEvents::PRE_SET_DATA event.

Unfortunately there does not seem to be an easy way to "override" the default options, and we're left with having to override the entire field.

Here's what the TagListener looks like:

<?php
// src/Form/EventListener/TagListener.php

namespace eduMedia\TagBundle\Form\EventListener;

use eduMedia\TagBundle\Entity\TaggableInterface;
use eduMedia\TagBundle\Form\Type\TagType;
use eduMedia\TagBundle\Service\TagService;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;

class TagListener implements EventSubscriberInterface
{

    public function __construct(private TagService $tagService)
    {
    }

    /**
     * @inheritDoc
     */
    public static function getSubscribedEvents(): array
    {
        return [
            FormEvents::PRE_SET_DATA => 'onPreSetData',
        ];
    }

    public function onPreSetData(FormEvent $event): void
    {
        $form = $event->getForm();

        $parentForm = $event->getForm()->getParent();
        /** @var TaggableInterface $taggable */
        $taggable = $parentForm->getData();

        // We retrieve the existing options to override some of them
        $options = $form->getConfig()->getOptions();

//         if ($options['pre_set_data_called']) {
//            return;
//        }

        // We prefill options with the existing tags for this resource type
        $allTagNames = $this->tagService->getTypeTagNames($taggable->getTaggableType());

        // They are our new choices
        $options['choices'] = array_combine($allTagNames, $allTagNames);
        
        // We also need to select the entity's tags
        $options['data'] = $this->tagService->loadTagging($taggable)->getTagNames($taggable);
        
        // We override the form field
//        $options['pre_set_data_called'] = true;
        $parentForm->add($form->getName(), TagType::class, $options);
    }

}

Doing so seems to create an infinite loop, where onPreSetData is called when calling $parentForm->add(). Is that normal? Is PRE_SET_DATA dispatched again when adding a field in a listener? Is there a way to prevent this from happening?

I tried adding a pre_set_data_called form option, setting it to true when calling $parentForm->add() and exiting the listener when it is indeed true. It kind of works, but then I get this error:

An exception has been thrown during the rendering of a template ("Field "tags" has already been rendered, save the result of previous render call to a variable and output that instead.").

How can I manage to allow extra items in my custom field type?

For reference, here is my TagType class:

<?php

namespace eduMedia\TagBundle\Form\Type;

use eduMedia\TagBundle\Form\EventListener\TagListener;
use eduMedia\TagBundle\Service\TagService;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class TagType extends AbstractType
{

    public function __construct(private TagService $tagService)
    {
    }

    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->addEventSubscriber(new TagListener($this->tagService));
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'mapped'   => false,
            'multiple' => true,
//            'pre_set_data_called' => false,
        ]);
    }

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

}

And my TagField class:

<?php

namespace eduMedia\TagBundle\Admin\Field;

use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface;
use EasyCorp\Bundle\EasyAdminBundle\Field\FieldTrait;
use eduMedia\TagBundle\Form\Type\TagType;

class TagField implements FieldInterface
{

    use FieldTrait;

    public static function new(string $propertyName, ?string $label = null)
    {
        return (new self())
            ->setProperty($propertyName)
            ->setLabel($label)
            ->setFormType(TagType::class)
            ->setFormTypeOption('attr', [ 'data-ea-widget' => 'ea-autocomplete', 'data-ea-autocomplete-allow-item-create' => 'true' ])
            ->setTemplatePath('@eduMediaTag/fields/tag.html.twig')
            ;
    }

}
1 Answers

I ended up not using the ChoiceType as the parent (<select> element), but rather the TextType (<input type=text> element), and splitting/exploding a simple string.

The actual bundle is live on GitHub and even though it might not be perfect (yet ), the implementation is way simpler and the end-user behaviour is exactly what I expected.

Related