Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,14 @@

{# Rows #}

{%- block bounds_row -%}
{#
No need to render the errors here, as all errors are mapped
to the first child (see BoundsTypeValidatorExtension).
#}
{{- block('form_rows') -}}
{%- endblock bounds_row -%}

{%- block repeated_row -%}
{#
No need to render the errors here, as all errors are mapped
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace Symfony\Bridge\Twig\Tests\Extension;

use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\Form\Extension\Core\Type\BoundsType;
use Symfony\Component\Form\FormError;
use Symfony\Component\Security\Csrf\CsrfToken;

Expand Down Expand Up @@ -495,6 +496,37 @@ public function testCsrf()
);
}

public function testBoundsRow()
{
if (!class_exists(BoundsType::class)) {
$this->markTestSkipped('Requires symfony/form 8.2+.');
}

$form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\BoundsType');
$form->addError(new FormError('[trans]Error![/trans]'));
$view = $form->createView();
$html = $this->renderRow($view);

// The errors of the form are not rendered by intention!
// In practice, ranges cannot have errors as all errors
// on them are mapped to the lower bound.
// (see BoundsTypeValidatorExtension)

$this->assertMatchesXpath($html,
'/div
[
./label[@for="name_from"]
/following-sibling::input[@id="name_from"]
]
/following-sibling::div
[
./label[@for="name_to"]
/following-sibling::input[@id="name_to"]
]
'
);
}

public function testRepeated()
{
$form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\RepeatedType', 'foobar', [
Expand Down
9 changes: 9 additions & 0 deletions src/Symfony/Bundle/FrameworkBundle/Resources/config/form.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Symfony\Component\Form\ChoiceList\Factory\DefaultChoiceListFactory;
use Symfony\Component\Form\ChoiceList\Factory\PropertyAccessDecorator;
use Symfony\Component\Form\EnumFormTypeGuesser;
use Symfony\Component\Form\Extension\Core\Type\BoundsType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\ColorType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
Expand All @@ -27,6 +28,7 @@
use Symfony\Component\Form\Extension\HttpFoundation\HttpFoundationRequestHandler;
use Symfony\Component\Form\Extension\HttpFoundation\Type\FormFlowTypeSessionDataStorageExtension;
use Symfony\Component\Form\Extension\HttpFoundation\Type\FormTypeHttpFoundationExtension;
use Symfony\Component\Form\Extension\Validator\Type\BoundsTypeValidatorExtension;
use Symfony\Component\Form\Extension\Validator\Type\FormTypeValidatorExtension;
use Symfony\Component\Form\Extension\Validator\Type\RepeatedTypeValidatorExtension;
use Symfony\Component\Form\Extension\Validator\Type\SubmitTypeValidatorExtension;
Expand Down Expand Up @@ -126,6 +128,10 @@
->args([service('translator')->ignoreOnInvalid()])
->tag('form.type')

->set('form.type.bounds', BoundsType::class)
->args([service('translator')->ignoreOnInvalid()])
->tag('form.type')

->set('form.type_extension.form.transformation_failure_handling', TransformationFailureExtension::class)
->args([service('translator')->ignoreOnInvalid()])
->tag('form.type_extension', ['extended-type' => FormType::class])
Expand Down Expand Up @@ -158,6 +164,9 @@
])
->tag('form.type_extension', ['extended-type' => FormType::class])

->set('form.type_extension.bounds.validator', BoundsTypeValidatorExtension::class)
->tag('form.type_extension')

->set('form.type_extension.repeated.validator', RepeatedTypeValidatorExtension::class)
->tag('form.type_extension')

Expand Down
2 changes: 1 addition & 1 deletion src/Symfony/Bundle/FrameworkBundle/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@
"symfony/asset": "<8.2",
"symfony/asset-mapper": "<8.2",
"symfony/console": "<8.1.2",
"symfony/form": "<7.4",
"symfony/form": "<8.2",
"symfony/html-sanitizer": "<8.2",
"symfony/http-client": "<8.2",
"symfony/json-path": "<8.2",
Expand Down
1 change: 1 addition & 0 deletions src/Symfony/Component/Form/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ CHANGELOG
8.2
---

