diff --git a/src/Symfony/Bridge/Twig/Tests/Extension/AbstractBootstrap3LayoutTestCase.php b/src/Symfony/Bridge/Twig/Tests/Extension/AbstractBootstrap3LayoutTestCase.php index 0982b2e0e9ef0..5b02b69576e6d 100644 --- a/src/Symfony/Bridge/Twig/Tests/Extension/AbstractBootstrap3LayoutTestCase.php +++ b/src/Symfony/Bridge/Twig/Tests/Extension/AbstractBootstrap3LayoutTestCase.php @@ -576,6 +576,31 @@ public function testSingleChoiceWithPreferred() ); } + public function testSingleChoiceWithPreferredIsNotDuplicated() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'preferred_choices' => ['&b'], + 'duplicate_preferred_choices' => false, + 'multiple' => false, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['separator' => '-- sep --', 'attr' => ['class' => 'my&class']], + '/select + [@name="name"] + [@class="my&class form-control"] + [not(@required)] + [ + ./option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + /following-sibling::option[@disabled="disabled"][not(@selected)][.="-- sep --"] + /following-sibling::option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + ] + [count(./option)=3] +' + ); + } + public function testSingleChoiceWithSelectedPreferred() { $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&a', [ diff --git a/src/Symfony/Bridge/Twig/Tests/Extension/AbstractBootstrap5LayoutTestCase.php b/src/Symfony/Bridge/Twig/Tests/Extension/AbstractBootstrap5LayoutTestCase.php index 630663a60da2a..576f2b18f66fc 100644 --- a/src/Symfony/Bridge/Twig/Tests/Extension/AbstractBootstrap5LayoutTestCase.php +++ b/src/Symfony/Bridge/Twig/Tests/Extension/AbstractBootstrap5LayoutTestCase.php @@ -584,6 +584,31 @@ public function testSingleChoiceWithPreferred() ); } + public function testSingleChoiceWithPreferredIsNotDuplicated() + { + $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b'], + 'preferred_choices' => ['&b'], + 'duplicate_preferred_choices' => false, + 'multiple' => false, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['separator' => '-- sep --', 'attr' => ['class' => 'my&class']], + '/select + [@name="name"] + [@class="my&class form-select"] + [not(@required)] + [ + ./option[@value="&b"][not(@selected)][.="[trans]Choice&B[/trans]"] + /following-sibling::option[@disabled="disabled"][not(@selected)][.="-- sep --"] + /following-sibling::option[@value="&a"][@selected="selected"][.="[trans]Choice&A[/trans]"] + ] + [count(./option)=3] +' + ); + } + public function testSingleChoiceWithSelectedPreferred() { $form = $this->factory->createNamed('name', ChoiceType::class, '&a', [ diff --git a/src/Symfony/Bridge/Twig/composer.json b/src/Symfony/Bridge/Twig/composer.json index 36014a590b41f..160e107417d48 100644 --- a/src/Symfony/Bridge/Twig/composer.json +++ b/src/Symfony/Bridge/Twig/composer.json @@ -28,7 +28,7 @@ "symfony/asset-mapper": "^6.3|^7.0", "symfony/dependency-injection": "^5.4|^6.0|^7.0", "symfony/finder": "^5.4|^6.0|^7.0", - "symfony/form": "^6.3|^7.0", + "symfony/form": "^6.4|^7.0", "symfony/html-sanitizer": "^6.1|^7.0", "symfony/http-foundation": "^5.4|^6.0|^7.0", "symfony/http-kernel": "^6.2|^7.0", diff --git a/src/Symfony/Component/Form/CHANGELOG.md b/src/Symfony/Component/Form/CHANGELOG.md index 3918636e566ad..9fba1a3f5acfc 100644 --- a/src/Symfony/Component/Form/CHANGELOG.md +++ b/src/Symfony/Component/Form/CHANGELOG.md @@ -8,6 +8,8 @@ CHANGELOG `model_timezone` option in `DateType`, `DateTimeType`, and `TimeType` * Deprecate `PostSetDataEvent::setData()`, use `PreSetDataEvent::setData()` instead * Deprecate `PostSubmitEvent::setData()`, use `PreSubmitDataEvent::setData()` or `SubmitDataEvent::setData()` instead + * Add `duplicate_preferred_choices` option in `ChoiceType` + * Add `$duplicatePreferredChoices` parameter to `ChoiceListFactoryInterface::createView()` 6.3 --- diff --git a/src/Symfony/Component/Form/ChoiceList/Factory/CachingFactoryDecorator.php b/src/Symfony/Component/Form/ChoiceList/Factory/CachingFactoryDecorator.php index 40c0604ea4de8..03bdff5dc9d5e 100644 --- a/src/Symfony/Component/Form/ChoiceList/Factory/CachingFactoryDecorator.php +++ b/src/Symfony/Component/Form/ChoiceList/Factory/CachingFactoryDecorator.php @@ -145,8 +145,12 @@ public function createListFromLoader(ChoiceLoaderInterface $loader, mixed $value return $this->lists[$hash]; } - public function createView(ChoiceListInterface $list, mixed $preferredChoices = null, mixed $label = null, mixed $index = null, mixed $groupBy = null, mixed $attr = null, mixed $labelTranslationParameters = []): ChoiceListView + /** + * @param bool $duplicatePreferredChoices + */ + public function createView(ChoiceListInterface $list, mixed $preferredChoices = null, mixed $label = null, mixed $index = null, mixed $groupBy = null, mixed $attr = null, mixed $labelTranslationParameters = []/* , bool $duplicatePreferredChoices = true */): ChoiceListView { + $duplicatePreferredChoices = \func_num_args() > 7 ? func_get_arg(7) : true; $cache = true; if ($preferredChoices instanceof Cache\PreferredChoice) { @@ -193,11 +197,12 @@ public function createView(ChoiceListInterface $list, mixed $preferredChoices = $index, $groupBy, $attr, - $labelTranslationParameters + $labelTranslationParameters, + $duplicatePreferredChoices, ); } - $hash = self::generateHash([$list, $preferredChoices, $label, $index, $groupBy, $attr, $labelTranslationParameters]); + $hash = self::generateHash([$list, $preferredChoices, $label, $index, $groupBy, $attr, $labelTranslationParameters, $duplicatePreferredChoices]); if (!isset($this->views[$hash])) { $this->views[$hash] = $this->decoratedFactory->createView( @@ -207,7 +212,8 @@ public function createView(ChoiceListInterface $list, mixed $preferredChoices = $index, $groupBy, $attr, - $labelTranslationParameters + $labelTranslationParameters, + $duplicatePreferredChoices, ); } diff --git a/src/Symfony/Component/Form/ChoiceList/Factory/ChoiceListFactoryInterface.php b/src/Symfony/Component/Form/ChoiceList/Factory/ChoiceListFactoryInterface.php index 62c3e8d2eaa24..89633710b619e 100644 --- a/src/Symfony/Component/Form/ChoiceList/Factory/ChoiceListFactoryInterface.php +++ b/src/Symfony/Component/Form/ChoiceList/Factory/ChoiceListFactoryInterface.php @@ -77,6 +77,9 @@ public function createListFromLoader(ChoiceLoaderInterface $loader, callable $va * pass false to discard the label * @param array|callable|null $attr The callable generating the HTML attributes * @param array|callable $labelTranslationParameters The parameters used to translate the choice labels + * @param bool $duplicatePreferredChoices Whether the preferred choices should be duplicated + * on top of the list and in their original position + * or only in the top of the list */ - public function createView(ChoiceListInterface $list, array|callable $preferredChoices = null, callable|false $label = null, callable $index = null, callable $groupBy = null, array|callable $attr = null, array|callable $labelTranslationParameters = []): ChoiceListView; + public function createView(ChoiceListInterface $list, array|callable $preferredChoices = null, callable|false $label = null, callable $index = null, callable $groupBy = null, array|callable $attr = null, array|callable $labelTranslationParameters = []/* , bool $duplicatePreferredChoices = true */): ChoiceListView; } diff --git a/src/Symfony/Component/Form/ChoiceList/Factory/DefaultChoiceListFactory.php b/src/Symfony/Component/Form/ChoiceList/Factory/DefaultChoiceListFactory.php index fb30fc6ded4cc..aa371362c811c 100644 --- a/src/Symfony/Component/Form/ChoiceList/Factory/DefaultChoiceListFactory.php +++ b/src/Symfony/Component/Form/ChoiceList/Factory/DefaultChoiceListFactory.php @@ -52,8 +52,12 @@ public function createListFromLoader(ChoiceLoaderInterface $loader, callable $va return new LazyChoiceList($loader, $value); } - public function createView(ChoiceListInterface $list, array|callable $preferredChoices = null, callable|false $label = null, callable $index = null, callable $groupBy = null, array|callable $attr = null, array|callable $labelTranslationParameters = []): ChoiceListView + /** + * @param bool $duplicatePreferredChoices + */ + public function createView(ChoiceListInterface $list, array|callable $preferredChoices = null, callable|false $label = null, callable $index = null, callable $groupBy = null, array|callable $attr = null, array|callable $labelTranslationParameters = []/* , bool $duplicatePreferredChoices = true */): ChoiceListView { + $duplicatePreferredChoices = \func_num_args() > 7 ? func_get_arg(7) : true; $preferredViews = []; $preferredViewsOrder = []; $otherViews = []; @@ -92,7 +96,8 @@ public function createView(ChoiceListInterface $list, array|callable $preferredC $preferredChoices, $preferredViews, $preferredViewsOrder, - $otherViews + $otherViews, + $duplicatePreferredChoices, ); } @@ -130,7 +135,8 @@ public function createView(ChoiceListInterface $list, array|callable $preferredC $preferredChoices, $preferredViews, $preferredViewsOrder, - $otherViews + $otherViews, + $duplicatePreferredChoices, ); } @@ -139,7 +145,7 @@ public function createView(ChoiceListInterface $list, array|callable $preferredC return new ChoiceListView($otherViews, $preferredViews); } - private static function addChoiceView($choice, string $value, $label, array $keys, &$index, $attr, $labelTranslationParameters, ?callable $isPreferred, array &$preferredViews, array &$preferredViewsOrder, array &$otherViews): void + private static function addChoiceView($choice, string $value, $label, array $keys, &$index, $attr, $labelTranslationParameters, ?callable $isPreferred, array &$preferredViews, array &$preferredViewsOrder, array &$otherViews, bool $duplicatePreferredChoices): void { // $value may be an integer or a string, since it's stored in the array // keys. We want to guarantee it's a string though. @@ -180,12 +186,16 @@ private static function addChoiceView($choice, string $value, $label, array $key if (null !== $isPreferred && false !== $preferredKey = $isPreferred($choice, $key, $value)) { $preferredViews[$nextIndex] = $view; $preferredViewsOrder[$nextIndex] = $preferredKey; - } - $otherViews[$nextIndex] = $view; + if ($duplicatePreferredChoices) { + $otherViews[$nextIndex] = $view; + } + } else { + $otherViews[$nextIndex] = $view; + } } - private static function addChoiceViewsFromStructuredValues(array $values, $label, array $choices, array $keys, &$index, $attr, $labelTranslationParameters, ?callable $isPreferred, array &$preferredViews, array &$preferredViewsOrder, array &$otherViews): void + private static function addChoiceViewsFromStructuredValues(array $values, $label, array $choices, array $keys, &$index, $attr, $labelTranslationParameters, ?callable $isPreferred, array &$preferredViews, array &$preferredViewsOrder, array &$otherViews, bool $duplicatePreferredChoices): void { foreach ($values as $key => $value) { if (null === $value) { @@ -208,7 +218,8 @@ private static function addChoiceViewsFromStructuredValues(array $values, $label $isPreferred, $preferredViewsForGroup, $preferredViewsOrder, - $otherViewsForGroup + $otherViewsForGroup, + $duplicatePreferredChoices, ); if (\count($preferredViewsForGroup) > 0) { @@ -234,12 +245,13 @@ private static function addChoiceViewsFromStructuredValues(array $values, $label $isPreferred, $preferredViews, $preferredViewsOrder, - $otherViews + $otherViews, + $duplicatePreferredChoices, ); } } - private static function addChoiceViewsGroupedByCallable(callable $groupBy, $choice, string $value, $label, array $keys, &$index, $attr, $labelTranslationParameters, ?callable $isPreferred, array &$preferredViews, array &$preferredViewsOrder, array &$otherViews): void + private static function addChoiceViewsGroupedByCallable(callable $groupBy, $choice, string $value, $label, array $keys, &$index, $attr, $labelTranslationParameters, ?callable $isPreferred, array &$preferredViews, array &$preferredViewsOrder, array &$otherViews, bool $duplicatePreferredChoices): void { $groupLabels = $groupBy($choice, $keys[$value], $value); @@ -256,7 +268,8 @@ private static function addChoiceViewsGroupedByCallable(callable $groupBy, $choi $isPreferred, $preferredViews, $preferredViewsOrder, - $otherViews + $otherViews, + $duplicatePreferredChoices, ); return; @@ -286,7 +299,8 @@ private static function addChoiceViewsGroupedByCallable(callable $groupBy, $choi $isPreferred, $preferredViews[$groupLabel]->choices, $preferredViewsOrder[$groupLabel], - $otherViews[$groupLabel]->choices + $otherViews[$groupLabel]->choices, + $duplicatePreferredChoices, ); } } diff --git a/src/Symfony/Component/Form/ChoiceList/Factory/PropertyAccessDecorator.php b/src/Symfony/Component/Form/ChoiceList/Factory/PropertyAccessDecorator.php index fa66290e34485..dab8a5d77acb2 100644 --- a/src/Symfony/Component/Form/ChoiceList/Factory/PropertyAccessDecorator.php +++ b/src/Symfony/Component/Form/ChoiceList/Factory/PropertyAccessDecorator.php @@ -109,8 +109,12 @@ public function createListFromLoader(ChoiceLoaderInterface $loader, mixed $value return $this->decoratedFactory->createListFromLoader($loader, $value, $filter); } - public function createView(ChoiceListInterface $list, mixed $preferredChoices = null, mixed $label = null, mixed $index = null, mixed $groupBy = null, mixed $attr = null, mixed $labelTranslationParameters = []): ChoiceListView + /** + * @param bool $duplicatePreferredChoices + */ + public function createView(ChoiceListInterface $list, mixed $preferredChoices = null, mixed $label = null, mixed $index = null, mixed $groupBy = null, mixed $attr = null, mixed $labelTranslationParameters = []/* , bool $duplicatePreferredChoices = true */): ChoiceListView { + $duplicatePreferredChoices = \func_num_args() > 7 ? func_get_arg(7) : true; $accessor = $this->propertyAccessor; if (\is_string($label)) { @@ -182,7 +186,8 @@ public function createView(ChoiceListInterface $list, mixed $preferredChoices = $index, $groupBy, $attr, - $labelTranslationParameters + $labelTranslationParameters, + $duplicatePreferredChoices, ); } } diff --git a/src/Symfony/Component/Form/Extension/Core/Type/ChoiceType.php b/src/Symfony/Component/Form/Extension/Core/Type/ChoiceType.php index e31d810df12d3..1cc25c3b6ed5a 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/ChoiceType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/ChoiceType.php @@ -354,6 +354,7 @@ public function configureOptions(OptionsResolver $resolver) 'choice_attr' => null, 'choice_translation_parameters' => [], 'preferred_choices' => [], + 'duplicate_preferred_choices' => true, 'group_by' => null, 'empty_data' => $emptyData, 'placeholder' => $placeholderDefault, @@ -383,6 +384,7 @@ public function configureOptions(OptionsResolver $resolver) $resolver->setAllowedTypes('choice_translation_parameters', ['null', 'array', 'callable', ChoiceTranslationParameters::class]); $resolver->setAllowedTypes('placeholder_attr', ['array']); $resolver->setAllowedTypes('preferred_choices', ['array', \Traversable::class, 'callable', 'string', PropertyPath::class, PreferredChoice::class]); + $resolver->setAllowedTypes('duplicate_preferred_choices', 'bool'); $resolver->setAllowedTypes('group_by', ['null', 'callable', 'string', PropertyPath::class, GroupBy::class]); } @@ -465,7 +467,8 @@ private function createChoiceListView(ChoiceListInterface $choiceList, array $op $options['choice_name'], $options['group_by'], $options['choice_attr'], - $options['choice_translation_parameters'] + $options['choice_translation_parameters'], + $options['duplicate_preferred_choices'], ); } } 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 3a9b7a7ecce4d..27371fd6f668a 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 @@ -12,6 +12,7 @@ "choice_translation_parameters", "choice_value", "choices", + "duplicate_preferred_choices", "expanded", "group_by", "multiple", 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 a15ac42dae0f7..c8aee5e783270 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 @@ -14,13 +14,13 @@ Symfony\Component\Form\Extension\Core\Type\ChoiceType (Block prefix: "choice") choice_translation_parameters invalid_message auto_initialize csrf_token_manager choice_value trim block_name choices block_prefix - expanded by_reference - group_by data - multiple disabled - placeholder form_attr - placeholder_attr getter - preferred_choices help - help_attr + duplicate_preferred_choices by_reference + expanded data + group_by disabled + multiple form_attr + placeholder getter + placeholder_attr help + preferred_choices help_attr help_html help_translation_parameters inherit_data