Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

JS to Auto-Update the Select Options

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Thanks 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.

Adding the Options Endpoint

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!

JS Setup: Adding data- Attributes & Classes

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

Adding the JavaScript

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!

Leave a comment!

27
Login or Register to join the conversation
Quentin D. Avatar
Quentin D. Avatar Quentin D. | posted 7 months ago

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 ^^

Reply

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!

Reply
akincer Avatar

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!

Reply

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!

Reply
akincer Avatar

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!

Reply

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!

Reply
akincer Avatar

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.

1. The field is a self-referential relationship with a parent/child concept. Think if you were modeling for a family tree application.
2. The dataset will be way to big to just do 'options' => <repository>->findall() so autocomplete is being used with a custom query function in the repository.
3. The options in the target field are set to null on initial rendering.
4. The stimulus controller manually adds the option selected by input with the proper id to the HTML select and sets it to selected.

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.

Reply
akincer Avatar

OK that's interesting how that gets parsed. So that should be get('fieldThatHasTheDataSetInTheControllerEndpoint')

Reply

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!

Reply

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!

Reply
Daniel Avatar

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 :)

Reply
Emus Avatar

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!

Reply

Hey Emilio,

I'm glad you were able to solve it yourself, well done!

Cheers!

Reply
Default user avatar
Default user avatar Cristóbal Rejdych | posted 2 years ago

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.

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!

Reply
Default user avatar
Default user avatar Cristóbal Rejdych | victor | posted 2 years ago

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.

Reply

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!

Reply
Default user avatar
Default user avatar Cristóbal Rejdych | victor | posted 2 years ago

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 :) !

Reply

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!

Reply
Dan Avatar

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!

Reply
MolloKhan Avatar MolloKhan | SFCASTS | Dan | posted 3 years ago | edited

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!

Reply
Default user avatar
Default user avatar skyCatalysm | posted 3 years ago

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 :)

Reply

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!

Reply

Great videos, thanks !
Form events and dynamic forms are really complicated and annoying to work with, too bad there is no easier way :p

Reply

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!

Reply

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

2 Reply

Hey Leif,

That would be a headache without Form Component :p

Cheers!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// 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
    }
}