For now I've successful used validation groups, but now I'm stuck with validation groups and nested mapped entities.
I'll explain the problem by a simplified example.
My entities: Address, Damage, Appliance
/**
* #ORM\Entity()
*/
class Address extends ...
{
/**
* #var string
* #ORM\Column(type="string", name="postcode", nullable=true)
* #Assert\NotBlank(
* groups={
* "damage_responsible_address",
* "appliance_repairer_address",
* })
*/
private $postcode;
...
/**
* #ORM\Entity()
*/
class Damage extends ...
{
/**
* #var boolean
* #ORM\Column(type="boolean", name="responsible", nullable=true)
* #Assert\NotBlank(groups={"damage"})
*/
private $responsible;
/**
* #ORM\OneToOne(targetEntity="Address", cascade={"persist","remove"})
* #ORM\JoinColumn(name="responsible_address_id", referencedColumnName="id")
* #Assert\Valid()
*/
private $responsibleAddress;
/**
* #ORM\ManyToMany(targetEntity="Appliance", orphanRemoval=true, cascade={"persist", "remove"})
* #ORM\JoinTable(name="coronadirect_cuzo_home_damage_appliances",
* joinColumns={#ORM\JoinColumn(name="damage_id", referencedColumnName="id")},
* inverseJoinColumns={#ORM\JoinColumn(name="appliance_id", referencedColumnName="id")}
* )
*/
private $appliances;
...
/**
* #ORM\Entity()
*/
class Appliance extends ...
{
/**
* #var boolean
* #ORM\Column(type="boolean", name="to_repair", nullable=true)
* #Assert\NotBlank(groups={"appliance"})
*/
private $toRepair;
/**
* #ORM\OneToOne(targetEntity="Address", cascade={"persist","remove"})
* #ORM\JoinColumn(name="repairer_address_id", referencedColumnName="id")
* #Assert\Valid()
*/
private $repairAddress;
...
To define my forms I use a AddressType, DamageType and ApplianceType:
class DamageType extends ...
{
/**
* {#inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add('appliances', 'collection', array(
'type' => 'home_damage_appliance_type',
'allow_add' => true,
'allow_delete' => true,
'prototype' => true,
'options' => array(
'cascade_validation' => true,
)
));
$builder->add('responsible', 'choice', array(
'choices' => $this->getYesNoChoiceArray(),
'expanded' => true,
'multiple' => false,
));
$builder->add('responsibleAddress', 'address_type', array(
'required' => true
));
...
}
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'Damage',
'cascade_validation' => true,
'validation_groups' =>
function(FormInterface $form) {
$groups = array('damage');
if ($form->getData()->getResponsible() == true) {
$groups[] = 'damage_responsible_address';
}
return $groups;
}
));
}
I'm adding the damage_responsible_address group when responsible is set to true in the form.
Otherwise I don't want the address to be validated.
class ApplianceType extends ...
{
/**
* {#inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add('toRepair', 'choice', array(
'choices' => $this->getYesNoChoiceArray(),
'expanded' => true,
'multiple' => false,
));
$builder->add('repairAddress', 'address_type', array(
'required' => true
));
...
}
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'Appliannce',
'cascade_validation' => true,
'validation_groups' =>
function(FormInterface $form) {
$groups = array('appliance');
if ($form->getData()->getToRepair() == true) {
$groups[] = 'appliance_repairer_address';
}
return $groups;
}
));
}
Same as previous, when toRepair is true I want to validate the address.
What's going wrong ?
I the Damage responsible is true and the appliance toRepair is false, the form does give validation errors on the responsible address BUT also on the the appliance address.
The same for the other way arround: When an appliance address is invalid (toRepar is true), then the responsibleAddress is also invalid (even when responsible is false).
The address validation groups don't look on which form they are defined, but just attatch them to every address item in the form.
Is it possible to define validation groups specific for a form only?
I am using Doctrine and Symfony 2.3.6.
The problem is that symfony uses OR-logic for validation groups. So then your apply one of groups to form it will validate address in both cases.
If you move the groups to a parent entity, it will not solve the problem?
/**
* #ORM\Entity()
*/
class Damage extends ...
{
/**
* #ORM\OneToOne(targetEntity="Address", cascade={"persist","remove"})
* #ORM\JoinColumn(name="responsible_address_id", referencedColumnName="id")
* #Assert\Valid(
* groups={
* "damage_responsible_address"
* })
* )
*/
private $responsibleAddress;
}
italic is for general case, bold for the particular case of this question.
The address validation groups don't look on which form they are defined, but just attach them to every address item in the form.
True, your validation_group callback in DamageType set validation_group for all the form, and as you use cascade_validation, for all embed AddressType the form contains. There is no reason it should understand that you want it set only for responsible address.
Generally, setting a validation group through callback for a parent form, with cascade validation set to true, will make all child forms validated against this validation group. If we want to set different validation groups for each children, we have to put a validation group callback in children formtype.
You must place your validation callback in AddressType, so it will be called for each Address Form. Of course, in order to do so, each Adress entity must be aware of its owner.
An alternative to validation callback is Group Sequence Provider. But in any case you still have to set your validation groups in each Address entity and not in Damage, so your Adress must still be aware of its owner.
I know it has been a while, but here is the documentation that may answer the problem with symfony v3:
http://symfony.com/doc/current/form/data_based_validation.html
Related
Symfony 5.2.5
Minified code
//Entities
class Article {
/**
* #ORM\Id
* #ORM\GeneratedValue
* #ORM\Column(type="integer")
*/
private $id;
/**
* #ORM\OneToMany(targetEntity=ArticleTranslation::class, mappedBy="article", cascade={"persist"}, orphanRemoval=true)
* #Assert\Valid
*/
private $translations;
}
class ArticleTranslation {
/**
* #ORM\Id
* #ORM\GeneratedValue
* #ORM\Column(type="integer")
*/
private $id;
/**
* #ORM\Column(type="string", length=255)
* #Assert\NotBlank
*/
private $title;
/**
* #ORM\Column(type="text")
* #Assert\NotBlank
*/
private $body;
/**
* #ORM\ManyToOne(targetEntity=Article::class, inversedBy="translations")
* #ORM\JoinColumn(nullable=false)
*/
private $article;
/**
* #ORM\Column(type="string", length=5)
* #Assert\NotBlank
*/
private $locale;
}
//FormTypes
class ArticleType extends AbstractType {
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add(/*other fields*/)
->add('translations', ArticleTranslationType::class, ['label' => false, 'data' => new ArticleTranslation(), 'mapped' => false])
->add('save', SubmitType::class, ['label' => 'Save']);
$builder->get('translations')->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) {
$entity = $event->getForm()->getParent()->getData();
$translation = $event->getData();
$translation->setLocale($this->localeService->getCurrentLocale()); //custom service returns e.g. "en"
$entity->addTranslation($translation);
});
}
}
class ArticleTranslationType extends AbstractType {
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('title', TextType::class)
->add('body', TextareaType::class);
}
}
I have two entities called Article and ArticleTranslation with a OneToMany relationship. When creating an Article I want to add a ArticleTranslation to it (e.g. English) - that way there is atleast 1 translation provided. The Article itself just stores generic data like publish-date, while the translation stores title and the content (called body). The above code works fine my only issue is following:
When the validation for title or body fails, the error message is shown above the formular, instead of right next to the associated field. Every other field correctly has the error message right next to it. I am using the default bootstrap 4 form theme.
How can I move the error message to the correct field? The Symfony profiler returns that data.translations[0].body should not be null (since its a collection it has an index) - I guess I need somehow make that into data.translations.body for it to work?
Temporary fix: When adding the validation inside my ArticleTranslationType & remove the Assert\Valid constraint it works. Still interested in another solution with my provided code - Thanks
What you're looking for is the error_bubbling FormType field option.
error_bubbling
type: boolean default: false unless the form is compound.
If true, any errors for this field will be passed to the parent field or form. For example, if set to true on a normal field, any errors for that field will be attached to the main form, not to the specific field.
Your ArticleTranslationType is compound, therefore error_bubbling defaults to true.
The following should do the trick.
$builder->add(
'translations', ArticleTranslationType::class, array(
'data' => new ArticleTranslation(),
'error_bubbling' => false,
'mapped' => false,
'label' => false
)
);
After playing around I finally got my solution. Since the validation tries to validate the first element in my collection e.g. data.translations[0].body I needed just to provide the correct property path for it to know.
$builder->add(
'translations', ArticleTranslationType::class, array(
'data' => new ArticleTranslation(),
'mapped' => false,
'label' => false,
'property_path' => 'translations[0]' //first element of collection
)
);
This maps the error messages to the corresponding field.
On Symfony 2.8, I got the following entities:
Contact:
class Contact
{
/**
* #ORM\Id
* #ORM\Column(type="integer")
* #ORM\GeneratedValue
*/
protected $id;
/**
* #ORM\Column
* #Assert\NotBlank
*/
protected $name;
/**
* #ORM\OneToMany(targetEntity="EmailContact", mappedBy="contact", cascade={"persist"})
* #Assert\Valid
*/
protected $emails;
// ...
/**
* Add emails
*
* #param \AppBundle\Entity\EmailContact $emails
* #return Contact
*/
public function addEmail(\AppBundle\Entity\EmailContact $emails)
{
$this->emails[] = $emails;
$emails->setContact($this); //this line added by me
return $this;
}
// ...
EmailContact:
class EmailContact
{
/**
* #ORM\Id
* #ORM\Column(type="integer")
* #ORM\GeneratedValue
*/
protected $id;
/**
* #ORM\Column
* #Assert\NotBlank
*/
protected $email;
/**
* #ORM\ManyToOne(targetEntity="Contact", inversedBy="emails")
* #ORM\JoinColumn(nullable=false)
*/
protected $contact;
// ...
The rest of the methods were automatically generated by the doctrine:generate:entities command.
My forms are as follows:
ContactType:
class ContactType extends AbstractType
{
/**
* #param FormBuilderInterface $builder
* #param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('name', null, [
'label' => 'contact.name',
])
->add('emails', CollectionType::class, [
'label' => false,
'entry_options' => array('label' => false),
'entry_type' => EmailContactType::class,
'allow_add' => true,
'allow_delete' => true,
'delete_empty' => true,
'prototype' => true,
])
;
}
/**
* #param OptionsResolver $resolver
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => 'AppBundle\Entity\Contact'
]);
}
EmailContactType:
class EmailContactType extends AbstractType
{
/**
* #param FormBuilderInterface $builder
* #param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('email', EmailType::class, [
'label' => 'emailContact.email',
])
;
}
/**
* #param OptionsResolver $resolver
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => 'AppBundle\Entity\EmailContact'
]);
}
I do the javascript to add extra fields to the request, and submit it. Example request (from Symfony Profiler):
[
name => test4,
emails => [
0 => [
email => t#t.t4
],
1 => [
email => t#t.t5
]
],
_token => ...
]
But I get the following error:
An exception occurred while executing 'INSERT INTO email_contact ...
SQLSTATE[23000]: Integrity constraint violation: 1048 Column 'contact_id' cannot be null
Debugging, I see that the addEmail method above never gets called. What is happening here?
You missed by_reference => false in form collection definition
->add('emails', CollectionType::class, [
'label' => false,
'entry_options' => array('label' => false),
'entry_type' => EmailContactType::class,
'allow_add' => true,
'allow_delete' => true,
'delete_empty' => true,
'prototype' => true,
'by_reference' => false; // <--- you missed this
]);
Take a look here
Your code should run as expected after this modification.
Moreover remember that if you have a setEmails() method inside Contact class, the framework end up to calling it and so you need (for each element of the collection) to set contact as well (as you're correctly doing in addEmails())
I have a list of Doctrine entities (called "Circuit") and would like to generate a form listing them within a <table> and add a way to tick them for mass deletion (kind of what Sonata Admin does, without the need for an admin class).
I've looked everywhere but I can't figure out for the life of me what to do. There is just one layer to this class (plain old object), and every time I try to add a collection type to my form builder I get the following error:
Neither the property "circuits" nor one of the methods "getCircuits()", "circuits()", "isCircuits()", "hasCircuits()", "__get()", "__call()" exist and have public access in class "NetDev\CoreBundle\Entity\Circuit".
Am I supposed to create a "proxy" class to create a collection of circuits ? Did I miss something ?
All the howtos I found so far are using a "master" class like "Article" and a collection of child classes like "Categories" which doesn't apply to my present issue.
Here is my CircuitsController.php (I use the "addAction" for the tests, eventually everything will be located in indexAction):
<?php
namespace NetDev\WebManagerBundle\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use NetDev\CoreBundle\Form\CircuitType;
use NetDev\CoreBundle\Entity\Circuit;
class CircuitsController extends Controller {
public function indexAction($page = 1) {
$listCircuits = $this->getDoctrine()->getManager()->getRepository('NetDevCoreBundle:Circuit')->findAll();
$content = $this->get('templating')->render('NetDevWebManagerBundle:Circuits:index.html.twig',
array('listCircuits' => $listCircuits));
return new Response($content);
}
public function addAction(Request $request) {
$circuit = new Circuit();
$form = $this->createForm(new CircuitType(), $circuit);
if ($request->isMethod('POST') && $form->handleRequest($request)->isValid()) {
/* some action that is not actually relevant */
}
return new Response($this->get('templating')->render('NetDevWebManagerBundle:Circuits:add.html.twig',
array('circuit' => $circuit,
'form' => $form->createView())));
}
The CircuitType.php file:
<?php
namespace NetDev\CoreBundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;
class CircuitType extends AbstractType
{
/**
* #param FormBuilderInterface $builder
* #param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('circuits', 'collection', array('type' => 'entity', 'allow_add' => true,
'allow_delete' => true, 'by_reference' => false,
'label' => false,
'options' => array('class' => 'NetDevCoreBundle:Circuit',
'label' => false, 'multiple' => true,
'expanded' => true)
))
/* ->add('vlanId', 'integer', array('required' => true, 'label' => 'VLAN ID')) */
/* ->add('popOut', 'text', array('required' => true, 'label' => 'Injecting PoP', */
/* 'max_length' => 3)) */
/* ->add('popsIn', 'textarea', array('required' => true, 'label' => 'Listening PoP')) */
/* ->add('bandwidth', 'integer', array('label' => 'Bandwidth')) */
/* ->add('xconnectId', 'text', array('label' => 'Cross-connect ID')) */
/* ->add('Create', 'submit') */
;
}
/**
* #return string
*/
public function getName()
{
return 'netdev_corebundle_circuit';
}
}
And finally, the Circuit.php entity file:
<?php
namespace NetDev\CoreBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Circuit
*
* #ORM\Table()
* #ORM\Entity(repositoryClass="NetDev\CoreBundle\Entity\CircuitRepository")
*/
class Circuit
{
/**
* #var integer
*
* #ORM\Column(name="id", type="integer")
* #ORM\Id
* #ORM\GeneratedValue(strategy="AUTO")
*/
private $id;
/**
* #var integer
*
* #ORM\Column(name="vlan_id", type="integer")
* #Assert\Type(type="int")
* #Assert\Range(min="1", max="4096")
*/
private $vlanId;
/**
* #var array
*
* #ORM\Column(name="pop_out", type="array")
* #Assert\NotBlank()
* #Assert\Length(max=3)
*/
private $popOut;
/**
* #var array
*
* #ORM\Column(name="pops_in", type="array")
* #Assert\NotBlank()
*/
private $popsIn;
/**
* #var integer
*
* #ORM\Column(name="bandwidth", type="integer")
* #Assert\Type(type="int")
*/
private $bandwidth;
/**
* #var string
*
* #ORM\Column(name="xconnect_id", type="string", length=255)
* #Assert\NotBlank()
* #Assert\Length(max="255")
*/
private $xconnectId;
/* Getters and Setters stripped for clarity's sake */
public function __toString() {
return "{$this->vlanId}-{$this->popOut}";
}
}
If you need the twig template tell me, I haven't added it because I am not even close to having something outputted aside from that Exception.
As said by #Cerad, the answer here was to pass the result of
$listCircuits = $this->getDoctrine()->getManager()->getRepository('NetDevCoreBundle:Circuit')->findAll();
directly to the collection. Everything worked nicely afterwards.
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';
}
}
I've been trying to implement embed forms in Sonata Admin Bundle 2.0, Sonata User Bundle 2.0 and Symfony 2.0.16 (yes, I know it's kind of old right now) and after reading a lot of forum posts and manual I could be able to implement it... but just at form level, it can't display data in edition mode, or persist it in creation mode.
Being more expecific, I'm trying to make work a relationship between an User entity (from Sonata User Bundle) with an Email entity, in an one-to-many relationship (one User has many Emails, or just one). So in the User form is gonna have one or more emails forms dynamically embeded, which seems to be working, but are disconnected with the email table.
systemUser is pointing to the table user. I got to change it because I'm using PostgreSQL and word is reserved.
UserAdmin.php
<?php
class UserAdmin extends Admin
{
// more code
$formMapper
->with('General')
->add('username')
->add('email')
->add('plainPassword', 'text', array('required' => false))
->end()
->with('Groups')
->add('groups', 'sonata_type_model', array('required' => false))
->end()
->with('Profile')
->add('dateOfBirth', 'date', array('required' => false))
->add('firstname', null, array('required' => false))
->add('lastname', null, array('required' => false))
->add('website', 'url', array('required' => false))
->add('locale', null, array('required' => false))
->add('timezone', null, array('required' => false))
->end()
->with('Emails')
->add('emails', 'collection', array(
'label' => 'Emails',
'required' => true,
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
),
array(
'edit' => 'inline',
'inline' => 'table',
'sortable' => 'id',
'targetEntity'=> 'MyProject\xBundle\Entity\Email',
'link_parameters' => array('context' => 'user'),
)
)
;
// more code ...
}
User.php
<?php
class User extends BaseUser
{
/**
* #var array emails
*/
protected $emails;
/**
* #var string user
*/
protected $user;
public function __construct()
{
$this->emails = new \Doctrine\Common\Collections\ArrayCollection();
}
/**
* Add emails
*
* #param MyProject\xBundle\Email $email
*/
public function addEmails(\MyProject\xBundle\Entity\Email $email)
{
$this->emails[] = $email;
}
/**
* Get emails
*
* #return Doctrine\Common\Collections\Collection
*/
public function getEmails()
{
return $this->emails;
}
/**
* Set emails
*
* #param $emails
* #return Email
*/
public function setEmails($emails)
{
$this->$emails = $emails;
foreach ($emails as $email) {
$email->setUser($this);
}
}
/**
*
* #param string $user
*/
public function setUser($user)
{
$this->user = $user;
}
}
Email.php
<?php
class Email
{
/**
* #var SystemUser
*
* #ORM\ManyToOne(targetEntity="User", cascade={"persist"})
* #ORM\JoinColumns({
* #ORM\JoinColumn(name="system_user_id", referencedColumnName="id")
* })
*
*/
private $systemUser;
public function __construct()
{
$this->systemUser = new ArrayCollection();
}
/**
*
* #param MyProject\xBundle\Entity\User $systemUser
*/
public function setSystemUser(\MyProject\xBundle\Entity\User $systemUsers = null)
{
$this->systemUser = $systemUser;
return $this;
}
/**
* Get systemUser
*
* #return MyProject\xBundle\Entity\User
*/
public function getSystemUser()
{
return $this->systemUser;
}
}
Your approach needs a 'forward' relationship from the User to the Email entity, otherwise the admin doesn't know what to change and persist. Change your code in User.php so that the email variable actually knows about its relationship. That means you should add something like
/**
* #var array emails
* #ORM\OneToMany(targetEntity="Email", mappedBy="systemUser", cascade={"all"})
*/
protected $emails;