Symfony - forms with variable number of fields - forms

I want to create form, that serve for adding, editing (and removing when url field is empty) menu items. Problem is that the count of rows/items are variable. (As you can see on the first picture)
Questions:
1)How to write a form that has variable number of fields.
2)How to parse data into the fields at this form.
class GalleryType extends AbstractType {
public function buildForm(FormBuilderInterface $builder, array $options) {
$builder->add(
//...some textType, fileType fields,...etc
);
}
public function configureOptions(OptionsResolver $resolver) {
$resolver->setDefaults([
//...
//some data/data_class option that parse data into the field
]);
}
Extra Information:
I am working on own simple content management system with Symfony 3 framework. I want to allow user to add menu item with information like: URL, Title and for instance FA icon, background image,..etc .
-There is always one empty row for adding item and the rest of fields are fulfilled with existing data (menu item/s). When you confirm the form, this row is added into the form (and empty row as well).
-There are few different kind of menu: main menu, slider, side menu, that has diferent type of fields. (you can see it on the second picture)
-Main menu has: title, url and some item can have children items (as sub menu)
-Slider has: title, url, color of title, background image
-Side menu has: title, url and Font Awesome Icon
I have already done form for navigation menu (footer) where is just 2 fields(title and link), but I feel this is not propertly way how to programming it... for illustrative purposes here is how I've done navigation
Controller:
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use AppBundle\Entity\SMBundle\Navigation;
use AppBundle\Entity\Sett;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\GetSetMethodNormalizer;
use Symfony\Component\Form\Extension\Core\Type\TextType;
class SettingsController extends Controller {
//....
/**
* #Route("/admin/menu/navigation", name="navigation")
*/
public function navigationAction(Request $request) {
$set = $this->getDoctrine()->getRepository('AppBundle:Sett')->findOneByName('navigation');
$navigation = $this->deserializeFromStringToObject('navigation');
if (!$navigation) {
$set = new Sett();
$navigation = new Navigation();
}
$form = $this->createFormFromArray($navigation->getLinksArray());
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$set->setEditedAt(new \DateTime());
$set->setName('navigation');
$this->brutalHack($navigation, $form);
$set->setContent($this->serializeFromObjectToString($navigation));
// Save
$this->save($set);
return $this->redirect($this->generateUrl('navigation'));
}
return $this->render("viewSM/menu/navigation.html.twig", array('form' => $form->createView()));
}
private function deserializeFromStringToObject($name) {
$object = $this->getDoctrine()->getRepository('AppBundle:Sett')->findOneByName($name);
if (!$object) {
return null;
}
$serializer = new Serializer(array(new GetSetMethodNormalizer()), array('json' => new JsonEncoder()));
return $serializer->deserialize($object->getContent(), 'AppBundle\\Entity\\SMBundle\\' . ucfirst($name), 'json');
}
private function serializeFromObjectToString($object) {
$serializer = new Serializer(array(new GetSetMethodNormalizer()), array('json' => new JsonEncoder()));
return $serializer->serialize($object, 'json');
}
private function createFormFromArray(array $collection) {
$i = 0;
$formBuilder = $this->createFormBuilder();
foreach ($collection as $key => $value) {
$formBuilder
->add('url' . $i, TextType::class, ['label' => 'URL ', 'data' => '' . $key, 'attr' => ['class' => 'form-control']])
->add('name' . $i, TextType::class, ['label' => 'Titulek ', 'data' => '' . $value, 'attr' => ['class' => 'form-control']]);
$i++;
}
$formBuilder
->add('url' . $i, TextType::class, ['label' => 'URL ', 'attr' => ['class' => 'form-control']])
->add('name' . $i, TextType::class, ['label' => 'Titulek ', 'attr' => ['class' => 'form-control']])
->add('submit', \Symfony\Component\Form\Extension\Core\Type\SubmitType::class, ['label' => 'Uložit', 'attr' => ['class' => 'btn btn-primary']]);
$form = $formBuilder->getForm();
return $form;
}
private function save($set) {
$em = $this->getDoctrine()->getManager();
$em->persist($set);
$em->flush();
}
private function brutalHack($navigation, $form) {
$nav = array();
if (count($navigation->getLinksArray()) == 0) {
$nav[$form['url0']->getData()] = $form['name0']->getData();
}
for ($i = 0; $i < count($navigation->getLinksArray()); $i++) {
$key = $form['url' . $i]->getData();
$value = $form['name' . $i]->getData();
if ($key != NULL && $value != NULL) {
$nav[$key] = $value;
}
}
$key = $form['url' . $i]->getData();
$value = $form['name' . $i]->getData();
if ($key != NULL && $value != NULL) {
$nav[$key] = $value;
}
$navigation->setLinksArray($nav);
}
//...
}
Entity:
use Doctrine\ORM\Mapping as ORM;
/**
* #ORM\Entity
* #ORM\Table(name="sett")
* #ORM\HasLifecycleCallbacks
*/
class Sett
{
/**
* #ORM\Id
* #ORM\Column(type="integer")
* #ORM\GeneratedValue(strategy="AUTO")
*/
private $id;
/**
* #ORM\Column(name="name", length=255)
*/
private $name;
/**
* #ORM\Column(name="content", type="json_array")
*/
private $content;
/**
* #ORM\Column(name="edited_at", type="datetime")
*/
private $editedAt;
/**
* #ORM\Column(name="created_at", type="datetime")
*/
private $createdAt;
/**
* #ORM\PrePersist
*/
public function onPrePersist()
{
$this->createdAt = new \DateTime();
}
/**
* Get id
*
* #return integer
*/
public function getId()
{
return $this->id;
}
/**
* Set name
*
* #param string $name
*
* #return Set
*/
public function setName($name)
{
$this->name = $name;
return $this;
}
/**
* Get name
*
* #return string
*/
public function getName()
{
return $this->name;
}
/**
* Set content
*
* #param array $content
*
* #return Set
*/
public function setContent($content)
{
$this->content = $content;
return $this;
}
/**
* Get content
*
* #return array
*/
public function getContent()
{
return $this->content;
}
/**
* Set editedAt
*
* #param \DateTime $editedAt
*
* #return Set
*/
public function setEditedAt($editedAt)
{
$this->editedAt = $editedAt;
return $this;
}
/**
* Get editedAt
*
* #return \DateTime
*/
public function getEditedAt()
{
return $this->editedAt;
}
/**
* Set createdAt
*
* #param \DateTime $createdAt
*
* #return Set
*/
public function setCreatedAt($createdAt)
{
$this->createdAt = $createdAt;
return $this;
}
/**
* Get createdAt
*
* #return \DateTime
*/
public function getCreatedAt()
{
return $this->createdAt;
}
}
Data class:
class Navigation
{
private $linksArray;
public function __construct() {
$this->linksArray=array();
}
function getLinksArray() {
return $this->linksArray;
}
function setLinksArray($linksArray) {
$this->linksArray = $linksArray;
}
function add($key,$value){
$this->linksArray[$key]=$value;
}
}

