2015-11-03

How to prevent choice field from rendering attr on each option element in Symfony 2.7

In my Symfony 2.6 application, I use some forms with the choice field type. As I wanted those choice fields to match the rest of my UI which uses jQuery UI, I added the relevant classes to my fields when rendering them.
<?php echo $view['form']->widget($form['myField'], ['attr' => ['class' => "text ui-widget-content ui-corner-all"]]); ?>
This worked perfectly and generated the correct attribute on my <select> tag, but not on my <option> tags. Just as I wanted it.
<select class="text ui-widget-content ui-corner-all" name="myField">
    <option value="1">Option 1</option>
    <option value="2">Option 2</option>
</select>
At a later point in time, I upgraded my application from Symfony 2.6 to 2.7 and ever since then the class attribute was rendered on the <select> tag as well as on the <option> tags.
<select class="text ui-widget-content ui-corner-all" name="myField">
    <option value="1" class="text ui-widget-content ui-corner-all">Option 1</option>
    <option value="2" class="text ui-widget-content ui-corner-all">Option 2</option>
</select>
Unfortunately, this made borders appear around the single elements in my select lists which looks quite ugly.

After a bit of searching the internet, I found a blog post about this new behavior on the Symfony blog. In New in Symfony 2.7: Choice form type refactorization, the new choice_attr options is explained. When rendering the <option> tags, Symfony now merges the entries in choice_attr with the ones in attr.
So the easiest solution seems to be to overwrite our class entry in the choice_attr option and we should be done. I did this directly in my form class.
namespace MyBundle\Form;

public function buildForm(FormBuilderInterface $builder, array $options) {
    $builder
        ->add('roles',
            'entity',
            [
                'class' => 'MyBundle:Role',
                'choice_label' => 'name',
                'multiple' => true,
                'choice_attr' => function () { return ["class" => ""]; }
            ]);
}
While this solution works, you will need to do it for each choice field that you use in your entire application. As I was using quite a few of those, I decided I wanted something more flexible which would allow me to change the behavior of the choice field without touching every single form. So I reverted my previous change and searched for a better solution.

The first step was to create a custom field type which is based on the choice field type but exhibits the same behavior I was used to from Symfony 2.6. So I created a new field type that would always override the class attribute in the choice_attr options.
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;

class ChoiceNoOptAttrType extends ChoiceType {
    public function configureOptions(OptionsResolver $resolver) {
        parent::configureOptions($resolver);

        $resolver->setDefault("choice_attr", function () { return ["class" => ""]; });
    }
}
This at least means that I don't need to touch the choice_attr option for all my form types. However, I would still need to change each of my existing form types to use my newly created choice form type. Also not a nice solution.
I checked the Symfony documentation to find out how Symfony actually creates a choice field. Symfony uses dependency injetion to locate its built-in field types. So what we need to do is change the dependency injection container to return our newly created custom choice field type replacement.
Symfony provides what they call “Compiler Passes” to make changes to the DI container after it has been initialized. The basic idea is that you create a custom class that is given the fully instantiated DI container on which you can then perform changes, e.g. replace an existing service with a different one. (Further reading in the Symfony docs: Creating a Compiler Pass)
This seems like the perfect entry point for what we want to do: replace the built-in choice field type. Thanks to the verbose Symfony documentation, it is not hard to figure out what code we need to accomplish this task.
namespace MyBundle\DependencyInjection\Compiler;

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;

class MyCompilerPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container)
    {
        $definition = $container->getDefinition("form.type.choice");
        $definition->setClass('MyBundle\Form\ChoiceNoOptAttrType');
    }
}
Now that we have created a compiler pass, we still need to make sure that it is actually executed. Compiler passes need to be registered for Symfony to find and execute them when building the container. As my compiler pass was in my bundle, I added the required code to my bundle class. (Further reading in the Symfony docs: How to Work with Compiler Passes in Bundles)
namespace MyBundle;

use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Bundle\Bundle;
use MyBundle\DependencyInjection\Compiler\MyCompilerPass;

class MyBundle extends Bundle
{
    public function build(ContainerBuilder $container)
    {
        parent::build($container);

        $container->addCompilerPass(new MyCompilerPass());
    }
}
And this is enough to let Symfony work its magic. Now all choice fields in my application use my custom created form type. Now I don't have to make any changes to my existing form types and they still exhibit the same behavior as in version 2.6.

No comments:

Post a Comment