symfony2 chained selectors - symfony

I have three entities: Country, State and City with the following relationships:
When creating a City, I want two selectors, one for the Country and one for the State where the city belongs. These two selectors need to be chained so changing the Country will "filter" the States shown in the other selector.
I found a tutorial showing how to do this using Form Events but their example it's not quite my case. My entity City it's not directly related to the Country entity (they are indirectly related through State) so, when setting the country field in the City form (inside the class CityType), I'm forced to declare that field as 'property_path'=>false as you can see in the code below:
class CityType extends AbstractType
{
public function buildForm(FormBuilder $builder, array $options)
{
$builder->add('country', 'entity', array(
'class'=>'TestBundle:Country',
'property'=>'name',
'property_path'=>false //Country is not directly related to City
));
$builder->add('name');
$factory = $builder->getFormFactory();
$refreshStates = function ($form, $country) use ($factory)
{
$form->add($factory->createNamed('entity', 'state', null, array(
'class' => 'Test\TestBundle\Entity\State',
'property' => 'name',
'query_builder' => function (EntityRepository $repository) use ($country)
{
$qb = $repository->createQueryBuilder('state')
->innerJoin('state.country', 'country');
if($country instanceof Country) {
$qb->where('state.country = :country')
->setParameter('country', $country);
} elseif(is_numeric($country)) {
$qb->where('country.id = :country')
->setParameter('country', $country);
} else {
$qb->where('country.name = :country')
->setParameter('country', "Venezuela");;
}
return $qb;
}
)));
};
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (DataEvent $event) use ($refreshStates)
{
$form = $event->getForm();
$data = $event->getData();
if($data == null)
return;
if($data instanceof City){
if($data->getId()) { //An existing City
$refreshStates($form, $data->getState()->getCountry());
}else{ //A new City
$refreshStates($form, null);
}
}
});
$builder->addEventListener(FormEvents::PRE_BIND, function (DataEvent $event) use ($refreshStates)
{
$form = $event->getForm();
$data = $event->getData();
if(array_key_exists('country', $data)) {
$refreshStates($form, $data['country']);
}
});
}
public function getName()
{
return 'city';
}
public function getDefaultOptions(array $options)
{
return array('data_class' => 'Test\TestBundle\Entity\City');
}
}
The problem is that when I try to edit an existing City, the related Country is not selected by default in the form. If I remove the line 'property_path'=>false I get (not surprisingly) the error message:
Neither property "country" nor method "getCountry()" nor method "isCountry()" exists in class "Test\TestBundle\Entity\City"
Any ideas?

OK, I finally figured out how to do it properly:
namespace Test\TestBundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilder;
use Doctrine\ORM\EntityRepository;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\Event\DataEvent;
use Test\TestBundle\Entity\Country;
use Test\TestBundle\Entity\State;
use Test\TestBundle\Entity\City;
class CityType extends AbstractType
{
public function buildForm(FormBuilder $builder, array $options)
{
$builder->add('name');
$factory = $builder->getFormFactory();
$refreshStates = function ($form, $country) use ($factory) {
$form->add($factory->createNamed('entity','state', null, array(
'class' => 'Test\TestBundle\Entity\State',
'property' => 'name',
'empty_value' => '-- Select a state --',
'query_builder' => function (EntityRepository $repository) use ($country) {
$qb = $repository->createQueryBuilder('state')
->innerJoin('state.country', 'country');
if ($country instanceof Country) {
$qb->where('state.country = :country')
->setParameter('country', $country);
} elseif (is_numeric($country)) {
$qb->where('country.id = :country')
->setParameter('country', $country);
} else {
$qb->where('country.name = :country')
->setParameter('country', null);
}
return $qb;
})
));
};
$setCountry = function ($form, $country) use ($factory) {
$form->add($factory->createNamed('entity', 'country', null, array(
'class' => 'TestBundle:Country',
'property' => 'name',
'property_path' => false,
'empty_value' => '-- Select a country --',
'data' => $country,
)));
};
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (DataEvent $event) use ($refreshStates, $setCountry) {
$form = $event->getForm();
$data = $event->getData();
if ($data == null) {
return;
}
if ($data instanceof City) {
$country = ($data->getId()) ? $data->getState()->getCountry() : null ;
$refreshStates($form, $country);
$setCountry($form, $country);
}
});
$builder->addEventListener(FormEvents::PRE_BIND, function (DataEvent $event) use ($refreshStates) {
$form = $event->getForm();
$data = $event->getData();
if(array_key_exists('country', $data)) {
$refreshStates($form, $data['country']);
}
});
}
public function getName()
{
return 'city';
}
public function getDefaultOptions(array $options)
{
return array('data_class' => 'Test\TestBundle\Entity\City');
}
}
The jQuery AJAX selector
$(document).ready(function () {
$('#city_country').change(function(){
$('#city_state option:gt(0)').remove();
if($(this).val()){
$.ajax({
type: "GET",
data: "country_id=" + $(this).val(),
url: Routing.generate('state_list'),
success: function(data){
$('#city_state').append(data);
}
});
}
});
});
I hope this will be helpful to somebody else facing the same situation.