I am not sure if this will work but you should give it a try.
2)How to parse data into the form that has variable number of fields.
You can send the data as form $options.
in your controller
$oForm = $this->createForm(YourFormType::class,
$FormObject, [
'your_options' => [
'Checkbox' => 'FieldName1',
'TextArea' => 'FieldName2'
]);
in your form
public function buildForm(FormBuilderInterface $builder, array $options)
{
foreach($options['your_options'] as $key, $option) { //you can name $option as $filedName or whatever you find convenient
$builder->add($option, $key.Type::class);
}
...}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'your_options' => null
])
}

Related

Symfony 4 form: three dynamic select box

Hello I am using Symfony 4.
I have managed to link up to two select box with form events, but I need to have three dynamic select box.
This is the relation between my entities:
Country -> Province -> City.
These are linked to a Person entity like this
When I add a new person I should be able to select a Country and have the Province dropdown updated in accordance to Country selection; same thing for the City dropdown after I have selected a Province.
I have made things working for Country and Province following the official Symfony guide here
https://symfony.com/doc/current/form/dynamic_form_modification.html#dynamic-generation-for-submitted-forms
How should I manage adding the third dropdown?
This is my Country entity:
<?php
namespace App\Entity\Geo;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* #ORM\Entity(repositoryClass="App\Repository\Geo\CountryRepository")
*/
class Country
{
/**
* #ORM\Id()
* #ORM\GeneratedValue()
* #ORM\Column(type="integer")
*/
private $id;
/**
* #ORM\Column(type="string", length=255)
* #Assert\NotBlank()
*/
private $name;
/**
* #ORM\OneToMany(targetEntity="App\Entity\Geo\Province", mappedBy="country")
* #ORM\JoinColumn(nullable=false)
*/
private $provinces;
/**
* #ORM\OneToMany(targetEntity="App\Entity\Geo\City", mappedBy="country")
* #ORM\JoinColumn(nullable=false)
*/
private $cities;
/**
* #ORM\OneToMany(targetEntity="App\Entity\Geo\Person", mappedBy="country")
* #ORM\JoinColumn(nullable=false)
*/
private $persons;
/**
* #return mixed
*/
public function getId()
{
return $this->id;
}
/**
* #return mixed
*/
public function getName()
{
return $this->name;
}
/**
* #param mixed $name
*/
public function setName($name): void
{
$this->name = $name;
}
/**
* #return mixed
*/
public function getProvinces()
{
return $this->provinces;
}
/**
* #param mixed $provinces
*/
public function setProvinces($provinces): void
{
$this->provinces = $provinces;
}
/**
* #return mixed
*/
public function getCities()
{
return $this->cities;
}
/**
* #param mixed $cities
*/
public function setCities($cities): void
{
$this->cities = $cities;
}
/**
* #return mixed
*/
public function getPersons()
{
return $this->persons;
}
/**
* #param mixed $persons
*/
public function setPersons($persons): void
{
$this->persons = $persons;
}
}
This is my Province entity:
<?php
namespace App\Entity\Geo;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* #ORM\Entity(repositoryClass="App\Repository\Geo\ProvinceRepository")
*/
class Province
{
/**
* #ORM\Id()
* #ORM\GeneratedValue()
* #ORM\Column(type="integer")
*/
private $id;
/**
* #ORM\Column(type="string", length=255)
* #Assert\NotBlank()
*/
private $name;
/**
* #ORM\ManyToOne(targetEntity="App\Entity\Geo\Country", inversedBy="provinces")
* #ORM\JoinColumn(nullable=false)
*/
private $country;
/**
* #ORM\OneToMany(targetEntity="App\Entity\Geo\City", mappedBy="province")
* #ORM\JoinColumn(nullable=false)
*/
private $cities;
/**
* #ORM\OneToMany(targetEntity="App\Entity\Geo\Person", mappedBy="province")
* #ORM\JoinColumn(nullable=false)
*/
private $persons;
public function __toString() {
return $this->name;
}
/**
* #return mixed
*/
public function getId()
{
return $this->id;
}
/**
* #return mixed
*/
public function getName()
{
return $this->name;
}
/**
* #param mixed $name
*/
public function setName($name): void
{
$this->name = $name;
}
/**
* #return mixed
*/
public function getCountry()
{
return $this->country;
}
/**
* #param mixed $country
*/
public function setCountry($country): void
{
$this->country = $country;
}
/**
* #return mixed
*/
public function getCities()
{
return $this->cities;
}
/**
* #param mixed $cities
*/
public function setCities($cities): void
{
$this->cities = $cities;
}
/**
* #return mixed
*/
public function getPersons()
{
return $this->persons;
}
/**
* #param mixed $persons
*/
public function setPersons($persons): void
{
$this->persons = $persons;
}
}
This is my City entity:
<?php
namespace App\Entity\Geo;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* #ORM\Entity(repositoryClass="App\Repository\Geo\CityRepository")
*/
class City
{
/**
* #ORM\Id()
* #ORM\GeneratedValue()
* #ORM\Column(type="integer")
*/
private $id;
/**
* #ORM\Column(type="string", length=255)
* #Assert\NotBlank()
*/
private $name;
/**
* #ORM\ManyToOne(targetEntity="App\Entity\Geo\Province", inversedBy="cities")
* #ORM\JoinColumn(nullable=false)
*/
private $province;
/**
* #ORM\ManyToOne(targetEntity="App\Entity\Geo\Country", inversedBy="cities")
* #ORM\JoinColumn(nullable=false)
*/
private $country;
/**
* #ORM\OneToMany(targetEntity="App\Entity\Geo\Person", mappedBy="city")
* #ORM\JoinColumn(nullable=false)
*/
private $persons;
/**
* #return mixed
*/
public function getId()
{
return $this->id;
}
/**
* #return mixed
*/
public function getName()
{
return $this->name;
}
/**
* #param mixed $name
*/
public function setName($name): void
{
$this->name = $name;
}
/**
* #return mixed
*/
public function getProvince()
{
return $this->province;
}
/**
* #param mixed $province
*/
public function setProvince($province): void
{
$this->province = $province;
}
/**
* #return mixed
*/
public function getCountry()
{
return $this->country;
}
/**
* #param mixed $country
*/
public function setCountry($country): void
{
$this->country = $country;
}
/**
* #return mixed
*/
public function getPersons()
{
return $this->persons;
}
/**
* #param mixed $persons
*/
public function setPersons($persons): void
{
$this->persons = $persons;
}
}
This is my form to add a Person (PersonType.php)
<?php
namespace App\Form\Geo;
use App\Entity\Geo\Person;
use App\Entity\Geo\Country;
use App\Entity\Geo\Province;
use App\Entity\Geo\City;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
class PersonType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('name', TextType::class, ['label' => "Name"])
->add('country', EntityType::class, [
'class' => Country::class,
'choice_label' => function(Country $country) {
return $country->getName();
},
'placeholder' => 'Choose a Country'
])
;
$formModifier = function (FormInterface $form, Country $country = null) {
$provinces = null === $country ? [] : $country->getProvinces();
$form->add('province', EntityType::class, [
'class' => Province::class,
'placeholder' => 'Choose a Province',
'choices' => $provinces,
]);
};
$builder->addEventListener(
FormEvents::PRE_SET_DATA,
function (FormEvent $event) use ($formModifier) {
$data = $event->getData();
$formModifier($event->getForm(), $data->getCountry());
}
);
$builder->get('country')->addEventListener(
FormEvents::POST_SUBMIT,
function (FormEvent $event) use ($formModifier) {
$country = $event->getForm()->getData();
$formModifier($event->getForm()->getParent(), $country);
}
);
$builder->add( 'save', SubmitType::class);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' =>Person::class
]);
}
}
This is my twig template (person-add.html.twig)
{% extends 'base.html.twig' %}
{% block title %}Add Person{% endblock %}
{% block body %}
{{ form_start(form) }}
{{ form_row(form.name) }}
{{ form_row(form.country) }}
{{ form_row(form.province) }}
{{ form_end(form) }}
<script>
$(document).ready(function() {
var $country = $('#person_country');
// When sport gets selected ...
$country.change(function () {
// ... retrieve the corresponding form.
var $form = $(this).closest('form');
// Simulate form data, but only include the selected sport value.
var data = {};
data[$country.attr('name')] = $country.val();
// Submit data via AJAX to the form's action path.
$.ajax({
url: $form.attr('action'),
type: $form.attr('method'),
data: data,
success: function (html) {
// Replace current position field ...
$('#person_province').replaceWith(
// ... with the returned one from the AJAX response.
$(html).find('#person_province')
);
// Position field now displays the appropriate positions.
}
});
})
});
</script>
{% endblock %}
Thanks to this post I have managed to change my PersonType.php form file like this:
<?php
namespace App\Form\Geo;
use App\Entity\Geo\Person;
use App\Entity\Geo\Country;
use App\Entity\Geo\Province;
use App\Entity\Geo\City;
use App\Repository\Geo\CityRepository;
use App\Repository\Geo\ProvinceRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
class PersonType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('name', TextType::class)
//
->add('country', EntityType::class, [
'class' => Country::class,
'label' => 'Country',
'required' => true,
'choice_label' => function(Country $country) {
return $country->getName();
},
'invalid_message' => 'You must select a Country',
'placeholder' => 'Select Country',
]);
//**************** Start Province Form
$addProvinceForm = function (FormInterface $form, $country_id) {
// it would be easier to use a Park entity here,
// but it's not trivial to get it in the PRE_SUBMIT events
$form->add('province', EntityType::class, [
'class' => Province::class,
'label' => 'Province',
'required' => true,
'invalid_message' => 'Choose a Province',
'placeholder' => null === $country_id ? 'Choose a Country first' : 'Select Province',
'query_builder' => function (ProvinceRepository $repository) use ($country_id) {
return $repository->createQueryBuilder('p')
->innerJoin('p.country', 'c')
->where('c.id = :country')
->setParameter('country', $country_id)
;
}
]);
};
$builder->addEventListener(
FormEvents::PRE_SET_DATA,
function (FormEvent $event) use ($addProvinceForm) {
$country = $event->getData()->getCountry();
$country_id = $country ? $country->getId() : null;
$addProvinceForm($event->getForm(), $country_id);
}
);
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($addProvinceForm) {
$data = $event->getData();
$country_id = array_key_exists('country', $data) ? $data['country'] : null;
$addProvinceForm($event->getForm(), $country_id);
}
);
//**************** End Province Form
//**************** Start City Form
$addCityForm = function (FormInterface $form, $province_id) {
$form->add('city', EntityType::class, [
'class' => City::class,
'label' => 'City',
'required' => true,
'invalid_message' => 'You must choose a City',
'placeholder' => null === $province_id ? 'Choose a Province first' : 'Choose a City',
'query_builder' => function (CityRepository $repository) use ($province_id) {
return $repository->createQueryBuilder('ci')
->innerJoin('ci.province', 'pr')
->where('pr.id = :province')
->setParameter('province', $province_id)
;
}
]);
};
$builder->addEventListener(
FormEvents::PRE_SET_DATA,
function (FormEvent $event) use ($addCityForm) {
$province = $event->getData()->getProvince();
$province_id = $province ? $province->getId() : null;
$addCityForm($event->getForm(), $province_id);
}
);
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($addCityForm) {
$data = $event->getData();
$province_id = array_key_exists('province', $data) ? $data['province'] : null;
$addCityForm($event->getForm(), $province_id);
}
);
//**************** End City Form
$builder->add( 'save', SubmitType::class);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' =>Person::class
]);
}
}
The Province dropdown works as expected when you first select a Country.
The problem is the City dropdown: nothing changes after you select a Province.
If everything is ok with the query executed inside the PersonType.php file, I think I am doing something wrong with the javascript. Here's my code:
<script>
$(document).ready(function() {
var $country = $('#person_country');
var $province = $('#person_province');
// When country gets selected ...
$country.change(function () {
// ... retrieve the corresponding form.
var $form = $(this).closest('form');
// Simulate form data, but only include the selected country value.
var data = {};
data[$country.attr('name')] = $country.val();
// Submit data via AJAX to the form's action path.
$.ajax({
url: $form.attr('action'),
type: $form.attr('method'),
data: data,
success: function (html) {
// Replace current province field ...
$('#person_province').replaceWith(
// ... with the returned one from the AJAX response.
$(html).find('#person_province')
);
}
});
});
// When province gets selected ...
$province.change( function () {
// ... retrieve the corresponding form.
var $form = $(this).closest('form');
// Simulate form data, but only include the selected province value.
var data = {};
data[$province.attr('name')] = $province.val();
// Submit data via AJAX to the form's action path.
$.ajax({
url: $form.attr('action'),
type: $form.attr('method'),
data: data,
success: function (html) {
// Replace current city field ...
$('#person_city').replaceWith(
// ... with the returned one from the AJAX response.
$(html).find('#person_city')
);
}
});
});
});
</script>

