Symfony Check if at least one of two fields isn't empty on form validation - forms

I've been turning this around in my head for quite a while now and still wasn't able to find a solution to my problem. Using Symfony 4 forms and constraints I'm unable to setup a check to say that at least one of two fields must not be empty when submitting form that contains a sub-form.
I have a Booking entity which contains a Visitor entity which has a phoneNumber property and a email property. I'd like to be able to create a Booking which has a "visitors" CollectionType (where I'm allowed to add visitors from the BookingType form).
My BookingType form (a bit simplified):
class BookingType extends AbstractType
{
private $router;
private $translator;
public function __construct(UrlGeneratorInterface $router, TranslatorInterface $translator)
{
$this->router = $router;
$this->translator = $translator;
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('bookableTimeSlot', EntityType::class, [
'label' => 'entity.booking.bookable-time-slot',
'class' => BookableTimeSlot::class,
'choice_label' => function ($bookableTimeSlot) {
return $bookableTimeSlot->getStartDateTime()->format('d.m.Y h\hi');
}
])
->add('visitors', CollectionType::class, [
'entry_type' => VisitorType::class,
'label' => 'entity.booking.visitors',
'allow_add' => true,
'by_reference' => false,
'entry_options' => ['label' => false]
])
;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Booking::class,
'user' => User::class,
]);
}
}
My Visitor entity (a bit simplified):
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* #ORM\Entity(repositoryClass="App\Repository\VisitorRepository")
*/
class Visitor
{
/**
* #ORM\Id()
* #ORM\GeneratedValue()
* #ORM\Column(type="integer")
*/
private $id;
/**
* #ORM\Column(type="string", length=255)
*/
private $firstName;
/**
* #ORM\Column(type="string", length=255)
*/
private $lastName;
/**
* #ORM\Column(type="string", length=45, nullable=true)
*/
private $phone;
/**
* #ORM\ManyToOne(targetEntity="App\Entity\Booking", inversedBy="visitors")
* #ORM\JoinColumn(nullable=false)
*/
private $booking;
/**
* #ORM\Column(type="string", length=255, nullable=true)
*/
private $email;
public function getId(): ?int
{
return $this->id;
}
public function getFirstName(): ?string
{
return $this->firstName;
}
public function setFirstName(string $firstName): self
{
$this->firstName = $firstName;
return $this;
}
public function getLastName(): ?string
{
return $this->lastName;
}
public function setLastName(string $lastName): self
{
$this->lastName = $lastName;
return $this;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function setPhone(string $phone): self
{
$this->phone = $phone;
return $this;
}
public function getBooking(): ?Booking
{
return $this->booking;
}
public function setBooking(?Booking $booking): self
{
$this->booking = $booking;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(?string $email): self
{
$this->email = $email;
return $this;
}
}
And finaly my VisitorType form (a bit simplified):
class VisitorType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('firstName', TextType::class, [
'label' => 'entity.visitor.first-name',
])
->add('lastName', TextType::class, [
'label' => 'entity.visitor.last-name',
])
->add('phone', TextType::class, [
'label' => 'entity.visitor.phone-number',
'required' => false,
])
->add('email', TextType::class, [
'label' => 'entity.visitor.email',
'required' => false,
'constraints' => [
new Email()
]
])
;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Visitor::class,
]);
}
}
I've tried to add an Expression constraint to the email and phone field which looked something like this:
new Expression([
'expression' => 'this.getPhone() == null && this.getEmail() == null'
])
Also tried to add constraint directly to the entity, but nothing seems to work correctly for me.
Any help would be greatly appreciated.
UPDATE
I haven't specified this, but my problem comes from the fact that I would like to validate the VisitorType form from another form which adds the VisitorType as a CollectionType.

try with callback
/**
* #Assert\Callback
*/
public function validate(ExecutionContextInterface $context, $payload)
{
if (null === $this->getEmail() && null === $this->getPhone())
$context->buildViolation('Your message here.')
->atPath('email')
->addViolation();
// you can add onther "if" if you like
}

Related

Form Subscriber and "this form should not contain extra fields" error

