Skip to content

Commit

Permalink
[Form] Implemented MergeCollectionListener which calls addXxx() and r…
Browse files Browse the repository at this point in the history
…emoveXxx() in your model if found

The listener is used by the Collection type as well as the Choice and Entity type (with multiple
selection). The effect is that you can have for example this model:

    class Article
    {
        public function addTag($tag) { ... }
        public function removeTag($tag) { ... }
        public function getTags($tag) { ... }
    }

You can create a form for the article with a field "tags" of either type "collection" or "choice"
(or "entity"). The field will correctly use the three methods of the model for displaying and
editing tags.
  • Loading branch information
webmozart committed Feb 2, 2012
1 parent 7837f50 commit 49d1464
Show file tree
Hide file tree
Showing 15 changed files with 653 additions and 44 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG-2.1.md
Expand Up @@ -195,6 +195,9 @@ To get the diff between two versions, go to https://github.com/symfony/symfony/c
* choice fields now throw a FormException if neither the "choices" nor the
"choice_list" option is set
* the radio type is now a child of the checkbox type
* the Collection, Choice (with multiple selection) and Entity (with multiple
selection) types now make use of addXxx() and removeXxx() methods in your
model

### HttpFoundation

Expand Down
Expand Up @@ -24,37 +24,24 @@
*
* @see Doctrine\Common\Collections\Collection
*/
class MergeCollectionListener implements EventSubscriberInterface
class MergeDoctrineCollectionListener implements EventSubscriberInterface
{
static public function getSubscribedEvents()
{
return array(FormEvents::BIND_NORM_DATA => 'onBindNormData');
// Higher priority than core MergeCollectionListener so that this one
// is called before
return array(FormEvents::BIND_NORM_DATA => array('onBindNormData', 10));
}

public function onBindNormData(FilterDataEvent $event)
{
$collection = $event->getForm()->getData();
$data = $event->getData();

if (!$collection) {
$collection = $data;
} elseif (count($data) === 0) {
// If all items were removed, call clear which has a higher
// performance on persistent collections
if ($collection && count($data) === 0) {
$collection->clear();
} else {
// merge $data into $collection
foreach ($collection as $entity) {
if (!$data->contains($entity)) {
$collection->removeElement($entity);
} else {
$data->removeElement($entity);
}
}

foreach ($data as $entity) {
$collection->add($entity);
}
}

$event->setData($collection);
}
}
4 changes: 2 additions & 2 deletions src/Symfony/Bridge/Doctrine/Form/Type/DoctrineType.php
Expand Up @@ -16,7 +16,7 @@
use Symfony\Component\Form\FormBuilder;
use Symfony\Bridge\Doctrine\Form\ChoiceList\EntityChoiceList;
use Symfony\Bridge\Doctrine\Form\ChoiceList\EntityLoaderInterface;
use Symfony\Bridge\Doctrine\Form\EventListener\MergeCollectionListener;
use Symfony\Bridge\Doctrine\Form\EventListener\MergeDoctrineCollectionListener;
use Symfony\Bridge\Doctrine\Form\DataTransformer\CollectionToArrayTransformer;
use Symfony\Component\Form\AbstractType;

Expand All @@ -36,7 +36,7 @@ public function buildForm(FormBuilder $builder, array $options)
{
if ($options['multiple']) {
$builder
->addEventSubscriber(new MergeCollectionListener())
->addEventSubscriber(new MergeDoctrineCollectionListener())
->prependClientTransformer(new CollectionToArrayTransformer())
;
}
Expand Down
@@ -0,0 +1,158 @@
<?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 Symfony\Component\Form\Extension\Core\EventListener;

use Symfony\Component\Form\Util\FormUtil;

use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\Event\FilterDataEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Exception\UnexpectedTypeException;

/**
* @author Bernhard Schussek <bschussek@gmail.com>
*/
class MergeCollectionListener implements EventSubscriberInterface
{
/**
* Whether elements may be added to the collection
* @var Boolean
*/
private $allowAdd;

/**
* Whether elements may be removed from the collection
* @var Boolean
*/
private $allowDelete;

public function __construct($allowAdd = false, $allowDelete = false)
{
$this->allowAdd = $allowAdd;
$this->allowDelete = $allowDelete;
}

static public function getSubscribedEvents()
{
return array(FormEvents::BIND_NORM_DATA => 'onBindNormData');
}

public function onBindNormData(FilterDataEvent $event)
{
$originalData = $event->getForm()->getData();
$form = $event->getForm();
$data = $event->getData();
$parentData = $form->hasParent() ? $form->getParent()->getData() : null;
$adder = null;
$remover = null;

if (null === $data) {
$data = array();
}

if (!is_array($data) && !($data instanceof \Traversable && $data instanceof \ArrayAccess)) {
throw new UnexpectedTypeException($data, 'array or (\Traversable and \ArrayAccess)');
}

if (null !== $originalData && !is_array($originalData) && !($originalData instanceof \Traversable && $originalData instanceof \ArrayAccess)) {
throw new UnexpectedTypeException($originalData, 'array or (\Traversable and \ArrayAccess)');
}

// Check if the parent has matching methods to add/remove items
if (is_object($parentData)) {
$plural = ucfirst($form->getName());
$singulars = (array) FormUtil::singularify($plural);
$reflClass = new \ReflectionClass($parentData);

foreach ($singulars as $singular) {
$adderName = 'add' . $singular;
$removerName = 'remove' . $singular;

if ($reflClass->hasMethod($adderName) && $reflClass->hasMethod($removerName)) {
$adder = $reflClass->getMethod($adderName);
$remover = $reflClass->getMethod($removerName);

if ($adder->isPublic() && $adder->getNumberOfRequiredParameters() === 1
&& $remover->isPublic() && $remover->getNumberOfRequiredParameters() === 1) {

// We found a public, one-parameter add and remove method
break;
}

// False alert
$adder = null;
$remover = null;
}
}
}

// Check which items are in $data that are not in $originalData and
// vice versa
$itemsToDelete = array();
$itemsToAdd = is_object($data) ? clone $data : $data;

if ($originalData) {
foreach ($originalData as $originalKey => $originalItem) {
foreach ($data as $key => $item) {
if ($item === $originalItem) {
// Item found, next original item
unset($itemsToAdd[$key]);
continue 2;
}
}

// Item not found, remember for deletion
$itemsToDelete[$originalKey] = $originalItem;
}
}

if ($adder && $remover) {
// If methods to add and to remove exist, call them now, if allowed
if ($this->allowDelete) {
foreach ($itemsToDelete as $item) {
$remover->invoke($parentData, $item);
}
}

if ($this->allowAdd) {
foreach ($itemsToAdd as $item) {
$adder->invoke($parentData, $item);
}
}
} elseif (!$originalData) {
// No original data was set. Set it if allowed
if ($this->allowAdd) {
$originalData = $data;
}
} else {
// Original data is an array-like structure
// Add and remove items in the original variable
if ($this->allowDelete) {
foreach ($itemsToDelete as $key => $item) {
unset($originalData[$key]);
}
}

if ($this->allowAdd) {
foreach ($itemsToAdd as $key => $item) {
if (!isset($originalData[$key])) {
$originalData[$key] = $item;
} else {
$originalData[] = $item;
}
}
}
}

$event->setData($originalData);
}
}
Expand Up @@ -66,7 +66,8 @@ static public function getSubscribedEvents()
return array(
FormEvents::PRE_SET_DATA => 'preSetData',
FormEvents::PRE_BIND => 'preBind',
FormEvents::BIND_NORM_DATA => 'onBindNormData',
// (MergeCollectionListener, MergeDoctrineCollectionListener)
FormEvents::BIND_NORM_DATA => array('onBindNormData', 50),
);
}

Expand Down
12 changes: 9 additions & 3 deletions src/Symfony/Component/Form/Extension/Core/Type/ChoiceType.php
Expand Up @@ -19,6 +19,7 @@
use Symfony\Component\Form\Extension\Core\ChoiceList\SimpleChoiceList;
use Symfony\Component\Form\Extension\Core\ChoiceList\ChoiceListInterface;
use Symfony\Component\Form\Extension\Core\EventListener\FixRadioInputListener;
use Symfony\Component\Form\Extension\Core\EventListener\MergeCollectionListener;
use Symfony\Component\Form\FormView;
use Symfony\Component\Form\Extension\Core\DataTransformer\ChoiceToValueTransformer;
use Symfony\Component\Form\Extension\Core\DataTransformer\ChoiceToBooleanArrayTransformer;
Expand Down Expand Up @@ -80,7 +81,10 @@ public function buildForm(FormBuilder $builder, array $options)

if ($options['expanded']) {
if ($options['multiple']) {
$builder->appendClientTransformer(new ChoicesToBooleanArrayTransformer($options['choice_list']));
$builder
->appendClientTransformer(new ChoicesToBooleanArrayTransformer($options['choice_list']))
->addEventSubscriber(new MergeCollectionListener(true, true))
;
} else {
$builder
->appendClientTransformer(new ChoiceToBooleanArrayTransformer($options['choice_list']))
Expand All @@ -89,12 +93,14 @@ public function buildForm(FormBuilder $builder, array $options)
}
} else {
if ($options['multiple']) {
$builder->appendClientTransformer(new ChoicesToValuesTransformer($options['choice_list']));
$builder
->appendClientTransformer(new ChoicesToValuesTransformer($options['choice_list']))
->addEventSubscriber(new MergeCollectionListener(true, true))
;
} else {
$builder->appendClientTransformer(new ChoiceToValueTransformer($options['choice_list']));
}
}

}

