I've got some issues with Symfony's form validation handling. I'd like to validate a form bound to an entity based on its data. There are quite a bunch of information how to dynamically modify the form fields using FormEvents. What I'm missing on this topic is how to control/modify the validation.
My simplified use case is:
A user can add an event to a calendar.
The validation checks if there's already an event.
If there's a collision, the validation will throw an error.
The user should now be able to ignore this error/warning.
The validation is implemented as a Validator with Constraint::CLASS_CONSTRAINT as the target (as it's taking some more stuff into account).
I tried to:
Hack around the validation groups, but couldn't find access to the entity wide validators.
Hack around the FormEvents and add an extra field like "Ignore date warning".
Hack around the submit button to change it to something like "Force submit".
... but never found a working solution. Even simpler hacks with a single property based validator didn't work out. :(
Is there a Symfony way to dynamically control the validation?
Edit: My code looks like this:
use Doctrine\ORM\Mapping as ORM;
use Acme\Bundle\Validator\Constraints as AcmeAssert;
/**
* Appointment
*
* #ORM\Entity
* #AcmeAssert\DateIsValid
*/
class Appointment
{
/**
* #ORM\Column(name="title", type="string", length=255)
*
* #var string
*/
protected $title;
/**
* #ORM\Column(name="date", type="date")
*
* #var \DateTime
*/
protected $date;
}
The validator used as a service:
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
/**
* Validates the date of an appointment.
*/
class DateIsValidValidator extends ConstraintValidator
{
/**
* {#inheritdoc}
*/
public function validate($appointment, Constraint $constraint)
{
if (null === $date = $appointment->getDate()) {
return;
}
/* Do some magic to validate date */
if (!$valid) {
$this->context->addViolationAt('date', $constraint->message);
}
}
}
The corresponding Constraint class is set to target the entity class.
use Symfony\Component\Validator\Constraint;
/**
* #Annotation
*/
class DateIsValid extends Constraint
{
public $message = 'The date is not valid!';
/**
* {#inheritdoc}
*/
public function getTargets()
{
return self::CLASS_CONSTRAINT;
}
/**
* {#inheritdoc}
*/
public function validatedBy()
{
return 'acme.validator.appointment.date';
}
}
Edit 2: Try with FormEvents... I also tried all the different events.
$form = $formFactory->createBuilder()
->add('title', 'text')
->add('date', 'date')
->addEventListener(FormEvents::WHICHONE?, function(FormEvent $event) {
$form = $event->getForm();
// WHAT TO DO HERE?
$form->getErrors(); // Is always empty as all events run before validation?
// I need something like
if (!$dateIsValid) {
$form->setValidationGroup('ignoreWarning');
}
});
Edit 3: Constraint are correctly declared. That's not the issue:
services:
validator.acme.date:
class: AcmeBundle\Validator\Constraints\DateValidator
arguments: ["#acme.other_service"]
tags:
- { name: validator.constraint_validator, alias: acme.validator.appointment.date }
Validation is done on the entity, all Forms does is execute the Object's validations.
You can choose groups based on submitted data
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'validation_groups' => function(FormInterface $form) {
$data = $form->getData();
if (Entity\Client::TYPE_PERSON == $data->getType()) {
return array('person');
} else {
return array('company');
}
},
));
}
I have had issues when using this approach on embedded forms && cascade-validation
Edit: using flash to determine if validation must take place.
// service definition
<service id="app.form.type.callendar" class="%app.form.type.callendar.class%">
<argument type="service" id="session" />
<tag name="form.type" alias="my_callendar" />
</service>
// some controller
public function somAvtion()
{
$form = $this->get('app.form.type.callendar');
...
}
// In the form
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'validation_groups' => function(FormInterface $form) {
$session = $form->getSession();
if ($session->getFlashBag()->get('callendar_warning', false)) {
return array(false);
} else {
return array('Validate_callendar');
}
},
));
}
How does your user interact with the application to tell it to ignore the warning? Is there some kind of additional button?
In that case you could simply check the button used for submitting the form or add some kind of hidden field (ignore_validation) etc.
Wherever you end up getting that user input from (flash and dependency injection, based on submitted data etc.), I would then use validation groups and a closure to determine what to validate (just like juanmf explained in his answer).
RE your second approach (Form Events), you can add a priority to event listeners: As you can see in Symfony's Form Validation Event Listener, they use FormEvents::POST_SUBMIT for starting the validation process. So if you just add an event listener, it gets called before the validation listener and so no validation has happened yet.
If you add a negative priority to your listener, you should be able to also access the form validation errors:
$builder->addEventListener(FormEvents::POST_SUBMIT, function(){...}, -900);
Old question but...
I would first add a field (acceptCollision) in the form as suggested by you and other answers above.
Then you validator can do something like:
public function validate($appointment, Constraint $constraint)
{
if (null === $date = $appointment->getDate()) {
return;
}
if ($appointment->getAcceptCollision()) {
$valid = true;
} elseif (
// Check Unicity of the date (no collision)
) {
$valid = true;
} else {
$valid = false;
}
if (!$valid) {
$this->context->addViolationAt('date', $constraint->message);
}
}
I think you run into a problem because you are using the wrong concept. The decision which validation should be running belongs to the controller, not the validator.
So I would simply check in the controller which submit button is pressed (or weither there is a checkbox checked) and switch validation groups. However the form should be visually different, so I would probably create 2 forms for both states (both extend a base one or one form type that use options).
Related
Im trying to create an impersonate operation within my user controller, I have been following this guide..
impersonate for backpack
The setupImpersonateDefaults function gets called ok but i get a 404 error, after some testing i figured out the setupImpersonateRoutes is not getting triggered
Any ideas on why?
<?php
namespace App\Http\Controllers\Admin\Operations;
use Backpack\CRUD\app\Library\CrudPanel\CrudPanelFacade as CRUD;
use Illuminate\Support\Facades\Route;
use Session;
use Alert;
trait ImpersonateOperation
{
/**
* Define which routes are needed for this operation.
*
* #param string $segment Name of the current entity (singular). Used as first URL segment.
* #param string $routeName Prefix of the route name.
* #param string $controller Name of the current CrudController.
*/
protected function setupImpersonateRoutes($segment, $routeName, $controller)
{
Route::get($segment.'/{id}/impersonate', [
'as' => $routeName.'.impersonate',
'uses' => $controller.'#impersonate',
'operation' => 'impersonate',
]);
}
/**
* Add the default settings, buttons, etc that this operation needs.
*/
protected function setupImpersonateDefaults()
{
CRUD::allowAccess('impersonate');
CRUD::operation('impersonate', function () {
CRUD::loadDefaultOperationSettingsFromConfig();
});
CRUD::operation('list', function () {
// CRUD::addButton('top', 'impersonate', 'view', 'crud::buttons.impersonate');
CRUD::addButton('line', 'impersonate', 'view', 'crud::buttons.impersonate');
});
}
/**
* Show the view for performing the operation.
*
* #return Response
*/
public function impersonate()
{
CRUD::hasAccessOrFail('impersonate');
// prepare the fields you need to show
$this->data['crud'] = $this->crud;
$this->data['title'] = CRUD::getTitle() ?? 'Impersonate '.$this->crud->entity_name;
$entry = $this->crud->getCurrentEntry();
backpack_user()->setImpersonating($entry->id);
Alert::success('Impersonating '.$entry->name.' (id '.$entry->id.').')->flash();
// load the view
return redirect('dashboard');
// load the view
//return view('crud::operations.impersonate', $this->data);
}
}
Have tried following the guides and the routes are not getting added.
for anyone else looking at this, you need to call the route from the \routes\backpack\custom.php file, if its not called from this file it wont trigger the setupXXXRoute function
One of the official Backpack team members has created an add-on for impersonating users. You can use his add-on or get inspiration from it:
https://github.com/maurohmartinez/impersonate-users-backpack-laravel
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)
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'
])
I have created a CRUD with Symfony 3 that allows me to create different missions with a few specificities. I want to to create a function that allows someone with a specific role to change a mission's status just by clicking a button, that would be shown in the view like this
{{form_start(missionInProgress) }}
<input type="submit" value="Submit" />
{{form_end(missionInProgress) }}
Since I'm a real newbie and I can't find concrete example on Google, I tried a lot of things, but none worked so far. I tried to create a public function that would modify the mission's status when someone clicks on the input button
public function that updates the mission's status:
/**
* #Route("/{id}/encours", name="mission_encours")
* #Security has_role('ROLE_RECRUTEUR')
* #Method("POST")
*/
public function enCoursAction(Request $request, Mission $mission){
$form = $this->missionInProgress($mission);
$form->handleRequest($request);
if($form->isSubmitted() && $form->isValid()){
$em = $this->getDoctrine()->getManager();
$mission->setStatut("En cours");
$em->persist($mission);
}
}
And I also tried to create a private function like the one that allows a mission to be deleted from anywhere.
**Private function that calls the public function: **
/**
* #param Mission $mission
* #Security has_role('ROLE_RECRUTEUR')
* #return \Symfony\Component\Form\Form The form
*/
private function missionInProgress(Mission $mission){
$this->createFormBuilder()
->setAction($this->generateUrl('mission_encours', array('id' => $mission->getId())))
->setMethod('POST')
->getForm();
}
Following the "createDeleteForm" example, I implemented it in the showAction as follow:
/**
* Finds and displays a Mission entity.
*
* #Route("/{id}", name="mission_show")
* #Method("GET")
*/
public function showAction(Mission $mission)
{
$deleteForm = $this->createDeleteForm($mission);
$enCours = $this->missionInProgress($mission); /* There */
return $this->render('mission/show.html.twig', array(
'mission' => $mission,
'delete_form' => $deleteForm->createView(),
'missionInProgress' => $enCours->createView(), /* And there */
));
}
But when I try to see the result, I get the following error:
Error: Call to a member function createView() on null
Obviously nothing gets inside missionInProgress(), but I can't figure why and how to make this damn thing work. I also don't think that everything I did was necessary, but I thought that if I do this, I might increase my success chances...
Anyone has an idea ?
Thank you in advance
Try to add returnin your missionInProgress() method
I have 3 entity (Country, Region, City)
namespace ****\****Bundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
class Country
{
private $id;
private $name;
/**
* #var integer $regions
*
* #ORM\OneToMany(targetEntity="Region", mappedBy="Country")
*/
protected $regions;
//...
}
class Region
{
private $id;
private $name;
/**
* #var integer $country
*
* #Assert\Type(type="****\***Bundle\Entity\Country")
* #ORM\ManyToOne(targetEntity="Country", inversedBy="regions")
* #ORM\JoinColumn(name="country_id", referencedColumnName="id", nullable=false)
*/
private $country;
/**
* #ORM\OneToMany(targetEntity="City", mappedBy="Region")
*/
protected $cities;
}
class City
{
private $id;
private $name;
/**
* #var integer $region
*
* #Assert\Type(type="****\****Bundle\Entity\Region")
* #ORM\ManyToOne(targetEntity="Region", inversedBy="cities")
* #ORM\JoinColumn(name="region_id", referencedColumnName="id", nullable=false)
*/
private $region;
/**
* #ORM\OneToMany(targetEntity="Company", mappedBy="City")
*/
protected $companys;
//...
}
Here is my Form Class for City:
namespace ****\****Bundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;
class CityType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('name')
->add('region');
}
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'data_class' => '****\****Bundle\Entity\City',
));
}
public function getName()
{
return 'city';
}
}
This make a basic HTML5 Form with a textBox for the name and a SelectBox where all the region is available.
My question is what is the best way for adding a first SelectBox that will allow me to select the country in order to filter the second SelectBox and decrease the number of choice of Region?
EventListener ?
The Event Dispatcher Component ?
NO, EventListener and Event dispatcher are for events that happen ON THE SERVER, not on the client side. You need to use Javascript. When one of the select boxes changes, this should fire a javascript function and either make an AJAX call and fill the other select box with the results of that call or use some javascript code to select which options to show on the second box.
Look here for some ideas
As Carlos Granados said you have basically two options:
Create a separate Symfony actions that takes a country as parameter and returns a list of associated regions in XML or JSON format. (You can use Symfony\Component\HttpFoundation\JsonResponseto send a JSON response, however, there is no corresponding XmlResponse class). Use jQuery (or any other JS library or even plain Javascript) to send a request to the server whenever a user changes the currently selected item. In the callback (when you retrieved the response in the Javascript) you can update the region select box. You may find the documentation of jQuery Ajax interesting.
You store a list of all countries and its associated regions in your HTML code (you can use JSON or generate a native Javascript array) and whenever a user changes the value of the country select box you just have to replace the list of regions in the regions select box.
The second method has a heavier load on the initial loading of the form since all countries and its associated regions must be loaded (from the database or a text file or wherever you store them) and rendered in a format that can be easily read by JS.
The first method however must send a request every time a user selects another country. Additionally you have to implement another action.
I'm doing this myself on a form.
I change a field (a product) and the units in which the quantity can be measured are updated.
I am using a macro with parameters to adapt it more easily.
The macro :
{% macro javascript_filter_unit(event, selector) %}
<script>
$(function(){
$('#usersection')
.on('{{ event }}', '{{ selector }}', function(e){
e.preventDefault();
if (!$(this).val()) return;
$.ajax({
$parent: $(this).closest('.child_collection'),
url: $(this).attr('data-url'),
type: "get",
dataType: "json",
data: {'id' : $(this).val(), 'repo': $(this).attr('data-repo'), parameter: $(this).attr('data-parameter')},
success: function (result) {
if (result['success'])
{
var units = result['units'];
this.$parent.find('.unit').eq(0).html(units);
}
}
});
})
});
</script>
{% endmacro %}
The ajax returns an array : array('success' => $value, 'units' => $html). You use the $html code and put it in place of the select you want to change.
Of course the javascript code of the ajax call need to be modfied to match your fields.
You call the macro like you would normally do:
{% import ':Model/Macros:_macros.html.twig' as macros %}
{{ macros.javascript_filter_unit('change', '.unitTrigger') }}
So I have two arguments : the event, often a change of a select. and a selector, the one whose change triggers the ajax call.
I hope that helps.
As Carlos Granados said, you have to use client-side programming:Javascript.
I run also into the same problem as yours and came out with a solution that worked perfectly for me, here is the snippet on codepen, you can get inspired from (Cascade Ajax Selects)
//-------------------------------SELECT CASCADING-------------------------//
var currentCities=[];
// This is a demo API key that can only be used for a short period of time, and will be unavailable soon. You should rather request your API key (free) from http://battuta.medunes.net/
var BATTUTA_KEY="00000000000000000000000000000000"
// Populate country select box from battuta API
url="http://battuta.medunes.net/api/country/all/?key="+BATTUTA_KEY+"&callback=?";
$.getJSON(url,function(countries)
{
console.log(countries);
$('#country').material_select();
//loop through countries..
$.each(countries,function(key,country)
{
$("<option></option>")
.attr("value",country.code)
.append(country.name)
.appendTo($("#country"));
});
// trigger "change" to fire the #state section update process
$("#country").material_select('update');
$("#country").trigger("change");
});
$("#country").on("change",function()
{
countryCode=$("#country").val();
// Populate country select box from battuta API
url="http://battuta.medunes.net/api/region/"
+countryCode
+"/all/?key="+BATTUTA_KEY+"&callback=?";
$.getJSON(url,function(regions)
{
$("#region option").remove();
//loop through regions..
$.each(regions,function(key,region)
{
$("<option></option>")
.attr("value",region.region)
.append(region.region)
.appendTo($("#region"));
});
// trigger "change" to fire the #state section update process
$("#region").material_select('update');
$("#region").trigger("change");
});
});
$("#region").on("change",function()
{
// Populate country select box from battuta API
countryCode=$("#country").val();
region=$("#region").val();
url="http://battuta.medunes.net/api/city/"
+countryCode
+"/search/?region="
+region
+"&key="
+BATTUTA_KEY
+"&callback=?";
$.getJSON(url,function(cities)
{
currentCities=cities;
var i=0;
$("#city option").remove();
//loop through regions..
$.each(cities,function(key,city)
{
$("<option></option>")
.attr("value",i++)
.append(city.city)
.appendTo($("#city"));
});
// trigger "change" to fire the #state section update process
$("#city").material_select('update');
$("#city").trigger("change");
});
});
$("#city").on("change",function()
{
currentIndex=$("#city").val();
currentCity=currentCities[currentIndex];
city=currentCity.city;
region=currentCity.region;
country=currentCity.country;
lat=currentCity.latitude;
lng=currentCity.longitude;
$("#location").html('<i class="fa fa-map-marker"></i> <strong> '+city+"/"+region+"</strong>("+lat+","+lng+")");
});
//-------------------------------END OF SELECT CASCADING-------------------------//