Since your link to this approach is down i decided to complement your excelent answer so anyone can use it:
In order to execute the following javascript command:
url: Routing.generate('state_list'),
You need to install FOSJsRoutingBundle that can be found in here.
ATENTION: in the read me section of the bundle there are instalation instructions but there is something missing. If you use the deps with this:
[FOSJsRoutingBundle]
git=git://github.com/FriendsOfSymfony/FOSJsRoutingBundle.git
target=/bundles/FOS/JsRoutingBundle
You must run the php bin/vendors update before the next steps.
I'm still trying to find out what route is needed in the routing.yml for this solution to work. As soon as i discover i will edit this answer.

You will need a dedicated FieldType for the chained selectbox. And also an xhr controller that can return child options based on passed parameter. Ofcourse property_path should be set to false.

Related

Symfony 5 - DataTransformer with Form Validation Constraint

I build a FormType in Symfony5 and make use of DataTransformer on 2 fields :
compagnie_princ
compagnie_sec.
DataTransformer basically takes an object ID and renders It to a label.
DataTransformer works fine, when Form is initially rendered on browser, see below capture:
Problem is after validation callbacks are executed, If an error occured, It fails to transform my Id back to a text value.
Code samples (most important parts) :
AccordCommercialFormType.php
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('compagnie_princ', TextType::class,
[
'label' => 'forms.parameter.accord.compagnie_princ',
]
)
->add('compagnie_sec', TextType::class,
[
'label' => 'forms.parameter.accord.compagnie_sec',
]
);
/** ... **/
$builder>addEventListener(
FormEvents::PRE_SET_DATA,
[$this, 'onPreSetData']
)
->addEventListener(
FormEvents::PRE_SUBMIT,
[$this, 'onPreSubmit']
);
$builder->get('compagnie_princ')->addModelTransformer($this->transformer);
$builder->get('compagnie_sec')->addModelTransformer($this->transformer);
}
Events are captured on preSubmit to fetch ID, because fields 'compagnie_princ' and 'compagnie_sec' are autocompleted with AJAX, populating hidden inputs. My guess is something is going wrong on that part.
public function onPreSetData(FormEvent $event): void
{
$form = $event->getForm();
$form->add('compagnie_princ_id', HiddenType::class,
['mapped' => false,
'attr' => ['class' => 'hidden-field'],
'data' => $event->getData()->getCompagniePrinc() ? $event->getData()->getCompagniePrinc()->getId() : null
]
);
$form->add('compagnie_sec_id', HiddenType::class,
['mapped' => false,
'attr' => ['class' => 'hidden-field'],
'data' => $event->getData()->getCompagnieSec() ? $event->getData()->getCompagnieSec()->getId() : null,
]
);
}
public function onPreSubmit(FormEvent $event): void
{
$data = $event->getData();
$data['compagnie_princ'] = (int)$data['compagnie_princ_id'];
$data['compagnie_sec'] = (int)$data['compagnie_sec_id'];
$event->setData($data);
}
CompagnieToIdTransformer.php
class CompagnieToIdTransformer implements DataTransformerInterface
{
public function __construct(private EntityManagerInterface $em){
}
public function transform($compagnie)
{
if (null === $compagnie) {
return '';
}
return $compagnie->getCodeIata();
}
public function reverseTransform($compagnieId):?Compagnie
{
if (!$compagnieId) {
return null;
}
$compagnie = $this->em
->getRepository(Compagnie::class)
->find($compagnieId)
;
if (null === $compagnie) {
throw new TransformationFailedException(sprintf(
'A company with number "%s" does not exist!',
$compagnieId
));
}
return $compagnie;
}
}

Symfony post submit event doesnt modify dynamic field