I'm using symfony 2.3, so apparently, I can't use the 'allow_extra_fields' option discussed here.
I have a main Form Type, RegistrationStep1UserType :
/**
* Class RegistrationStep1UserType
* #package Evo\DeclarationBundle\Form\Type
*/
class RegistrationStep1UserType extends AbstractType
{
/**
* #param FormBuilderInterface $builder
* #param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('customer', new RegistrationStep1CustomerType(), [
'label' => false,
'data_class' => 'Evo\UserBundle\Entity\Customer',
'cascade_validation' => true,
])
->add('declaration', 'evo_declaration_bundle_registration_step1_declaration_type', [
'label' => false,
'cascade_validation' => true,
])
;
}
/**
* #param OptionsResolverInterface $resolver
*/
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'Evo\UserBundle\Entity\User',
'validation_groups' => false,
));
}
/**
* #return string
*/
public function getName()
{
return 'evo_declaration_bundle_registration_step1_user_type';
}
}
This form type includes an embedded Form Type (on "declaration" field), RegistrationStep1DeclarationType, registered as a service :
/**
* Class RegistrationStep1DeclarationType
* #package Evo\DeclarationBundle\Form\Type
*/
class RegistrationStep1DeclarationType extends AbstractType
{
/**
* #var EntityManagerInterface
*/
private $em;
/**
* #var EventSubscriberInterface
*/
private $addBirthCountyFieldSubscriber;
/**
* RegistrationStep1DeclarationType constructor.
* #param EntityManagerInterface $em
* #param EventSubscriberInterface $addBirthCountyFieldSubscriber
*/
public function __construct(EntityManagerInterface $em, EventSubscriberInterface $addBirthCountyFieldSubscriber)
{
$this->em = $em;
$this->addBirthCountyFieldSubscriber = $addBirthCountyFieldSubscriber;
}
/**
* #param FormBuilderInterface $builder
* #param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('birthLastname', null, [
'label' => 'Nom de naissance',
'attr' => [
'required' => true,
],
])
->add('nationality', 'entity', [
'label' => 'Nationalité',
'class' => 'Evo\GeoBundle\Entity\Country',
'property' => 'nationalityFr',
'attr' => [
'required' => true,
'class' => 'selectpicker',
],
'preferred_choices' => $this->fillPreferredNationalities(),
])
->add('birthCountry', 'entity', [
'label' => 'Pays de naissance',
'class' => 'Evo\GeoBundle\Entity\Country',
'property' => 'nameFr',
'empty_value' => '',
'empty_data' => null,
'attr' => [
'required' => true,
'class' => 'trigger-form-modification selectpicker',
],
'preferred_choices' => $this->fillPreferredBirthCountries(),
])
->add('birthCity', null, [
'label' => 'Ville de naissance',
'attr' => [
'required' => true,
],
])
;
$builder->get("birthCountry")->addEventSubscriber($this->addBirthCountyFieldSubscriber);
}
/**
* #return array
*/
private function fillPreferredNationalities()
{
$nationalities = $this->em->getRepository("EvoGeoBundle:Country")->findBy(["isDefault" => true]);
return $nationalities;
}
/**
* #return array|\Evo\GeoBundle\Entity\Country[]
*/
private function fillPreferredBirthCountries()
{
$countries = $this->em->getRepository("EvoGeoBundle:Country")->findBy(["isDefault" => true]);
return $countries;
}
/**
* #param OptionsResolverInterface $resolver
*/
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'required' => false,
'data_class' => 'Evo\DeclarationBundle\Entity\Declaration',
'validation_groups' => false,
));
}
/**
* #return string
*/
public function getName()
{
return 'evo_declaration_bundle_registration_step1_declaration_type';
}
}
This embedded Form Type adds a Subscriber (registered as a service too, because it needs injection of EntityManager) on the "birthCountry" field.
The goal is to dynamically add or remove an extra field (called "birthCounty") depending on the value of the birthCountry choice list (note the 2 fields are different here, "birthCountry" and "birthCounty").
Here is the Subscriber :
/**
* Class AddBirthCountyFieldSubscriber
* #package Evo\CalculatorBundle\Form\EventListener
*/
class AddBirthCountyFieldSubscriber implements EventSubscriberInterface
{
/**
* #var EntityManagerInterface
*/
private $em;
/**
* AddBirthCountyFieldSubscriber constructor.
* #param EntityManagerInterface $em
*/
public function __construct(EntityManagerInterface $em)
{
$this->em = $em;
}
/**
* #return array
*/
public static function getSubscribedEvents()
{
return array(
FormEvents::POST_SET_DATA => 'postSetData',
FormEvents::POST_SUBMIT => 'postSubmit',
);
}
/**
* #param FormEvent $event
*/
public function postSetData(FormEvent $event)
{
$birthCountry = $event->getData();
$form = $event->getForm()->getParent();
$this->removeConditionalFields($form);
if ($birthCountry instanceof Country && true === $birthCountry->getIsDefault()) {
$this->addBirthCountyField($form);
}
}
/**
* #param FormEvent $event
*/
public function postSubmit(FormEvent $event)
{
$birthCountry = $event->getData();
$form = $event->getForm()->getParent();
if (!empty($birthCountry)) {
$country = $this->em->getRepository("EvoGeoBundle:Country")->find($birthCountry);
$this->removeConditionalFields($form);
if ($country instanceof Country && true === $country->getIsDefault()) {
$this->addBirthCountyField($form);
}
}
}
/**
* #param FormInterface $form
*/
private function addBirthCountyField(FormInterface $form)
{
$form
->add('birthCounty', 'evo_geo_bundle_autocomplete_county_type', [
'label' => 'Département de naissance',
'attr' => [
'required' => true,
],
])
;
}
/**
* #param FormInterface $form
*/
private function removeConditionalFields(FormInterface $form)
{
$form->remove('birthCounty');
}
}
In the view, when the "birthCountry" choice list changes, it trigger an AJAX request to the controller, which handles the request and render the view again (as explained in the documentation about dynamic form submission) :
$form = $this->createForm(new RegistrationStep1UserType(), $user);
if ($request->isXmlHttpRequest()) {
$form->handleRequest($request);
} elseif ("POST" == $request->getMethod()) {
[...]
}
The problem is the following :
When I make a change on the birthCountry choice list and select a Country supposed to hide the "birthCounty" field, the form correctly render without that field, but it shows an error message :
Ce formulaire ne doit pas contenir des champs supplémentaires.
or
this form should not contain extra fields (in english)
I tried many different solutions :
adding a 'validation_groups' option to RegistrationStep1UserType and RegistrationStep1DeclarationType
adding a preSubmit event to AddBirthCountyFieldSubscriber replicating the logic of preSetData and postSubmit methods
even adding 'mapped' => false, to the birthCounty field triggers the error. very surprising
Even $form->getExtraData() is empty if I dump it just after $form->handleRequest($request);
But in vendor\symfony\symfony\src\Symfony\Component\Form\Extension\Validator\Constraints\FormValidator, I can see an extra field
array(1) {
["birthCounty"]=>
string(0) ""
}
here :
// Mark the form with an error if it contains extra fields
if (count($form->getExtraData()) > 0) {
echo '<pre>';
\Doctrine\Common\Util\Debug::dump($form->getExtraData());
echo '</pre>';
die();
$this->context->addViolation(
$config->getOption('extra_fields_message'),
array('{{ extra_fields }}' => implode('", "', array_keys($form->getExtraData()))),
$form->getExtraData()
);
}
Did I miss something about form dynamic extra fields ?
I did not analyzed all the question but, I guess, that you can invert the logic: always add that field and remove it when the condition is not satisfied.
That way you don't need to perform operations in postSubmit (that is where the issue is)

