Symfony2 Collection of Entities - How to Add/Remove Association with Existing Entities

Symfony2 collection of Entities - how to add/remove association with existing entities?

So a year has passed, and this question has become quite popular. Symfony has changed since, my skills and knowledge have also improved, and so has my current approach to this problem.

I've created a set of form extensions for symfony2 (see FormExtensionsBundle project on github) and they include a form type for handleing One/Many ToMany relationships.

While writing these, adding custom code to your controller to handle collections was unacceptable - the form extensions were supposed to be easy to use, work out-of-the-box and make life easier on us developers, not harder. Also.. remember.. DRY!

So I had to move the add/remove associations code somewhere else - and the right place to do it was naturally an EventListener :)

Have a look at the EventListener/CollectionUploadListener.php file to see how we handle this now.

PS. Copying the code here is unnecessary, the most important thing is that stuff like that should actually be handled in the EventListener.

Delete a 3-entity (one-to-many-to-one) association with Symfony 3 using Doctrine

create an intermediate service, in which you can also use doctrine to remove the existing entities

symfony2 form create new type combining collection and entity

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 <fabien@symfony.com>
*
* 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 <bschussek@gmail.com>
*/
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.

Symfony2: How to add/remove entry forms for the collection field type?

Symfony does not provide the add/remove JavaScript methods/buttonss or any session-based solution without JavaScript out of the box.

It renders a data-prototype attribute that can be used as described in the documentation chapter How to Embed a Collection of Forms -> Allowing "new" tags with the "prototype".

Some bundles provide this functionality though. Those are primarily the bootstrap bundles:

  • braincrafted/bootstrap-bundle
  • mopa/bootstrap-bundle
  • ...

Just dive into their code - i.e. braincrafted/bootstrapbundle's bc-bootstrap-collection.js.



Related Topics



Leave a reply



Submit