Symfony 4 form: three dynamic select box

Hello I am using Symfony 4.
I have managed to link up to two select box with form events, but I need to have three dynamic select box.
This is the relation between my entities:
Country -> Province -> City.
These are linked to a Person entity like this
enter image description here

When I add a new person I should be able to select a Country and have the Province dropdown updated in accordance to Country selection; same thing for the City dropdown after I have selected a Province.
I have made things working for Country and Province following the official Symfony guide here
https://symfony.com/doc/current/form/dynamic_form_modification.html#dynamic-generation-for-submitted-forms
How should I manage adding the third dropdown?

This is my Country entity:

<?php

namespace AppEntityGeo;

use DoctrineORMMapping as ORM;
use SymfonyComponentValidatorConstraints as Assert;


/**
 * @ORMEntity(repositoryClass="AppRepositoryGeoCountryRepository")
 */
class Country
{
    /**
     * @ORMId()
     * @ORMGeneratedValue()
     * @ORMColumn(type="integer")
     */
    private $id;

    /**
     * @ORMColumn(type="string", length=255)
     * @AssertNotBlank()
     */
    private $name;

    /**
     * @ORMOneToMany(targetEntity="AppEntityGeoProvince", mappedBy="country")
     * @ORMJoinColumn(nullable=false)
     */
    private $provinces;

    /**
     * @ORMOneToMany(targetEntity="AppEntityGeoCity", mappedBy="country")
     * @ORMJoinColumn(nullable=false)
     */
    private $cities;

    /**
     * @ORMOneToMany(targetEntity="AppEntityGeoPerson", mappedBy="country")
     * @ORMJoinColumn(nullable=false)
     */
    private $persons;

    /**
     * @return mixed
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * @return mixed
     */
    public function getName()
    {
        return $this->name;
    }

    /**
     * @param mixed $name
     */
    public function setName($name): void
    {
        $this->name = $name;
    }

    /**
     * @return mixed
     */
    public function getProvinces()
    {
        return $this->provinces;
    }

    /**
     * @param mixed $provinces
     */
    public function setProvinces($provinces): void
    {
        $this->provinces = $provinces;
    }

    /**
     * @return mixed
     */
    public function getCities()
    {
        return $this->cities;
    }

    /**
     * @param mixed $cities
     */
    public function setCities($cities): void
    {
        $this->cities = $cities;
    }

    /**
     * @return mixed
     */
    public function getPersons()
    {
        return $this->persons;
    }

    /**
     * @param mixed $persons
     */
    public function setPersons($persons): void
    {
        $this->persons = $persons;
    }

}

This is my Province entity:

<?php

namespace AppEntityGeo;

use DoctrineORMMapping as ORM;
use SymfonyComponentValidatorConstraints as Assert;


/**
 * @ORMEntity(repositoryClass="AppRepositoryGeoProvinceRepository")
 */
class Province
{
    /**
     * @ORMId()
     * @ORMGeneratedValue()
     * @ORMColumn(type="integer")
     */
    private $id;

    /**
     * @ORMColumn(type="string", length=255)
     * @AssertNotBlank()
     */
    private $name;

    /**
     * @ORMManyToOne(targetEntity="AppEntityGeoCountry", inversedBy="provinces")
     * @ORMJoinColumn(nullable=false)
     */
    private $country;

    /**
     * @ORMOneToMany(targetEntity="AppEntityGeoCity", mappedBy="province")
     * @ORMJoinColumn(nullable=false)
     */
    private $cities;

    /**
     * @ORMOneToMany(targetEntity="AppEntityGeoPerson", mappedBy="province")
     * @ORMJoinColumn(nullable=false)
     */
    private $persons;

    public function __toString() {
        return $this->name;
    }

    /**
     * @return mixed
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * @return mixed
     */
    public function getName()
    {
        return $this->name;
    }

    /**
     * @param mixed $name
     */
    public function setName($name): void
    {
        $this->name = $name;
    }

    /**
     * @return mixed
     */
    public function getCountry()
    {
        return $this->country;
    }

    /**
     * @param mixed $country
     */
    public function setCountry($country): void
    {
        $this->country = $country;
    }

    /**
     * @return mixed
     */
    public function getCities()
    {
        return $this->cities;
    }

    /**
     * @param mixed $cities
     */
    public function setCities($cities): void
    {
        $this->cities = $cities;
    }

    /**
     * @return mixed
     */
    public function getPersons()
    {
        return $this->persons;
    }

    /**
     * @param mixed $persons
     */
    public function setPersons($persons): void
    {
        $this->persons = $persons;
    }


}

This is my City entity:

<?php

namespace AppEntityGeo;

use DoctrineORMMapping as ORM;
use SymfonyComponentValidatorConstraints as Assert;


/**
 * @ORMEntity(repositoryClass="AppRepositoryGeoCityRepository")
 */
class City
{
    /**
     * @ORMId()
     * @ORMGeneratedValue()
     * @ORMColumn(type="integer")
     */
    private $id;

    /**
     * @ORMColumn(type="string", length=255)
     * @AssertNotBlank()
     */
    private $name;

