Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

symfony2 form create new type combining collection and entity

With symfony 2, I am willing to create a new field type combining the behaviour of the entity field type and the one of the collection field type: - if the user selects an existing entity, the collection of new entity is null - if the user creates a new entity, the first field is not required

Do ou have any idea as how to proceed? Can I reuse existing symfony types? Where do I put the logic (if old, collection is not required, if new, entity is not required) ?

Thansk a lot

like image 555
Sébastien Avatar asked Mar 18 '23 15:03

Sébastien


2 Answers

I finally got it ! Wow, this was not that easy.

So basically, adding a new entry to a select with javascript when the form type is an entity type triggers a Symfony\Component\Form\Exception\TransformationFailedException.

This exception comes from the getChoicesForValues method called on a ChoiceListInterface in the reverseTransform method of the ChoicesToValuesTransformer. This DataTransformer is used in the ChoiceType so to overcome this, I had to build a new type extending the ChoiceType and replacing just a tiny part of it.

The steps to make it work :

Create a new type :

<?php

namespace AppBundle\Form\Type;

use AppBundle\Form\DataTransformer\ChoicesToValuesTransformer;
use Doctrine\Common\Persistence\ObjectManager;
use Doctrine\ORM\EntityManager;
use Symfony\Bridge\Doctrine\Form\ChoiceList\ORMQueryBuilderLoader;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Doctrine\Common\Persistence\ManagerRegistry;
use Symfony\Component\Form\Exception\RuntimeException;
use Symfony\Bridge\Doctrine\Form\ChoiceList\EntityChoiceList;
use Symfony\Bridge\Doctrine\Form\EventListener\MergeDoctrineCollectionListener;
use Symfony\Bridge\Doctrine\Form\DataTransformer\CollectionToArrayTransformer;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\Form\Extension\Core\View\ChoiceView;
use Symfony\Component\Form\Exception\LogicException;
use Symfony\Component\Form\Extension\Core\EventListener\FixRadioInputListener;
use Symfony\Component\Form\Extension\Core\EventListener\FixCheckboxInputListener;
use Symfony\Component\Form\Extension\Core\EventListener\MergeCollectionListener;
use Symfony\Component\Form\Extension\Core\DataTransformer\ChoiceToValueTransformer;
use Symfony\Component\Form\Extension\Core\DataTransformer\ChoiceToBooleanArrayTransformer;
use Symfony\Component\Form\Extension\Core\DataTransformer\ChoicesToBooleanArrayTransformer;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;

class TagType extends ChoiceType
{
    /**
     * @var ManagerRegistry
     */
    protected $registry;

    /**
     * @var array
     */
    private $choiceListCache = array();

    /**
     * @var PropertyAccessorInterface
     */
    private $propertyAccessor;

    /**
     * @var EntityManager
     */
    private $entityManager;

    public function __construct(EntityManager $entityManager, ManagerRegistry $registry, PropertyAccessorInterface $propertyAccessor = null)
    {
        $this->registry = $registry;
        $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor();
        $this->entityManager = $entityManager;
        $this->propertyAccessor = $propertyAccessor;
    }

    public function buildForm(FormBuilderInterface $builder, array $options)
    {

        if (!$options['choice_list'] && !is_array($options['choices']) && !$options['choices'] instanceof \Traversable) {
            throw new LogicException('Either the option "choices" or "choice_list" must be set.');
        }

        if ($options['expanded']) {
            // Initialize all choices before doing the index check below.
            // This helps in cases where index checks are optimized for non
            // initialized choice lists. For example, when using an SQL driver,
            // the index check would read in one SQL query and the initialization
            // requires another SQL query. When the initialization is done first,
            // one SQL query is sufficient.
            $preferredViews = $options['choice_list']->getPreferredViews();
            $remainingViews = $options['choice_list']->getRemainingViews();

            // Check if the choices already contain the empty value
            // Only add the empty value option if this is not the case
            if (null !== $options['placeholder'] && 0 === count($options['choice_list']->getChoicesForValues(array('')))) {
                $placeholderView = new ChoiceView(null, '', $options['placeholder']);

                // "placeholder" is a reserved index
                $this->addSubForms($builder, array('placeholder' => $placeholderView), $options);
            }

            $this->addSubForms($builder, $preferredViews, $options);
            $this->addSubForms($builder, $remainingViews, $options);

            if ($options['multiple']) {
                $builder->addViewTransformer(new ChoicesToBooleanArrayTransformer($options['choice_list']));
                $builder->addEventSubscriber(new FixCheckboxInputListener($options['choice_list']), 10);
            } else {
                $builder->addViewTransformer(new ChoiceToBooleanArrayTransformer($options['choice_list'], $builder->has('placeholder')));
                $builder->addEventSubscriber(new FixRadioInputListener($options['choice_list'], $builder->has('placeholder')), 10);
            }
        } else {
            if ($options['multiple']) {
                $builder->addViewTransformer(new ChoicesToValuesTransformer($options['choice_list']));
            } else {
                $builder->addViewTransformer(new ChoiceToValueTransformer($options['choice_list']));
            }
        }

        if ($options['multiple'] && $options['by_reference']) {
            // Make sure the collection created during the client->norm
            // transformation is merged back into the original collection
            $builder->addEventSubscriber(new MergeCollectionListener(true, true));
        }

        if ($options['multiple']) {
            $builder
                ->addEventSubscriber(new MergeDoctrineCollectionListener())
                ->addViewTransformer(new CollectionToArrayTransformer(), true)
            ;
        }
    }

    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
        $choiceListCache = & $this->choiceListCache;

