Symfony 3 - Form - CollectionType in Entity without Doctrine - forms

I'm struggling with symfony3 forms and the CollectionType class:
I have a page with several complex forms. I do not use any database (the validated forms are sent to a foreign REST-service)
Now let's say I have an entity object for my request called "ProductService":
class ProductService
{
/** #var string */
private $msg;
/** #var array*/
private $products
}
And a class ProductServiceType to render the form:
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('products', CollectionType::class, [
'entry_type' => ProductType::class,
])
->add('msg' [...]);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => ProductService::class,
]);
}
With this setup, everything works like a charm, all products will be added in the products array of my entity.
The problem is, i want $products to be a SplObjectStorage-object:
class ProductService
{
/** #var string */
private $msg;
/** #var SplObjectStorage */
private $products
}
If i set it to this and attach an empty object into it, symfony can't render the form any more. It throws following error:
Warning: SplObjectStorage::offsetExists() expects parameter 1 to be object, string given
So, can anybody tell me, how to handle the collectionType in an entity when NOT using doctrine and orm?
Is the only possibility using arrays, or is there any documentation for this case, i did not find?
(I'm still wondering how symfony calls offsetExists, there must be someting implemented to handle SplObjectStorage, or am i wrong?)

I believe your error is caused because a form collection has not been implemented to handle SplObjectStorage as you would expect. You can create an issue for it at the symfony repository.
The error is caused when symfony is trying to populate the form collection by reading your products from ProductService this way:
$products = $productService->getProducts();
$products->offsetExists(0); //here is the error.
because it expects any storage that implements ArrayAccess will be read this way, but for SplObjectStorage this is not the case.
Form elements have a setting property_path link which you can take advantage to work around your issue.
My solution is to use this setting and return return an array to populate your collection:
$builder
->add('products', CollectionType::class, [
'entry_type' => ProductType::class,
'property_path' => 'productsArray'
])
class ProductService
{
...
public function getProductsArray() {
$prArray= [];
foreach ($this->products as $value) {
$prArray[] = $value;
}
return $prArray;
}
This way you can populate your form collection using the array produced.
Another solution I think would be to use a data transformer. Hope this helps

Related

Why doesn't non doctrine-mapped ArrayCollection get initialized?

My Setup is a Symfony 3.4 App with the typical 'ManyToMany'-Relation with additional fields, something like this:
Entity Article
Entity Specialty
Entity ArticleSpecialtyRelation
In a Form for an Article i wanted it to look like as if it were a ManyToMany-Relation rendered as an EntityType with multiple=true and expanded=true, so all entries of Specialty are rendered as checkboxes.
To achieve that i created a non orm-mapped property specialties that is an ArrayCollection, gets initialized in the Constructor and has a Getter, Adder and Remover.
/**
*
* #var ArrayCollection;
*
*/
protected $specialties;
public function __construct()
{
$this->specialties = new ArrayCollection();
}
/**
* #return Collection|Specialty[]
*/
public function getSpecialties()
{
return $this->specialties;
}
/**
* #param Specialty $specialties
*/
public function addSpecialties(Specialty $specialties)
{
$this->specialties->add($specialties);
}
/**
* #param Specialty $specialties
*/
public function removeSpecialties(Specialty $specialties)
{
$this->specialties->removeElement($specialties);
}
This property is used to render the Specialty Entity as checkboxes:
add('specialties', EntityType::class,array(
'class' => Specialty::class,
'expanded'=>true,
'multiple'=>true,
'label'=>'Specialties',
'required' => false,
'mapped'=>true,
));
To populate it with the data from SpecialtyRelation i added a PreSetData Formevent:
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
$form = $event->getForm();
$article = $event->getData();
if ($article instanceof Article) {
$form->get('specialties')->setData($article->getUsedSpecialties());
}
});
The used Getter of $artikel just iterates over $article->getArtikelSpecialties and returns a collection of Specialty.
It all works until the submit. Because the formfield is mapped=true, somewhere in handleRequest($form) where the entity is hydrated with the form data, it explodes when the Adder for $specialty is called:
Call to a member function add() on null
Because as i just learned, the Constructor is never called by Doctrine and obviously initializes all ORM-ArrayCollections but not the ArrayCollection for the non-mapped property specialties -
Of course I can check if the ArrayCollection is initialized in the Adder and Remover and initialize it there if it is null, but that just feels a bit hacky in a already at least hacky-felt setup and i am wondering if my setup is completely stupid, especially since i didn't find anybody trying to do that (or getting problems with that) on here or elsewhere.
Is there a better solution to this or should i just check the ArrayCollection in Adder and Remover and live happily ever after?
Also, just curious, is there any other way to initialize the ArrayCollection?
P.S. If there are typos in the names it's because i translated the names into english.
Partial Stacktrace
Symfony\Component\Debug\Exception\FatalThrowableError: Call to a
member function add() on null
at src/Test/Bundle/TestBundle/Entity/Article.php:312 at
Test\Bundle\TestBundle\Entity\Article->addSpecialties(object(Specialty))
(vendor/symfony/symfony/src/Symfony/Component/PropertyAccess/PropertyAccessor.php:674)
at
Symfony\Component\PropertyAccess\PropertyAccessor->writeCollection(array(object(Article),
object(Article)), 'specialties', object(ArrayCollection),
'addSpecialties', 'removeSpecialties')
(vendor/symfony/symfony/src/Symfony/Component/PropertyAccess/PropertyAccessor.php:622)
at
Symfony\Component\PropertyAccess\PropertyAccessor->writeProperty(array(object(Article),
object(Article)), 'specialties', object(ArrayCollection))
(vendor/symfony/symfony/src/Symfony/Component/PropertyAccess/PropertyAccessor.php:216)
at
Symfony\Component\PropertyAccess\PropertyAccessor->setValue(object(Article),
object(PropertyPath), object(ArrayCollection))
(vendor/symfony/symfony/src/Symfony/Component/Form/Extension/Core/DataMapper/PropertyPathMapper.php:86)
at
Symfony\Component\Form\Extension\Core\DataMapper\PropertyPathMapper->mapFormsToData(object(RecursiveIteratorIterator),
object(Article))
(vendor/symfony/symfony/src/Symfony/Component/Form/Form.php:636) at Symfony\Component\Form\Form->submit(array(), true)
(vendor/symfony/symfony/src/Symfony/Component/Form/Form.php:580)