    /**
     * @ORMManyToOne(targetEntity="AppEntityGeoProvince", inversedBy="cities")
     * @ORMJoinColumn(nullable=false)
     */
    private $province;

    /**
     * @ORMManyToOne(targetEntity="AppEntityGeoCountry", inversedBy="cities")
     * @ORMJoinColumn(nullable=false)
     */
    private $country;

    /**
     * @ORMOneToMany(targetEntity="AppEntityGeoPerson", mappedBy="city")
     * @ORMJoinColumn(nullable=false)
     */
    private $persons;

    /**
     * @return mixed
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * @return mixed
     */
    public function getName()
    {
        return $this->name;
    }

    /**
     * @param mixed $name
     */
    public function setName($name): void
    {
        $this->name = $name;
    }

    /**
     * @return mixed
     */
    public function getProvince()
    {
        return $this->province;
    }

    /**
     * @param mixed $province
     */
    public function setProvince($province): void
    {
        $this->province = $province;
    }

    /**
     * @return mixed
     */
    public function getCountry()
    {
        return $this->country;
    }

    /**
     * @param mixed $country
     */
    public function setCountry($country): void
    {
        $this->country = $country;
    }

    /**
     * @return mixed
     */
    public function getPersons()
    {
        return $this->persons;
    }

    /**
     * @param mixed $persons
     */
    public function setPersons($persons): void
    {
        $this->persons = $persons;
    }

}

This is my form to add a Person (PersonType.php)

<?php


namespace AppFormGeo;

use AppEntityGeoPerson;
use AppEntityGeoCountry;
use AppEntityGeoProvince;
use AppEntityGeoCity;
use SymfonyBridgeDoctrineFormTypeEntityType;
use SymfonyComponentFormAbstractType;
use SymfonyComponentFormExtensionCoreTypeSubmitType;
use SymfonyComponentFormExtensionCoreTypeTextType;
use SymfonyComponentFormFormBuilderInterface;
use SymfonyComponentFormFormInterface;
use SymfonyComponentOptionsResolverOptionsResolver;

use SymfonyComponentFormFormEvent;
use SymfonyComponentFormFormEvents;

class PersonType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('name', TextType::class, ['label' => "Name"])
            ->add('country', EntityType::class, [
                'class' => Country::class,
                'choice_label' => function(Country $country) {
                    return $country->getName();
                },
                'placeholder' => 'Choose a Country'
            ])
            ;

        $formModifier = function (FormInterface $form, Country $country = null) {
            $provinces = null === $country ? [] : $country->getProvinces();

            $form->add('province', EntityType::class, [
                'class' => Province::class,
                'placeholder' => 'Choose a Province',
                'choices' => $provinces,
            ]);
        };

        $builder->addEventListener(
            FormEvents::PRE_SET_DATA,
            function (FormEvent $event) use ($formModifier) {
                $data = $event->getData();

                $formModifier($event->getForm(), $data->getCountry());
            }
        );

        $builder->get('country')->addEventListener(
            FormEvents::POST_SUBMIT,
            function (FormEvent $event) use ($formModifier) {
                $country = $event->getForm()->getData();

                $formModifier($event->getForm()->getParent(), $country);
            }
        );
        $builder->add( 'save', SubmitType::class);
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' =>Person::class
        ]);
    }
}

This is my twig template (person-add.html.twig)

{% extends 'base.html.twig' %}

{% block title %}Add Person{% endblock %}

{% block body %}

    {{ form_start(form) }}

    {{ form_row(form.name) }}
    {{ form_row(form.country) }}
    {{ form_row(form.province) }}

    {{ form_end(form) }}


    <script>
        $(document).ready(function() {
            var $country = $('#person_country');

            // When sport gets selected ...
            $country.change(function () {
                // ... retrieve the corresponding form.
                var $form = $(this).closest('form');
                // Simulate form data, but only include the selected sport value.
                var data = {};
                data[$country.attr('name')] = $country.val();
                // Submit data via AJAX to the form's action path.
                $.ajax({
                    url: $form.attr('action'),
                    type: $form.attr('method'),
                    data: data,
                    success: function (html) {
                        // Replace current position field ...
                        $('#person_province').replaceWith(
                            // ... with the returned one from the AJAX response.
                            $(html).find('#person_province')
                        );
                        // Position field now displays the appropriate positions.
                    }
                });
            })
        });
    </script>

{% endblock %}

Thanks to this post I have managed to change my PersonType.php form file like this:

<?php


namespace AppFormGeo;

use AppEntityGeoPerson;
use AppEntityGeoCountry;
use AppEntityGeoProvince;
use AppEntityGeoCity;
use AppRepositoryGeoCityRepository;
use AppRepositoryGeoProvinceRepository;
use SymfonyBridgeDoctrineFormTypeEntityType;
use SymfonyComponentFormAbstractType;
use SymfonyComponentFormExtensionCoreTypeSubmitType;
use SymfonyComponentFormExtensionCoreTypeTextType;
use SymfonyComponentFormFormBuilderInterface;
use SymfonyComponentFormFormInterface;
use SymfonyComponentOptionsResolverOptionsResolver;