        $choiceList = function (Options $options) use (&$choiceListCache) {
            // Harden against NULL values (like in EntityType and ModelType)
            $choices = null !== $options['choices'] ? $options['choices'] : array();

            // Reuse existing choice lists in order to increase performance
            $hash = hash('sha256', serialize(array($choices, $options['preferred_choices'])));

            if (!isset($choiceListCache[$hash])) {
                $choiceListCache[$hash] = new SimpleChoiceList($choices, $options['preferred_choices']);
            }

            return $choiceListCache[$hash];
        };

        $emptyData = function (Options $options) {
            if ($options['multiple'] || $options['expanded']) {
                return array();
            }

            return '';
        };

        $emptyValue = function (Options $options) {
            return $options['required'] ? null : '';
        };

        // for BC with the "empty_value" option
        $placeholder = function (Options $options) {
            return $options['empty_value'];
        };

        $placeholderNormalizer = function (Options $options, $placeholder) {
            if ($options['multiple']) {
                // never use an empty value for this case
                return;
            } elseif (false === $placeholder) {
                // an empty value should be added but the user decided otherwise
                return;
            } elseif ($options['expanded'] && '' === $placeholder) {
                // never use an empty label for radio buttons
                return 'None';
            }

            // empty value has been set explicitly
            return $placeholder;
        };

        $compound = function (Options $options) {
            return $options['expanded'];
        };

        $resolver->setDefaults(array(
                'multiple' => false,
                'expanded' => false,
                'choice_list' => $choiceList,
                'choices' => array(),
                'preferred_choices' => array(),
                'empty_data' => $emptyData,
                'empty_value' => $emptyValue, // deprecated
                'placeholder' => $placeholder,
                'error_bubbling' => false,
                'compound' => $compound,
                // The view data is always a string, even if the "data" option
                // is manually set to an object.
                // See https://github.com/symfony/symfony/pull/5582
                'data_class' => null,
            ));

        $resolver->setNormalizers(array(
                'empty_value' => $placeholderNormalizer,
                'placeholder' => $placeholderNormalizer,
            ));

        $resolver->setAllowedTypes(array(
                'choice_list' => array('null', 'Symfony\Component\Form\Extension\Core\ChoiceList\ChoiceListInterface'),
            ));

        $choiceListCache = & $this->choiceListCache;
        $registry = $this->registry;
        $propertyAccessor = $this->propertyAccessor;
        $type = $this;

        $loader = function (Options $options) use ($type) {
            if (null !== $options['query_builder']) {
                return $type->getLoader($options['em'], $options['query_builder'], $options['class']);
            }
        };