Constraint on Value Object in form Symfony

I'm quite new to Symfony and I started digging around Symfony forms.
As described here https://webmozart.io/blog/2015/09/09/value-objects-in-symfony-forms/ I'm using value objects in my subform. A constructor of value object can throw an exception if invalid values are provided. Therefore when I put invalid value to my field I'm getting ugly exception from VO, hence I want to connect a Validator Constraint on this but the validate() function gets already a Value object... Any thoughts on this issue?
class AddressType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
/....
$builder->add('latitude', LatitudeType::class, [
'label' => false,
'constraints' => [new Valid()],
]);
}
Latitude type
class LatitudeType extends AbstractType implements DataMapperInterface
{
const INPUT_NAME = 'latitude';
/**
* #param FormBuilderInterface $builder
* #param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add(self::INPUT_NAME, TextType::class, [
'label' => 'FORM.LATITUDE',
'attr' => [
'placeholder' => 'PLACEHOLDER.LATITUDE',
],
'required' => false,
'constraints' => [new LatitudeValidator()],
]);
$builder->setDataMapper($this);
}
/**
* #param OptionsResolver $resolver
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Latitude::class,
'empty_data' => null,
'error_bubbling' => true
]);
}
/**
* #param Latitude $data
* #param FormInterface[]|\Traversable $forms
*/
public function mapDataToForms($data, $forms)
{
$forms = iterator_to_array($forms);
$forms[self::INPUT_NAME]->setData($data);
}
/**
* #param FormInterface[]|\Traversable $forms
* #param mixed $data
*/
public function mapFormsToData($forms, &$data)
{
$forms = iterator_to_array($forms);
if ($forms[self::INPUT_NAME]->getData()) {
$data = new Latitude((float)$forms[self::INPUT_NAME]->getData());
}
}
This validation method is receiving already a created VO
class LatitudeValidator extends ConstraintValidator
{
/**
* {#inheritdoc}
*/
public function validate($value, Constraint $constraint)
{
if (null === $value || '' === $value) {
return;
}
But I want to be able to do something like
try {
new \ValueObject\Latitude((float)$value);
} catch (\InvalidArgumentException $e) {
$this->context->buildViolation($e->getMessage())
->addViolation();
}
You have differents methods to use form with Value Objects but after a lot of troubles by my side I decided to stop this. Symfony have to construct your Value Object even if your VO is invalid. You gave an example on an invalid state but you have also others example when you form doesn't fit well your Domain like when you have not enought fields to complete your required properties on your VOs.
Symfony Forms can be complexe and the use of VOs inside them can bring more complexity whereas the forms should be linked to the interface and not always to the domain objects.
The best solution for me is to use the command pattern. You have a simple example with other reasons to use it here. You can also avoid to put this logic into your controllers and avoid code duplication with a command bus librairy like tactician or now the messenger component of Symfony.
With a command you can simply represent an action by the form. The form can have validators related to the VO or directly to the form.
With the command bus you can create your Value Object in a valid state and throw exceptions in a second layer when you forget a use case.
This approach is more robust and avoid a lot of troubles for my point of view.
The best thing you achieve this, is to accept any kind of value into the ValueObject and then perform validation on it.
This way you're not forced to handle exception due to invalid types passed through constructor.
Moreover remember that creation or "value setting" of the underlying object is performed by the framework before validation (otherwise you'll never have to use VO) so you should leverage on this and let the Form component do his job (as you done correclty with transformers). Then, you can perform any kind of validation on underlying object.

Validation with ManyToOne

I have an Entity "Element" with a ManyToOne relationship with List (a list can have multiple elements)
/**
* #ORM\ManyToOne(targetEntity="Liste")
*/
private $list;
How can I validate a form to add a new element, with just passing the id of the list and not the list itself ? (The list has to exist)
in the old days (pre 2.8) we were able to set the cascade_validation flag which would then validate any child objects pre-persist. This was at best hit and miss.
That gone, the correct way is to do the following (note the valid constraint):
from the docs
use use Symfony\Component\Validator\Constraints as Assert;
class stuff
{
// ....
/**
* #ORM\ManyToOne(targetEntity="Liste")
* #Assert\Valid
*/
private $list;
// ....
}
this will force the framework the call any validators that you have on the related entity.
this is available from symfony 2.7
You have to follow by this steps:
1) Assign/Set entity class in Form
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => 'CoreBundle\Form\Type\YourEntity',
])
}
2) Create custom validation on YourEntity
#Assert\CheckExistingList()
class YourEntity
{
public function __construct()
}
3) Create new validator file CheckExistingListValidator.php and write your validation logic inside below function.
public function validate(Constraint $constraint)
{
// logic here.
}
So whenever your Form will submit then this validation should be called and error message show in Form error list.
You must add the form field with entity type like this:
->add('list', EntityType::class, [
'choice_label' => 'my test title'
])

