diff --git a/src/Symfony/Component/Form/CHANGELOG.md b/src/Symfony/Component/Form/CHANGELOG.md index b6d83d613b01e..436fa690cd49c 100644 --- a/src/Symfony/Component/Form/CHANGELOG.md +++ b/src/Symfony/Component/Form/CHANGELOG.md @@ -8,6 +8,8 @@ CHANGELOG option is set to `single_text` * added `block_prefix` option to `BaseType`. * added `help_html` option to display the `help` text as HTML. + * added `FilterChoiceLoader` + * added `choice_filter` option to `ChoiceType` 4.2.0 ----- diff --git a/src/Symfony/Component/Form/ChoiceList/Loader/FilterChoiceLoader.php b/src/Symfony/Component/Form/ChoiceList/Loader/FilterChoiceLoader.php new file mode 100644 index 0000000000000..053a3b66cdde9 --- /dev/null +++ b/src/Symfony/Component/Form/ChoiceList/Loader/FilterChoiceLoader.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\ChoiceList\Loader; + +use Symfony\Component\Form\ChoiceList\ArrayChoiceList; + +/** + * @author Roland Franssen + */ +class FilterChoiceLoader implements ChoiceLoaderInterface +{ + private $loader; + private $filter; + private $choiceList; + + public function __construct(ChoiceLoaderInterface $loader, callable $filter) + { + $this->loader = $loader; + $this->filter = $filter; + } + + /** + * {@inheritdoc} + */ + public function loadChoiceList($value = null) + { + if (null !== $this->choiceList) { + return $this->choiceList; + } + + $choiceList = $this->loader->loadChoiceList($value); + $structured = $choiceList->getStructuredValues(); + $choices = $choiceList->getChoices(); + $visitor = function (array $list) use ($choices, &$visitor) { + foreach ($list as $k => $v) { + if (\is_array($v)) { + if ($v = $visitor($v)) { + $list[$k] = $v; + } else { + unset($list[$k]); + } + continue; + } + + $choice = $choices[$v] ?? $v; + if (!\call_user_func($this->filter, $choice)) { + unset($list[$k]); + continue; + } + + $list[$k] = $choice; + } + + return $list; + }; + + return $this->choiceList = new ArrayChoiceList($visitor($structured), $value); + } + + /** + * {@inheritdoc} + */ + public function loadChoicesForValues(array $values, $value = null) + { + // Optimize + if (empty($values)) { + return []; + } + if (null !== $this->choiceList) { + return $this->choiceList->getChoicesForValues($values); + } + + return array_filter($this->loader->loadChoicesForValues($values, $value), $this->filter); + } + + /** + * {@inheritdoc} + */ + public function loadValuesForChoices(array $choices, $value = null) + { + // Optimize + if (empty($choices)) { + return []; + } + if (null !== $this->choiceList) { + return $this->choiceList->getValuesForChoices($choices); + } + + return $this->loader->loadValuesForChoices(array_filter($choices, $this->filter), $value); + } +} diff --git a/src/Symfony/Component/Form/Extension/Core/Type/ChoiceType.php b/src/Symfony/Component/Form/Extension/Core/Type/ChoiceType.php index 8d2d109699fba..606bca2830bfa 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/ChoiceType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/ChoiceType.php @@ -17,6 +17,8 @@ use Symfony\Component\Form\ChoiceList\Factory\ChoiceListFactoryInterface; use Symfony\Component\Form\ChoiceList\Factory\DefaultChoiceListFactory; use Symfony\Component\Form\ChoiceList\Factory\PropertyAccessDecorator; +use Symfony\Component\Form\ChoiceList\Loader\CallbackChoiceLoader; +use Symfony\Component\Form\ChoiceList\Loader\FilterChoiceLoader; use Symfony\Component\Form\ChoiceList\View\ChoiceGroupView; use Symfony\Component\Form\ChoiceList\View\ChoiceListView; use Symfony\Component\Form\ChoiceList\View\ChoiceView; @@ -305,6 +307,7 @@ public function configureOptions(OptionsResolver $resolver) 'choice_name' => null, 'choice_value' => null, 'choice_attr' => null, + 'choice_filter' => null, 'preferred_choices' => [], 'group_by' => null, 'empty_data' => $emptyData, @@ -329,6 +332,7 @@ public function configureOptions(OptionsResolver $resolver) $resolver->setAllowedTypes('choice_name', ['null', 'callable', 'string', 'Symfony\Component\PropertyAccess\PropertyPath']); $resolver->setAllowedTypes('choice_value', ['null', 'callable', 'string', 'Symfony\Component\PropertyAccess\PropertyPath']); $resolver->setAllowedTypes('choice_attr', ['null', 'array', 'callable', 'string', 'Symfony\Component\PropertyAccess\PropertyPath']); + $resolver->setAllowedTypes('choice_filter', ['null', 'callable']); $resolver->setAllowedTypes('preferred_choices', ['array', '\Traversable', 'callable', 'string', 'Symfony\Component\PropertyAccess\PropertyPath']); $resolver->setAllowedTypes('group_by', ['null', 'callable', 'string', 'Symfony\Component\PropertyAccess\PropertyPath']); } @@ -391,7 +395,7 @@ private function createChoiceList(array $options) { if (null !== $options['choice_loader']) { return $this->choiceListFactory->createListFromLoader( - $options['choice_loader'], + null === $options['choice_filter'] ? $options['choice_loader'] : new FilterChoiceLoader($options['choice_loader'], $options['choice_filter']), $options['choice_value'] ); } @@ -399,6 +403,14 @@ private function createChoiceList(array $options) // Harden against NULL values (like in EntityType and ModelType) $choices = null !== $options['choices'] ? $options['choices'] : []; + if (null !== $options['choice_filter']) { + $loader = new FilterChoiceLoader(new CallbackChoiceLoader(function () use ($choices) { + return $choices; + }), $options['choice_filter']); + + return $this->choiceListFactory->createListFromLoader($loader, $options['choice_value']); + } + return $this->choiceListFactory->createListFromChoices($choices, $options['choice_value']); } diff --git a/src/Symfony/Component/Form/Tests/ChoiceList/Loader/FilterChoiceLoaderTest.php b/src/Symfony/Component/Form/Tests/ChoiceList/Loader/FilterChoiceLoaderTest.php new file mode 100644 index 0000000000000..3bac3c0bcbc2d --- /dev/null +++ b/src/Symfony/Component/Form/Tests/ChoiceList/Loader/FilterChoiceLoaderTest.php @@ -0,0 +1,148 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Tests\ChoiceList\Loader; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Form\ChoiceList\ChoiceListInterface; +use Symfony\Component\Form\ChoiceList\LazyChoiceList; +use Symfony\Component\Form\ChoiceList\Loader\CallbackChoiceLoader; +use Symfony\Component\Form\ChoiceList\Loader\FilterChoiceLoader; + +/** + * @author Roland Franssen + */ +class FilterChoiceLoaderTest extends TestCase +{ + /** + * @var callable + */ + private static $value; + + /** + * @var array + */ + private static $choices; + + /** + * @var string[] + */ + private static $choiceValues; + + public static function setUpBeforeClass() + { + self::$value = function (\stdClass $choice) { + return $choice->value; + }; + self::$choices = [ + (object) ['value' => 'choice_one'], + (object) ['value' => 'choice_two'], + (object) ['value' => 'choice_three'], + (object) ['value' => 'choice_four'], + ]; + self::$choiceValues = ['choice_one', 'choice_two', 'choice_three', 'choice_four']; + } + + public function testLoadChoiceList() + { + $loader = $this->createLoader(); + + $this->assertInstanceOf(ChoiceListInterface::class, $loader->loadChoiceList(self::$value)); + } + + public function testLoadChoiceListOnlyOnce() + { + $loader = $this->createLoader(); + $loadedChoiceList = $loader->loadChoiceList(self::$value); + + $this->assertSame($loadedChoiceList, $loader->loadChoiceList(self::$value)); + } + + public function testLoadChoicesForValuesLoadsChoiceListOnFirstCall() + { + $loader = $this->createLoader(); + $lazyList = new LazyChoiceList($loader, self::$value); + + $this->assertSame( + $loader->loadChoicesForValues(self::$choiceValues, self::$value), + $lazyList->getChoicesForValues(self::$choiceValues), + 'Choice list should not be reloaded.' + ); + } + + public function testLoadValuesForChoicesLoadsChoiceListOnFirstCall() + { + $loader = $this->createLoader(); + $lazyList = new LazyChoiceList($loader, self::$value); + + $this->assertSame( + $loader->loadValuesForChoices(self::$choices, self::$value), + $lazyList->getValuesForChoices(self::$choices), + 'Choice list should not be reloaded.' + ); + } + + public function testLoadChoiceListFilters() + { + $choiceList = $this->createLoader()->loadChoiceList(); + + $this->assertSame([self::$choices[2], self::$choices[3]], $choiceList->getChoices()); + $this->assertSame(['0', '1'], $choiceList->getValues()); + $this->assertSame([1, 'key'], $choiceList->getOriginalKeys()); + $this->assertSame([1 => '0', 'Group' => ['key' => '1']], $choiceList->getStructuredValues()); + $this->assertSame([self::$choices[3]], $choiceList->getChoicesForValues(['1', '2'])); + $this->assertSame([], $choiceList->getChoicesForValues(['foo'])); + $this->assertSame([1 => '0'], $choiceList->getValuesForChoices([self::$choices[1], self::$choices[2]])); + $this->assertSame([], $choiceList->getValuesForChoices(['foo'])); + } + + public function testLoadChoicesForValuesFilters() + { + $loader = $this->createLoader(); + $choices = $loader->loadChoicesForValues(['choice_one', 'choice_three'], self::$value); + + $this->assertSame([1 => self::$choices[2]], $choices); + } + + public function testLoadValuesForChoicesFilters() + { + $loader = $this->createLoader(); + $values = $loader->loadValuesForChoices([ + self::$choices[0], + self::$choices[2], + ], self::$value); + + $this->assertSame([1 => 'choice_three'], $values); + } + + public static function tearDownAfterClass() + { + self::$value = null; + self::$choices = []; + self::$choiceValues = []; + } + + private function createLoader(): FilterChoiceLoader + { + return new FilterChoiceLoader(new CallbackChoiceLoader(function () { + return [ + self::$choices[0], + self::$choices[2], + 'Group' => [ + self::$choices[1], + 'key' => self::$choices[3], + ], + ]; + }), function (\stdClass $choice) { + return \in_array($choice->value, ['choice_three', 'choice_four'], true); + }); + } +} diff --git a/src/Symfony/Component/Form/Tests/Extension/Core/Type/ChoiceTypeTest.php b/src/Symfony/Component/Form/Tests/Extension/Core/Type/ChoiceTypeTest.php index 4a7f006f6f2ec..6e63866b38d3c 100644 --- a/src/Symfony/Component/Form/Tests/Extension/Core/Type/ChoiceTypeTest.php +++ b/src/Symfony/Component/Form/Tests/Extension/Core/Type/ChoiceTypeTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Form\Tests\Extension\Core\Type; +use Symfony\Component\Form\ChoiceList\Loader\CallbackChoiceLoader; use Symfony\Component\Form\ChoiceList\View\ChoiceGroupView; use Symfony\Component\Form\ChoiceList\View\ChoiceView; @@ -2053,4 +2054,54 @@ public function provideTrimCases() 'Multiple expanded' => [true, true], ]; } + + /** + * @expectedException \Symfony\Component\OptionsResolver\Exception\InvalidOptionsException + */ + public function testChoiceFilterOptionExpectsCallable() + { + $this->factory->create(static::TESTED_TYPE, null, [ + 'choice_filter' => new \stdClass(), + ]); + } + + public function testClosureChoiceFilterOptionWithChoiceLoaderOption() + { + $form = $this->factory->create(static::TESTED_TYPE, null, [ + // defined by superclass + 'choice_loader' => new CallbackChoiceLoader(function () { + return $this->choices; + }), + // defined by subclass or userland + 'choice_filter' => function ($choice) { + return \in_array($choice, ['b', 'c'], true); + }, + ]); + + $options = []; + foreach ($form->createView()->vars['choices'] as $choiceView) { + $options[$choiceView->label] = $choiceView->value; + } + + $this->assertSame(['Fabien' => 'b', 'Kris' => 'c'], $options); + } + + public function testChoiceFilterOptionWithChoicesOption() + { + $form = $this->factory->create(static::TESTED_TYPE, null, [ + // defined by superclass + 'choices' => $this->choices, + // defined by subclass or userland + 'choice_filter' => function ($choice) { + return \in_array($choice, ['b', 'c'], true); + }, + ]); + + $options = []; + foreach ($form->createView()->vars['choices'] as $choiceView) { + $options[$choiceView->label] = $choiceView->value; + } + + $this->assertSame(['Fabien' => 'b', 'Kris' => 'c'], $options); + } } diff --git a/src/Symfony/Component/Form/Tests/Fixtures/Descriptor/resolved_form_type_1.json b/src/Symfony/Component/Form/Tests/Fixtures/Descriptor/resolved_form_type_1.json index c0f335f27ea20..51c7dbcd5cef3 100644 --- a/src/Symfony/Component/Form/Tests/Fixtures/Descriptor/resolved_form_type_1.json +++ b/src/Symfony/Component/Form/Tests/Fixtures/Descriptor/resolved_form_type_1.json @@ -4,6 +4,7 @@ "options": { "own": [ "choice_attr", + "choice_filter", "choice_label", "choice_loader", "choice_name", diff --git a/src/Symfony/Component/Form/Tests/Fixtures/Descriptor/resolved_form_type_1.txt b/src/Symfony/Component/Form/Tests/Fixtures/Descriptor/resolved_form_type_1.txt index 49fba719da4ca..96bfdd2805e1e 100644 --- a/src/Symfony/Component/Form/Tests/Fixtures/Descriptor/resolved_form_type_1.txt +++ b/src/Symfony/Component/Form/Tests/Fixtures/Descriptor/resolved_form_type_1.txt @@ -6,18 +6,18 @@ Symfony\Component\Form\Extension\Core\Type\ChoiceType (Block prefix: "choice") Options Overridden options Parent options Extension options --------------------------- -------------------- ------------------------- ----------------------- choice_attr FormType FormType FormTypeCsrfExtension - choice_label -------------------- ------------------------- ----------------------- - choice_loader compound action csrf_field_name - choice_name data_class allow_file_upload csrf_message - choice_translation_domain empty_data attr csrf_protection - choice_value error_bubbling auto_initialize csrf_token_id - choices trim block_name csrf_token_manager - expanded block_prefix - group_by by_reference - multiple data - placeholder disabled - preferred_choices help - help_attr + choice_filter -------------------- ------------------------- ----------------------- + choice_label compound action csrf_field_name + choice_loader data_class allow_file_upload csrf_message + choice_name empty_data attr csrf_protection + choice_translation_domain error_bubbling auto_initialize csrf_token_id + choice_value trim block_name csrf_token_manager + choices block_prefix + expanded by_reference + group_by data + multiple disabled + placeholder help + preferred_choices help_attr help_html inherit_data label