        $choiceList = function (Options $options) use (&$choiceListCache, $propertyAccessor) {
            // Support for closures
            $propertyHash = is_object($options['property'])
                ? spl_object_hash($options['property'])
                : $options['property'];

            $choiceHashes = $options['choices'];

            // Support for recursive arrays
            if (is_array($choiceHashes)) {
                // A second parameter ($key) is passed, so we cannot use
                // spl_object_hash() directly (which strictly requires
                // one parameter)
                array_walk_recursive($choiceHashes, function (&$value) {
                        $value = spl_object_hash($value);
                    });
            } elseif ($choiceHashes instanceof \Traversable) {
                $hashes = array();
                foreach ($choiceHashes as $value) {
                    $hashes[] = spl_object_hash($value);
                }

                $choiceHashes = $hashes;
            }

            $preferredChoiceHashes = $options['preferred_choices'];

            if (is_array($preferredChoiceHashes)) {
                array_walk_recursive($preferredChoiceHashes, function (&$value) {
                        $value = spl_object_hash($value);
                    });
            }

            // Support for custom loaders (with query builders)
            $loaderHash = is_object($options['loader'])
                ? spl_object_hash($options['loader'])
                : $options['loader'];

            // Support for closures
            $groupByHash = is_object($options['group_by'])
                ? spl_object_hash($options['group_by'])
                : $options['group_by'];

            $hash = hash('sha256', json_encode(array(
                        spl_object_hash($options['em']),
                        $options['class'],
                        $propertyHash,
                        $loaderHash,
                        $choiceHashes,
                        $preferredChoiceHashes,
                        $groupByHash,
                    )));

            if (!isset($choiceListCache[$hash])) {
                $choiceListCache[$hash] = new EntityChoiceList(
                    $options['em'],
                    $options['class'],
                    $options['property'],
                    $options['loader'],
                    $options['choices'],
                    $options['preferred_choices'],
                    $options['group_by'],
                    $propertyAccessor
                );
            }

            return $choiceListCache[$hash];
        };

        $emNormalizer = function (Options $options, $em) use ($registry) {
            /* @var ManagerRegistry $registry */
            if (null !== $em) {
                if ($em instanceof ObjectManager) {
                    return $em;
                }

                return $registry->getManager($em);
            }

            $em = $registry->getManagerForClass($options['class']);

            if (null === $em) {
                throw new RuntimeException(sprintf(
                        'Class "%s" seems not to be a managed Doctrine entity. '.
                        'Did you forget to map it?',
                        $options['class']
                    ));
            }

            return $em;
        };

        $resolver->setDefaults(array(
                'em' => null,
                'property' => null,
                'query_builder' => null,
                'loader' => $loader,
                'choices' => null,
                'choice_list' => $choiceList,
                'group_by' => null,
            ));

        $resolver->setRequired(array('class'));

        $resolver->setNormalizers(array(
                'em' => $emNormalizer,
            ));

        $resolver->setAllowedTypes(array(
                'em' => array('null', 'string', 'Doctrine\Common\Persistence\ObjectManager'),
                'loader' => array('null', 'Symfony\Bridge\Doctrine\Form\ChoiceList\EntityLoaderInterface'),
            ));
    }

    /**
     * @return string
     */
    public function getName()
    {
        return 'fmu_tag';
    }

    /**
     * Return the default loader object.
     *
     * @param ObjectManager $manager
     * @param mixed         $queryBuilder
     * @param string        $class
     *
     * @return ORMQueryBuilderLoader
     */
    public function getLoader(ObjectManager $manager, $queryBuilder, $class)
    {
        return new ORMQueryBuilderLoader(
            $queryBuilder,
            $manager,
            $class
        );
    }

    /**
     * Adds the sub fields for an expanded choice field.
     *
     * @param FormBuilderInterface $builder     The form builder.
     * @param array                $choiceViews The choice view objects.
     * @param array                $options     The build options.
     */
    private function addSubForms(FormBuilderInterface $builder, array $choiceViews, array $options)
    {
        foreach ($choiceViews as $i => $choiceView) {
            if (is_array($choiceView)) {
                // Flatten groups
                $this->addSubForms($builder, $choiceView, $options);
            } else {
                $choiceOpts = array(
                    'value' => $choiceView->value,
                    'label' => $choiceView->label,
                    'translation_domain' => $options['translation_domain'],
                    'block_name' => 'entry',
                );

                if ($options['multiple']) {
                    $choiceType = 'checkbox';
                    // The user can check 0 or more checkboxes. If required
                    // is true, he is required to check all of them.
                    $choiceOpts['required'] = false;
                } else {
                    $choiceType = 'radio';
                }

                $builder->add($i, $choiceType, $choiceOpts);
            }
        }
    }
}

Register the type in your services :

tag.type:
    class: %tag.type.class%
    arguments: [@doctrine.orm.entity_manager, @doctrine ,@property_accessor]
    tags:
        - { name: form.type, alias: fmu_tag }

Create a new view for the type copying the choice one :

