feature #40430 [Form] Add "form_attr" FormType option (cristoforocervino)
This PR was squashed before being merged into the 5.3-dev branch.
Discussion
----------
[Form] Add "form_attr" FormType option
| Q | A
| ------------- | ---
| Branch? | 5.x
| Bug fix? |no
| New feature? | yes
| Deprecations? | no
| Tickets | N/A
| License | MIT
| Doc PR | [#15108](https://github.com/symfony/symfony-docs/pull/15108)
## What is this about
This PR add support for [`form` attribute](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#attr-fae-form) to Symfony Form ([browser compatibility](https://caniuse.com/form-attribute)).
The `form` attribute allows form elements to override their associated form (which is their nearest ancestor form element by default). This is extremely useful to solve **nested form problem** and allows **form children** to be **rendered outside form tag** while still working as expected.
## New "form_attr" FormType option
#### form_attr
**type**: `bool` or `string` **default**: `false`
If set to `true` on a **root form**, adds [`form` attribute](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#attr-fae-form) on every children with their root **form id**.
This allows you to render form children outside the form tag and avoid **nested form problem** in some situations while keeping the form working properly.
If set to `true` on a **child**, adds [`form` attribute](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#attr-fae-form) on it with its **root form id**.
This allows you to render **that child** outside the form tag and avoid **nested form problem** in some situations while keeping the form working properly.
If root form has no `id` (this may happen by create an *unnamed* form), you can set it to a `string` identifier to be used at `FormView` level to link children and root form anyway.
## Usage on Root Form Example
#### Form Type
Enable the feature by setting `form_attr` to `true` on the root form.
```php
use Symfony\Component\Form\Extension\Core\Type;
class ListFilterType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('search', Type\SearchType::class)
->add('orderBy', Type\ChoiceType::class, [
'choices' => [
// ...
],
])
->add('perPage', Type\ChoiceType::class, [
'choices' => [
// ...
],
])
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefault('form_attr', true); // <--- Set this to true
}
}
```
#### Twig
The following Twig template **works properly** even if form children are **outside** their form tag ([browser compatibility](https://caniuse.com/form-attribute)).
```twig
<div class="header-filters">
{{ form_errors(form) }}
{{ form_row(form.search) }} {# has attribute form="list_filter" #}
{{ form_row(form.orderBy) }} {# has attribute form="list_filter" #}
</div>
<!-- -->
<!-- Some other HTML content, like a table or even another Symfony form -->
<!-- -->
<div class="footer-filters">
{{ form_row(form.perPage) }} {# has attribute form="list_filter" #}
</div>
{{ form_start(form) }} {# id="list_filter" #}
{{ form_end(form) }}
```
✅ Every form elements work properly even outside form tag.
## Usage on Form Child Example
Enable the feature by setting `form_attr` to `true` on selected child.
```php
use Symfony\Component\Form\Extension\Core\Type;
class ListFilterType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('search', Type\SearchType::class)
->add('orderBy', Type\ChoiceType::class, [
'choices' => [
// ...
],
])
->add('perPage', Type\ChoiceType::class, [
'form_attr' => true, // <--- Set this to true
'choices' => [
// ...
],
])
}
}
```
#### Twig
The following Twig template **works properly** even if `form.perPage` is **outside** form tag ([browser compatibility](https://caniuse.com/form-attribute)).
```twig
<div class="header-filters">
{{ form_start(form) }} {# id="list_filter" #}
{{ form_errors(form) }}
{{ form_row(form.search) }}
{{ form_row(form.orderBy) }}
{{ form_end(form, {'render_rest': false}) }}
</div>
<!-- -->
<!-- Some other HTML content, like a table or even another Symfony form -->
<!-- -->
<div class="footer-filters">
{{ form_row(form.perPage) }} {# has attribute form="list_filter" #}
</div>
```
✅ `form.perPage` element work properly even outside form tag.
Commits
-------
5f913cec74
[Form] Add "form_attr" FormType option
This commit is contained in:
commit
c8b48d8bbb
@ -96,6 +96,16 @@ class FormType extends BaseType
|
||||
}
|
||||
|
||||
$helpTranslationParameters = array_merge($view->parent->vars['help_translation_parameters'], $helpTranslationParameters);
|
||||
|
||||
$rootFormAttrOption = $form->getRoot()->getConfig()->getOption('form_attr');
|
||||
if ($options['form_attr'] || $rootFormAttrOption) {
|
||||
$view->vars['attr']['form'] = \is_string($rootFormAttrOption) ? $rootFormAttrOption : $form->getRoot()->getName();
|
||||
if (empty($view->vars['attr']['form'])) {
|
||||
throw new LogicException('"form_attr" option must be a string identifier on root form when it has no id.');
|
||||
}
|
||||
}
|
||||
} elseif (\is_string($options['form_attr'])) {
|
||||
$view->vars['id'] = $options['form_attr'];
|
||||
}
|
||||
|
||||
$formConfig = $form->getConfig();
|
||||
@ -210,6 +220,7 @@ class FormType extends BaseType
|
||||
'is_empty_callback' => null,
|
||||
'getter' => null,
|
||||
'setter' => null,
|
||||
'form_attr' => false,
|
||||
]);
|
||||
|
||||
$resolver->setAllowedTypes('label_attr', 'array');
|
||||
@ -221,6 +232,7 @@ class FormType extends BaseType
|
||||
$resolver->setAllowedTypes('is_empty_callback', ['null', 'callable']);
|
||||
$resolver->setAllowedTypes('getter', ['null', 'callable']);
|
||||
$resolver->setAllowedTypes('setter', ['null', 'callable']);
|
||||
$resolver->setAllowedTypes('form_attr', ['bool', 'string']);
|
||||
|
||||
$resolver->setInfo('getter', 'A callable that accepts two arguments (the view data and the current form field) and must return a value.');
|
||||
$resolver->setInfo('setter', 'A callable that accepts three arguments (a reference to the view data, the submitted value and the current form field).');
|
||||
|
@ -14,6 +14,7 @@ namespace Symfony\Component\Form\Tests\Extension\Core\Type;
|
||||
use Symfony\Component\Form\CallbackTransformer;
|
||||
use Symfony\Component\Form\DataMapperInterface;
|
||||
use Symfony\Component\Form\Exception\InvalidArgumentException;
|
||||
use Symfony\Component\Form\Exception\LogicException;
|
||||
use Symfony\Component\Form\Exception\TransformationFailedException;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CurrencyType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FormType;
|
||||
@ -774,6 +775,67 @@ class FormTypeTest extends BaseTypeTest
|
||||
$this->assertSame($error, $form->get('inherit_data_type')->getErrors()[0]);
|
||||
$this->assertCount(0, $form->get('inherit_data_type')->get('child')->getErrors());
|
||||
}
|
||||
|
||||
public function testFormAttrOnRoot()
|
||||
{
|
||||
$view = $this->factory
|
||||
->createNamedBuilder('parent', self::TESTED_TYPE, null, [
|
||||
'form_attr' => true,
|
||||
])
|
||||
->add('child1', $this->getTestedType())
|
||||
->add('child2', $this->getTestedType())
|
||||
->getForm()
|
||||
->createView();
|
||||
$this->assertArrayNotHasKey('form', $view->vars['attr']);
|
||||
$this->assertSame($view->vars['id'], $view['child1']->vars['attr']['form']);
|
||||
$this->assertSame($view->vars['id'], $view['child2']->vars['attr']['form']);
|
||||
}
|
||||
|
||||
public function testFormAttrOnChild()
|
||||
{
|
||||
$view = $this->factory
|
||||
->createNamedBuilder('parent', self::TESTED_TYPE)
|
||||
->add('child1', $this->getTestedType(), [
|
||||
'form_attr' => true,
|
||||
])
|
||||
->add('child2', $this->getTestedType())
|
||||
->getForm()
|
||||
->createView();
|
||||
$this->assertArrayNotHasKey('form', $view->vars['attr']);
|
||||
$this->assertSame($view->vars['id'], $view['child1']->vars['attr']['form']);
|
||||
$this->assertArrayNotHasKey('form', $view['child2']->vars['attr']);
|
||||
}
|
||||
|
||||
public function testFormAttrAsBoolWithNoId()
|
||||
{
|
||||
$this->expectException(LogicException::class);
|
||||
$this->expectErrorMessage('form_attr');
|
||||
$this->factory
|
||||
->createNamedBuilder('', self::TESTED_TYPE, null, [
|
||||
'form_attr' => true,
|
||||
])
|
||||
->add('child1', $this->getTestedType())
|
||||
->add('child2', $this->getTestedType())
|
||||
->getForm()
|
||||
->createView();
|
||||
}
|
||||
|
||||
public function testFormAttrAsStringWithNoId()
|
||||
{
|
||||
$stringId = 'custom-identifier';
|
||||
$view = $this->factory
|
||||
->createNamedBuilder('', self::TESTED_TYPE, null, [
|
||||
'form_attr' => $stringId,
|
||||
])
|
||||
->add('child1', $this->getTestedType())
|
||||
->add('child2', $this->getTestedType())
|
||||
->getForm()
|
||||
->createView();
|
||||
$this->assertArrayNotHasKey('form', $view->vars['attr']);
|
||||
$this->assertSame($stringId, $view->vars['id']);
|
||||
$this->assertSame($view->vars['id'], $view['child1']->vars['attr']['form']);
|
||||
$this->assertSame($view->vars['id'], $view['child2']->vars['attr']['form']);
|
||||
}
|
||||
}
|
||||
|
||||
class Money
|
||||
|
@ -40,6 +40,7 @@
|
||||
"by_reference",
|
||||
"data",
|
||||
"disabled",
|
||||
"form_attr",
|
||||
"getter",
|
||||
"help",
|
||||
"help_attr",
|
||||
|
@ -17,8 +17,9 @@ Symfony\Component\Form\Extension\Core\Type\ChoiceType (Block prefix: "choice")
|
||||
expanded by_reference
|
||||
group_by data
|
||||
multiple disabled
|
||||
placeholder getter
|
||||
preferred_choices help
|
||||
placeholder form_attr
|
||||
preferred_choices getter
|
||||
help
|
||||
help_attr
|
||||
help_html
|
||||
help_translation_parameters
|
||||
|
@ -17,6 +17,7 @@
|
||||
"disabled",
|
||||
"empty_data",
|
||||
"error_bubbling",
|
||||
"form_attr",
|
||||
"getter",
|
||||
"help",
|
||||
"help_attr",
|
||||
|
@ -19,6 +19,7 @@ Symfony\Component\Form\Extension\Core\Type\FormType (Block prefix: "form")
|
||||
disabled
|
||||
empty_data
|
||||
error_bubbling
|
||||
form_attr
|
||||
getter
|
||||
help
|
||||
help_attr
|
||||
|
Reference in New Issue
Block a user