Constraint on Value Object in form Symfony - forms

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.

Related

Symfony 3 - Form - CollectionType in Entity without Doctrine

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

How to preset data on forms 'HiddenType::class' field with relational data?

I just want to preset hidden "fkCar" field with Car object with the id matching the one in the url pattern. So that when a user clicks on a link next to a car entry, he can directly add history to that car, without the need to select that car id from a drop down list. I can preset data on the dropdown list but whenever I try to use HiddenType in my FormType I get this error message:
"Expected argument of type "AdminBundle\Entity\Car", "string" given"
From what I have noticed it's like that because the instance of the Car Object is converted by __toString() magic method which returns "string" and not Car object anymore. On the other hand, the same thing happens on the dropdown choice field but no error are thrown and it works fine...
When I use ->add('fkCar') in my FormType instead, it works fine but I have a dropdown list which I don't want
when I use HiddenType like so:
->add('fkCar',HiddenType::class, [
// ...
])
I get quoted error message.
This is my code:
My FormType
<?php
namespace AdminBundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Intl\DateFormatter\IntlDateFormatter;
class CarHistoryCarIdType extends AbstractType
{
/**
* #param FormBuilderInterface $builder
* #param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('issueDate', DateType::class,[
'format'=> IntlDateFormatter::LONG,
])
->add('invoiceNum')
->add('invoiceTotal')
->add('quoteNum')
->add('mileage')
->add('description')
// ->add('fkCar') // works - but dropdown choice field is there able to be edited
->add('fkCar',HiddenType::class, [ // generate above error message
// ...
])
;
}
/**
* #param OptionsResolver $resolver
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'AdminBundle\Entity\CarHistory'
));
}
}
My controller action:
use AdminBundle\Form\CarHistoryCarIdType;
use AdminBundle\Entity\Car;
use AdminBundle\Entity\CarHistory;
...
/**
* Creates a new History for selected car.
*
* #Route("/new/history/{carId}", name="car_new_history")
* #Method({"GET", "POST"})
*/
public function newHistoryAction(Request $request, $carId)
{
// get car data
$car = $this->getDoctrine()->getRepository(Car::class)->find($carId);
dump($car); // test
dump(get_class($car)); // test
// create History Entity Object
$history = new CarHistory();
// set History Entity fkCar to Car Entity Object with id == $carId
$history->setFkCar($car);
$history->setIssueDate(new \DateTime('now'));
// build form and set data
$form = $this->createForm(CarHistoryCarIdType::class, $history);
dump($request->request->all()); // test
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em = $this->getDoctrine()->getManager();
$em->persist($history);
$em->flush();
return $this->redirectToRoute('full_car_history', array('id' => $carId));
}
return $this->render('car/newCarHistory.html.twig', array(
'carId' => $carId,
'form' => $form->createView(),
));
}
This is my dump test result. As you can see, the doctrine returns the car object as expected but form 'fkCar' stays empty:
the problem is the hidden type in the form won't allow edit the data inside this. you must change the type and in the view you must hide the field using css. usually I do this:
<div style="display:none">{{form_widget(form.fkCar)}}</div>
change the type in your form class and use the default text type

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

Symfony2: 1 form to edit a translatable entity

I have a translatable entity using the translatable behaviour of doctrine2.
I'm trying to build a form that looks like this:
| French |English| Spanish |
+--+--------| |---------+------------+
| |
| name: [___my_english_name___] |
| |
| title: [___my_english_title__] |
| |
+------------------------------------------+
Order: [___1___]
Online: (x) Yes
( ) No
So basically, there are the order & online attributes of the object that are not translatable, and the name & title attribute that have the translatable behaviour.
In case my drawing is not clear: the form contain a 1 tab per locale that hold the field that are translatable.
The problem I have is that by default, Symfony2 bind a form to an entity, but the doctrine translatable behaviour force me to have one entity per locale. Personally the doctrine behaviour is fine (and I like it), but I'm unable to make a form that allow me to edit the entity in all the locale -- in the same form.
So far, I've the main form:
namespace myApp\ProductBundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilder;
/**
* Form for the productGroup.
*/
class ProductType extends AbstractType
{
/**
* Decide what field will be present in the form.
*
* #param FormBuilder $builder FormBuilder instance.
* #param array $options Custom options.
*
* #return null;
*/
public function buildForm(FormBuilder $builder, array $options)
{
//Todo: get the available locale from the service.
$arrAvailableLocale = array('en_US', 'en_CA', 'fr_CA', 'es_ES');
//Render a tab for each locale
foreach ($arrAvailableLocale as $locale) {
$builder->add(
'localeTab_' . $locale,
new ProductLocaleType(),
array('property_path' => false, //Do not map the type to an attribute.
));
}
//Uni-locale attributes of the entity.
$builder
->add('isOnline')
->add('sortOrder');
}
/**
* Define the defaults options for the form building process.
*
* #param array $options Custom options.
*
* #return array Options with the defaults values applied.
*/
public function getDefaultOptions(array $options)
{
return array(
'data_class' => 'myApp\ProductBundle\Entity\Product',
);
}
/**
* Define the unique name of the form.
*
* #return string
*/
public function getName()
{
return 'myapp_productbundle_producttype';
}
}
And the tab-form:
<?php
namespace MyApp\ProductBundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilder;
use invalidArgumentException;
/**
* Form for the productGroupLocale tabs.
*/
class ProductLocaleType extends AbstractType
{
/**
* Decide what field will be present in the form.
*
* #param FormBuilder $builder FormBuilder instance.
* #param array $options Custom options.
*
* #return null;
*/
public function buildForm(FormBuilder $builder, array $options)
{
$builder->add('name', 'text', array('data' => ???));
$builder->add('title', 'text', array('data' => ???));
}
/**
* Define the defaults options for the form building process.
*
* #param array $options Custom options.
*
* #return array Options with the defaults values applied.
*/
public function getDefaultOptions(array $options)
{
return array(
//'data_class' => 'MyApp\ProductBundle\Entity\Product',
'name' => '',
'title' => '',
);
}
/**
* Define the unique name of the form.
*
* #return string
*/
public function getName()
{
return 'myapp_productbundle_productlocaletype';
}
}
But as you can't see, I've no idea how to get the name and title values from the translated entity, and neither I know how to persist them once the form will be submitted.
Hi if you use gedmo extensions Translatable is not meant to handle multiple translations per request. Try using knplabs alternative may be a better option to handle it in more general ways.
You may be interested in TranslationFormBundle, which add a form type to work with DoctrineTranslatable extension.
I've check the Translator extension, and even if it's interesting, it wasn't corresponding to our needs. (Basically, all the examples we found require that we change the site locale in order to edit an entity in another locale. I don't know Chinese, and I don't want my interface to be in Chinese, but I do have a translation that I have to copy/paste. Seems weird to explain that as it's really basic in every solid CMS you'll find out there, but I was looking a bit complex to do that kind of CMS functionnality using Symfony.)
So we've developed a solution and builded a BreadGeneratorBundle that we've decide to share:
https://github.com/idealtech/BreadGeneratorBundle
At the time of posting this, it still under development, but it can be used as an alternative to the CrudGenerator in order to generate form for translatable entity.
We also manage to use the Gedmo Extension -- even if Gediminas said it's not meant to handle multiple translation ;)
Hope this will help someone ! :)