Search code examples
phpsymfonytagging

Symfony 7 EntityType form field with taggable behaviour


Environment:

  • Symfony 7.1
  • Doctrine 2.9
  • Webpack encore installed and running

jQuery is prefered when writing JS code

Issue:

What i'm trying to do is to have a form field which is an EntityType (so it loads existing values from db) which is multiple => true and should allow user to add missing items with something like https://select2.org/tagging#tagging-with-multi-value-select-boxes

The base error i get when inserting new values after integrating select2 tagging is: The selected choice is invalid.

Another issue i've noticed while trying to make it work using EventSubscriver is that one of the many select i need to render as "taggable" is in fact a select filled with numeric integer values. This may arise another front to the problem because when checking for incoming values i have something like "1,10,20" where "1" is actually the ID of an existing value (lets say 200) and "10","20" are new values with no respective ID on the DB

What i've searched/tried so far:

none of the above allowed me to reach a complete and stable solution. I've tried DataTransformer (which doesn't work because the validation triggers earlier and blocks everything else), EventSubscriber (which indeed fire before validation but i can't make symfony accept new custom values), choice_loader for the EntityType but i can't seem to understand how to make it work properly and it always fire AFTER validation so it doesn't even come to play.

My key points are:

  • i need something reusable because the application i'm trying to build for my customer is full of these fields (i have like 10+ only in one page)
  • it has to work with EntityType, not with ChoiceType, TextType or CollectionType
  • circumvent the validation error "The selected choice is invalid."

Alternative accepted solution:

i would accept even a solution where ajax is involved in the creation of newly added option but that would be a backup solution because i would like to just have the possibility to submit the form and handle the new values

Bonus award:

  • should eventually be able to support ajax search for the results because eventually the available options will be over 200+ and i can't really think of having it load the full list on every page load

Solution

  • I'll post my temporary solution since i couldn't manage to achieve a better one atm. I ended up doing two things:

    1. A custom FormType so that all my "taggable" fields can be tagged with this new type which allows me to find them in the middle of all the others form field so that i don't have to manually hardcode and pass around which are the fields names that are "taggable"
    2. A custom EvenListener attached to the form on the PRE_SUBMIT phase to handle the new values

    So, first new file is located at /src/Form/TagFormType

    
    namespace App\Form;
    
    use Symfony\Bridge\Doctrine\Form\Type\EntityType;
    use Symfony\Component\Form\AbstractType;
    use Symfony\Component\OptionsResolver\OptionsResolver;
    
    class TagFormType extends AbstractType {
    
        /*public function buildForm(FormBuilderInterface $builder, array $options): void {
    
        }*/
    
        public function configureOptions(OptionsResolver $resolver): void {
            $resolver->setDefaults([]);
        }
    
        public function getParent(): string {
            return EntityType::class;
        }
    }
    
    

    Content is very basic, we just need it as a form of identification

    Another new file at /src/CodeTrait/TagFormFieldTrait

    
    use App\Form\TagFormType;
    use Doctrine\ORM\EntityManagerInterface;
    use Symfony\Component\Form\Extension\Core\Type\TextType;
    use Symfony\Component\Form\FormBuilderInterface;
    
    trait TagFormFieldTrait {
    
        public function entityTypeTagField(
            FormBuilderInterface $builder,
            EntityManagerInterface $em,
            string $fieldName,
            string $classFqn,
            array $additionalFieldOptions = []
        ): void {
    
            $defaultFieldOptions = [
                'class'       => $classFqn,
                //'choice_label' => 'name',
                'multiple'    => true,
                'expanded'    => false,
                'attr'        => [
                    'class'               => 'fg-select2-tokenizable',
                    'data-new-tags-input' => ".{$fieldName}-new-input",
                ],
                'choice_attr' => function($choice, $key, $index) use ($classFqn) {
                    // This is used just to have an hint when inspecting code to quickly see which are the pre-existing values
                    return ['data-id' => $choice->getId()];
                },
            ];
    
            $builder
                ->add($fieldName, TagFormType::class, array_merge($defaultFieldOptions, $additionalFieldOptions))
                ->add("new{$fieldName}", TextType::class, [
                    'mapped'   => false,
                    'required' => false,
                    'attr'     => [
                        'class' => "{$fieldName}-new-input",
                        'style' => 'display:none;',
                    ],
                ]);
        }
    
    }
    
    

    This is to make it reusable across multiple forms, the only special thing is that for every "taggable" field i create a sibling which has the same name prefixed with "new". This is basically an hidden field used as storage for the new tags which are handled later by the PRE_SUBMIT form event. This became necessary because if i just use a single field i couldn't easily discriminate between the newly added integer values and the existing integer values which are real IDS on the Database (i know this is only true if your select has integer values like one of my use-case but i feel this can be useful for string values aswell to make the identification process easier for the new values)

    Inside the form where u are using these fields you will need to add the event listener which is the following:

    $builder->addEventListener(FormEvents::PRE_SUBMIT, function(FormEvent $event) {
        $form = $event->getForm();
        // Contains an array of data which are present in the FORM
        $formData = $event->getData();
    
    
        foreach ($form->all() as $fieldName => $formField) {
            $innerType = $formField->getConfig()->getType()->getInnerType();
            // This way we only target our taggable fields
            if (is_a($innerType,TagFormType::class)) {
                // The 'new[fieldName]' contains string values, comma separated, generated by the JS code
                $formFieldArrayData = explode(',', $formData['new' . $fieldName]) ?? null;
                /*
                 * This is to ensure that we have a "clean" array of values.
                 * Example:
                 * $formData[$fieldName] = ["1","2","250","300"] where 1 and 2 are real ID on DB while 250 and 300 are new values
                 *  $formFieldArrayData = ["250","300"]
                 *
                 * $formFieldArrayDataSanitized = ["1","2"] <--- this contains only the existing ID which have a match on DB
                 */
                $formFieldArrayDataSanitized = array_diff($formData[$fieldName], $formFieldArrayData);
                // Reads the class associated to the EntityField
                $entityClassFqn = $formField->getConfig()->getOption('class');
                // Get field data
                $formFieldData = $formField->getData();
                if (empty($entityClassFqn)) {
                    // Hard stop execution, we have something to fix via code before proceding
                    throw new Exception(sprintf(
                        'Unable to recover fully qualified class name linked to the field %s',
                        $fieldName
                    ));
                }
                // Tmp array for all the Ids to be passed as the final form field values. These will all have a match on DB
                $itemIds = [];
                if (!empty($formFieldArrayData)) {
                    foreach ($formFieldArrayData as $fieldData) {
                        if (class_exists($entityClassFqn)) {
                            $existingItem = $this->em->getRepository($entityClassFqn)->findOneBy(['label' => $fieldData]);
                            // If searching for label gives 0 results, then it should be a new value to be inserted in the DB table
                            if (empty($existingItem)) {
                                /** @var BaseLookupTable $newElement */
                                $newElement = new $entityClassFqn();
                                $newElement->setLabel($fieldData);
                                $this->em->persist($newElement);
                                $this->em->flush();
                                $itemIds[] = $newElement->getId();
                                $formFieldData->add($newElement);
                            } else {
                                $itemIds[] = $existingItem->getId();
                            }
                        }
                    }
                    $formField->setData($formFieldData);
                    //$form->add($formField);// Maybe it can be avoided, ill leave it here just in case
                    /*
                     * This change the initial values received from the FORM (which was a mix of real DB Ids and new integer values) and replaces them with
                     * an array full of real DB Ids. This is needed to be done so that the next Doctrine inner validation will find a match for all the values
                     * avoiding the error 'The selected choice is invalid.'
                     */
                    $formData[$fieldName] = array_merge(array_map('intval', $formFieldArrayDataSanitized), $itemIds);
                }
            }
        }
    
        $event->setData($formData);
    
    }, -50);
    

    BaseLookupTable is a placeholder to identify a simple generic lookup table (made with basically two columns, id and a label).

    Pay special attention to these things:

    • The event listener priority is set to "-50". This is not a case, this is needed to make sure our listener will fire before Doctrine one. If this is not set up correctly we will get the "The selected choice is invalid." error
    • The listener MUST end with "$event->setData($formData);", this is because if we don't change the $event underlaying data, the next eventListener in the chain will not have our updated data which of course will lead to the same error as above

    Lastly we need a few line of JS to handle the frontend UX.

        initializeTagSelect() {
            if (!$.fn.select2) {
                return;
            }
            let $tagSelect = $('.fg-select2-tokenizable'); <--- this is the class used inside the PHP Trait
            $tagSelect.select2({
                tags:      true,
                createTag: function (params) {
                    // This avoid creating a new tag for empty values
                    let term = $.trim(params.term);
                    if (term === '') {
                        return null;
                    }
                    // This is to create a proper <option> inside the <select> tag
                    return {
                        id:        term,
                        text:      term,
                        newOption: true
                    };
                }
            });
    
            $tagSelect.on('select2:select', function (e) {
                let newTagInputSelector = $tagSelect.data('new-tags-input');
                if (!newTagInputSelector) {
                    console.error('Impossibile to find a linked input for the creation of new tags. Searched one with class: ', newTagInputSelector);
                }
                let $newTagInput = $(newTagInputSelector);
    
                let newTag = e.params.data.text;
    
                if (e.params.data.newOption) {
                    let existingTags = $newTagInput.val() ? $newTagInput.val().split(',') : [];
                    if (!existingTags.includes(newTag)) {
                        existingTags.push(newTag);
                        $newTagInput.val(existingTags.join(',')); // Updates the relative tmp storage used to discriminate new tag values
                    }
                }
            });
        }
    

    This is it, all the relative code is here, improvements/critics are accepted