Symfony2 form collection not calling addxxx and removexxx even if 'by_reference' => false

I have the Customer entity and two one-to-many relations CustomerPhone and CustomerAddress.
The Customer entity has addPhone/removePhone and addAddress/removeAddress "adders".
CustomerType collections options has 'by_reference' => false for both collections.
Entity functions addPhone/removePhone and addAddress/removeAddress not called after form submitted, so CustomerPhone and CustomerAddress have no parent id after persist.
Why could addPhone/removePhone and addAddress/removeAddress not called on form submit?
UPD 1.
After #Baig suggestion now I have addPhone/removePhone "adders" called, but addAddress/removeAddress not. Can't get why because they are identical.
# TestCustomerBundle/Entity/Customer.php
/**
* #var string
*
* #ORM\OneToMany(targetEntity="CustomerPhone", mappedBy="customerId", cascade={"persist"}, orphanRemoval=true)
*/
private $phone;
/**
* #var string
*
* #ORM\OneToMany(targetEntity="CustomerAddress", mappedBy="customerId", cascade={"persist"}, orphanRemoval=true)
*/
private $address;
Same file "adders"
# TestCustomerBundle/Entity/Customer.php
/**
* Add customer phone.
*
* #param Phone $phone
*/
public function addPhone(CustomerPhone $phone) {
$phone->setCustomerId($this);
$this->phone->add($phone);
return $this;
}
/**
* Remove customer phone.
*
* #param Phone $phone customer phone
*/
public function removePhone(CustomerPhone $phone) {
$this->phone->remove($phone);
}
/**
* Add customer address.
*
* #param Address $address
*/
public function addAddress(CustomerAddress $address) {
$address->setCustomerId($this);
$this->address->add($address);
return $this;
}
/**
* Remove customer address.
*
* #param Address $address customer address
*/
public function removeAddress(CustomerAddress $address) {
$this->address->remove($address);
}
Relations:
# TestCustomerBundle/Entity/CustomerPhone.php
/**
* #ORM\ManyToOne(targetEntity="Customer", inversedBy="phone")
* #ORM\JoinColumn(name="customer_id", referencedColumnName="id")
**/
private $customerId;
#TestCustomerBundle/Entity/CustomerAddress.php
/**
* #ORM\ManyToOne(targetEntity="Customer", inversedBy="address")
* #ORM\JoinColumn(name="customer_id", referencedColumnName="id")
**/
private $customerId;
CustomerType form:
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('name')
->add('phone', 'collection', array(
'type' => new CustomerPhoneType(),
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'options' => array('label' => false)
))
->add('address', 'collection', array(
'type' => new CustomerAddressType(),
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'options' => array('label' => false)
))
->add('submit', 'submit')
;
}
Controller.
# TestCustomerBundle/Controller/DefaultController.php
public function newAction(Request $request)
{
$customer = new Customer();
// Create form.
$form = $this->createForm(new CustomerType(), $customer);
// Handle form to store customer obect with doctrine.
if ($request->getMethod() == 'POST')
{
$form->bind($request);
if ($form->isValid())
{
/*$em = $this->get('doctrine')->getEntityManager();
$em->persist($customer);
$em->flush();*/
$request->getSession()->getFlashBag()->add('success', 'New customer added');
}
}
// Display form.
return $this->render('DeliveryCrmBundle:Default:customer_form.html.twig', array(
'form' => $form->createView()
));
}
UPD 2.
Test if addAddress called.
/**
* Add customer address.
*
* #param Address $address
*/
public function addAddress(Address $address) {
jkkh; // Test for error if method called. Nothing throws.
$address->setCustomerId($this);
$this->address->add($address);
}
UPD 3.
CustomerAddressType.php
<?php
namespace Delivery\CrmBundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;
class CustomerAddressType extends AbstractType
{
/**
* #param FormBuilderInterface $builder
* #param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('street')
->add('house')
->add('building', 'text', ['required' => false])
->add('flat', 'text', ['required' => false])
;
}
/**
* #param OptionsResolverInterface $resolver
*/
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'Delivery\CrmBundle\Entity\CustomerAddress'
));
}
/**
* #return string
*/
public function getName()
{
return 'delivery_crmbundle_customeraddress';
}
}
CustomerPhoneType.php
<?php
namespace Delivery\CrmBundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;
class CustomerPhoneType extends AbstractType
{
/**
* #param FormBuilderInterface $builder
* #param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('number')
;
}
/**
* #param OptionsResolverInterface $resolver
*/
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'Delivery\CrmBundle\Entity\CustomerPhone'
));
}
/**
* #return string
*/
public function getName()
{
return 'phone';
}
}
For me this was eventually solved by adding getXXX, which returns the collection to the PropertyAccessor. Without that you keep on wondering why addXXX or removeXXX aren't called.
So make sure that:
The option by_reference is set to false at the field,
You have both the adder and remover method on the owning side of the relationship,
The getter is accessible for the PropertyAccessor to check if by_reference can be used,
If you want to use the prototype to handle adding/removing via Javascript, make sure allow_add is set to true.
This answer corresponds to Symfony 3, but I am sure this applies to Symfony 2 as well. Also this answer is more as a reference than addressing OP's issue in particular (which I am not to clear)
On ..Symfony/Component/PropertyAccess/PropertyAccessor.php the method writeProperty is responsible for calling either setXXXXs or addXXX & removeXXXX methods.
So here is order on which it looks for the method:
If the entity is array or instance of Traversable (which ArrayCollection is) then pair of
addEntityNameSingular()
removeEntityNameSingular()
Source for reference:
if (is_array($value) || $value instanceof \Traversable) {
$methods = $this->findAdderAndRemover($reflClass, $singulars);
if (null !== $methods) {
$access[self::ACCESS_TYPE] = self::ACCESS_TYPE_ADDER_AND_REMOVER;
$access[self::ACCESS_ADDER] = $methods[0];
$access[self::ACCESS_REMOVER] = $methods[1];
}
}
If not then:
setEntityName()
entityName()
__set()
$entity_name (Should be public)
__call()
Source for reference:
if (!isset($access[self::ACCESS_TYPE])) {
$setter = 'set'.$camelized;
$getsetter = lcfirst($camelized); // jQuery style, e.g. read: last(), write: last($item)
if ($this->isMethodAccessible($reflClass, $setter, 1)) {
$access[self::ACCESS_TYPE] = self::ACCESS_TYPE_METHOD;
$access[self::ACCESS_NAME] = $setter;
} elseif ($this->isMethodAccessible($reflClass, $getsetter, 1)) {
$access[self::ACCESS_TYPE] = self::ACCESS_TYPE_METHOD;
$access[self::ACCESS_NAME] = $getsetter;
} elseif ($this->isMethodAccessible($reflClass, '__set', 2)) {
$access[self::ACCESS_TYPE] = self::ACCESS_TYPE_PROPERTY;
$access[self::ACCESS_NAME] = $property;
} elseif ($access[self::ACCESS_HAS_PROPERTY] && $reflClass->getProperty($property)->isPublic()) {
$access[self::ACCESS_TYPE] = self::ACCESS_TYPE_PROPERTY;
$access[self::ACCESS_NAME] = $property;
} elseif ($this->magicCall && $this->isMethodAccessible($reflClass, '__call', 2)) {
// we call the getter and hope the __call do the job
$access[self::ACCESS_TYPE] = self::ACCESS_TYPE_MAGIC;
$access[self::ACCESS_NAME] = $setter;
} else {
$access[self::ACCESS_TYPE] = self::ACCESS_TYPE_NOT_FOUND;
$access[self::ACCESS_NAME] = sprintf(
'Neither the property "%s" nor one of the methods %s"%s()", "%s()", '.
'"__set()" or "__call()" exist and have public access in class "%s".',
$property,
implode('', array_map(function ($singular) {
return '"add'.$singular.'()"/"remove'.$singular.'()", ';
}, $singulars)),
$setter,
$getsetter,
$reflClass->name
);
}
}
To answer OP's issue, based on the above mentioned information, the PropertyAccessor class of symfony is not able to read your addXX and removeXX method properly. The potential reason might be that is not identified as array or ArrayCollection which has to be done from the constructor of the entity
public function __construct() {
$this->address = new ArrayCollection();
// ....
}
I had the same problem, but I'm not sure that it's the same cause.
I your entity's attribute that has OneToMany relation ship must have an 's' at the end. So that in "handleRequest" (leave it a black box, I didn't look up inside), symfony would find your "addxxx" without "s".
In the exemple 'Task - Tag', he declared "tags" but getTag.
In your case I would think you change your $phone to $phones and the method follow:
public function setPhones($phones){}
public function addPhone(Phone $phone){}
To the name of method your form searching for, just delete temporarily setter in your entity and submit your form, symfony will told you.
Just hope this would help you out :)