{#app/Resources/views/Form/fmu_tag.html.twig#}

{% block fmu_tag_widget %}
    {% if expanded %}
        {{- block('choice_widget_expanded') -}}
    {% else %}
        {{- block('choice_widget_collapsed') -}}
    {% endif %}
{% endblock %}

Register the view in your twig config.yml :

# Twig Configuration
twig:
    form:
        resources:
            - 'Form/fmu_tag.html.twig'

Create a new ChoiceToValueDataTransformer replace the default class used in the choiceType

<?php

/*
 * This file is part of the Symfony package.
 *
 * (c) Fabien Potencier <[email protected]>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace AppBundle\Form\DataTransformer;

use AppBundle\Entity\Core\Tag;
use Symfony\Component\Form\Exception\TransformationFailedException;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Extension\Core\ChoiceList\ChoiceListInterface;

/**
 * @author Bernhard Schussek <[email protected]>
 */
class ChoicesToValuesTransformer implements DataTransformerInterface
{
    private $choiceList;

    /**
     * Constructor.
     *
     * @param ChoiceListInterface $choiceList
     */
    public function __construct(ChoiceListInterface $choiceList)
    {
        $this->choiceList = $choiceList;
    }

    /**
     * @param array $array
     *
     * @return array
     *
     * @throws TransformationFailedException If the given value is not an array.
     */
    public function transform($array)
    {
        if (null === $array) {
            return array();
        }

        if (!is_array($array)) {
            throw new TransformationFailedException('Expected an array.');
        }

        return $this->choiceList->getValuesForChoices($array);
    }

    /**
     * @param array $array
     *
     * @return array
     *
     * @throws TransformationFailedException If the given value is not an array
     *                                       or if no matching choice could be
     *                                       found for some given value.
     */
    public function reverseTransform($array)
    {
        if (null === $array) {
            return array();
        }

        if (!is_array($array)) {
            throw new TransformationFailedException('Expected an array.');
        }

        $choices = $this->choiceList->getChoicesForValues($array);

        if (count($choices) !== count($array)) {
            $missingChoices = array_diff($array, $this->choiceList->getValues());
            $choices = array_merge($choices, $this->transformMissingChoicesToEntities($missingChoices));
        }


        return $choices;
    }

    public function transformMissingChoicesToEntities(Array $missingChoices)
    {
        $newChoices = array_map(function($choice){
                return new Tag($choice);
            }, $missingChoices);

        return $newChoices;
    }

}

Loot at the last method of this file : transformMissingChoicesToEntities This is where, when missing, I have created a new entity. So if you want to use all this, you need to adapt the new Tag($choice) ie. replace it by a new entity of your own.

So the form to which you add a collection now uses your new type:

$builder
            ->add('tags', 'fmu_tag', array(
                    'by_reference' => false,
                    'required' => false,
                    'class' => 'AppBundle\Entity\Core\Tag',
                    'multiple' => true,
                    'label'=>'Tags',
                ));

In order to create new choices, I am using the select2 control. Add the file in your javascripts : http://select2.github.io Add the following code in your view :

<script>

    $(function() {

        $('#appbundle_marketplace_product_ingredient_tags').select2({
            closeOnSelect: false,
            multiple: true,
            placeholder: 'Tapez quelques lettres',
            tags: true,
            tokenSeparators: [',', ' ']
        });

    });

</script>

That's all, you're good to select existing entities or create new ones from a new entry generated by the select2.

like image 117
Sébastien Avatar answered Apr 06 '23 08:04

Sébastien


You shouldn't really need a brand new form type for this behavior (although you can certainly create one if you want).

Check out Symfony dynamic form modification which has an example of modifying form fields depending on if an entity is 'new' or not. You can start with that as a base and modify to your needs.

If you already know what you want as you're creating the form from your Controller, then you could instead pass options flagging what you would like to display. For example, from your Controller:

$form = $this->createForm(
    new MyType(),
    $entity,
    array('show_my_entity_collection' => false)
);

Then in your form type:

public function buildForm(FormBuilderInterface $builder, array $options)
{
    if ($options['show_my_entity_collection'])
    {
        $builder->add('entity', 'entity', array(
            'class' => 'MyBundle:MyEntity',
            'required' => false,
            'query_builder' => function(MyEntityRepository $repository) { 
                return $repository->findAll();
            }, 
        ));
    }
    // rest of form builder here
}

public function setDefaultOptions(OptionsResolverInterface $resolver)
{
    $resolver->setDefaults(array(
        'data_class' => 'MyBundle\Entity\MyEntity',
        'show_my_entity_collection' => true,
    ));
}
like image 38
Jason Roman Avatar answered Apr 06 '23 08:04

Jason Roman