My wish was simple: I wanted to have an extra dropdown box, to be able to filter a table, so the second dropdown box would have less items. So the second dropdown box depends on the first. I found two nice articles about this, but I missed a few things before I got it to work. This post tries to describe what my pitfalls were.
The great articles I found were: Symfony2.4: Dependent Forms and Symfony2 – Dynamic forms, an event-driven approach
The difference with my approach is: building the lists of the selectboxes should be inside the form builder. This way I think it’s more reusable and code is in one place. I don’t want to write extra methods in my controller to fill the selectboxes with javascript. My idea was just to submit the form, and let the form figure out what it should do: save the object, or fill the second list with options.
To describe my pitfall’s, I better first describe my situation. I changed the use case for the sake of this article, but it’s the same with my problem. Suppose you have a Person who can own several cars. On the person edit page, where you can edit his name and other properties, I wanted to add a table with the cars he owns. Under this table I have action buttons for New, Edit and Delete. When you use new, the div on that page is reloaded with the form, so the page isn’t reloaded, you are still on the edit Person page.
This would be my database model:
The list of cars with all their types would be huge, so I wanted to select the Brand (Opel, Mercedes, BMW) first. This wasn’t a value that I should save to my Person_has_Car model, so I set mapped to false. This was my main problem, because now this value wouldn’t be mapped to the entity, so how should I read it?
Initially I wanted to post the BrandId to the form, and based on this BrandId I wanted to build the select. I found that the PRE_SUBMIT and POST_SUBMIT weren’t called when you don’t submit the entire form. Probably because of CSFR that doesn’t match.
Then after a long search I found that at the PRE_SUBMIT Form Event, the data was just an array instead of an object.
Well, after a few days of frustration, googling, trying, googling and trying, this is what I came up with. Maybe it’s not the best approach, but it seems to work.
So Image that you are on a “User edit” page, and you want to add a car to his account.
Form
My form class PersonHasCarType would be something like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 |
<?php namespace Acme\DemoBundle\Form\Type; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; use Doctrine\ORM\EntityRepository; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; use Acme\DemoBundle\Entity\Person; use Acme\DemoBundle\Entity\Car; use Acme\DemoBundle\Entity\Brand; use Symfony\Component\PropertyAccess\PropertyAccess; class PersonHasCarType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder ->add('brand', 'entity', array( 'class' => 'AcmeDemoBundle:Brand', 'property' => 'name', 'mapped' => false, 'empty_value' => 'Choose a brand', 'attr' => array( 'class' => 'submitOnChange', ) )) ->add('save', 'submit'); $addCars = function($form, $brand) { if (!empty($brand)) { $form->add('car', 'entity', array( 'class' => 'AcmeDemoBundle:Car', 'label' => 'Select car', 'query_builder' => function (EntityRepository $er) use ($brand) { $qb = $er->createQueryBuilder('car'); if ($brand instanceof Brand) { $qb = $qb->where('car.brand = :brand') ->setParameter('brand', $brand); } else if (is_numeric($brand)) { $qb = $qb->innerJoin('car.brand', 'brand') ->where('brand.id = :id') ->setParameter('id', $brand); } return $qb; }, 'empty_value' => 'Choose car', 'position' => array( // requires egeloen/ordered-form-bundle 'after' => 'brand' ), 'property' => 'name', 'validation_groups' => false, 'attr' => array( 'class' => 'clearOnChange' ) )); } }; // Below is used in edit modus, when a car is bound to the form, to load car selectbox $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($addCars) { $data = $event->getData(); $form = $event->getForm(); if (null === $data) return; $accessor = PropertyAccess::createPropertyAccessor(); $car = $accessor->getValue($data, 'car'); $brand = ($car) ? $car->getBrand() : null; $addCars($form, $brand); }); // Below is used in edit modus, to set the brand selectbox to the right selected value $builder->addEventListener(FormEvents::POST_SET_DATA, function(FormEvent $event) { $data = $event->getData(); $form = $event->getForm(); if (null === $data) return; $accessor = PropertyAccess::createPropertyAccessor(); $car = $accessor->getValue($data, 'car'); $brand = ($car) ? $car->getBrand() : null; if ($brand) $form->get('brand')->setData($brand); }); // Below is used to load the car selectbox when brand is submitted $builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($addCars) { $form = $event->getForm(); $data = $event->getData(); if (array_key_exists('brand', $data)) { $addCars($form, $data['brand']); } }); } public function getName() { return 'acme_demobundle_personhascartype'; } } |
Twig template
The (simplified) twig form would look something like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
<div id="myContainer"> {% block personHasCarForm %} {% if personHasCarForm is defined and personHasCarForm is not empty %} {{ form(personHasCarForm, {'style' : 'horizontal'}) }} {% endif %} {% endblock %} {% block carTable %} {% if carTable is defined and carTable is iterable and carTable|length>0 %} ... render car table here any way you want {% endif %} {% endblock carTable %} </div> <script> jQuery(document).ready(function() { $('#myContainer').on('change', '.submitOnChange', function(event) { var form = $(this).closest('form'); $('.clearOnChange').val(''); $.post(form.attr('action'), form.serialize(), function(result) { $.parseHtmlBlock(result.htmlBlock); }, "json"); }); }); </script> |
The $.parseHtmlBlock is just a little code snippet that replaces HTML based on a json response. It also does some initialisation. The snippet looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
<script> jQuery.parseHtmlBlock = function(htmlBlock) { if (typeof htmlBlock != 'undefined') { $.each(htmlBlock, function(htmlBlock, htmlCode) { $(htmlBlock).html(htmlCode); App.initAjax(); if ($(htmlBlock).is(':visible') == false) $(htmlBlock).show(); }); } } </script> |
Note the clearOnChange in the javascript that clears the second selectbox (if it exists) with empty values. If I don’t do this, the form has validation errors the second time you change the brand of the car. This was an easy hack to prevent this, and also I think it’s actually not that bad to reset invalid fields.
Controller
Now my controller is something like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
<?php /** * @Route("/editPerson/{personId}/{personHasCarId}", requirements={"personId" = "\d+", "personHasCarId" = "\d+"}, name="_person_edit") */ public function editPersonAction(Request $request, $personId, $personHasCarId=null) { $em = $this->getDoctrine() ->getManager(); if (!empty($personHasCarId)) { $entity = $em->getRepository('AcmeDemoBundle:PersonHasCar') ->find($personHasCarId); if (!$entity) throw $this->createNotFoundException('Entity not found with id '.$personHasCarId); } else { $entity = new PersonHasCar(); } $entity->setPerson ( $em->getReference('AcmeDemoBundle\Entity\Person', $personId) ); $form = $this->createForm(new PersonHasCarType(), $entity, array( 'action' => $this->generateUrl('_person_edit', array( 'personId' => $personId, 'personHasCarId' => $personHasCarId, )) )); $form->handleRequest($request); $tpl = $this->get('twig')->loadTemplate('AcmeDemoBundle:Person:edit.html.twig'); if ($form->isValid()) { if (is_object($entity->getCar()) && $entity->getCar() instanceof Car && $entity->getCar()->getCarId()) { if (!$personHasCarId) $em->persist($entity); $em->flush(); // Not in this article: this renders the table with cars of this person. $out['htmlBlock']['#myContainer'] = $tpl->renderBlock('carTable', array( 'carTable' => $this->getCarTable($personId), 'id' => $personId, // could be needed to render paths in template )); } } if (empty($out)) { $out['htmlBlock']['#myContainer'] = $tpl->renderBlock('personHasCarsForm', array( 'personHasCarsForm' => $form->createView(), )); } return new JsonResponse($out); } |
So basically we now have a form that submits itself whenever the first selectbox changes. The controller checks if the form is fully entered by checking if the car object is set. This approach will probably conflict if you use validation. But for me this seems to work in my application.