automatching property_path in Symfony API

I'm having a REST-API built in Symfony3.
As an example here are the API-fields of Price in a form, made with the FormBuilderInterface. The code-example below is of ApiBundle/Form/PriceType.php
class PriceType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('name', TextType::class, array(
'description' => 'Name',
))
->add('price_category', EntityPublicKeyTextType::class, array(
'class' => 'MyCustomBundle:PriceCategory',
'property_path' => 'priceCategory',
))
The issue is about good response messages of fields which have e.g. a validation error. For default symfony-types (e.g. IntegerType, TextType) it can find the property_path automatically and hands me out an useful error message. Here is the API-response with two errors:
name can be resolved in a good way (because I see what field it is about,
for price_category it can't resolve it (second message).
{
"name": [
"This value is too long. It should have 50 characters or less."
],
"0": "This value should not be null."
}
To resolve the issue. I add 'property_path' => 'priceCategory' for the field price_category. The value of property_path is matching with BaseBundle/Entity/Price.php where the var protected $priceCategory; is defined.
After adding property_path the error message looks fine.
{
"name": [
"This value is too long. It should have 50 characters or less."
],
"price_category": [
"This value should not be null."
]
}
The class of price_category is EntityPublicKeyTextType which is abstracted from TextType (which can do errors just fine).
Therefore I have the following question: What do i have to add to my inherited class EntityPublicKeyTextType to avoid adding the property_path for all fields by hand?
Any hint to fix this is highly welcome
Best endo
EDIT:
EntityPublicKeyTextType:
class EntityPublicKeyTextType extends AbstractType
{
/**
* #var ObjectManager
*/
private $om;
/**
* #param ObjectManager $om
*/
public function __construct(ObjectManager $om)
{
$this->om = $om;
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
$transformer = new ObjectToPublicKeyTransformer(
$this->om,
$options['class'],
$options['public_key'],
$options['remove_whitespaces'],
$options['multiple'],
$options['string_separator'],
$options['extra_find_by']
);
$builder->addModelTransformer($transformer);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver
->setRequired(array(
'class',
'public_key'
))
->setDefaults(array(
'multiple' => false,
'string_separator' => false,
'extra_find_by' => array(),
'remove_whitespaces' => true,
));
}
public function getParent()
{
return TextType::class;
}
public function getBlockPrefix()
{
return 'entity_public_key_text';
}
}
ObjectToPublicKeyTransformer:
class ObjectToPublicKeyTransformer implements DataTransformerInterface
{
/**
* #var PropertyAccessorInterface
*/
private $propertyAccessor;
/**
* #var ObjectManager
*/
private $om;
/**
* #var string
*/
private $class;
/**
* #var string|string[]
*/
private $publicKey;
/**
* #var bool
*/
private $removeWhitespaces;
/**
* #var boolean
*/
private $multiple;
/**
* #var boolean|string
*/
private $stringSeparator;
/**
* #var array
*/
private $extraFindBy;
public function __construct(
ObjectManager $om,
string $class,
$publicKey,
bool $removeWhitespaces,
bool $multiple = false,
$stringSeparator = false,
array $extraFindBy = array(),
PropertyAccessorInterface $propertyAccessor = null
) {
$this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor();
$this->om = $om;
$classMetadata = $om->getClassMetadata($class);
$this->class = $classMetadata->getName();
$this->publicKey = $publicKey;
$this->stringSeparator = $stringSeparator;
$this->multiple = $multiple;
$this->extraFindBy = $extraFindBy;
$this->removeWhitespaces = $removeWhitespaces;
}
/**
* Transforms an object / Collection of objects to a publicKey string / array of publicKey strings.
*
* #param object|Collection $object
* #return string|array
*/
public function transform($object)
{
if (null == $object) {
return null;
}
if (is_array($this->publicKey)) {
$publicKey = $this->publicKey[0];
} else {
$publicKey = $this->publicKey;
}
if ($this->multiple) {
if ($object instanceof Collection) {
$values = array();
foreach ($object as $objectItem) {
$values[] = (string)$this->propertyAccessor->getValue($objectItem, $publicKey);
}
if ($this->stringSeparator) {
return implode($this->stringSeparator, $values);
}
return $values;
}
} else {
return (string)$this->propertyAccessor->getValue($object, $publicKey);
}
}
/**
* Transforms an publicKey string / array of public key strings to an object / Collection of objects.
*
* #param string|array $value
* #return object|Collection
*
* #throws TransformationFailedException if object is not found.
*/
public function reverseTransform($value)
{
if (null === $value) {
return $this->multiple ? new ArrayCollection() : null;
}
if (is_array($this->publicKey)) {
$publicKeys = $this->publicKey;
} else {
$publicKeys = array($this->publicKey);
}
if ($this->multiple) {
if ($this->stringSeparator) {
$value = explode($this->stringSeparator, $value);
}
if (is_array($value)) {
$objects = new ArrayCollection();
foreach ($value as $valueItem) {
foreach ($publicKeys as $publicKey) {
$object = $this->findObject($valueItem, $publicKey);
if ($object instanceof $this->class) {
$objects->add($object);
break;
}
}
}
return $objects;
}
}
foreach ($publicKeys as $publicKey) {
$object = $this->findObject($value, $publicKey);
if ($object instanceof $this->class) {
return $object;
}
}
return $this->multiple ? new ArrayCollection() : null;
}
private function findObject($value, $publicKey)
{
if ($this->removeWhitespaces) {
$value = str_replace(' ', '', $value);
}
$findBy = array_merge([$publicKey => $value], $this->extraFindBy);
$object = $this->om->getRepository($this->class)->findOneBy($findBy);
return $object;
}
}
It would be useful if you also provide your Price model/entity class. It seems that you are using camel case for the property name in your model (priceCategory) and then you use snake case in your form (price_category).
If you use the same convention for the model and the form, the validation errors will automatically map to the correct property.
The explanation is that Symfony's mappers can still map your fields by transforming snake to camel case and vice versa, that's why your form is still working and submitting values even without using the property_path option. But the problem is that the validator does not do this mapping and cannot match the correct property (price_category -> priceCategory).

How to unit test Symfony 4 Form with EntityType field

How to unit test Symfony 4 Form with EntityType field
When I run my test:
$ ./vendor/bin/simple-phpunit tests/Unit/Form/ProductFormTest.php
This is the output in my terminal:
PHPUnit 6.5.8 by Sebastian Bergmann
and contributors.
Runtime: PHP 7.2.4-1+ubuntu16.04.1+deb.sury.org+1 with Xdebug
2.7.0alpha2-dev Configuration: /var/www/project/phpunit.xml.dist
Testing App\Tests\Unit\Form\ProductFormTest E
1 / 1 (100%)
Time: 551 ms, Memory: 6.00MB
There was 1 error:
1) App\Tests\Unit\Form\ProductFormTest::formSubmitsValidData
Symfony\Component\Form\Exception\RuntimeException: Class
"App\Entity\Supplier" seems not to be a managed Doctrine entity. Did
you forget to map it?
/var/www/project/vendor/symfony/doctrine-bridge/Form/Type/DoctrineType.php:205
/var/www/project/vendor/symfony/options-resolver/OptionsResolver.php:858
/var/www/project/vendor/symfony/doctrine-bridge/Form/Type/DoctrineType.php:130
/var/www/project/vendor/symfony/options-resolver/OptionsResolver.php:766
/var/www/project/vendor/symfony/options-resolver/OptionsResolver.php:698
/var/www/project/vendor/symfony/form/ResolvedFormType.php:95
/var/www/project/vendor/symfony/form/FormFactory.php:76
/var/www/project/vendor/symfony/form/FormBuilder.php:97
/var/www/project/vendor/symfony/form/FormBuilder.php:256
/var/www/project/vendor/symfony/form/FormBuilder.php:206
/var/www/project/vendor/symfony/form/FormFactory.php:30
/var/www/project/tests/Unit/Form/ProductFormTest.php:86
ERRORS! Tests: 1, Assertions: 0, Errors: 1.
This error started after mocking the ManagerRegistry class. It seems that in this unit test there is no mapping for doctrine entities present.
Is there a clean way to test a form with "Symfony\Bridge\Doctrine\Form\Type\EntityType" fields?
src\App\Entity\Product.php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use App\Entity\Supplier;
/**
* Product Entity
*
* #ORM\Entity(repositoryClass = "App\Repository\ProductRepository")
* #ORM\Table(name = "product")
*/
class Product
{
/**
* Constructor
*/
public function __construct()
{
parent::__construct();
$this->setType(AbstractProduct::TYPE_PARENT);
}
/**
* To String
*
* #return string
*/
public function __toString()
{
return "[" . $this->id . "] Product: " . $this->ean . " | " . $this->name;
}
/**
* ID
*
* #var integer
*
* #ORM\Id
* #ORM\Column(name = "product_id", type = "integer")
* #ORM\GeneratedValue(strategy = "AUTO")
*/
protected $id;
/**
* EAN (European Article Number)
*
* #var string
*
* #ORM\Column(name = "product_ean", type = "string", length = 13)
*/
protected $ean;
/**
* Name
*
* #var string
*
* #ORM\Column(name = "product_name", type = "string", length = 128)
*/
protected $name;
/**
* Description
*
* #var string
*
* #ORM\Column(name = "product_description", type = "text", nullable = true)
*/
protected $description;
/**
* Supplier
*
* Many Products have one Supplier
*
* #var Supplier
*
* #ORM\ManyToOne(targetEntity = "Supplier", inversedBy = "products")
* #ORM\JoinColumn(name = "supplier_id", referencedColumnName = "supplier_id")
*/
protected $supplier;
/**
* Get id
*
* #return integer
*/
public function getId()
{
return $this->id;
}
/**
* Set ean
*
* #param string $ean
*
* #return AbstractProduct
*/
public function setEan($ean)
{
$this->ean = $ean;
return $this;
}
/**
* Get ean
*
* #return string
*/
public function getEan()
{
return $this->ean;
}
/**
* Set name
*
* #param string $name
*
* #return AbstractProduct
*/
public function setName($name)
{
$this->name = $name;
return $this;
}
/**
* Get name
*
* #return string
*/
public function getName()
{
return $this->name;
}
/**
* Set description
*
* #param string $description
*
* #return AbstractProduct
*/
public function setDescription($description)
{
$this->description = $description;
return $this;
}
/**
* Get description
*
* #return string
*/
public function getDescription()
{
return $this->description;
}
/**
* Set supplier
*
* #param \App\Entity\Supplier $supplier
*
* #return Product
*/
public function setSupplier(Supplier $supplier = null)
{
$this->supplier = $supplier;
return $this;
}
/**
* Get supplier
*
* #return \App\Entity\Supplier
*/
public function getSupplier()
{
return $this->supplier;
}
}
src\App\Form\ProductForm.php
namespace App\Form;
use App\Entity\Supplier;
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;
class ProductForm extends AbstractType
{
/**
* {#inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$translationDomain = "product";
/*
* Card
*/
$builder->add("ean", TextType::class, [
"label" => "product.ean",
"required" => true,
"translation_domain" => $translationDomain,
]);
$builder->add("name", TextType::class, [
"label" => "product.name",
"required" => true,
"translation_domain" => $translationDomain,
]);
$builder->add("supplier", EntityType::class, [
"class" => Supplier::class,
"choice_label" => "name",
"label" => "supplier.name",
"required" => false,
"translation_domain" => "supplier",
]);
}
}
tests\Unit\Form\ProductFormTest.php
namespace App\Tests\Unit\Form;
use App\Entity\Product;
use App\Form\ProductForm;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Bridge\Doctrine\ManagerRegistry;
use Symfony\Component\Form\PreloadedExtension;
use Symfony\Component\Form\Test\TypeTestCase;
class ProductFormTest extends TypeTestCase
{
/**
* #var ManagerRegistry
*/
private $_managerRegistry;
/**
* {#inheritdoc}
*/
protected function setUp()
{
$this->_managerRegistry = $this->createMock(ManagerRegistry::class);
parent::setUp();
}
/**
* {#inheritdoc}
*/
protected function tearDown()
{
$this->_managerRegistry = null;
parent::tearDown();
}
/**
* {#inheritdoc}
*/
protected function getExtensions()
{
$entityType = new EntityType($this->_managerRegistry);
return [
new PreloadedExtension([$entityType], [])
];
}
/**
* #test
*/
public function formSubmitsValidData()
{
$createdAt = new \DateTime();
$formData = [
"ean" => "8718923400440",
"name" => "Plumbus",
"description" => "This is a household device so common it does not need an introduction",
];
$productComparedToForm = new Product();
$productComparedToForm
->setEan($formData["ean"])
->setName($formData["name"])
;
$productHandledByForm = new Product();
$form = $this->factory->create(ProductForm::class, $productHandledByForm);
$form->submit($formData);
static::assertTrue($form->isSynchronized());
static::assertEquals($productComparedToForm, $productHandledByForm);
$view = $form->createView();
foreach (array_keys($formData) as $key) {
static::assertArrayHasKey($key, $view->children);
}
}
}
First your test case should extends from Symfony\Component\Form\Test\TypeTestCase.
Then your test should look like this:
// Example heavily inspired by EntityTypeTest inside the Symfony Bridge
class ProductTypeTest extends TypeTestCase
{
/**
* #var EntityManager
*/
private $em;
/**
* #var \PHPUnit_Framework_MockObject_MockObject|ManagerRegistry
*/
private $emRegistry;
protected function setUp()
{
$this->em = DoctrineTestHelper::createTestEntityManager();
$this->emRegistry = $this->createRegistryMock('default', $this->em);
parent::setUp();
$schemaTool = new SchemaTool($this->em);
// This is the important part for you !
$classes = [$this->em->getClassMetadata(Supplier::class)];
try {
$schemaTool->dropSchema($classes);
} catch (\Exception $e) {
}
try {
$schemaTool->createSchema($classes);
} catch (\Exception $e) {
}
}
protected function createRegistryMock($name, $em)
{
$registry = $this->getMockBuilder('Doctrine\Common\Persistence\ManagerRegistry')->getMock();
$registry->expects($this->any())
->method('getManager')
->with($this->equalTo($name))
->will($this->returnValue($em));
return $registry;
}
protected function getExtensions()
{
return array_merge(parent::getExtensions(), array(
new DoctrineOrmExtension($this->emRegistry),
));
}
protected function tearDown()
{
parent::tearDown();
$this->em = null;
$this->emRegistry = null;
}
}
another solution if the one above does not work. Example on another application.
namespace App\Tests\Form;
use App\Entity\BusinessDepartment;
use App\Entity\Contact;
use App\Form\ContactForm;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Bridge\Doctrine\Form\DoctrineOrmExtension;
use Symfony\Component\Form\Test\TypeTestCase;
class ContactFormTest extends TypeTestCase
{
protected function getExtensions() {
$mockEntityManager = $this->createMock(EntityManager::class);
$mockEntityManager->method('getClassMetadata')
->willReturn(new ClassMetadata(BusinessDepartment::class))
;
$execute = $this->createMock(AbstractQuery::class);
$execute->method('execute')
->willReturn([]);
$query = $this->createMock(QueryBuilder::class);
$query->method('getQuery')
->willReturn($execute);
$entityRepository = $this->createMock(EntityRepository::class);
$entityRepository->method('createQueryBuilder')
->willReturn($query)
;
$mockEntityManager->method('getRepository')->willReturn($entityRepository);
$mockRegistry = $this->createMock(ManagerRegistry::class);
$mockRegistry->method('getManagerForClass')
->willReturn($mockEntityManager)
;
return array_merge(parent::getExtensions(), [new DoctrineOrmExtension($mockRegistry)]);
}
public function testBuildForm()
{
$data = [
'name' => 'nameTest',
'firstName' => 'firstnameTest',
'email' => 'test_email#gmail.com',
'message' => 'messageTest'
];
$contact = new Contact();
$form = $this->factory->create( ContactForm::class, $contact);
$contactToCompare = new Contact();
$contactToCompare->setName($data['name']);
$contactToCompare->setFirstName($data['firstName']);
$contactToCompare->setEmail($data['email']);
$contactToCompare->setMessage($data['message']);
//check the submission
$form->submit($data);
$this->assertTrue($form->isSynchronized());
$this->assertEquals($contact->getName(), $contactToCompare->getName());
$this->assertEquals($contact->getFirstName(), $contactToCompare->getFirstName());
$this->assertEquals($contact->getEmail(), $contactToCompare->getEmail());
$this->assertEquals($contact->getMessage(), $contactToCompare->getMessage());
}
}
another solution if the one above does not work. Example on another application.
ContactFromTest.php
namespace App\Tests\Form;
use App\Entity\BusinessDepartment;
use App\Entity\Contact;
use App\Form\ContactForm;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Bridge\Doctrine\Form\DoctrineOrmExtension;
use Symfony\Component\Form\Test\TypeTestCase;
class ContactFormTest extends TypeTestCase
{
protected function getExtensions() {
$mockEntityManager = $this->createMock(EntityManager::class);
$mockEntityManager->method('getClassMetadata')
->willReturn(new ClassMetadata(BusinessDepartment::class))
;
$execute = $this->createMock(AbstractQuery::class);
$execute->method('execute')
->willReturn([]);
$query = $this->createMock(QueryBuilder::class);
$query->method('getQuery')
->willReturn($execute);
$entityRepository = $this->createMock(EntityRepository::class);
$entityRepository->method('createQueryBuilder')
->willReturn($query)
;
$mockEntityManager->method('getRepository')->willReturn($entityRepository);
$mockRegistry = $this->createMock(ManagerRegistry::class);
$mockRegistry->method('getManagerForClass')
->willReturn($mockEntityManager)
;
return array_merge(parent::getExtensions(), [new DoctrineOrmExtension($mockRegistry)]);
}
public function testBuildForm()
{
$data = [
'name' => 'nameTest',
'firstName' => 'firstnameTest',
'email' => 'test_email#gmail.com',
'message' => 'messageTest'
];
$contact = new Contact();
$form = $this->factory->create( ContactForm::class, $contact);
$contactToCompare = new Contact();
$contactToCompare->setName($data['name']);
$contactToCompare->setFirstName($data['firstName']);
$contactToCompare->setEmail($data['email']);
$contactToCompare->setMessage($data['message']);
//check the submission
$form->submit($data);
$this->assertTrue($form->isSynchronized());
$this->assertEquals($contact->getName(), $contactToCompare->getName());
$this->assertEquals($contact->getFirstName(), $contactToCompare->getFirstName());
$this->assertEquals($contact->getEmail(), $contactToCompare->getEmail());
$this->assertEquals($contact->getMessage(), $contactToCompare->getMessage());
}
}
ContacForm.php
class ContactForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('name',TextType::class, [
'label' => 'Nom'
])
->add('firstName',TextType::class, [
'label' => 'Prénom'
])
->add('email', EmailType::class, [
'label' => 'Email'
])
->add('businessDepartment', EntityType::class, [
'label' => 'Département à contacter',
'class' => BusinessDepartment::class,
'choice_value' => 'id',
'choice_label' => 'nameDepartment',
])
->add('message', TextareaType::class, [
'label' => 'Votre message'
])
;
}
}