Check if a specific validation_group is valid in Symfony Form?

I'm building a complex symfony form which is a bit long and contains other embedded forms. Thus the form is displayed in the UI in separate tabs to make it more readable and convenient for the user.
Because the form is long and separated in the UI there is a chance you've missed something while populating it or you just inserted something incorrect. That's when the validation would kick in and stop the form from being saved. The validation itself is configured and works flawlessly.
My problem here is I have a gigantic form, separated in tabs, which has an error somewhere and I need to browse each one of the tabs to see exactly what's wrong. I was thinking to make that specific tab, containing fields with errors, in another color so it could stand out and save you the time of wondering what's wrong and where it is located.
From what I could see, I have two options:
Check all fields per tab, manually, using something like:
{% if not form.children.FIELD_NAME.vars.valid %}
which would take forever to complete and I would do only if it's the only possible way.
Try using validation_groups => array('Default', 'my_tab_name') and logically group the fields for each tab.
I'm really hoping to use the second method, but I can't seem to figure out how to check if the validation group i.e. my_tab_1 contains any errors. I'm aware I can do something like this:
$validator = $this->get('validator');
$my_tab_1 = $validator->validate($entity, null, array('my_tab_1'));
$my_tab_2 = $validator->validate($entity, null, array('my_tab_2'));
$my_tab_3 = $validator->validate($entity, null, array('my_tab_3'));
// so on
But the form is already being validated with $form->validate() and using this approach would trigger N more unnecessary validations.
So the question here is how to check if a specific validation group is valid from a twig template? If that's not possible, can one get it from the Controller and pass it as a variable without doing yet another validation?
I don't think I need to post the FormTypes because they're long, nested and might only confuse you. However, this is an oversimplified version of the parent form:
class CompanyType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('name')
->add('address')
->add('representedBy')
->add('category')
->add('phone')
->add('member', new MemberType())
->add('contacts', new ContactType())
->add('notes', new NoteType())
// and a couple more embedded form types.
;
}
/**
* #param OptionsResolverInterface $resolver
*/
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'App\FooBundle\Entity\Company',
'cascade_validation' => true
));
}
/**
* #return string
*/
public function getName()
{
return 'app_company';
}
}
If anybody has a better idea or solution, I would really appreciate it.
First you can use tabs in two different ways:
a) With javascript. All the content of the tabs are loaded once and can be found in the source of the page. All tab-content is hidden except one.
b) With links and PHP. In this case every tab is another webpage with another URL.
(hopefully you understand the difference)
I always use the second method for my advanced forms. Thus for each page i only add a part of all the formfields in the formtype. For each page i use one validation group too. This is already enough to EDIT existing entities.
But a problem is a new Entity. You might want to avoid partly filled entities in your database, thus you need to validate and then store every 'step' in the session and after the user has finished last step (and validation was okay) you might want to store all the form-fields in one time into the database.
This method is used by the craueformflowbundle.
To get a part of your formfields simply use a switch in your formType or create a formType for each step.
namespace AppBundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;
class CompanyType extends AbstractType
{
/**
* #param FormBuilderInterface $builder
* #param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
switch ($options['flow_step']) {
case 1:
$builder
->add('company')
->add('origin')
;
break;
case 2:
$builder
->add('contactPerson', NULL, array('label' => 'Volledige naam'))
->add('email', 'email', array('label' => 'Email'))
->add('telephone', NULL, array('label' => 'Telefoonnummer'))
;
break;
}
}
/**
* #param OptionsResolverInterface $resolver
*/
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'AppBundle\Entity\Company',
'flow_step' => 1
));
}
/**
* #return string
*/
public function getName()
{
return 'appbundle_company';
}
}