Here is my case:
I have two fields in a form, the second field values depends on the value selected on the first field.
I'm doing this to modify the second field:
$builder->add('firstField',EntityType::class, array(class => First::class));
$formModifier = function(FormInterface $formBuilder, $data = null){
if($data != null){
$formBuilder->add('secondField', EntityType::class, array(
class => Second::class,
'query_builder' => function (EntityRepository $er) use ($data) {
return $er->createQueryBuilder('s')
->where('s.field = :data')
->setParameter('data', $data)
;
});
} else {
$formBUilder->add('secondField', HiddenType::class, array('required' => false);
}
}
$builder->addEventListener(
FormEvents::PRE_SET_DATA,
function (FormEvent $event) use ($formModifier) {
$data = $event->getData();
$formModifier($event->getForm(), $data->getData());
}
);
$builder->get('firstField')->addEventListener(
FormEvents::POST_SUBMIT,
function (FormEvent $event) use ($formModifier){
$data = $event->getForm()->getData();
$formModifier($event->getForm()->getParent(), $data);
}
);
Ajax:
var $firstField = $('#firstField');
$(document).on('change', '#firstField', function(){
var $form = $(this).closest('form');
var data = {};
data['id'] = $(this).val();
$.ajax({
url: $form.attr('action'),
type: $form.attr('method'),
data: data,
success: function (html) {
$('#secondField').html(
$(html).find('#secondField').html()
);
}
})
});
The data is sent correctly via ajax, but i never get the correct response because it doesn't ever enter in the POST_SUBMIT event listener as i tried to debug and use some var_dump to check it. Any help would be appreciated. Thanks.

Dynamic ChoiceType (select2 + AJAX)

I need a form field to choose from thousands of entities, so a dynamic choice system like select2 (with AJAX) is perfectly suited.
My AJAX endpoint works fine, but the custom form type does not work:
class Select2AjaxDataCategoryType extends AbstractType
{
/**
* #var EntityManagerInterface
*/
private $entityManager;
/**
* #var RouterInterface
*/
private $router;
public function __construct(EntityManagerInterface $entityManager,
RouterInterface $router)
{
$this->entityManager = $entityManager;
$this->router = $router;
}
public function getParent()
{
return ChoiceType::class;
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->resetModelTransformers();
$builder->resetViewTransformers();
$builder->addModelTransformer(new CallbackTransformer(
function (?DataCategory $dc) {
dump('model transform is called ' . ($dc ? $dc->getId()->toString() : 'null'));
return $dc ? $dc->getId()->toString() : '';
},
function ($id) : ?DataCategory{
dump('model reversetransform is called ' . $id);
$dc = $this->entityManager->getRepository(DataCategory::class)->find($id);
if($dc === null)
throw new TransformationFailedException("Konnte keine Datenkategorie mit ID $id finden");
return $dc;
}
));
$builder->addViewTransformer(new CallbackTransformer( // Identity !!!
function ($dc) {
dump('view transform is called ' . $dc);
return $dc;
},
function ( $id) {
dump('view reversetransform is called ' . $id);
return $id;
}
));
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) { // makes validation pass
$data = $event->getData();
dump($data); // select2'd id, correct
dump($event->getForm()->getName()); // name of my form field
$event->getForm()->getParent()->add( // so this is lik "overwriting"? Documented nowhere :-/
$event->getForm()->getName(),
ChoiceType::class,
['choices' => [$data => $data]]);
$event->getForm()->getParent()->get($event->getForm()->getName())->setData($data);
});
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setRequired('currentDataCategory');
$resolver->setAllowedTypes('currentDataCategory', [DataCategory::class]);
$resolver->setDefaults([
'attr' => [
'data-ajax' => '1',
'data-ajax-endpoint' => $this->router->generate('data-category-manage-select2')
]
]);
}
}
When using this form type, it seems to work, but finally no entity object is returned, but null. According to symfony debug toolbar however, the value is received:
Also the dumps indicate that the view and model transformers were called:
For the sake of completeness (I hope we'll find a perfect solution and help others), here is my js code (it works):
$('select[data-ajax=1]').select2({
theme: "bootstrap4",
placeholder: "Bitte wählen",
ajax: {
url: function() { return $(this).data('ajax-endpoint');},
dataType: 'json',
data: function (params) {
var query = {
search: params.term,
page: params.page || 0
}
// Query parameters will be ?search=[term]&page=[page]
return query;
}
}
});
I have solved the problem, here is my complete solution:
$('select[data-ajax=1]').select2({
theme: "bootstrap4",
placeholder: "Bitte wählen",
ajax: {
url: function() { return $(this).data('ajax-endpoint');},
dataType: 'json',
data: function (params) {
var query = {
search: params.term,
page: params.page || 0
}
// Query parameters will be ?search=[term]&page=[page]
return query;
}
}
});
The new form type is fixed for one class DataCategory, and works both for single and multiple select's.
I have build-in a distinction between select2 frontend and the standard EntityType (mainly for testing reasons, because the new select2 based approach does not allow PHPUnit tests that use symfony's Client (WebTestCase)): If there are less than 50 DataCategory entities in the DB, the field falls back to EntityType
class Select2AjaxDataCategoryType extends AbstractType
{
/**
* #var EntityManagerInterface
*/
private $entityManager;
/**
* #var RouterInterface
*/
private $router;
private $transformCallback;
public function __construct(EntityManagerInterface $entityManager,
RouterInterface $router)
{
$this->entityManager = $entityManager;
$this->router = $router;
$this->transformCallback = function ($stringOrDc) {
if (is_string($stringOrDc)) return $stringOrDc;
else return $stringOrDc->getId()->toString();
};
}
public function getParent()
{
if($this->entityManager->getRepository(DataCategory::class)->count([]) > 50)
return ChoiceType::class;
else
return EntityType::class;
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
if($this->entityManager->getRepository(DataCategory::class)->count([]) > 50) {
$builder->addModelTransformer(new CallbackTransformer(
function ($dc) {
/** #var $dc DataCategory|DataCategory[]|string|string[] */
/** #return string|string[] */
dump('model transform', $dc);
if($dc === null) return '';
if(is_array($dc)) {
return array_map($this->transformCallback, $dc);
} else if($dc instanceof Collection) {
return $dc->map($this->transformCallback);
} else {
return ($this->transformCallback)($dc);
}
},
function ($id) {
dump('model reversetransform', $id);
if (is_string($id)) {
$dc = $this->entityManager->getRepository(DataCategory::class)->find($id);
if ($dc === null)
throw new TransformationFailedException("Konnte keine Datenkategorie mit ID $id finden");
dump($dc);
return $dc;
} else {
$ret = [];
foreach($id as $i){
$dc = $this->entityManager->getRepository(DataCategory::class)->find($i);
if ($dc === null)
throw new TransformationFailedException("Konnte keine Datenkategorie mit ID $id finden");
$ret[] = $dc;
}
return $ret;
}
}
));
$builder->resetViewTransformers();
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) {
$dataId = $event->getData();
dump('presubmit', $dataId, $event->getForm()->getConfig()->getOptions()['choices']);
if(empty($dataId))
return;
$name = $event->getForm()->getName();
if (is_array($dataId)) { // multiple-true-case
if (!empty(array_diff($dataId, $event->getForm()->getConfig()->getOptions()['choices']))) {
$options = $event->getForm()->getParent()->get($name)->getConfig()->getOptions();
$options['choices'] = array_combine($dataId, $dataId);
$event->getForm()->getParent()->add($name, Select2AjaxDataCategoryType::class, $options);
$event->getForm()->getParent()->get($name)->submit($dataId);
$event->stopPropagation();
}
} else { // multiple-false-case
if($dataId instanceof DataCategory){
$dataId = $dataId->getId()->toString();
throw new \Exception('Hätte ich nicht erwartet, sollte string sein');
}
if (!in_array($dataId, $event->getForm()->getConfig()->getOptions()['choices'])) {
$options = $event->getForm()->getParent()->get($name)->getConfig()->getOptions();
$options['choices'] = [$dataId => $dataId];
$event->getForm()->getParent()->add($name, Select2AjaxDataCategoryType::class, $options);
$event->getForm()->getParent()->get($name)->submit($dataId);
$event->stopPropagation();
}
}
});
// $builder->addEventListener(FormEvents::PRE_SET_DATA, function(FormEvent $event){
// dump("pre set data", $event->getData());
// });
} else {
}
}
public function configureOptions(OptionsResolver $resolver)
{
if($this->entityManager->getRepository(DataCategory::class)->count([]) > 50) {
$resolver->setDefaults([
'attr' => [
'data-ajax' => '1',
'data-ajax-endpoint' => $this->router->generate('data-category-manage-select2')
],
'choices' => function (Options $options) {
$data = $options['data'];
dump('data', $data);
if($data !== null) {
if(is_array($data) || $data instanceof Collection){
$ret = [];
foreach ($data as $d) {
$ret[$d->description . ' (' . $d->name . ')'] = $d->getId()->toString();
}
dump($ret);
return $ret;
} else if ($data instanceof DataCategory){
return [$data->description . ' (' . $data->name . ')' => $data->getId()->toString()];
} else {
throw new \InvalidArgumentException("Argument unerwartet.");
}
} else {
return [];
}
}
]);
} else {
$resolver->setDefaults([
'class' => DataCategory::class,
'choice_label' => function ($cat, $key, $index) { return DataCategory::choiceLabel($cat);},
'choices' => function (Options $options) {
return $this->entityManager->getRepository(DataCategory::class)->getValidChildCategoryChoices($options['currentDataCategory']);
}
]);
}
}
}
It is very important to set the 'data' option when using this new type, otherwise the choices option is not correctly set:
$builder->add('summands', Select2AjaxDataCategoryType::class,[
'currentDataCategory' => $mdc,
'data' => $mdc->summands->toArray(),
'multiple' => true,
'required' => false,
'label' => 'Summierte Kategorien',
]);