* Add `BoundsType` to render a lower and an upper bound of the same inner type
* Add `#[AsFormType]` and `#[FormField]` attributes to derive a form type from the properties of a data class
* Add the `allow_array_submission` option to let `PRE_SUBMIT` listeners turn a submitted array into data the form accepts
* Add support for grouping and nested steps in `FormFlowType`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ protected function loadTypes(): array
return [
new Type\FormType($this->propertyAccessor),
new Type\BirthdayType(),
new Type\BoundsType($this->translator),
new Type\CheckboxType(),
new Type\ChoiceType($this->choiceListFactory, $this->translator),
new Type\CollectionType(),
Expand Down
177 changes: 177 additions & 0 deletions src/Symfony/Component/Form/Extension/Core/Type/BoundsType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
<?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 Symfony\Component\Form\Extension\Core\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Exception\LogicException;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;

/**
* Renders a lower and an upper bound of the same inner type.
*
* @author Sébastien Jean <[email protected]>
*/
class BoundsType extends AbstractType
{
public function __construct(
private ?TranslatorInterface $translator = null,
) {
}

public function buildForm(FormBuilderInterface $builder, array $options): void
{
$fromOptions = $toOptions = [
'invalid_message' => $options['invalid_message'],
'invalid_message_parameters' => $options['invalid_message_parameters'],
];

// when the form is compound the entries of the array are ignored in favor of children
// data, so we need to handle the cascade setting here. A bound the parent says
// nothing about keeps the empty data of the inner type. An empty data that is not
// an array describes the range as a whole, the way the one FormType derives from
// "data_class" does, and a lazy empty data per bound goes through
// "from_options" / "to_options".
$emptyData = $builder->getEmptyData();

if (\is_array($emptyData)) {
if (isset($emptyData['from'])) {
$fromOptions['empty_data'] = $emptyData['from'];
}
if (isset($emptyData['to'])) {
$toOptions['empty_data'] = $emptyData['to'];
}
}

// Append generic carry-along options. A bound that bubbles its errors moves them to
// the range, which "bounds_row" does not render, so error_bubbling goes down too.
foreach (['required', 'translation_domain', 'error_bubbling'] as $passOpt) {
$fromOptions[$passOpt] = $toOptions[$passOpt] = $options[$passOpt];
}

$builder
->add('from', $options['type'], array_merge($fromOptions, $options['options'], $options['from_options']))
->add('to', $options['type'], array_merge($toOptions, $options['options'], $options['to_options']))
->addEventListener(FormEvents::SUBMIT, static function (FormEvent $event): void {
if (null === $event->getData()) {
return;
}

foreach (['from', 'to'] as $name) {
if (!self::isEmptyBound($event->getForm()->get($name)->getData())) {
return;
}
}

$event->setData(null);
})
;

if (false !== $options['compare']) {
$compare = true === $options['compare'] ? self::compare(...) : $options['compare'];
$messageTemplate = $options['compare_message'];
$translator = $this->translator;

$builder->addEventListener(FormEvents::POST_SUBMIT, static function (FormEvent $event) use ($compare, $messageTemplate, $translator): void {
$from = $event->getForm()->get('from');
$to = $event->getForm()->get('to');

// the normalized data, not the model one: it is the shape the inner
// type reasons in, so a DateType is ordered as a date whatever its
// "input" option turns it into
$lower = $from->getNormData();
$upper = $to->getNormData();

// a half-open range has nothing to order, and a bound that failed
// to transform already reports an error of its own
if (self::isEmptyBound($lower) || self::isEmptyBound($upper)) {
return;
}

if (0 >= $compare($lower, $upper)) {
return;
}

$message = $translator?->trans($messageTemplate, [], 'validators') ?? $messageTemplate;

// report it on the lower bound, the one that has to move to make the range valid
$from->addError(new FormError($message, $messageTemplate));
});
}
}

public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'type' => TextType::class,
'options' => [],
'from_options' => [],
'to_options' => [],
'compare' => false,
'compare_message' => 'The lower bound must not be greater than the upper bound.',
'error_bubbling' => false,
'invalid_message' => 'Please enter a valid range.',
]);

$resolver->setAllowedTypes('type', 'string');
$resolver->setAllowedTypes('options', 'array');
$resolver->setAllowedTypes('from_options', 'array');
$resolver->setAllowedTypes('to_options', 'array');
$resolver->setAllowedTypes('compare', ['bool', 'callable']);
$resolver->setAllowedTypes('compare_message', 'string');
$resolver->setAllowedValues('compound', true);

$resolver->setInfo('type', 'The form type rendered for both bounds.');
$resolver->setInfo('options', 'The options passed to both bounds.');
$resolver->setInfo('from_options', 'The options passed to the lower bound only. Merged over "options".');
$resolver->setInfo('to_options', 'The options passed to the upper bound only. Merged over "options".');
$resolver->setInfo('compare', 'Whether to check that the lower bound is not greater than the upper one. The bounds are compared in their normalized form: scalars, DateTimeInterface and enums are ordered natively, enums following their declaration order. Pass a callable returning an integer less than, equal to or greater than zero for any other inner type.');
$resolver->setInfo('compare_message', 'The message reported on the lower bound when the bounds are out of order.');
}

public function getBlockPrefix(): string
{
return 'bounds';
}

private static function isEmptyBound(mixed $bound): bool
{
return null === $bound || '' === $bound || [] === $bound;
}

private static function compare(mixed $from, mixed $to): int
{
if ($from instanceof \DateTimeInterface && $to instanceof \DateTimeInterface) {
return $from <=> $to;
}

// enums are ordered the way they are declared, which is the order the
// inner type lists them in; this covers pure enums as well as backed ones
if ($from instanceof \UnitEnum && $to instanceof \UnitEnum && $from::class === $to::class) {
$cases = $from::cases();

return array_search($from, $cases, true) <=> array_search($to, $cases, true);
}

foreach ([$from, $to] as $bound) {
if (!\is_scalar($bound)) {
throw new LogicException(\sprintf('The "compare" option cannot order bounds of type "%s", pass a callable comparing them instead.', get_debug_type($bound)));
}
}

return $from <=> $to;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?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 Symfony\Component\Form\Extension\Validator\Type;

use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\BoundsType;
use Symfony\Component\OptionsResolver\OptionsResolver;

class BoundsTypeValidatorExtension extends AbstractTypeExtension
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
// Map errors to the lower bound, the one that has to move to make the range valid
'error_mapping' => ['.' => 'from'],
]);
}

public static function getExtendedTypes(): iterable
{
return [BoundsType::class];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ protected function loadTypeExtensions(): array
{
return [
new Type\FormTypeValidatorExtension($this->validator, $this->violationMapper, $this->formRenderer, $this->translator),
new Type\BoundsTypeValidatorExtension(),
new Type\RepeatedTypeValidatorExtension(),
new Type\SubmitTypeValidatorExtension(),
];
Expand Down
Loading
Loading