#Assert\Valid() on Entity, remove the validation on form

I been searching online, couldn't find the answer to my problem.
I want to disable #Assert/Valid() on first field, if second field is selected by the user. right now validation is happening on both fields.
Form type
AppBundle/Form/ParcelType.php
class ParcelType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$user = 1;
$builder
//TODO if address is selected from history, then dont validate this field
->add('pickupAddressNew', new AddressType())
->add('pickupAddressHistory', 'entity', [
'class' => 'AppBundle\Entity\Address',
'property' => 'formatAddress',
'query_builder' => function (EntityRepository $er) use ($user) {
return $er->createQueryBuilder('a')
->where('a.user = :user')
->andWhere('a.type = :type')
->setParameter('user', $user)
->setParameter('type', 'pickup')
->orderBy('a.isDefault', 'DESC')
->addOrderBy('a.id', 'DESC');
}
]););
}
public function getName()
{
return 'parcel';
}
}
AppBundle/Entity/Model/Parcel.php
class Parcel
{
protected $name;
/**
* #Assert\Type(type="AppBundle\Entity\Address")
* #Assert\Valid()
*/
protected $pickupAddressNew;
/**
* #Assert\Type(type="AppBundle\Entity\Address")
* #Assert\Valid()
*/
protected $pickupAddressHistory;
...
}
Address
AppBundle/Entity/Address.php
class Address
{
...
private $id;
..
private $firstName;
/**
* #var string
*
* #Assert\NotBlank(message="field.address.blank")
* #Assert\Length(
* min = 3,
* max = 255,
* minMessage = "field.address.min",
* maxMessage = "field.address.max"
* )
* #ORM\Column(name="format_address", type="string", length=255, nullable=false)
*/
private $address;
}
After long search, I couldn't find any answer, but found another solution which will solve it. Sharing with community, so others can solve it quickly.
Remove #Assert/Valid() from the annotation and add following on the form type
public function buildForm(...) {
...
$form->add('pickupAddressNew', new AddressType(), [
'label' => 'form.label.pickupAddressNew',
'constraints' => new Valid()
])
// also add event listener
$builder->addEventListener(FormEvents::SUBMIT, array($this, 'conditionValid'));
}
now create condition valid method on same formType class.
public function conditionValid (FormEvent $event)
{
$parcel = $event->getData();
$form = $event->getForm();
if ($parcel->getPickupAddressHistory() > 0)
{
$form->add('pickupAddressNew', new AddressType(), [
'label' => 'form.label.pickupAddress'
]);
}
}
On this method, we check if second field has value and its selected, then recreate the first field without the validation rule, this will bypass the group validation.