Get multi data from one entity with one method

How I can get multiple data from entity data row
public function findByUser1Id($userId)
{
$connections = $this->_em
->getRepository('AppBundle:ConnectionsList')
->findBy(array('user1Id' => $userId));
foreach($connections as $con)
{
$user = $this->_em
->getRepository('AppBundle\Entity\User')
->findOneBy(array('id' => $con->getUser2Id()));
var_dump($user->getFirstname());
var_dump($user->getLastname());
var_dump($user->getEmail());
var_dump($user->getId());
die;
}
}
I need some way to get data in one row in example:
$user->get(array('firstname', 'lastname', 'email' ...))
I found solution, but can't get all data in one row
code is here:
public function findByUser1Id($userId)
{
$connections = $this->_em
->getRepository('AppBundle:ConnectionsList')
->findBy(array('user1Id' => $userId));
foreach($connections as $con)
{
$user = $this->_em
->getRepository('AppBundle\Entity\User')
->findOneBy(array('id' => $con->getUser2Id()));
array_push($user, array(
'id' => $data->getId(),
'email'=>$data->getEmail(),
'firstname'=>$data->getFirstname(),
'lastname'=>$data->getLastname()
));
}
return $data
}

passing data from controller to Type symfony2

if i show a field of type "entity" in my form, and i want to filter this entity type based on a argument I pass from the controller, how do i do that.. ?
//PlumeOptionsType.php
public function buildForm(FormBuilder $builder, array $options)
{
$builder->add('framePlume', 'entity', array(
'class' => 'DessinPlumeBundle:PhysicalPlume',
'query_builder' => function(EntityRepository $er) {
return $er->createQueryBuilder('pp')
->where("pp.profile = :profile")
->orderBy('pp.index', 'ASC')
->setParameter('profile', ????)
;
},
));
}
public function getName()
{
return 'plumeOptions';
}
public function getDefaultOptions(array $options)
{
return array(
'data_class' => 'Dessin\PlumeBundle\Entity\PlumeOptions',
'csrf_protection' => true,
'csrf_field_name' => '_token',
// a unique key to help generate the secret token
'intention' => 'plumeOptions_item',
);
}
}
and inside the controller, i create the form :
i have that argument that i need to pass in my action code:
$profile_id = $this->getRequest()->getSession()->get('profile_id');
...
and then i create my form like this
$form = $this->createForm(new PlumeOptionsType(), $plumeOptions);
the $plumeOptions is just a class to persist. But it has a one-to-one relationship with another class called PhysicalPlume. Now, when i want to display the 'framePlume' in my code, i want to show a filtered PhysicalPlume entity.
You can pass parameters to the form class as follows:
//PlumeOptionsType.php
protected $profile;
public function __construct (Profile $profile)
{
$this->profile = $profile;
}
Then use it in the query_builder of your buildForm:
$profile = $this->profile;
$builder->add('framePlume', 'entity', array(
'class' => 'DessinPlumeBundle:PhysicalPlume',
'query_builder' => function(EntityRepository $er) use ($profile) {
return $er->createQueryBuilder('pp')
->where("pp.profile = :profile")
->orderBy('pp.index', 'ASC')
->setParameter('profile', $profile)
;
},
));
And finally in your controller:
// fetch $profile from DB
$form = $this->createForm(new PlumeOptionsType($profile), $plumeOptions);
You can use $plumeOptions to pass everything your argument, but you'll need to add a getDefaultOptions() in PlumeOptionsType to specify the default value for your option.
See for instance https://github.com/symfony/symfony/blob/master/src/Symfony/Component/Form/Extension/Core/Type/CheckboxType.php to see what this method should look like.

Resources