Update for Symfony 3.x: See this comment to see the changes needed.
Symfony forms are quite horrible when you want to do custom things (like javascript, autocomplete, and collections or both 🙂 ). There are some solutions available on the internet, but they often “hack” the symfony form using DataTransformers and text fields.
I think we found the right way to create a working javascript autocomplete mapped to a Symfony form.
The models
Let’s assume we have an Article class, which may be written by a list of User. The classes will look like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
namespace Acme\AcmeBundle\Entity; class Article { private $id; private $title; private $description; private $userList; // getters and setters... } class User { private $id; private $name; // getters and setters... } |
The javascript part
I will not show example on the javascript part because there are already many solutions on the internet. The solution we use is pretty “basic”, we call an url showing a list of items, we create a new item having the right name for symfony ( ex: < <label> <input type="checkbox" name="my_form[user][]" val="id" /> title <label>  )
The Symfony form part
The big tweak between is to NOT include your “userList” in the buildForm method, but to delay this addition in an FormEvent PRE_SET_DATA. This way, you can list the current userList of the article (if any).
The User collection form type
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 |
<?php namespace Acme\AcmeBundle\Form; use Symfony\Component\Form\AbstractType; use Symfony\Component\OptionsResolver\OptionsResolverInterface; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; class UserType extends AbstractType { private $choiceList; public function __construct($choiceList) { // the constructor requires a choice list, which will be the current articles selected users $this->choiceList = $choiceList; } public function setDefaultOptions(OptionsResolverInterface $resolver) { $params = [ 'class' => 'Acme\AcmeBundle\Entities\User', 'compound' => true, 'multiple' => true, 'expanded' => true, 'property' => 'title', ]; if (!empty($this->choiceList['documentList'])) { // this is used in form "show" mode $params['choices'] = $this->choiceList; } elseif (!empty($this->choiceList['idList'])) { // this is used on the form validation $idList = $this->choiceList['idList']; $params['query_builder'] = function ($repo) use ($idList) { return $repo->createQueryBuilder() ->field('id')->in($idList) } } else { #$params['choices'] = []; } $resolver->setDefaults($params); } public function getParent() { return 'entity'; // or document on doctrine mongo document or probably what you want } public function getName() { return 'user'; } } |
The Article form
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 |
namespace Acme\AcmeBundle\Form; use Symfony\Component\Form\AbstractType; use Symfony\Component\OptionsResolver\OptionsResolverInterface; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; class ArticleType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder->add('title') ->add('description'); // we do not add our userList now, but attach an event $builder->addEventListener(FormEvents::PRE_SET_DATA, [$this, 'onPreSetData']); $builder->addEventListener(FormEvents::PRE_SUBMIT, [$this, 'onSubmit']); } public function onPreSetData(FormEvent $event) { $form = $event->getForm(); $currentUserList = $event->getData()->getUserList(); // now you can add the current user list to your form $form->add('userList', new UserType(['documentList' => $currentUserList])); } public function onSubmit(FormEvent $event) { $idList = $event->getData()['userList']; // will override the userList set in the preSetData on the form validation $event->getForm()->add('userList', new UserType(['idList' => $idList])); } public function setDefaultOptions(OptionsResolverInterface $resolver) { $resolver->setDefaults( [ 'data_class' => 'Acme\AcmeBundle\Entity\User' ] ); } public function getName() { return 'my_form'; } } |
Explanations
The “preSetData” method will be called before the data normalizations.
The “preSubmit” event is only called on the submission of the form. It is necessary, because if you only use the preSetData, the “choices” will be a small subset of your database, and if you add a user, he will not be in the choices, so Symfony will throw an error.
The solution here is to pass the id passed before the submission, and use in this case the “query_builder” option to still validate that the id exists in the database.
That’s it !
Featured images taken from hakim.se checkwave
Thanks for this article,
Please can you give us more explanation about “because if you only use the preSetData, the “choices” will be a small subset of your database”
The things I understood is that when you “submit” your form, Symfony automatically checks if the ids you have submitted are in the available id list.
As we manually set the id list of the collection, Symfony will check that the ids are in our collection. But as the collection is filled on the ‘preSetData’ event, it will not contain new ids and throw an Exception.
An example will be more easy to understand I think:
Lets edit a Article having 2 users already: “Alice” and “Bob”.
When you get the edit page, the “preSetData” method is called, your ‘ArticleType:userList’ is filled with Alice and Bob (l.27).
Now you add a third user: “Charles”, and you submit the form.
During the form validation, the ‘preSetData’ is called once again. As Charles is not stored in our database for now, the ‘userList’ will still be Alice and Bob.
(As our possible choices are only Alice and Bob, Symfony will reject another proposition if we do not had the preSubmit method).
After that, the ‘preSubmit’ method is called, so we decide to get the userIds directly from the submitted datas and t o change the ‘ArticleType:userList’ to the submitted form list of userId (Alice, Bob and Charles).
This way, the three users are valid users and Symfony will not throw an Exception.
That’s a little tricky but that’s it.
And if you care about the form validation, your userIds will be checked against your database in the ‘elseif’ of the ‘UserType’ (l.34).
Hi,
Thanks. This was extremely helpfull. I am using Symfony 3.1 and your example did work after some very slight changes.
My result looks like this:
———-
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Acme\EntityBundle\Entity\Shop;
use Acme\EntityBundle\Entity\ShopCategory;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ShopCategoryType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add(‘title’, TextType::class)
->add(‘save’, SubmitType::class)
// we do not set shopPromotions now, but attach an event
->addEventListener(FormEvents::PRE_SET_DATA, [$this, ‘onPreSetData’])
->addEventListener(FormEvents::PRE_SUBMIT, [$this, ‘onSubmit’])
;
}
public function onPreSetData(FormEvent $event)
{
$form = $event->getForm();
/** @var ShopCategory $shopCategory */
$shopCategory = $event->getData();
$shopPromotions = $shopCategory->getShopPromotions();
$form->add(‘shopPromotions’, EntityType::class, [
‘class’ => Shop::class,
‘choices’ => $shopPromotions,
‘choice_label’ => ‘title’,
‘multiple’ => true,
‘expanded’ => true
]);
}
public function onSubmit(FormEvent $event)
{
$form = $event->getForm();
$idList = $event->getData()[‘shopPromotions’];
$form->add(‘shopPromotions’, EntityType::class, [
‘class’ => Shop::class,
‘query_builder’ => function (EntityRepository $repo) use ($idList) {
/** @var QueryBuilder $repo */
$qb = $repo->createQueryBuilder(‘s’);
return $qb->where($qb->expr()->in(‘s.id’, $idList));
},
‘choice_label’ => ‘title’,
‘multiple’ => true,
‘expanded’ => true
]);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(array(
‘data_class’ => ‘Acme\EntityBundle\Entity\ShopCategory’,
));
}
}
Thanks, I added a link to your comment for SF 3.x