Symfony2 Form collection handle request error "Neither the property nor one of the methods exist and have public access"

I have a problem when I want to handle form with collection
I have two entities FeatureCategory and Feature
User chooses one feature in every featureCategory (collection of featureCategories).
Feature category entity:
class FeatureCategory
{
/**
* #var \Doctrine\Common\Collections\Collection
* #ORM\OneToMany(targetEntity="Site\BackendBundle\Entity\Feature", mappedBy="featureCategory", cascade={"persist"})
*/
private $features;
/**
* Constructor
*/
public function __construct()
{
$this->features = new \Doctrine\Common\Collections\ArrayCollection();
}
/**
* Add feature
*
* #param \Site\BackendBundle\Entity\Feature $feature
* #return FeatureCategory
*/
public function addFeature(\Site\BackendBundle\Entity\Feature $feature)
{
$feature->setFeatureCategory($this);
$this->features[] = $feature;
return $this;
}
/**
* Remove feature
*
* #param \Site\BackendBundle\Entity\Feature $feature
*/
public function removeFeature(\Site\BackendBundle\Entity\Feature $feature)
{
$this->features->removeElement($feature);
}
/**
* Get features
*
* #return \Doctrine\Common\Collections\Collection
*/
public function getFeatures()
{
return $this->features;
}
}
And feature entity:
class Feature
{
/**
* #var \Site\BackendBundle\Entity\FeatureCategory
*
* #ORM\ManyToOne(targetEntity="Site\BackendBundle\Entity\FeatureCategory", inversedBy="features")
* #ORM\JoinColumns({
* #ORM\JoinColumn(name="feature_category_id", referencedColumnName="id")
* })
*/
private $featureCategory;
/**
* Set featureCategory
*
* #param \Site\BackendBundle\Entity\FeatureCategory $featureCategory
* #return Feature
*/
public function setFeatureCategory(\Site\BackendBundle\Entity\FeatureCategory $featureCategory = null)
{
$this->featureCategory = $featureCategory;
return $this;
}
/**
* Get featureCategory
*
* #return \Site\BackendBundle\Entity\FeatureCategory
*/
public function getFeatureCategory()
{
return $this->featureCategory;
}
}
Feature categories form:
class FeatureCategoriesFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('featureCategories', 'collection', array(
'type' => 'site_frontend_feature_category',
'by_reference' => false
))
->add('quantity', 'text', array(
'data' => 1
))
;
}
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'validation_groups' => array('feature_categories')
));
}
public function getName()
{
return 'site_frontend_feature_categories';
}
}
And feature category form:
class FeatureCategoryFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($builder)
{
$form = $event->getForm();
$data = $event->getData();
/* Check we're looking at the right data/form */
if ($data instanceof FeatureCategory)
{
$choices = $data->getFeatures();
$form
->add('features', 'entity', array(
'multiple' => false,
'expanded' => true,
'class' => 'SiteBackendBundle:Feature',
'property' => 'value',
'choices' => $choices,
'data' => null
))
->add('name', 'hidden', array(
'read_only' => true,
'label' => $data->getName()
))
;
}
});
}
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'Site\BackendBundle\Entity\FeatureCategory',
'validation_groups' => array('feature_category')
));
}
public function getName()
{
return 'site_frontend_feature_category';
}
}
And finally my controller:
class OrderController extends Controller
{
public function addProductAction(Request $request, $slug)
{
$em = $this->get('doctrine.orm.default_entity_manager');
$item = $em->getRepository('SiteBackendBundle:Product')
->getItem($slug);
if (!$item) {
throw new NotFoundHttpException();
}
$featureCategories = $em->getRepository('SiteBackendBundle:FeatureCategory')
->getListByProduct($item);
$featureCategoriesForm = $this->createForm('site_frontend_feature_categories', array('featureCategories' => $featureCategories),
array(
'method' => 'POST',
'action' => $this->generateUrl('user_order_add_product', array(
'slug' => $item->getSlug(),
))
)
);
if ($request->isXmlHttpRequest()) {
if ($request->isMethod('POST')) {
$featureCategoriesForm->handleRequest($request);
if ($featureCategoriesForm->isValid()) {
//order add product logic
}
}
}
}
}
After $featureCategoriesForm->handleRequest($request); line, I got an error
Neither the property "features" nor one of the methods
"addFeatur()"/"removeFeatur()", "addFeature()"/"removeFeature()",
"setFeatures()", "features()", "__set()" or "__call()" exist and have
public access in class "Site\BackendBundle\Entity\FeatureCategory".
Sorry for this long post
Could anyone help?
Eventually, I fixed issue using entities as arrays with choice type form, without data_class and so on