use SymfonyComponentFormFormEvent;
use SymfonyComponentFormFormEvents;

class PersonType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('name', TextType::class)
            //
            ->add('country', EntityType::class, [
                'class' => Country::class,
                'label' => 'Country',
                'required' => true,
                'choice_label' => function(Country $country) {
                    return $country->getName();
                },
                'invalid_message' => 'You must select a Country',
                'placeholder' => 'Select Country',
            ]);

//**************** Start Province Form
        $addProvinceForm = function (FormInterface $form, $country_id) {
            // it would be easier to use a Park entity here,
            // but it's not trivial to get it in the PRE_SUBMIT events
            $form->add('province', EntityType::class, [
                'class' => Province::class,
                'label' => 'Province',
                'required' => true,
                'invalid_message' => 'Choose a Province',
                'placeholder' => null === $country_id ? 'Choose a Country first' : 'Select Province',
                'query_builder' => function (ProvinceRepository $repository) use ($country_id) {
                    return $repository->createQueryBuilder('p')
                        ->innerJoin('p.country', 'c')
                        ->where('c.id = :country')
                        ->setParameter('country', $country_id)
                        ;
                }
            ]);
        };
        $builder->addEventListener(
            FormEvents::PRE_SET_DATA,
            function (FormEvent $event) use ($addProvinceForm) {
                $country = $event->getData()->getCountry();
                $country_id = $country ? $country->getId() : null;
                $addProvinceForm($event->getForm(), $country_id);
            }
        );
        $builder->addEventListener(
            FormEvents::PRE_SUBMIT,
            function (FormEvent $event) use ($addProvinceForm) {
                $data = $event->getData();
                $country_id = array_key_exists('country', $data) ? $data['country'] : null;
                $addProvinceForm($event->getForm(), $country_id);
            }
        );
//**************** End Province Form

//**************** Start City Form
        $addCityForm = function (FormInterface $form, $province_id) {
            $form->add('city', EntityType::class, [
                'class' => City::class,
                'label' => 'City',
                'required' => true,
                'invalid_message' => 'You must choose a City',
                'placeholder' => null === $province_id ? 'Choose a Province first' : 'Choose a City',
                'query_builder' => function (CityRepository $repository) use ($province_id) {
                    return $repository->createQueryBuilder('ci')
                        ->innerJoin('ci.province', 'pr')
                        ->where('pr.id = :province')
                        ->setParameter('province', $province_id)
                        ;
                }
            ]);
        };
        $builder->addEventListener(
            FormEvents::PRE_SET_DATA,
            function (FormEvent $event) use ($addCityForm) {
                $province = $event->getData()->getProvince();
                $province_id = $province ? $province->getId() : null;
                $addCityForm($event->getForm(), $province_id);
            }
        );
        $builder->addEventListener(
            FormEvents::PRE_SUBMIT,
            function (FormEvent $event) use ($addCityForm) {
                $data = $event->getData();
                $province_id = array_key_exists('province', $data) ? $data['province'] : null;
                $addCityForm($event->getForm(), $province_id);
            }
        );
//**************** End City Form

        $builder->add( 'save', SubmitType::class);
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' =>Person::class
        ]);
    }
}

The Province dropdown works as expected when you first select a Country.
The problem is the City dropdown: nothing changes after you select a Province.
If everything is ok with the query executed inside the PersonType.php file, I think I am doing something wrong with the javascript. Here’s my code:

    <script>
            $(document).ready(function() {
                var $country = $('#person_country');
                var $province = $('#person_province');

                // When country gets selected ...
                $country.change(function () {
                    // ... retrieve the corresponding form.
                    var $form = $(this).closest('form');
                    // Simulate form data, but only include the selected country value.
                    var data = {};
                    data[$country.attr('name')] = $country.val();
                    // Submit data via AJAX to the form's action path.
                    $.ajax({
                        url: $form.attr('action'),
                        type: $form.attr('method'),
                        data: data,
                        success: function (html) {
                            // Replace current province field ...
                            $('#person_province').replaceWith(
                                // ... with the returned one from the AJAX response.
                                $(html).find('#person_province')
                            );
                        }
                    });
                });

                // When province gets selected ...
                $province.change( function () {
                    // ... retrieve the corresponding form.
                    var $form = $(this).closest('form');
                    // Simulate form data, but only include the selected province value.
                    var data = {};
                    data[$province.attr('name')] = $province.val();
                    // Submit data via AJAX to the form's action path.
                    $.ajax({
                        url: $form.attr('action'),
                        type: $form.attr('method'),
                        data: data,
                        success: function (html) {
                            // Replace current city field ...
                            $('#person_city').replaceWith(
                                // ... with the returned one from the AJAX response.
                                $(html).find('#person_city')
                            );
                        }
                    });
                });
            });
        </script>

Source: Symfony Questions

Was this helpful?

0 / 0

Leave a Reply 0

Your email address will not be published. Required fields are marked *