/**
Expand Down
Expand Up @@ -16,6 +16,7 @@
use Symfony\Component\Form\FormView;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\Extension\Core\EventListener\ResizeFormListener;
use Symfony\Component\Form\Extension\Core\EventListener\MergeCollectionListener;

class CollectionType extends AbstractType
{
Expand All @@ -29,16 +30,22 @@ public function buildForm(FormBuilder $builder, array $options)
$builder->setAttribute('prototype', $prototype->getForm());
}

$listener = new ResizeFormListener(
$resizeListener = new ResizeFormListener(
$builder->getFormFactory(),
$options['type'],
$options['options'],
$options['allow_add'],
$options['allow_delete']
);

$mergeListener = new MergeCollectionListener(
$options['allow_add'],
$options['allow_delete']
);

$builder
->addEventSubscriber($listener)
->addEventSubscriber($resizeListener)
->addEventSubscriber($mergeListener)
->setAttribute('allow_add', $options['allow_add'])
->setAttribute('allow_delete', $options['allow_delete'])
;
Expand Down
4 changes: 2 additions & 2 deletions src/Symfony/Component/Form/Util/FormUtil.php
Expand Up @@ -83,8 +83,8 @@ abstract class FormUtil
// hooves (hoof), dwarves (dwarf), elves (elf), leaves (leaf)
array('sev', 3, true, true, 'f'),

// axes (axis)
array('sexa', 4, false, false, 'axis'),
// axes (axis), axes (ax), axes (axe)
array('sexa', 4, false, false, array('ax', 'axe', 'axis')),

// indexes (index), matrixes (matrix)
array('sex', 3, true, false, 'x'),
Expand Down
10 changes: 5 additions & 5 deletions tests/Symfony/Tests/Bridge/Doctrine/Form/Type/EntityTypeTest.php
Expand Up @@ -344,13 +344,13 @@ public function testSubmitMultipleNonExpandedSingleIdentifier_existingData()
'property' => 'name',
));

$existing = new ArrayCollection(array($entity2));
$existing = new ArrayCollection(array(0 => $entity2));

$field->setData($existing);
$field->bind(array('1', '3'));

// entry with index 0 was removed
$expected = new ArrayCollection(array(1 => $entity1, 2 => $entity3));
// entry with index 0 ($entity2) was replaced
$expected = new ArrayCollection(array(0 => $entity1, 1 => $entity3));

$this->assertTrue($field->isSynchronized());
$this->assertEquals($expected, $field->getData());
Expand Down Expand Up @@ -406,8 +406,8 @@ public function testSubmitMultipleNonExpandedCompositeIdentifier_existingData()
$field->setData($existing);
$field->bind(array('0', '2'));

// entry with index 0 was removed
$expected = new ArrayCollection(array(1 => $entity1, 2 => $entity3));
// entry with index 0 ($entity2) was replaced
$expected = new ArrayCollection(array(0 => $entity1, 1 => $entity3));

$this->assertTrue($field->isSynchronized());
$this->assertEquals($expected, $field->getData());
Expand Down
@@ -0,0 +1,20 @@
<?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 Symfony\Tests\Component\Form\Extension\Core\EventListener;

class MergeCollectionListenerArrayObjectTest extends MergeCollectionListenerTest
{
protected function getData(array $data)
{
return new \ArrayObject($data);
}
}
@@ -0,0 +1,20 @@
<?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 Symfony\Tests\Component\Form\Extension\Core\EventListener;

class MergeCollectionListenerArrayTest extends MergeCollectionListenerTest
{
protected function getData(array $data)
{
return $data;
}
}

0 comments on commit 49d1464

Please sign in to comment.