saving embedded form (collection) Symfony2

I am making an invoicing system with an embedded form. A form where you can add a date, choose a customer company from a dropdown list (-> customer is an entity) and add details to the invoice item (-> details is also an entity, with properties like price, amount,...) - with javascript. This works just fine, but at saving the form I get an error.
I have 3 entities: InvoicingCustomer, InvoiceItem, InvoiceItemDetail.
(Sorry; this is going to be a long post)
InvoicingCustomer.php (with properties like street, address,...) =
/**
* #ORM\Table(name="invoicing_customer")
* #ORM\Entity
*/
class InvoicingCustomer
{
/**
* #ORM\OneToMany(targetEntity="Invoicing\InvoicingBundle\Entity\InvoiceItem", mappedBy="customer")
*/
private $invoice;
public function __construct()
{ $this->invoice = new ArrayCollection();}
public function getInvoice()
{ return $this->invoice; }
public function getAllInvoices()
{
$invoices = $this->getInvoice()->toArray();
return $invoices;
}
/**
* #var integer
* #ORM\Column(name="id", type="integer")
* #ORM\Id
* #ORM\GeneratedValue(strategy="IDENTITY")
*/
private $id;
/**
* #var string
* #ORM\Column(name="company_name", type="string", length=50, nullable=false)
* #Assert\NotBlank()
*/
private $companyName;
//idem for next properties:
private $firstName;
private $lastName;
private $street;
private $number;
private $postalCode;
private $city;
}
And off course the getters and setters.
InvoiceItem.php =
/**
* #ORM\Table(name="invoicing_invoice_item")
* #ORM\Entity
*/
class InvoiceItem
{
/**
* #ORM\OneToMany(targetEntity="Invoicing\InvoicingBundle\Entity\InvoiceItemDetail", mappedBy="item_nr", cascade={"ALL"}, fetch="EAGER", orphanRemoval=true)
*/
private $item_detail;
public function __construct()
{ $this->item_detail = new ArrayCollection(); }
/**
* #return mixed
*/
public function getItemDetail()
{ return $this->item_detail; }
/**
* #param mixed $item_detail
*/
public function setItemDetail(Collection $item_detail)
{
foreach ($item_detail as $v)
{
if (is_null($v->getId()))
{
$v->getId($this);
}
}
$this->item_detail = $item_detail;
}
public function addDetail(InvoiceItemDetail $detail){
$detail->$this->setItemDetail($this);
$this->detail[] = $detail;
return $this;
}
public function removeDetail(InvoiceItemDetail $detail){
//
}
/**
* #var integer
* #ORM\Column(name="id", type="integer")
* #ORM\Id
* #ORM\GeneratedValue(strategy="IDENTITY")
*/
private $id;
/**
* #var \DateTime
* #ORM\Column(name="date", type="date", nullable=false)
*/
private $date;
/**
* #ORM\ManyToOne(targetEntity="Invoicing\CustomerBundle\Entity\InvoicingCustomer", inversedBy="invoice")
* #ORM\JoinColumn(onDelete="CASCADE", nullable=false)
* #Assert\Type(type="Invoicing\CustomerBundle\Entity\InvoicingCustomer")
* #Assert\Valid()
*
*/
private $customer;
// here also getters and setters
}
InvoiceItemDetail.php =
/**
* #ORM\Table(name="invoicing_invoice_itemdetail")
* #ORM\Entity
*/
class InvoiceItemDetail
{
/**
* #var integer
* #ORM\Column(name="id", type="integer")
* #ORM\Id
* #ORM\GeneratedValue(strategy="IDENTITY")
*/
private $id;
/**
* #ORM\Column(name="description", type="text", length=200, nullable=false)
*/
private $description;
/**
* #var string
* #ORM\Column(name="price", type="decimal", precision=10, scale=0, nullable=false)
*/
private $price;
/**
* #var integer
* #ORM\Column(name="amount", type="decimal", precision=10, scale=0, nullable=false)
*/
private $amount;
/**
* #ORM\ManyToOne(targetEntity="Invoicing\InvoicingBundle\Entity\InvoiceItem", inversedBy="item_detail" )
* #ORM\JoinColumn(onDelete="CASCADE", nullable=false, name="item_nr_id", referencedColumnName="id")
* #Assert\Type(type="Invoicing\InvoicingBundle\Entity\InvoiceItem")
* #Assert\Valid()
*/
private $item_nr;
// + getters and setters
}
Then, I got the types.
InvoiceItemType.php =
class InvoiceItemType extends AbstractType
{
/**
* #param FormBuilderInterface $builder
* #param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('date', 'date', array(
'format' => 'dd-MM-yyyy',
'empty_value' => array('year' => 'Year', 'month' => 'Month', 'day' => 'Day'),
'years' => range(date('Y') -1, date('Y')),
))
->add('customer', null, array(
'empty_value' => 'Choose a company',
'label' => 'Company',
'required' => true,
))
->add('item_detail', 'collection', array(
'type' => new InvoiceItemDetailType(),
'allow_add' => true,
'constraints' => new NotBlank(),
'by_reference' => false,
));
}
public function getName()
{ return 'invoiceitem'; }
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'Invoicing\InvoicingBundle\Entity\InvoiceItem',
));
}
}
InvoicingCustomerType.php =
class InvoicingCustomerType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add('companyName', 'text');
}
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'Invoicing\CustomerBundle\Entity\InvoicingCustomer',
));
}
public function getName()
{ return 'customer'; }
}
InvoiceItemDetailType.php =
class InvoiceItemDetailType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('description', 'text')
->add('price', 'number', array(
'label' => 'Price - €',
))
->add('amount', 'number');
}
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'Invoicing\InvoicingBundle\Entity\InvoiceItemDetail',
));
}
public function getName()
{ return 'detail'; }
}
In my controller I have this (InvoiceItemController.php):
/** InvoiceItem controller */
class InvoiceItemController extends Controller
{
/**
* Creates a new invoiceitem entity.
*/
public function createAction(Request $request)
{
$entity = new InvoiceItem();
$form = $this->createCreateForm($entity);
$form->handleRequest($request);
if ($form->isValid()) {
$em = $this->getDoctrine()->getManager();
// hack to work around handleRequest not using class methods to populate data
foreach($entity->getItemDetail() as $detail){
foreach($detail as $i){
// if i didn't made a second loop, I get an error: "object could not be converted to string..."
$i->this->setItemNr($entity);
$em->persist($i);
}
}
$em->persist($entity);
$em->flush();
return $this->redirect($this->generateUrl('invoiceitem_show', array('id' => $entity->getId())));
}
return $this->render('InvoicingBundle:InvoiceItem:new.html.twig', array(
'entity' => $entity,
'form' => $form->createView(),
));
}
}
In my twig it's just like:
{% block body -%}
<h1>Invoice item creation</h1>
{{ form(form) }}
{% endblock %}
Everything in the form is displayed good (and with javascript I can add several details to one invoice item). But when I submit the form, symfony throws an error:
An exception occurred while executing 'INSERT INTO invoicing_invoice_itemdetail (description, price, amount, item_nr_id) VALUES (?, ?, ?, ?)' with params ["test", 300, 1, null]:
SQLSTATE[23000]: Integrity constraint violation: 1048 Column 'item_nr_id' cannot be null
I searched around on the docs of symfony (http://symfony.com/doc/current/cookbook/form/form_collections.html ) and on stackoverflow (for example: Saving embedded collections ), but none of these give me the right solution.
I know this is a long post: I am sorry. But I don't know how to sort this problem out (+ I am new in learning symfony2 & new in asking questions here).
I believe your problem is in InvoiceItem entity. Try to create method addItemDetail (or maybe addInvoiceItemDetail) instead addDetail. You can also delete method setItemDetail and maybe you will see good explanation what method is Symfony looking for.
public function addItemDetail(InvoiceItemDetail $detail){
$detail->setItemNr($this);
$this->item_detail[] = $detail;
return $this;
}
And delete the hack from controller.
// hack to work around handleRequest not using class methods to populate data
foreach($entity->getItemDetail() as $detail){
foreach($detail as $i){
// if i didn't made a second loop, I get an error: "object could not be converted to string..."
$i->this->setItemNr($entity);
$em->persist($i);
}
}
I hope it helps but it is a little hard to answer this question without live code.
The concept of Symfony forms is that for relations you can also specify the related entity in the form type.
In your case, you didn't add InvoiceItemType to the Type ItemInvoiceDetail:
I would expect the following code in your InvoiceDetailType:
class InvoiceItemDetailType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('description', 'text')
->add('invoice_item', 'invoice_item', array('error_bubbling' => true, 'mapped' => true))
->add('price', 'number', array(
'label' => 'Price - €',
))
->add('amount', 'number');
}
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'Invoicing\InvoicingBundle\Entity\InvoiceItemDetail',
));
}
public function getName()
{ return 'detail'; }
Notice i set the type of the form element as invoice_item.
you can achieve that by defining it as a service:
service_name:
class: invoiceitemfullclasspath
arguments: []
tags:
- { name: form.type, alias: invoice_item }

