If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeThanks to these event listeners, no matter what data we start with - or what data we submit - for the location
field, the specificLocationName
field choices will update so that everything saves.
The last step is to add some JavaScript! When the form loaded, the location
was set to "Near a star". When I change it to "The Solar System", we need to make an Ajax call that will fetch the list of planets and update the option elements.
In ArticleAdminController
, let's add a new endpoint for this: public function getSpecificLocationSelect()
. Add Symfony's Request
object as an argument. Here's the idea: our JavaScript will send the location that was just selected to this endpoint and it will return the new HTML needed for the entire specificLocationName
field. So, this won't be a pure API endpoint that returns JSON. We could do that, but because the form is already rendering our HTML, returning HTML simplifies things a bit.
... lines 1 - 14 | |
class ArticleAdminController extends AbstractController | |
{ | |
... lines 17 - 72 | |
public function getSpecificLocationSelect(Request $request) | |
{ | |
... lines 75 - 86 | |
} | |
... lines 88 - 99 | |
} |
Above the method add the normal @Route()
with /admin/article/location-select
. And give it a name="admin_article_location_select"
.
... lines 1 - 69 | |
/** | |
* @Route("/admin/article/location-select", name="admin_article_location_select") | |
*/ | |
public function getSpecificLocationSelect(Request $request) | |
... lines 74 - 101 |
Inside, the logic is kinda cool: create a new Article
: $article = new Article()
. Next, we need to set the new location onto that. When we make the AJAX request, we're going to add a ?location=
query parameter. Read that here with $request->query->get('location')
.
... lines 1 - 72 | |
public function getSpecificLocationSelect(Request $request) | |
{ | |
$article = new Article(); | |
$article->setLocation($request->query->get('location')); | |
... lines 77 - 86 | |
} | |
... lines 88 - 101 |
But, let's back up: we're not creating this Article
object so we can save it, or anything like that. We're going to build a temporary form using this Article's data, and render part of it as our response. Check it out: $form = $this->createForm(ArticleFormType::class,
$article). We know that, thanks to our event listeners - specifically our PRE_SET_DATA
event listener - this form will now have the correct specificNameLocation
options based on whatever location was just sent to us.
... lines 1 - 72 | |
public function getSpecificLocationSelect(Request $request) | |
{ | |
... lines 75 - 76 | |
$form = $this->createForm(ArticleFormType::class, $article); | |
... lines 78 - 86 | |
} | |
... lines 88 - 101 |
Or, the field may have been removed! Check for that first: if (!$form->has('specificLocationName')
then just return new Response()
- the one from HttpFoundation
- with no content. I'll set the status code to 204, which is a fancy way of saying that the call was successful, but we have no content to send back.
... lines 1 - 72 | |
public function getSpecificLocationSelect(Request $request) | |
{ | |
... lines 75 - 78 | |
// no field? Return an empty response | |
if (!$form->has('specificLocationName')) { | |
return new Response(null, 204); | |
} | |
... lines 83 - 86 | |
} | |
... lines 88 - 101 |
If we do have that field, we want to render it! Return and render a new template: article_admin/_specific_location_name.html.twig
. Pass this the form like normal 'articleForm' => $form->createView()
. Then, I'll put my cursor on the template name and press alt+enter to make PhpStorm create that template for me.
... lines 1 - 72 | |
public function getSpecificLocationSelect(Request $request) | |
{ | |
... lines 75 - 83 | |
return $this->render('article_admin/_specific_location_name.html.twig', [ | |
'articleForm' => $form->createView(), | |
]); | |
} | |
... lines 88 - 101 |
Inside, just say: {{ form_row(articleForm.specificLocationName) }}
and that's it.
{{ form_row(articleForm.specificLocationName) }} |
Yep, we're literally returning just the form row markup for this one field. It's a weird way to use a form, but it works!
Let's go try this out! Copy the new URL, open a new tab and go to http://localhost:8000/admin/article/location-select?location=star
Cool! A drop-down of stars! Try solar_system
and... that works too. Excellent!
Next, open _form.html.twig
. Our JavaScript will need to be able to find the location
select
element so it can read its value and the specificLocationName
field so it can replace its contents. It also needs to know the URL to our new endpoint.
No problem: for the location
field, pass an attr
array variable. Add a data-specific-location-url
key set to path('admin_article_location')
. Then, add a class set to js-article-form-location
.
{{ form_start(articleForm) }} | |
... lines 2 - 5 | |
{{ form_row(articleForm.location, { | |
attr: { | |
'data-specific-location-url': path('admin_article_location_select'), | |
'class': 'js-article-form-location' | |
} | |
}) }} | |
... lines 12 - 22 | |
{{ form_end(articleForm) }} |
Next, surround the specificLocationName
field with a new <div class="js-specific-location-target">
. I'm adding this as a new element around the field instead of on the select element so that we can remove the field without losing this target element.
... lines 1 - 11 | |
<div class="js-specific-location-target"> | |
{% if articleForm.specificLocationName is defined %} | |
... line 14 | |
{% endif %} | |
</div> | |
... lines 17 - 23 |
Ok, we're ready for the JavaScript! Open up the public/
directory and create a new file: admin_article_form.js
. I'm going to paste in some JavaScript that I prepped: you can copy this from the code block on this page.
$(document).ready(function() { | |
var $locationSelect = $('.js-article-form-location'); | |
var $specificLocationTarget = $('.js-specific-location-target'); | |
$locationSelect.on('change', function(e) { | |
$.ajax({ | |
url: $locationSelect.data('specific-location-url'), | |
data: { | |
location: $locationSelect.val() | |
}, | |
success: function (html) { | |
if (!html) { | |
$specificLocationTarget.find('select').remove(); | |
$specificLocationTarget.addClass('d-none'); | |
return; | |
} | |
// Replace the current field and show | |
$specificLocationTarget | |
.html(html) | |
.removeClass('d-none') | |
} | |
}); | |
}); | |
}); |
Before we talk about the specifics, let's include this with the script
tag. Unfortunately, we can't include JavaScript directly in _form.html.twig
because that's an included template. So, in the edit template, override {% block javascripts %}
, call the {{ parent() }}
function and then add a <script>
tag with src="{{ asset('js/admin_article_form.js') }}
.
... lines 1 - 10 | |
{% block javascripts %} | |
{{ parent() }} | |
<script src="{{ asset('js/admin_article_form.js') }}"></script> | |
{% endblock %} |
Copy that, open the new template, and paste this at the bottom of the javascripts
block.
... lines 1 - 2 | |
{% block javascripts %} | |
... lines 4 - 7 | |
<script src="{{ asset('js/admin_article_form.js') }}"></script> | |
{% endblock %} | |
... lines 10 - 24 |
Before we try this, let's check out the JavaScript so we can see the entire flow. I made the code here as simple, and unimpressive as possible - but it gets the job done. First, we select the two elements: $locationSelect
is the actual select
element and $specificLocationTarget
represents the div
that's around that field. The $
on the variables is meaningless - I'm just using it to indicate that these are jQuery elements.
Next, when the location
select changes, we make the AJAX call by reading the data-specific-location-url
attribute. The location
key in the data
option will cause that to be set as a query parameter.
Finally, on success, if the response is empty, that means that we've selected an option that should not have a specificLocationName
dropdown. So, we look inside the $specificLocationTarget
for the select and remove it to make sure it doesn't submit with the form. On the wrapper div, we also need to add a Bootstrap class called d-none
: that stands for display none. That will hide the entire element, including the label.
If there is some HTML returned, we do the opposite: replace the entire HTML of the target with the new HTML and remove the class so it's not hidden. And... that's it!
There are a lot of moving pieces, so let's try it! Refresh the edit page. The current location is "star" and... so far, no errors in my console. Change the option to "The Solar System". Yes! The options updated! Try "Interstellar Space"... gone!
If you look deeper, the js-specific-location-target
div is still there, but it's hidden, and only has the label
inside. Change back to "The Solar System". Yep! The d-none
is gone and it now has a select
field inside.
Try saving: select "Earth" and Update! We got it! We can keep changing this all day long - all the pieces are moving perfectly.
I'm super happy with this, but it is a complex setup - I totally admit that. If you have this situation, you need to choose the best solution: if you have a big form with 1 dependent field, what we just did is probably a good option. But if you have a small form, or it's even more complex, it might be better to skip the form component and code everything with JavaScript and API endpoints. The form component is a great tool - but not the best solution for every problem.
Next: there are a few small details we need to clean up before we are fully done with this form. Let's squash those!
Hey @Courtney-T
What part are you talking about? Anyway, you can check the EasyAdmin docs https://symfony.com/bundles/EasyAdminBundle/current/index.html or perhaps the UPGRADE file may be useful https://github.com/EasyCorp/EasyAdminBundle/blob/4.x/UPGRADE.md
Cheers!
Specifically with regards to needing to inject javascript to update the information live on a form. Or, at the very least on first creation or update. I got the auto update to occur with a BeforeUpdateSubscriber but, no such luck on first persist or create.
I'll try to describe what I'm trying to do. I'm creating a sale writing app. Any number of items can be added to the sale and I need the subtotal, tax, grand total fields to update as items are added. The items are collection fields and the subtotal, tax, grand total fields are number fields.
Hey everyone,
trying to replicate this on Symfony 6 .
The field update by js dynamically work great's , almost .
I got a strange error that the field newly updated aren't submitted in form.
When i debug in a FormEvents::SUBMIT and dump the data , i get only the field that i do not dynamicaly generate...
If someone get the same case ^^
Yo Quentin D.!
Ooof, yea, this stuff is super complex :/. Unfortunately, I can't offer any good suggestions. Have you triple-checked that the dynamically-generated data IS being submitted? Like, if you look at the POST data (you can look at this in your Network tools in your browser, find the request then find the POST data), do you see the dynamically-generated field? Does its name look correct? That's what I would check... but I'm really not sure what could be going wrong.
Cheers!
Hey folks,
I'm trying to replicate this in Symfony 6 using my own classes but trying to pass the non persisted entity to CreateForm is throwing this error:
Entity of type "App\Entity\MyEntity" passed to the choice field must be managed. Maybe you forget to persist it in the entity manager?
Searching has possible causes all over the map. Where does one even begin to solve such a thing?
Notes:
1. There are several EntityType fields that are choices in the form.
2. The field I'm setting from data provided by the endpoint is one of these fields.
3. The data I'm setting on this field is an actual persisted entity resolved in the endpoint.
4. I do see a possible workaround using non persisted fields in the entity but this seems like a bit of a hack.
Thanks!
Hey Aaron,
Are you sure the error is thrown when you pass the entity to the createForm()? You can easily double-check it by putting a dd('stop'); code right after this createForm() call. If you got your dd() - then the problem is somewhere else below. Most probably, you forget to call persist($entity) on your entity before calling flush() and the end of the request when trying to save results to the DB. If so, then the solution would be to call persist($entity) on that "App\Entity\MyEntity" instance before calling flush().
Also, consider that if you have some listeners that may also call flush() - you probably need to call persist() a bit earlier. Just in case, try to call the persist right after you created the entity, does it help?
Cheers!
Figured out why this was happening. The short short version is that my code was trying to set an unpersisted entity onto a form field and it didn't like that for obvious reasons. One mystery solved.
The primary problem I was trying to solve remains elusive but I have to move on so for now I've just introduced a hidden intermediate non persisted text field to set the id of the entity chosen in autocomplete and send that back on form submit. I'll then just grab the proper entity in the controller and set the persisted field right before persisting. I'll circle back around and give it another look later. I find that provides new insight sometimes.
Hopefully one day my understanding of the form system will make this an easy challenge or the process will get easier. Until then thanks for the help!
Hey Aaron,
Yes, your short version about the problem sounds valid, at least that's what I thought from the error you mentioned.
Anyway, I'm happy you were able to find the primary problem and solve it yourself! And thanks for sharing the problem with others.
I believe it should be so, you just need to get used to Symfony Form component. The mot common problem IMO is that people start creating very complex forms (probably because they need them in their projects) without understanding how simple things works, i.e. jump over a few levels. And this lead to the misunderstanding and confusing. But with more practice, it should get better I think, just start with simple forms and complicate them step by step. Also, I'd recommend you to look over all the available options of the specific form type you're going to use in Symfony docs, you may find some good options that will fit in your specific case.
Cheers!
There is no flushing going on in the listener. I did isolate it to when the listener is being added at this line:
`$builder->get('<field that has the data set in the controller endpoint>')->addEventListener(
FormEvents::POST_SUBMIT,
function (FormEvent $event) {`
Maybe it's because I'm setting this listener on the field itself? Perhaps there's a better approach to what I'm trying to accomplish.
Step 4 initially was causing strange reverse transform errors saying the value that corresponds to the id of the entity the field represents was invalid. So then I tried this method and so here I am.
OK that's interesting how that gets parsed. So that should be get('fieldThatHasTheDataSetInTheControllerEndpoint')
I'm looking for an example, where you fill a select box field ChoiceType, and depending on your choice, we gonna append some form field!
Hey ahmedbhs!
What you're trying to do "might" actually be much easier :). For example, you could choose to ALWAYS include this "extra" form field. Then, via JavaScript, listen to a "change" event on the select element and, if the value matches something, hide/show that "extra" field. This is much simpler than all the form listener magic. The only thing you need to be aware of is that a "bad" user could unhide the "extra" field and put data in it even if they have selected an option that should not have it. However, you could pretty easily "clean that up" in your controller: check to see if the field should have been hidden, and clear out that field (for example, if your form is bound to an entity, then set that extra field's property back to null). There are also fancier ways in the form itself to set the extra field's data back to null if the select field is set to some value, and you can look into that if you care enough about that :).
Let me know if that helps! Cheers!
Phew, that was complex!
I had some trouble with bootstrap select as it always rendered my „response select“ from Twig as display: none
... The solution for this was to call .selectpicker()
again after the js script was finished so it rendered all selects again. Just in case someone runs into the same problem ;)
Another very strange behaviour: when using the article_form_location
class it pushed the whole site into that select-div instead of showing me the Twig-select... Got that fixed by working with Symfony‘s FormID (not working on the article project in the video, but the same refresh logic)
Long story short — thanks a lot for your great tutorials. Keep up the good work please :)
Hi [solved]
/admin/article/location-select?location=
Error 500 (Notice: Undefined index:)
in src/Form/ArticleFormType.php (line 145)
$locationNameChoices = [
'solar_system' => array_combine($planets, $planets),
'star' => array_combine($stars, $stars),
'interstellar_space' => null,
];
return $locationNameChoices[$location];
}
}
Adding in :
public function getSpecificLocationSelect(Request $request)
{
$article = new Article();
$article->setLocation($request->query->get('location'));
#dd($article->getLocation());
if($article->getLocation() === ''){
return new Response(null, 204);
}
$form = $this->createForm(ArticleFormType::class, $article);
// specificLocationName no field? Return an empty response
if (!$form->has('specificLocationName')) {
return new Response(null, 204);
}
return $this->render('article_admin/_specific_location_name.html.twig', [
'articleForm' => $form->createView(),
]);
}
Bye!
Hi guys,
I'm sorry but I'm little bit confused about using dynamically changing forms. I had few entity classes which extends abstract class. I did for them form which dynamically change form fields depending of field 'type'. Everything works fine when I validate fields inside each form field, but now I need validate few fields depending of another one inside the same form. I think about few solutions:
I. Create FormModels which looks like entities ( each type of model extends one abstract model class by adding to it specific fields). Then get data from form (which data_class is set to null) and by switching options depending of field 'type', bind the data from form to right data model and validate it by using annotations inside FormModels and validatorInterface. In this moment I' m now.
II. Create one big FormModel which had all possible field and add custom constraints which will be check first 'type' field and depending of it require or not each other field. But that idea sounds for me really ugly.
III. Third idea was dynamically changing data_class of form ( for example if 'type' field is set to 'category_b' I add to form required fields like now but in the same time I wanna put data_class from null to specific data model). It will be the best solution to me, but I don't know it is ever possible. If it is can you point me to right direction?
Thank you so much for reply.
Hey Cristóbal,
1st solutions sounds good to me, probably the best on I could think of. You can take a look at validation Callbacks: https://symfony.com/doc/cur... - where you can operate the whole object and so validate a field depends on the value of the other field, etc.
Also, you can look at validation groups, they allow you to use the same model with same fields but with different validation rules for different forms, see https://symfonycasts.com/sc... .
I hope this helps!
Cheers!
Hi Victor,
First of all thanks a lot for your reply, it was very helpful for me because it makes me sure that what I' m doing here it's ok.I resolve the problem with validation depending fields by wrote annotations to specific model which extends abstract model so each model has own validation:
/**
* @UniqueFieldsPair(
* fields={"intensity", "name"},
* errorPath="name",
* entityClass="MovementActivity",
* message="The activity with the same name and intensity already exist"
* )
*/
class MovementActivityFormModel extends AbstractActivityFormModel
It works fine for me, but when I'm editing Activity entity I map data from it to specific model class and bind that model to form. After submit if it's errors inside form and I dump it by $form->getErrors(), each error is mapped to path 'data."propertyName"' for example 'data.name' (not like form expected just 'name') so it didn't pass form->isValid() and errors aren't displayed to user in form view. I improve that by adding following code:
if($request->isMethod('POST')) {
//dd($form->getErrors());
$errors = $validator->validate($dataModel);
if (count($errors) > 0) {
foreach ($errors as $error) {
$formError = new FormError($error->getMessage());
$form->get($error->getPropertyPath())->addError($formError);
}
}
}
It provides to display errors in form view but it just duplicates all errors making new with right path.
It's any way to do that better??? I looked at methods inside form to find something which will be helpful in my case (for example change path, or change error method), but didn't find anything like that. Thanks for help.
Hey Cristóbal,
Once again, if you want to operate entities in forms, you better use validation groups, and then specify what validation group use want to use in which form, see the docs for the reference:
https://symfony.com/doc/cur...
https://symfony.com/doc/cur...
This will allow you to use the same entity in different forms but apply different validation rules for each form, and so you can avoid using form model and use real entities in your forms.
But if we're talking about using form model - use them completely and only map the final valid data back to entities. I mean, you create a form model, e.g. MovementActivityFormModel, then create a specific form for it where you specify MovementActivityFormModel as data_class there, and then in controllers create that form and on post request do if ($form->isSubmitted() && $form->isValid()) call. If the form IS NOT valid - Symfony Form component will automatically map all errors to proper form model properties. But if the form model IS valid - only then map data from form model to the entity, basically, you just need do something like $entity->setName($formModel->getName()); and that's it. I think it should work as you expected but with less custom logic.
I hope this helps!
Cheers!
Hey Victor,
Once again, thanks a lot for your reply it was very helpful to me. I knew about your second suggestion but I think I can do It without duplicate similars forms. Your replies clarify possible solutions. Now I know it's two ways to do that what I want to do. First is by doing forms for all available activities forms which takes specific activity form and validate is working by isValid() on form. Second one option is set null to data_class, but this option require more custom validation.
Once again thanks for help.
Cheers :) !
Hey Cristóbal,
Yes, if we're talking about forms - then $form->isValid() is exactly what you need to validate form data. Otherwise, use validator directly only when you need to validate something that does NOT relate to forms.
Thank you for confirming that it was helpful for you, I'm happy now :)
Cheers!
Hi there,
I'm following along, I've also added another drop down to the form as an EntityType (just added to $builder as per normal). The form works when I go to add / edit but I can't get the admin_article_location_select endpoint to work.
So say for example, I have an EntityType a little further down (unrelated at this point) to select the kind of space ship you have using the SpaceShip entity:
$builder
->add('title', TextType::class, [
'help' => 'Choose something catchy!'
])
->add('spaceship', EntityType::class, [
'class' => SpaceShip::class,
'choice_label' => 'spaceShip',
'placeholder' => 'Choose a space ship'
])
</snip>
This is the only change. So far everything works fine until I try and output the API route 'admin_article_location_select' at which point it dies because it can't access properties on the SpaceShip object. The error comes from the create form line in the Article Controller:
$form = $this->createForm(ArticleFormType::class, $article);
Neither the property "spaceShip" nor one of the methods "getSpaceShip()", "spaceShip()", "isSpaceShip()", "hasSpaceShip()", "__get()" exist and have public access in class "App\Entity\SpaceShip".
Have I missed something basic? Do I need to construct the form differently? Any help would be much appreciated.
Thanks!
Hey Dan
Hmm, that's odd. Are you sure you are passing an instance of Article to the form? It seems to me that you are passing an instance of SpaceShip but I may be wrong, I need to see your code
Cheers!
Hello im having a trouble with when submiting a form with added specific location. it renders an error that says
"This form should not contain extra fields."
So the only one working is interstellar space. Do you guys know what causes this? thank you :)
Hey,
Double check your Form fields, you are submitting a field that doesn't belong to your FormType class. You can bypass this error by allowing extra fields to your form but I don't think that's what you want
Cheers!
Great videos, thanks !
Form events and dynamic forms are really complicated and annoying to work with, too bad there is no easier way :p
Hey Leif,
There's still a few more unreleased videos yet... will be released a bit later. Thanks for your feedback! Yeah, it's complex, but probably you can do about 80% of your forms without this complexity I think, but yeah, depends on your project.
Cheers!
It would be cool to see a complex form without using the form component
Do you just manage everything by yourself, get raw data in your controllers and use a lot of JS? :p
// composer.json
{
"require": {
"php": "^7.1.3",
"ext-iconv": "*",
"composer/package-versions-deprecated": "^1.11", // 1.11.99
"knplabs/knp-markdown-bundle": "^1.7", // 1.7.0
"knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
"knplabs/knp-time-bundle": "^1.8", // 1.8.0
"nexylan/slack-bundle": "^2.0,<2.2.0", // v2.0.0
"php-http/guzzle6-adapter": "^1.1", // v1.1.1
"sensio/framework-extra-bundle": "^5.1", // v5.2.1
"stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
"symfony/asset": "^4.0", // v4.1.6
"symfony/console": "^4.0", // v4.1.6
"symfony/flex": "^1.0", // v1.17.6
"symfony/form": "^4.0", // v4.1.6
"symfony/framework-bundle": "^4.0", // v4.1.6
"symfony/orm-pack": "^1.0", // v1.0.6
"symfony/security-bundle": "^4.0", // v4.1.6
"symfony/serializer-pack": "^1.0", // v1.0.1
"symfony/twig-bundle": "^4.0", // v4.1.6
"symfony/validator": "^4.0", // v4.1.6
"symfony/web-server-bundle": "^4.0", // v4.1.6
"symfony/yaml": "^4.0", // v4.1.6
"twig/extensions": "^1.5" // v1.5.2
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.0", // 3.0.2
"easycorp/easy-log-handler": "^1.0.2", // v1.0.7
"fzaninotto/faker": "^1.7", // v1.8.0
"symfony/debug-bundle": "^3.3|^4.0", // v4.1.6
"symfony/dotenv": "^4.0", // v4.1.6
"symfony/maker-bundle": "^1.0", // v1.8.0
"symfony/monolog-bundle": "^3.0", // v3.3.0
"symfony/phpunit-bridge": "^3.3|^4.0", // v4.1.6
"symfony/profiler-pack": "^1.0", // v1.0.3
"symfony/var-dumper": "^3.3|^4.0" // v4.1.6
}
}
I'm wondering how this is different for Symfony 6 and Easy Admin 3/4. Where would I look?