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:
Alexander M. Turek 2021-03-18 16:04:16 +01:00
commit c8b48d8bbb
6 changed files with 80 additions and 2 deletions

View File

@ -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).');

View File

@ -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

View File

@ -40,6 +40,7 @@
"by_reference",
"data",
"disabled",
"form_attr",
"getter",
"help",
"help_attr",

View File

@ -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

View File

@ -17,6 +17,7 @@
"disabled",
"empty_data",
"error_bubbling",
"form_attr",
"getter",
"help",
"help_attr",

View File

@ -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