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!

  • 2020-05-18 Victor Bocharsky

    Hey Emilio,

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

    Cheers!

  • 2020-05-17 emilio

    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!

  • 2020-03-12 Victor Bocharsky

    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!

  • 2020-03-11 Cristóbal Rejdych

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

  • 2020-03-06 Victor Bocharsky

    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!

  • 2020-03-05 Cristóbal Rejdych

    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.

  • 2020-03-05 Victor Bocharsky

    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!

  • 2020-03-03 Cristóbal Rejdych

    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.

  • 2019-07-11 Diego Aguiar

    Hey Mr Magoo

    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!

  • 2019-07-11 Mr Magoo

    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!

  • 2019-05-31 Diego Aguiar

    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!

  • 2019-05-31 skyCatalysm

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

  • 2018-12-31 Victor Bocharsky

    Hey Leif,

    That would be a headache without Form Component :p

    Cheers!

  • 2018-12-31 Leif__

    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

  • 2018-12-31 Victor Bocharsky

    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!

  • 2018-12-31 Leif__

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