Symfony 4 - performance issue on form with a selectbox with many options - forms

I built a form with a selectbox (EntityType) with a big amount of choices (about 50 000) :
->add(
'destination', EntityType::class, array(
'label' => 'à',
'multiple' => false,
'required' => false,
'class' => Stop::class,
'attr' => array(
'class' => 'form-control',
)
)
);
I am facing a big performance issue : dozens of seconds before the list is displayed when I click on it.
I guess the solution would be to initially only load a few elements (e.g. a hundred), and then use Ajax to request the DB when the user starts typing (I am using a select2 box with search field).
Problem is that I cannot figure out the most efficient way to do it through Symfony.
I have seen that the choice_loader functionality could do it, but there is no detailed documentation available : https://symfony.com/blog/new-in-symfony-3-2-lazy-loading-of-form-choices
Would be great if somebody can help on this,
Thanks for your support,

When I face this kind of trouble, I use another approach.
If the select option will have more than 20 entries, so I change it to a Input Text with autocomplete.
The Steps are:
Install a good autocomplete Javascript lib like jquery-typeahead
I like to use Wepack Encore in Symfony. With Webpack, Npm and Yarn the installation is easy like
yarn add jquery-typeahead --dev
You would need to run yarn run encore dev after installation.
Create a new FieldType for your form to replace the EntityType
Lets suppose that we need to create a register form with city field. The default behaviour will use EntityType and show a Select Option with all cities.
To change it to autocomplete lets create another FieldType.
<?php
// src/Form/Type/AutocompleteCityType.php
namespace App\Form\Type;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SearchType;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AutocompleteCityType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(array(
'attr' => ['autocomplete' => 'off']
));
}
public function getParent()
{
return SearchType::class;
}
}
NOTE: On the code above, I am extending SearchType::class that is a Input Type Search (HTML 5).
Our new field type can be used on our forms but it is just another string field. Won't work correctly to replace EntityType. We need to convert this string to an Entity. So we need a DataTransformer
Create a City to String DataTransformer
<?php
// src/Form/DataTransformer/CityToStringTransformer.php
namespace App\Form\DataTransformer;
use App\Entity\City; // Pay attention to use your Entities correctly
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;
class CityToStringTransformer implements DataTransformerInterface
{
private $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
}
/**
* Transforms an object (City) to a string.
*
* #param City|null $city
* #return string
*/
public function transform($city)
{
if (null === $city) {
return '';
}
return $city->getSomethingUnique();
}
/**
* Transforms a string to an object (city).
*
* #param string $somethingUnique
* #return City|null
* #throws TransformationFailedException if object (city) is not found.
*/
public function reverseTransform($somethingUnique)
{
// empty City? It's optional, so that's ok
if (!$somethingUnique) {
return;
}
$city = $this->entityManager
->getRepository(City::class)
->findByThatSomethingUnique($somethingUnique);
if (null === $city) {
// causes a validation error
// this message is not shown to the user
// see the invalid_message option
throw new TransformationFailedException(sprintf(
'The city "%s" cannot be found!',
$somethingUnique
));
}
return $city;
}
}
Note: The string must be some kind of unique key to work correctly and need to be good to show on Autocomplete and fill the field (Like [CityCode] CityName). The DataTransformation cannot return more than one result on findByThatSomethingUnique() method.
Note 2 : For exemple, $city->getSomethingUnique() cant be $city->getId()."-".$city->getName() and ->findByThatSomethingUnique($somethingUnique) can be ->findOneById(explode("-", $somethingUnique)[0])
Almost done. We can use both classes on our FormType to replace EntityType.
Using on the FormType
// src/Form/MyFormType.php
// (...) Other declarations(...)
use App\Form\DataTransformer\ContactToStringTransformer;
use App\Form\Type\AutocompleteContactType;
class MyFormType extends AbstractType
{
private $cityTransformer;
public function __construct(CityToStringTransformer $cityTransformer)
{
$this->cityTransformer = $cityTransformer;
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
/* #var $myEntity MyEntity */
$myEntity = $builder->getData();
$builder
->add('city', AutocompleteCityType::class, [
'label' => 'Custom City Label',
])
// (...) Other fields (...)
;
$builder->get('city')
->addModelTransformer($this->cityTransformer);
}
With the code until here, the form will be shown correctly, but the typeahead must be configured properly.
You can create a new twig block type for this new field type. The code below must reside in the your custom form_theme
The Twig block
{% block autocomplete_city_widget %}
{% spaceless %}
<div class="typeahead__container">
<div class="typeahead__field">
<div class="typeahead__query">
{{ form_widget(form) }}
</div>
</div>
</div>
{% endspaceless %}
{% endblock %}
NOTE: The Twig code above is related to jquery-typeahead and only works with a field called AutocompleteCityType. If you install another lib or change the name of FieldType class, change it properly. Also pay attention to form names that change the block name to be rendered.
The last thing is to write the javascript for typeahead get the entries.
The Typeahead Javascript
jQuery.typeahead({
input: "#myForm_city", // Related to FormName and Autocomplete Field Name
minLength: 1,
maxItem: 20,
order: "asc",
dynamic: true,
delay: 500,
backdrop: { "background-color": "#eeeeee" },
template: "<small style='color:#999;'>{{ '[{{citycode}}] {{cityname}}' }}</small>", // remember that this is a Twig template...
emptyTemplate: "No results for typed string",
source: {
city: {
display: ["citycode", "cityname"],
ajax: function (query) {
return {
type: "POST",
url: '{{ path('controller_with_city_list_json_response') }}',
path: "city",
data: {
"q": "{{ '{{query}}' }}",
"length" : "40",
},
callback: {
done: function (res) {
var d = {};
d.city = [];
jQuery(res.data).each(function(index, value) {
d.city.push(value.city);
});
return d;
}
}
}
}
}
},
callback: {
onClickAfter: function (node, a, item, event) {
event.preventDefault();
jQuery(node).val("[" + item.citycode + "] " + item.cityname);
}
},
debug: false
});
NOTE: The Typeahead code above want a json response in format
{"data":
[
{"city":
{"cityname":"City Name X", "citycode": "NXNX"}
},
{"city":
{"cityname":"City Name Y", "citycode": "NYNY"}
}
]
}

Related

Using a Pagerfanta/ non ArrayAccess list in a bulk form

I'm adding checkboxes for bulk actions to a CRUD list, using the solution provided here.
However, my results are paged with Pagerfanta, so it seems I need to use a DataMapper in my form.
I have tried various solutions, but cannot get the selected fields to be available in my form data:
class ModelEntitySelectionType extends AbstractType implements DataMapperInterface
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add('model_id', EntityType::class, [
'required' => false,
'class' => ModelFile::class,
'choice_label' => 'id',
'property_path' => '[id]', # in square brackets!
'multiple' => true,
'expanded' => true
])
->add('action', ChoiceType::class, [
'choices' => [
'Delete' => 'delete'
]
])
->add('submit', SubmitType::class, [
'label' => 'Process'
])
->setDataMapper($this)
;
}
public function setDefaultOptions(ExceptionInterface $resolver)
{
$resolver->setDefaults(array(
'data_class' => null,
'csrf_protection' => false
));
}
public function mapDataToForms($data, $forms)
{
// there is no data yet, so nothing to prepopulate
if (null === $data) {
return;
}
$formData = [];
/** #var FormInterface[] $forms */
$forms = iterator_to_array($forms);
$forms['model_id']->setData($formData);
}
public function mapFormsToData($forms, &$data)
{
//$forms = iterator_to_array($forms);
$data = [
'model_id' => iterator_to_array($data)
];
}
The missing piece is when I investigate mapFormsToData with a debugger:
$forms is a RecursiveIteratorIterator
$data is a PagerFanta object
I understand how I have to "loop" through the PagerFanta object, because it doesn't have ArrayAccess, but where is the data of which checkboxes have actually been ticked? Also, my other form fields (action) are not accessible here
I think your approach is problematic. The Form component is meant to modify the object passed to it, which is - as far as I can tell - not what you want. You don't want to modify a Pagerfanta object, you want to select entities for bulk actions.
So to solve your problem, the very very raw things that have to happen: A <form> must be displayed on the page with a checkbox for every entry that's a candidate for the bulk action, with some button(s) to trigger the bulk action(s).
Your form - besides the entry for checkboxes - is alright I guess and not really your problem, as far as I can tell. You're not even interested in editing the Pagerfanta object (I hope) and just want the selection. To do that, we provide the collection of objects that are queued to be displayed on the page to the form via an option, and then use that option to build the field (read: pass the collection to the EntityType field).
Adding the collection to the form (call) as an option:
Somewhere in your controller, you should have something like:
$form = $this->createForm(ModelEntitySelectionType::class, $pagerfanta);
Change this to:
$form = $this->createForm(ModelEntitySelectionType::class, [], [
'model_choices' => $pagerfanta->getCurrentPageResults(),
]);
the method getCurrentPageResults return the collection of entities for the current page (obviously). The empty array [] as the second parameter is ultimately the object/array you're trying to edit/create. I've chosen an array here, but you can also make it a new action class (like a DTO) e.g. ModelBulkAction with properties: model and action:
class ModelBulkAction {
public $model;
public $action;
}
Note these kinds of objects only make sense if used in more than one place - then the call would be:
$form = $this->createForm(ModelEntitySelectionType::class, new ModelBulkAction(), [
'model_choices' => $pagerfanta->getCurrentPageResults(),
]);
Pass the choices to the sub form:
The Form component will complain, if you provide an option to a form, which doesn't expect that option. That's the purpose of AbstractType::configureOptions(OptionsResolver $resolver). (side note: I don't know, what your setDefaultOptions is supposed to achieve, tbh, with an ExceptionInterface nonetheless. No clue, really).
public function configureOptions(OptionsResolver $resolver) {
$resolver->setRequired([
'model_choices', // adds model_choices as a REQUIRED option!
]);
$resolver->setDefaults([
// change null to ModelBulkAction::class, if applicable
'data_class' => null,
]);
}
and finally actually passing the collection to the entity type sub form:
// in ModelEntitySelectionType::buildForm($builder, $options)
$builder->add('model', EntityType::class, [
'required' => false,
'class' => ModelFile::class,
'choice_label' => 'id',
'choices' => $options['model_choices'], // set the choices explicitly
'multiple' => true,
'expanded' => true,
])
// ...
;
Also, your data mapping is not needed any more and should be removed.
Adding the form widgets to the output
This is pretty much similar to the Stack Overflow question and answer you linked. However, the keys in the form are different, because my approach is slightly different:
{{ form_start(form) }}
{% for entity in pagerfanta %}
{# stuff before the checkbox #}
{{ form_widget(form.model[entity.id]) }}
{# stuff after the checkbox #}
{% endfor %}
{# place the selection of action somewhere! and possibly the "submit" button #}
{{ form_widget(form.action) }}
{{ form_end(form) }}
(note: this will probably show the id of the entry next to the checkbox, since that's your choice_label, I believe this can be removed by: {{ form_widget(form.model[index], {label:false}) }} or alternatively by setting the choice_label to false instead of 'id').
Getting your bulk entities
After $form->handleRequest($request); you can check for submission and the form values:
if($form->isSubmitted() && $form->isValid()) {
$data = $form->getData();
// $data['model'] contains an array of entities, that were selected
// $data['action'] contains the selection of the action field
// do the bulk action ...
}
If you implemented the ModelBulkAction approach, $data is an object of that kind and has $data->model as well as $data->action for you to use (or pass on to a repository).
More stuff
Obviously the model_choices option can be named almost any way you like (but should not clash with existing options the AbstractType may have).
To make an entity selectable (besides the checkbox), you can for example use <label for="{{ form.model[index].vars.id }}"><!-- something to click --></label> as a non-javascript approach (may add styling). With js it's pretty much irrelevant because you probably just need to select the first checkbox in the row.
Alternatives
Alternative to providing the collection of objects to the form, you could theoretically also provide a list of ids and use the ChoiceType instead of the EntityType. There is nothing to be gained from this though.

Form themes - Add code at n block_name match

I have a front symfony application that takes a serialized symfony form from an API, parses it and finally renders it.
This application is supposed to be dumb, and should not be aware in any way of any remote app's logic.
It just takes json form, and displays it after parsing.
The fields in the serialized form have custom (remote app defined) block names which are then used in front app's form themes to build fields structures.
Said field example :
"field_1": {
"options": {
"block_name": "block_name_example",
"label": "Example",
"required": true,
"disabled": false,
"choices": {
"Choice 1": "1",
"Choice 2": "2"
},
"help_description": "",
"attr": {
"name": "field_name_1",
"short_name": "fieldName1"
}
},
"type": "Symfony\\Component\\Form\\Extension\\Core\\Type\\ChoiceType"
}
I would like, in the form_theme block, to add something "the first time this block name is matched" (for example), without any logic added on the remote app side, something like :
{% block _form_block_name_example %}
{% if match_occurrence = 1 %}
{# do something here #}
{% endif %}
{{ form_widget(form) }}
{% endblock %}
I know there is many ways (form field extra option, wrap it into a collection type field ...) to solve this through remote app code edits, but I don't want to for various reasons, the main one being to avoid any additional complexity to remote app's code.
Couldn't find a clean way to solve this. Will you be my hero ?
I though of a pretty clean way to handle this on the front side app only. Stripped to the max the classes from unrelated code & renamed some things to make it as much generic as I could for you.
I added a new extra first_of_type option to my fields through form extension, which I set at form's generation. I then use it as a casual option in my form theme's block. Code below.
The form extension :
class ExtraOptionsExtension extends AbstractTypeExtension
{
/**
* Add the width option.
*
* #param OptionsResolver $resolver
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefined([
/* ... */
'first_of_type'
]);
}
public function buildView(FormView $view, FormInterface $form, array $options)
{
/* ... */
$view->vars['first_of_type'] = false;
if (!empty($options['first_of_type'])) {
$view->vars['first_of_type'] = true;
}
}
/**
* Returns the name of the type being extended.
*
* #return string The name of the type being extended
*/
public function getExtendedType()
{
return FormType::class;
}
}
The FormType :
abstract class AbstractType extends BaseAbstractType
{
/* ... */
/**
* {#inheritdoc}
*
* #param FormBuilderInterface $builder
* #param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$blockNames = [];
if (is_array($this->fields)) {
foreach ($this->fields as $property => $data) {
$type = $data['type'] ?? null;
$opts = $data['options'] ?? [];
if (!in_array($type, $this::TYPES_WITHOUT_EXTRA) &&
isset($data['options']['block_name']) &&
!in_array($data['options']['block_name'], $blockNames)) {
$blockNames[] = $data['options']['block_name'];
$opts['first_of_type'] = true;
}
$builder->add($property, $type, $opts);
}
}
}
/* ... */
}
The form theme block :
{% block custom_block %}
<div class="form-group form-inline {% if not first_of_type %} hide {% else %}">
{{ form_label(form, null, {'label_attr': {'class': 'control-label'}}) }}
{{ form_widget(form) }}
</div>
{% endblock %}
Could still be improved to provide more information on element's position compared to it's pairs (not only the fact it's first).

Array-type property and Form CollectionType with data transformer

I have an entity with a property that is set as an array
/**
* #ORM\Column(type="array")
*/
private $labels = [];
this array of data stores translations of a label like
[
'en' => 'foo-in-English',
'de' => 'foo-in-German',
'ru' => 'foo-in-Russian'
]
I have a Form with the type set for the labels like:
$builder
->add('labels', CollectionType::class);
Note that the entry_type defaults (properly) to TextType here. Left as is, the template would be displayed with text fields, like:
Labels: en: _____FOO IN ENGLISH____
de: _____FOO IN GERMAN_____
ru: _____FOO IN RUSSIAN____
But, I would like the fields to be displayed with the actual language name and not the two-letter code as the label, so something like:
Labels: English: _____FOO IN ENGLISH____
German: _____FOO IN GERMAN_____
Russian: _____FOO IN RUSSIAN____
I also want to make sure that all my selected/supported languages are displayed - even if they currently have no value.
So, this seems like the proper place for a DataTransformer, but try as I might I could not get this concept to work within the Form class. It seems that attempting to transform the data of a collection type is more difficult (or impossible?) than a simpler type like text.
I've overcome this as a workaround by transforming the data within the controller before submitting it to the form and after processing the form before persistence. e.g.
$this->transformTranslations($fooEntity);
$form = $this->createForm(FooType::class, $fooEntity);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$fooEntity = $form->getData();
$this->reverseTransformTranslations($fooEntity);
$this->getDoctrine()->getManager()->persist($fooEntity);
$this->getDoctrine()->getManager()->flush();
...
I'm wondering if anyone has a better method (like how to use normal data or model transformers). I can't seem to find much online about using data transformers with collection types. TIA!
I have not personally used a doctrine array value before, however
you can define a 'default' form class for each of your translation options like so:
AppBundle\Form\LanguageStringEditorType.php
class LanguageStringEditorType extends AbstractType {
public function buildForm(FormBuilderInterface $builder, array $options) {
$builder
->add('en', TextareaType::class, ['label' => 'English'])
->add('de', TextareaType::class, ['label' => 'German'])
->add('ru', TextareaType::class, ['label' => 'Russian'])
;
}
}
If you keep the naming ('en', 'de' and 'ru') the same as your data array key names for example having an (doctrine) entity like this:
AppBundle\Entity\LanguageString.php
class LanguageString {
private $identifier;
private $translations; // this is the doctrine array type
// however I didn't feel like setting up a database for this
// test so I'm manually filling it see the next bit
... Getter and setter things ...
And create a type for that as well:
AppBundle\Form\LanguageStringType.php
class LanguageStringType extends AbstractType {
public function buildForm(FormBuilderInterface $builder, array $options) {
$builder
->add('identifier')
->add('translations', LanguageStringEditorType::class, ['label' => 'Translations'])
;
}
}
We can use this in the controller
$data = new LanguageString();
// fill some dummy content (not using database)..
$data->setIdentifier('hello_world');
$data->setTranslations([
'en' => 'Hello world!',
'de' => 'Hallo Welt!',
'ru' => 'Привет мир'
]);
$form = $this->createForm(LanguageStringType::class, $data);
return $this->render('default/index.html.twig', [
'form' => $form->createView()
]);
And the rest is done by magic, no transformers required. The data is placed in the form fields. And set to the entity when using the handleRequest. Just remember that the data key values are the same as the form builder names.
And as a bonus you have defined all your default language fields in the LanguageStringEditorType class, filled in or not.
So, I learned I needed to separate my two needs into different solutions. First I created a new form type to use instead of the text type I was using by default:
$builder
])
->add('labels', CollectionType::class, [
'entry_type' => TranslationType::class
])
This class is very simple and is only an extension of a regular TextType:
class TranslationType extends AbstractType
{
/**
* #var LocaleApiInterface
*/
private $localeApi;
/**
* TranslationType constructor.
* #param LocaleApiInterface $localeApi
*/
public function __construct(LocaleApiInterface $localeApi)
{
$this->localeApi = $localeApi;
}
/**
* {#inheritdoc}
*/
public function buildView(FormView $view, FormInterface $form, array $options)
{
$view->vars['label'] = array_search($view->vars['name'], $this->localeApi->getSupportedLocaleNames());
}
public function getParent()
{
return TextType::class;
}
}
This satisfied the labelling issue. Second, to ensure I had all the supported locales in my data, I used a FormEventListener:
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
$supportedLocales = $this->localeApi->getSupportedLocales();
$data = $event->getData();
$labels = $data['labels'];
foreach ($supportedLocales as $locale) {
if (!array_key_exists($locale, $labels)) {
$labels[$locale] = $labels['en'];
}
}
$data['labels'] = $labels;
$event->setData($data);
});
This adds the required keys to the data if they are not already present.

Form collection of imbricated form without entity never validates

I am currently working on a "Filters" form to add the possibility for the users to apply filters on item lists. The issue I am facing is that once the form is submitted, the controller considers that the form is empty and invalid.
Dumping what's returned by $form->getData() shows the following:
array(1) { ["filters"]=> array(0) { } }
There is neither errors nor warnings in the logs. The GUI returns an error on the field of the filter:
This value is not valid.
However if I modify the Twig widget to change the select's id to anything else, I no longer get the invalid value but the form's data still is an empty array.
Here's the layout of this project:
a FormType containing one select input, and one text input,
another FormType that implements the former in a collection,
The controller, which instantiates and use the form,
a Twig view of the 2nd FormType,
and the final Twig page
FilterSearchType.php
namespace NetDev\CoreBundle\Form\Helpers;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;
use Symfony\Component\Form\FormBuilderInterface;
use NetDev\CoreBundle\Form\Helpers\FilterType;
class FilterSearchType extends AbstractType {
public function buildForm(FormBuilderInterface $builder, array $options) {
$builder
->add('filters', 'collection', array('type' => new FilterType($options['entity']), 'allow_add' => true,
'allow_delete' => true, 'by_reference' => false))
->add('search', 'submit');
}
public function setDefaultOptions(OptionsResolverInterface $resolver) {
$resolver->setDefaults(array('filters' => [],
'entity' => null));
}
public function getName() {
return 'search_filter';
}
}
FilterType.php
<?php
namespace NetDev\CoreBundle\Form\Helpers;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;
use Symfony\Component\Form\FormBuilderInterface;
class FilterType extends AbstractType {
public function __construct($entity) {
$this->model = $entity;
}
public function buildForm(FormBuilderInterface $builder, array $options) {
$builder
/*getFilters returns something like that:
* ['Column A' => 'column_a', 'Column B' => 'column_b', ...]
*/
->add('column_name', 'choice', array('choices' => $this->model->getFilters(true)))
->add('search_value', 'text')
;
}
public function setDefaultOptions(OptionsResolverInterface $resolver) {
$resolver->setDefaults(['column_name' => '',
'search_value' => '']);
}
public function getName() {
return 'netdev_filter';
}
}
Here is how the form is given to Twig from the controller:
RoutesController.php
class RoutesController extends Controller {
public function indexAction($page = 1, Request $request) {
$em = $this->getDoctrine()->getManager();
$orderBy = $request->query->get('orderBy');
$orderType = $request->query->get('orderType');
$form = $this->createForm(new RouteManagerType($em), null, array('orderBy' => $orderBy,
'orderType' => $orderType));
$filterForm = $this->createForm(new FilterSearchType(), null, array('entity' => new Route()));
if ($request->isMethod('POST') && $filterForm->handleRequest($request)->isValid()) {
// Never reached, $filterForm is always marked as invalid
$formData = $filterForm->getData();
var_dump($formData);
exit();
if (!empty($formData['filters']) && count($formData['filters'])) {
if (empty($formData['action'])) $formData['action'] = 'filter';
$form = $this->createForm(new RouteManagerType($em), null,
array('orderBy' => $orderBy,
'orderType' => $orderType,
'filters' => $formData['filters']));
}
}
Twig widget: filter_search.html.twig
{% block netdev_filter_widget %}
{% spaceless %}
{{ form_errors(form) }}
<div class="form-group">
<div class="input-group">
<select {{ block('widget_attributes') }} class="form-control" id="search_column">
{% for group_label, choice in form.column_name.vars.choices %}
<option value="{{ choice.value }}" {{ block('attributes') }}>{{ choice.label }}</option>
{% endfor %}
</select>
<div class="input-group-addon">contains</div>
<input type="text" class="form-control" id="search_criteria" placeholder="search"/>
</div>
</div>
{% endspaceless %}
{% endblock %}
I've dumped pretty much everything I could and nothing was really interesting. I am not even sure that the Kernel understands / correctly "links" the submit the user has performed and the form the controller created.
Any help on that would be greatly appreciated.
OK, so the issue is that if you do the rendering yourself, you should very well be aware of the fact that the names as rendered in HTML are exactly what the backend expects, otherwise you'll get issues like these.
The best way to tackle that is take the default form rendering as a starting point and don't do any custom HTML until you're absolutely sure you need custom templating. When you do, inspect the default templates to see how the names of the elements are built, and follow the exact same naming, or even better, reuse base templates wherever possible (either by extending blocks and calling parent() and/or using the block function).

Symfony2: my form returns false from isValid() but empty array for getErrors() from unique constraint condition

I have a Customer entity that only has a unique Email field to it. I am trying to edit a customer's email and the validation works fine. However I have this in my controller:
public function updateAction(Request $request, $id) {
$em = $this->getDoctrine()->getManager();
$entity = $em->getRepository('AcmeDemoBundle:Customer')->find($id);
if (!$entity) {
throw $this->createNotFoundException('Unable to find Customer entity.');
}
$editForm = $this->createForm(new CustomerType(), $entity);
$editForm->bind($request);
if ($editForm->isValid()) {
$em->persist($entity);
$em->flush();
return $this->redirect($this->generateUrl('ticket_result'));
}
var_dump($editForm->getErrors());
return $this->render('AcmeDemoBundle:Customer:edit.html.twig', array(
'entity' => $entity,
'edit_form' => $editForm->createView(),
));
}
The var_dump returns an empty array but the validator sets a unique error and the $editForm->isValid() returns false. Is there a way to check for that specific error in the controller during validation, also can you explain why it returns an empty error array? Basically, I would like to provide the "merge" option if that error comes up.
EDIT: here is the formtype:
namespace Acme\DemoBundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;
class CustomerType extends AbstractType {
public function buildForm(FormBuilderInterface $builder, array $options) {
$builder
->add('email', 'email', array('required'=>true))
;
}
public function setDefaultOptions(OptionsResolverInterface $resolver) {
$resolver->setDefaults(array(
'data_class' => 'Acme\DemoBundle\Entity\Customer',
'cascade_validation' => true,
));
}
public function getName() {
return 'acme_demobundle_customertype';
}
}
And the twig template:
{% extends 'AcmeDemoBundle::layout.html.twig' %}
{% block body -%}
<h1>Customer edit</h1>
<form action="{{ path('customer_update', { 'id': entity.id }) }}" method="post" {{ form_enctype(edit_form) }}>
<input type="hidden" name="_method" value="PUT" />
{{ form_widget(edit_form) }}
<p>
<button type="submit">Edit</button>
</p>
</form>
{% endblock %}
Here is my validation:
Acme\DemoBundle\Entity\Customer:
constraints:
- Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity:
fields: email
message: "A customer under that email address already exists"
properties:
email:
- Email: ~
For debug purposes you can use $form->getErrorsAsString() instead of $form->getErrors() if you use Symfony 2.*
Quoted from this answer:
$form->getErrorsAsString() should only be used to debug the form...it
will contain the errors of each child elements which is not the case
of $form->getErrors().
UPDATE 1:
"With more recent Symfony versions, you must use $form->getErrors(true, false); instead. First param corresponds to deep and second to flatten" (see the comment by #Roubi)
Ok, found an answer here:
Symfony2 invalid form without errors
It turns out each form child has it's own separate errors. When doing a var_dump of
$editForm->getChildren()['email']->getErrors()
I get:
array (size=1)
0 =>
object(Symfony\Component\Form\FormError)[531]
private 'message' => string 'A customer under that email address already exists' (length=50)
protected 'messageTemplate' => string 'A customer under that email address already exists' (length=50)
protected 'messageParameters' =>
array (size=0)
empty
protected 'messagePluralization' => null
I am still wondering how to determine that the error is because of a unique conflict without parsing the error message string.
The following solutions works for me:
$form->getErrors(true)
You can use error_bubbling on each field to bubble the error up to your $form.
If not, you can also foreach through the errors
foreach ($children as $child) {
if ($child->hasErrors()) {
$vars = $child->createView()->getVars();
$errors = $child->getErrors();
foreach ($errors as $error) {
$this->allErrors[$vars["name"]][] = $this->convertFormErrorObjToString($error);
}
}
}
In Symfony 2.3, you can use this one :
if ($form->isValid()){
# Code...
} else {
foreach ($form->getIterator() as $key => $child) {
if ($child instanceof Form) {
foreach ($child->getErrors() as $error) {
$errors[$key] = $error->getMessage();
}
}
}
}
This will get you an array ($errors) with the errors from the children.
You could try to use the dump function when the form is submited and not valid. I use it like this
if($form->isSubmited() && $form->isValid()){
//SAVE TO DATABASE AND DO YOUR STUFF
}else if($form->isSubmited()){
//SUBMITED BUT WITH ERRORS
dump($form->getErrors(true));
die();
}
Note this is for debugging purposes only, It will show you your form, the data in it and all the errors any field could have.
In production mode you should return the error to the view and show them to the user.