Symfony2 embedded forms are nullified despite of cascade_validation being false

In a form (ContractType) I need the association to a Customer, which itself has an association to a Company and a BillingAddress. But the data itself of these 3 "nested entities" should not be editable at all in the contract view. That's why the field for the customer ManyToOne-Relation in the contract is hidden.
However for display purposes I need the whole customer-object in the form too (incl. the underlying company and billing address) - but as read-only. This is why I added an additional field customerFullObject to the contract form-type - which is not mapped (mapped => false). As it is an unmapped field, I set the data via a form event.
Now, when I edit the contract data, the company- and billing-address-data of the customer is nullified.
If I remove/comment the whole customerFullObject part, everything is fine.
I don't understand, how/why an unmapped field can change the data of an entity.
How can I prevent Symfony from this? Or is there a better "pattern"/way to achieve what I want? I couldn't find anything in the docs.
Below some code snippets from the model (removed namespaces for briefness):
Contract.php
/**
* #ORM\Entity
*/
class Contract {
/**
* #var Customer
* #ORM\ManyToOne(targetEntity="Customer", inversedBy="contracts")
* #Assert\Type(type="Customer")
* #Assert\NotBlank(message = "customer.notBlank")
**/
private $customer;
// Other fields left out as irrelevant.
}
Customer.php
/**
* #ORM\Entity
*/
class Customer {
/**
* #var Company
* #ORM\OneToOne(targetEntity="Company")
* #Assert\NotBlank(message="company.notBlank")
**/
private $company;
/**
* #var Address
* #ORM\OneToOne(targetEntity="Address")
* #Assert\NotBlank(message="billingAddress.notBlank")
**/
private $billingAddress;
/**
* #var ArrayCollection
* #ORM\OneToMany(targetEntity="Contract", mappedBy="customer", orphanRemoval=true, cascade={"all"})
**/
private $contracts;
// Other fields left out as irrelevant.
}
ContractType.php
class ContractType extends AbstractType {
public function buildForm(FormBuilderInterface $builder, array $options) {
$builder->add(
'customer',
'customerHiddenType',
array(
'label' => 'contract.customer',
)
);
$builder->add(
'customerFullObject',
'customerType',
array(
'cascade_validation' => false,
'mapped' => false
)
);
$builder->addEventListener(FormEvents::POST_SET_DATA, function (FormEvent $event) {
$form = $event->getForm();
$editMode = $form->getConfig()->getOption('editMode');
if (!empty($editMode) && $editMode === RequestAction::CREATE()) {
$form->remove('customerFullObject');
} else {
$form->get('customerFullObject')->setData($event->getData()->getCustomer());
}
}
);
// Other children left out as irrelevant.
}
public function setDefaultOptions(OptionsResolverInterface $resolver) {
$resolver->setDefaults(
array(
'data_class' => 'Contract',
'cascade_validation' => true
)
);
}
public function getName() {
return 'contractType';
}
}
CustomerType.php
class CustomerType extends AbstractType {
public function buildForm(FormBuilderInterface $builder, array $options) {
$builder->add(
'company',
'companyType',
array(
'cascade_validation' => false,
'label' => 'customer.company'
)
);
$builder->add(
'billingAddress',
'addressType',
array(
'cascade_validation' => false,
'label' => 'customer.billingAddress'
)
);
// Other children left out as irrelevant.
}
public function setDefaultOptions(OptionsResolverInterface $resolver) {
$resolver->setDefaults(
array(
'data_class' => 'Customer',
'cascade_validation' => true
)
);
}
public function getName() {
return 'customerType';
}
}