Adding options to select elements with javascript in Symfony2

Symfony2′s form component is magical. It takes care of a lot of things, keeps your code logically separated, and makes working with forms a real treat.

In one of my projects, I use Knockoutjs to enhance the user experience. One major problem that I faced was dealing with the entityType. The entityType utilizes the doctrine querybuilder to populate the select with available options. Upon form submission, the selected option is validated to ensure that it is present in the available options. This becomes a problem when you are changing options on the fly with knockoutjs. If the option is not in the available options you get the error “This option is not valid”.

The easiest way to overcome this error is by making sure that all of the possible options are loaded in the choices upon form creation. This is a bad fix in my opinion because it is memory intensive and also redundant since you query the db for the available options twice. Once using the querybuilder in the form component, and again in the controller to pass the available options to the view so they are available in your knockoutjs models.

Ideally, you should be able to pass an empty array of choices to the entityType and let knockoutjs/javascript populate it with options. Then, on submission, the form component transforms the selected id into its respective entity.

I came accross a solution in the post Dynamic forms, an event-driven approach written by Carlos.
This solution utilizes form event listeners that does just this. The example in Carlos’ post deals with a select element that is dependant on the value of another which makes it a little more complicated.

Here’s an example using this approach on a single select element as well as a multi-select element. The entity in this example is a crew. A crew has a name, a manager, and many employees.

The crew entity

<?php
namespace Avro\CrmBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;

/**
 * Avro\CrmBundle\Entity\Crew
 *
 * @ORM\Entity
 * @ORM\Table(name="crm_crew")
 */
class Crew
{
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @var string
     *
     * @ORM\Column(type="string", length=50)
     */
    protected $name;

    /**
     * @var \Avro\UserBundle\Entity\User
     *
     * @ORM\ManyToOne(targetEntity="Avro\UserBundle\Entity\User")
     */
    protected $manager;    

    /**
     * @var ArrayCollection
     *
     * @ORM\ManyToMany(targetEntity="Avro\UserBundle\Entity\User")
     * @ORM\JoinTable(name="crm_crew_employees")
     */
    protected $employees;

    public function __construct()
    {
        $this->employees = new ArrayCollection();
    }

    /**
     * Get crew id
     *
     * @return integer
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * Get name
     *
     * @return string
     */
    public function getName()
    {
        return $this->name;
    }

    /**
     * Set name
     *
     * @param string $name
     */
    public function setName($name)
    {
        $this->name = $name;
    }    

    /**
     * Get manager
     *
     * @return Avro\UserBundle\Entity\User
     */
    public function getManager()
    {
        return $this->manager;
    }

    /**
     * Set manager
     *
     * @param manyToOne $manager
     */
    public function setManager(\Avro\UserBundle\Entity\User $manager = null)
    {
        $this->manager = $manager;
    }     

    /**
     * Get employees
     *
     * @return ArrayCollection Avro\UserBundle\Entity\User
     */
    public function getEmployees()
    {
        return $this->employees;
    }

    /**
     * Set employees
     *
     * @param ArrayCollection $employees
     */
    public function setEmployees($employees)
    {
        $this->employees = $employees;
    } 

    /**
     * String output
     */
    public function __toString()
    {
        return $this->name;
    }
}

The crew form type. The data-bind attributes are what knockoutjs uses to manipulate the form.

<?php
namespace Avro\CrmBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilder;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\Event\DataEvent;
use Doctrine\ORM\EntityRepository;

/*
 * Crew Form Type
 *
 * @author Joris de Wit <[email protected]>
 */
class CrewFormType extends AbstractType
{
    private $class;

    /**
     * @param string $class The Crew class name
     */
    public function __construct($class) {
        $this->class = $class;
    }

    public function buildForm(FormBuilder $builder, array $options)
    {
        $builder
            ->add('name', 'text', array(
                'label' => ' Name',
                'required' => false,
                'attr' => array(
                    'title' => 'Enter the  Name',
                    'data-bind' => "
                        value: name
                    "
                )
            ))
        ;
        $addManager = function($form, $id) use ($builder) {
            $options = array(
                'label' => ' Manager',
                'required' => false,
                'class' =>'Avro\UserBundle\Entity\User',
                'choices' => array(),
                'attr' => array(
                    'title' => 'Choose a manager',
                    'data-bind' => "
                        value: selectedManager,
                        options: availableUsers,
                        optionsValue: 'id',
                        optionsText: 'email',
                        optionsCaption: 'Select a  Manager...'
                    "
                )
            );

            if ($id) {
                unset($options['choices']);
                $options = array_merge($options, array(
                     'query_builder' => function($repo) use ($id) {
                        $qb = $repo->createQueryBuilder('c');
                        $qb->where('c.id = ?1');
                        $qb->setParameter('1', $id);

                        return $qb;
                    },
                ));
            }
            $form->add($builder->getFormFactory()->createNamed('entity', 'manager', null, $options));
        };

        $addEmployees = function($form, $ids) use ($builder) {
            $options = array(
                'label' => 'Employees',
                'required' => false,
                'multiple' => true,
                'class' =>'Avro\UserBundle\Entity\User',
                'choices' => array(),
                'attr' => array(
                    'title' => 'Select employees',
                    'data-bind' => "
                        optionsValue: 'id',
                        optionsText: 'email',
                        options: availableUsers,
                        selectedOptions: selectedEmployees,
                        optionsCaption: 'Select some employees...'
                    "
                )
            );
            if ($ids) {
                unset($options['choices']);
                $options = array_merge($options, array(
                     'query_builder' => function($repo) use ($employees) {
                        $qb = $repo->createQueryBuilder('c');
                        $index = 1;
                        foreach ($ids as $id) {
                            if ($index == 1) {
                                $qb->where('c.id = ?'.$index);
                            } else {
                                $qb->orWhere('c.id = ?'.$index);
                            }
                            $qb->setParameter($index, $id);

                            $index++;
                        }

                        return $qb;
                    },
                ));
            }
            $form->add($builder->getFormFactory()->createNamed('entity', 'employees', null, $options));
        };

        $builder->addEventListener(FormEvents::PRE_SET_DATA,
            function (DataEvent $event) use ($addManager, $addEmployees) {
            $form = $event->getForm();
            $data = $event->getData();

            if ($data === null) {
                $addManager($form, null);
                $addEmployees($form, null);
            } elseif (is_object($data)) {
                $addManager($form, $data->getManager()->getId());
                $addEmployees($form, $data->getEmployees());
            }
        });

        $builder->addEventListener(FormEvents::PRE_BIND,
            function (DataEvent $event) use ($addManager, $addEmployees) {
            $form = $event->getForm();
            $data = $event->getData();

            if(array_key_exists('manager', $data)) {
                $addManager($form, $data['manager']);
            }
            if(array_key_exists('employees', $data)) {
                $addEmployees($form, $data['employees']);
            }
        });
    }

    public function getDefaultOptions()
    {
        return array(
            'data_class' => $this->class
        );
    }

    public function getName()
    {
        return 'avro_crm_crew';
    }
}

I have left a few things out for brevity.

Controller: The only thing different in the controller is that it passes an array of availableUsers to the view template so that it is available in the knockoutjs view model.

View: The view is pretty complex so I’ll save that for another post. The only relevent thing for this post is that knockoutjs populates the select elements with options.

As you can see, this is a pretty verbose solution to accomodate dynamic select elements.

I’d love to get some feedback on this to try and come up with a shorter solution.

You can leave a response, or trackback from your own site.

Leave a Reply