Symfony2 Entity Form Type gets data

I have 2 entities: Audio and Destination
In Audio:
/**
* #ORM\OneToOne(targetEntity="HearWeGo\HearWeGoBundle\Entity\Destination", inversedBy="audio")
* #Assert\NotBlank(message="This field must be filled")
*
*/
private $destination;
I created a Form Type name AddAudioType used to upload an audio to database
<?php
namespace HearWeGo\HearWeGoBundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use HearWeGo\HearWeGoBundle\Entity\Audio;
class AddAudioType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('name','text')
->add('content','file')
->add('destination','entity',array('class'=>'HearWeGoHearWeGoBundle:Destination','property'=>'name'))
;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(array('data_class'=>"HearWeGo\\HearWeGoBundle\\Entity\\Audio"));
}
public function getName()
{
return 'add_audio';
}
}
?>
In Controller
/**
* #Route("/admin/add/audio",name="add_audio")
*/
public function addAudioAction(Request $request)
{
if (!$this->get('security.authorization_checker')->isGranted('IS_AUTHENTICATED_FULLY')){
return new Response('Please login');
}
$this->denyAccessUnlessGranted('ROLE_ADMIN', null, 'Unable to access this page!');
$audio=new Audio();
$form=$this->createForm(new AddAudioType(),$audio,array(
'method'=>'POST',
'action'=>$this->generateUrl('add_audio')
));
$form->add('submit','submit');
if ($request->getMethod()=='POST')
{
$form->handleRequest($request);
if ($form->isValid())
{
$destination=$this->getDoctrine()->getRepository('HearWeGoHearWeGoBundle:Destination')
->findByName($form->get('destination')->getData()->getName());
$audio->setDestination($destination);
$name=$_FILES['add_audio']['name']['content'];
$tmp_name=$_FILES['add_audio']['tmp_name']['content'];
if (isset($name))
{
if (!empty($name))
{
$location=$_SERVER['DOCUMENT_ROOT']."/bundles/hearwegohearwego/uploads/";
move_uploaded_file($tmp_name,$location.$name);
$audio->setContent($location.$name);
$em=$this->getDoctrine()->getEntityManager();
$em->persist($audio);
$em->flush();
return new Response('Audio '.$audio->getName().' has been created!');
}
}
}
}
return $this->render('#HearWeGoHearWeGo/manage/addAudio.html.twig',array('form'=>$form->createView()));
}
In AddAudioType, I declared so that it gets all records from Destination entity table and allows user to choose one of them, then persist it to database
Now there's something another I have to handle: Because relationship between Audio and Destination is one-to-one, user is not allowed to choose a Destination which already appeared in Audio table. Now in AddAudioType, I don't want to get all records from Destination table, but only some that hasn't appeared in Audio table yet. How should I do it?
When you do in your form builder
->add('destination', 'entity', array(
'class'=>'HearWeGoHearWeGoBundle:Destination',
'property'=>'name'
));
you're saying that you want all of possible Destination entities
If you want to filter them, you have two possibilities
First one (recommended)
Write your own method to exclude already "associated" Destinations into DestionationRepository. If you don't know what is a repository or you don't know how to write one, please refer to this document. Method implementation is left to you as an exercise (No, really, I don't know all entities so I cannot make any guess)
Once you've done this, you have to pass DestinationRepository to your form, as an option (required I suppose [see setRequired() method below]), so, something like this (I'll omit uninteresting code)
//AddAudioType
<?php
[...]
public function buildForm(FormBuilderInterface $builder, array $options)
{
$destination_repo = $options['dr'];
$builder->[...]
->add('destination','entity',array(
'class'=>'HearWeGoHearWeGoBundle:Destination',
'choices'=> $destination_repo->yourCustomRepoFunctionName(),
'property'=>'name'));
}
$resolver->setRequired(array(
'dr',
));
Now that you have setted all for your form, you need to pass DestinationRepository to your form. How do you that?
It's quite simple indeed
//In controller you're instatiating your form
[...]
public function addAudioAction()
{
[...]
$destination_repo = $this->getDoctrine()
->getManager()
->getRepository('HearWeGoHearWeGoBundle:Destination');
$form=$this->createForm(new AddAudioType(), $audio, array(
'method' => 'POST',
'action' => $this->generateUrl('add_audio'),
'dr' => $destination_repo,
));
}
It's going to work like a charm as long as you write a good "filter" method (ie.: you exlude with NOT IN clause all Destinations that got the key into other table)
Second one
You simply write your method into the form
//AddAudioType
use Doctrine\ORM\EntityRepository;
<?php
[...]
public function buildForm(FormBuilderInterface $builder, array $options)
{
$destination_repo = $options['dr'];
$builder->[...]
->add('destination','entity',array(
'class'=>'HearWeGoHearWeGoBundle:Destination',
'choices'=> function(EntityRepository $repository) use ($someParametersIfNeeded) {
return $repository->createQueryBuilder('d')
->[...];},
'property'=>'name'));
}
In this second case, createQueryBuilder is also not implemented and left to you. One thing you need to remember: choices will need a query builder, so don't call ->getQuery() and ->getResult()
Why fist one?
Custom function should always stay within repos. So you are writing some code into the place that has to be (see points below to know way)
Because code, that way, is reusable (DRY principle)
Because you can test code more easily
Custom repo function
public function findDestinationWithoutAudio() {
$query= "SELECT d
FROM HearWeGoHearWeGoBundle:Destination d
WHERE d NOT IN (SELECT IDENTITY(a.destination)
FROM HearWeGoHearWeGoBundle:Audio a)"
;
return $this->getEntityManager()->createQuery($query)->getResult();
}
If you want to know why you should use IDENTITY() function and not foreign key directly: http://docs.doctrine-project.org/projects/doctrine-orm/en/latest/reference/dql-doctrine-query-language.html#dql-functions