How to set a default value in a Symfony 2 form field?

I've been trying to set up a form with Symfony 2.
So I followed the tutorial and I've created a special class for creating the form and handling the validation process outside the controller (as shown in the documentation)
But now I need to fill in a field automatically, I've heard that I have to do it in the ProductType.php, where the form (for my product) is created.
But I don't know how to do, here is my buildForm function in ProductType.php :
class QuotesType extends AbstractType
{
private $id;
public function __construct($id){
$this->product_id = $id;
}
public function buildForm(FormBuilder $builder, array $options)
{
$builder
->add('user_name', 'text')
->add('user_lastname', 'text')
->add('user_email', 'email')
->add('user_comments', 'textarea')
->add('user_product_id', 'hidden', array(
'data' => $this->product_id,
));
;
}
and it obviously doesnt work since I got a SQL error saying that my field is null.
How can I put a default value to the user_product_id ? should I do it directly to the object ?
EDIT:
Here is a part of the code of my entity :
namespace QN\MainBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* QN\MainBundle\Entity\Quotes
*
* #ORM\Table()
* #ORM\Entity(repositoryClass="QN\MainBundle\Entity\QuotesRepository")
*/
class Quotes
{
public function __construct($p_id)
{
$this->date = new \Datetime('today');
}
/**
* #var integer $id
*
* #ORM\Column(name="id", type="integer")
* #ORM\Id
* #ORM\GeneratedValue(strategy="AUTO")
*/
private $id;
/**
* #var integer $user_product_id
*
* #ORM\Column(name="user_product_id", type="integer")
*/
private $user_product_id = "1";
/**
* #var datetime $date
*
* #ORM\Column(name="date", type="datetime")
*/
private $date;
And my controller :
public function requestAction($id)
{
$repository = $this->getDoctrine()
->getEntityManager()
->getRepository('QNMainBundle:Categories');
$categories = $repository->findAll();
$quote = new Quotes($id);
$form = $this->createForm(new QuotesType(), $quote);
$formHandler = new QuotesHandler($form, $this->get('request'), $this->getDoctrine()->getEntityManager());
if( $formHandler->process() )
{
return $this->redirect( $this->generateUrl('QNMain_Product', array('id' => $id)) );
}
return $this->render('QNMainBundle:Main:requestaform.html.twig', array(
'categories' => $categories,
'id' => $id,
'form' => $form->createView(),
));
}
My Handler :
namespace QN\MainBundle\Form;
use Symfony\Component\Form\Form;
use Symfony\Component\HttpFoundation\Request;
use Doctrine\ORM\EntityManager;
use QN\MainBundle\Entity\Quotes;
class QuotesHandler
{
protected $form;
protected $request;
protected $em;
public function __construct(Form $form, Request $request, EntityManager $em)
{
$this->form = $form;
$this->request = $request;
$this->em = $em;
}
public function process()
{
if( $this->request->getMethod() == 'POST' )
{
$this->form->bindRequest($this->request);
if( $this->form->isValid() )
{
$this->onSuccess($this->form->getData());
return true;
}
}
return false;
}
public function onSuccess(Quotes $quote)
{
$this->em->persist($quote);
$this->em->flush();
}
}
I've also put here the Date I try to set up in the entity, I might do something wrong in both case since I can't make it work neither ..Date is not in the buildForm function, I don't know if I should ..
Another way is creating a Form Type Extension:
namespace App\Form\Extension;
// ...
class DefaultValueTypeExtension extends AbstractTypeExtension
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
if (null !== $default = $options['default']) {
$builder->addEventListener(
FormEvents::PRE_SET_DATA,
static function (FormEvent $event) use ($default) {
if (null === $event->getData()) {
$event->setData($default);
}
}
);
}
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefault('default', null);
}
public static function getExtendedTypes(): iterable
{
yield FormType::class;
}
}
Now any possible value can be passed as default to any form field:
$form->add('user', null, ['default' => $this->getUser()]);
$form->add('user_product_id', null, ['default' => 1]);
This method is specially useful when you don't have a chance to hook into the initialization process of the bound object.
What you're trying to do here is creating a security hole: anyone would be able to inject any ID in the user_product_id field and dupe you application. Not mentioning that it's useless to render a field and to not show it.
You can set a default value to user_product_id in your entity:
/**
* #ORM\Annotations...
*/
private $user_product_id = 9000;