EasyCorp / EasyAdminBundle

EasyAdmin is a fast, beautiful and modern admin generator for Symfony applications.
MIT License
4.06k stars 1.02k forks source link

Dependent / Chained AssociativeField #4716

Closed noorwachid closed 2 years ago

noorwachid commented 3 years ago

Short description of what this feature will allow to do: Multilevel select for locations or subcategories.

Example of how to use this feature (Show some PHP code and/or YAML config explaining how to use this feature in a real app)

AssociationField::new('state'),
AssociationField::new('city')->setParentFieldName('state'),
AssociationField::new('district')->setParentFieldName('city'),
pkly commented 3 years ago

This seems like something you'd implement in a custom form type, which is easily doable right now.

izhddm commented 3 years ago

This seems like something you'd implement in a custom form type, which is easily doable right now.

If it's not difficult for you, then could you show an example. As far as I understand the author implies, the connection of client fields between each other.

He wants the value of the second field to depend on the user's choice of the value of the first field, and the third field on the choice of the second and first.

pkly commented 3 years ago

If it's not difficult for you, then could you show an example. As far as I understand the author implies, the connection of client fields between each other.

It's not very hard, but it is a bit of work. As for an example, I cannot write one since I have a lot of other things to do. I can, however, explain how it'd work.

Depending how it'd be stored, it could either be an entity by itself, but by the names of your fields it seems like it's more of an address entity, so most likely you want something that'd filter by looking at the previous html element's value. As for how to implement it, we have something similar for places which would completely crash EA (filters with thousands of values), so we just have a TomSelect with an enpoint where we return the appropriate values. As long as the select's value is a valid id, it won't complain.

The rest is just writing a route, mapping it to the input, telling it the id of the previous selected input (I suggest adding a custom html attribute with the id of the input to look for) and dynamically putting in options in the TomSelect. Here's a snippet of what we do:

        $("select.js-dynamic-search:not(.tomselected)").each(function(i, e) {
            new TomSelect(e, {
                valueField: 'id',
                labelField: 'text',
                searchField: 'text',
                load: function (query, callback) {
                    fetch($(e).attr('data-endpoint-url') + '&q=' + encodeURIComponent(query))
                        .then(response => response.json())
                        .then(json => callback(json.results))
                        .then()
                },
                shouldLoad: query => query && query.trim().length >= 2,
            });
        })
noorwachid commented 2 years ago

I'm sorry but I still have no clue how to do it, could you please enlighten me?

If it's not difficult for you, then could you show an example. As far as I understand the author implies, the connection of client fields between each other.

It's not very hard, but it is a bit of work. As for an example, I cannot write one since I have a lot of other things to do. I can, however, explain how it'd work.

Depending how it'd be stored, it could either be an entity by itself, but by the names of your fields it seems like it's more of an address entity, so most likely you want something that'd filter by looking at the previous html element's value. As for how to implement it, we have something similar for places which would completely crash EA (filters with thousands of values), so we just have a TomSelect with an enpoint where we return the appropriate values. As long as the select's value is a valid id, it won't complain.

The rest is just writing a route, mapping it to the input, telling it the id of the previous selected input (I suggest adding a custom html attribute with the id of the input to look for) and dynamically putting in options in the TomSelect. Here's a snippet of what we do:

        $("select.js-dynamic-search:not(.tomselected)").each(function(i, e) {
            new TomSelect(e, {
                valueField: 'id',
                labelField: 'text',
                searchField: 'text',
                load: function (query, callback) {
                    fetch($(e).attr('data-endpoint-url') + '&q=' + encodeURIComponent(query))
                        .then(response => response.json())
                        .then(json => callback(json.results))
                        .then()
                },
                shouldLoad: query => query && query.trim().length >= 2,
            });
        })
javiereguiluz commented 2 years ago

Thanks for proposing this feature ... but I'm afraid we can't implement it. The reason is the same I said in the past when this was proposed too: this is a feature that Symfony Forms should provide. I know that Forms provide an event system ... but we need the whole feature to be provided by Symfony Forms so we can use it.

I know my answer is disappointing, but I hope you understand that we can't implement all this complex features missing in Symfony Forms. Sadly we don't have resources to do that. Thanks.

anybug commented 2 years ago

Hi, we also needed to dynamically change a list depending on a previous choice. All is described in Symfony documentation here https://symfony.com/doc/current/form/dynamic_form_modification.html however it was a bit tricky to apply the same process to an EasyAdmin 4 form. We could do it by overriding createNewForm and createEditForm in order to modify formBuilder dynamically, I'm not sure if this is the proper way though. Let's say you have an entity called Vehicule, then depending on the Vehicule category (Car or Cycle), a list a Brands is dynamically generated:

If user select Car then list displays [Alfa Romeo, Audi, Peugeot, etc...] If user select Cycle then list displays [Ducati, Suzuki, Yamaha, etc...]

#App\Controller\App\VehiculeAppCrudController

    public function createNewForm(EntityDto $entityDto, KeyValueStore $formOptions, AdminContext $context): FormInterface
    {
        $builder = parent::createNewFormBuilder($entityDto, $formOptions, $context);
        $builder = self::formBuilderModifier($builder);
        return $builder->getForm();
    }

    public function createEditForm(EntityDto $entityDto, KeyValueStore $formOptions, AdminContext $context): FormInterface
    {
        $builder = parent::createEditFormBuilder($entityDto, $formOptions, $context);
        $builder = self::formBuilderModifier($builder);
        return $builder->getForm();
    }

    static function formBuilderModifier($builder)
    {
        $formModifier = function (FormInterface $form, $category = null) {
            $c = null === $category ? 'Car' : $category ; //in our case we display Brands of cars list by default

            $form->add('brand', EntityType::class, [
                'required' => true,
                'class' => Brand::class,
                'query_builder' => function (EntityRepository $er) use($c){
                    return $er->createQueryBuilder('b')
                        ->andWhere('b.category = (:category)')
                        ->setParameter('category', $c);
                    },
                'attr' => ['class' => 'vehicule_brand']
            ]);
        };

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

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

        return $builder;
    }

The javascript is pretty much the same as Symfony doc :

$(".vehicule_category").on('change',function(){
            {% set url = ea_url()
                .setController('App\\Controller\\App\\VehiculeAppCrudController')
                .setAction('new') %} // has to be "new" action even for editForm (isXmlHttpRequest is not allowed in Edit, see line 221 in EasyCorp\Bundle\EasyAdminBundle\Controller)

        var data = {};
        data[obj.attr('name')] = $(this).val();
        $.ajax({
            type: 'POST',
            url: '{{ url|raw }}',
            data: data,
            success: function(html){
                $('.vehicule_brand').parent().replaceWith($(html).find('.vehicule_brand').parent());
            }
        });
        return false;
});

There must be a better way to do this because we lost EA configureField feature like "setColumns", we're still trying to figure it out.

Quentin-Sch commented 1 year ago

Hi, we also needed to dynamically change a .... There must be a better way to do this because we lost EA configureField feature like "setColumns", we're still trying to figure it out.

Hello, did you find a better way ? Thank you !

KDederichs commented 11 months ago

For anyone stumbling on this: Something like this works well for me:


    public function configureFields(string $pageName): iterable
    {
        return [
            CountryField::new('countryCode', 'Land')->setFormTypeOption('attr', [
                'data-stuff-target' => 'countryDropdown',
            ]),
            ChoiceField::new('stuff')
                ->addWebpackEncoreEntries('app')
                ->allowMultipleChoices()
                ->setChoices(fn(?Entity $entity, FieldDto $fieldDto) => /* load stuff */ : []) // This is needed to display labels in list and details
                ->renderExpanded()
            ,
         ];
     }

    private function extendForms(FormBuilderInterface $builder): void
    {
        $builder->addEventListener(
            FormEvents::PRE_SET_DATA,
            function (FormEvent $event): void {
                $country = $event->getData()->getCountryCode();

                $options = $event->getForm()->get('stuff')->getConfig()->getOptions();
                $options['choices'] = []; // Fetch your choices
                $options['attr']['data-stuff-target'] = 'stuffSelect';
                $event->getForm()?->add('stuff', ChoiceType::class,$options);
            }
        );

        $builder->get('countryCode')->addEventListener(
            FormEvents::POST_SUBMIT,
            function (FormEvent $event): void {
                $country = $event->getForm()->getData();
                $options = $event->getForm()->getParent()?->get('stuff')->getConfig()->getOptions() ?? [];
                $options['choices'] = [];  //Fetch Stuff
                $event->getForm()->getParent()?->add('stuff', ChoiceType::class, $options);
            }
        );
    }

    public function createNewFormBuilder(EntityDto $entityDto, KeyValueStore $formOptions, AdminContext $context): FormBuilderInterface
    {
        $formOptions->set('attr', [
            'data-controller' => 'stuffController',
            'data-stuff-target' => 'form',
            'data-dropdown-action' => $this->adminUrlGenerator->setController(self::class)->setAction(Action::NEW)->generateUrl()
        ]);

        $builder =  parent::createNewFormBuilder($entityDto, $formOptions, $context);

        $this->extendForms($builder);

        return $builder;
    }

and for the JS you do this (using the stimulus bridge):

import {Controller} from '@hotwired/stimulus';

export default class extends Controller {

  static targets = ['form', 'stuffSelect']

  connect() {
  }

  updateForm = async (data, url, method) => {
    const req = await fetch(url, {
      method: method,
      body: data,
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'charset': 'utf-8'
      }
    });

    return await req.text();
  };

  parseTextToHtml = (text) => {
    const parser = new DOMParser();
    return parser.parseFromString(text, 'text/html');
  };
    countryDropdownTargetConnected(target) {
      console.log('Country Dropdown connected, adding listener')
        this.selectedCountry = target.value
      target.addEventListener(
          'change',
          async () => {
            const requestBody = target.name + '=' + target.value;
            const updateFormResponse = await this.updateForm(requestBody, this.formTarget.dataset.dropdownAction, 'POST');
            const html = this.parseTextToHtml(updateFormResponse);

            const new_form_select_position = html.getElementById(this.stuffSelectTarget.id);
            this.stuffSelectTarget.innerHTML = new_form_select_position.innerHTML;

            if (undefined !== this.operatorSelectTarget.tomselect) {
              this.operatorSelectTarget.tomselect.clear()
              this.operatorSelectTarget.tomselect.sync()
            }
          },
          false
      )
    }
}

which is basically just the SF code.

Works well, and keeps all the easy admin settings... I feel that's a lot more complicated than it has to be though

anybug commented 8 months ago

Works well, and keeps all the easy admin settings... I feel that's a lot more complicated than it has to be though

true...but still this solution is shorter than the one I posted and much more efficient so thanks for sharing ;)

ahmed-bhs commented 4 months ago

Hi @KDederichs ,

Your solution is really great; thank you for sharing it! However, I'm facing an issue with refreshing the target TomSelect records. I initially thought this could be done using the following code:


this.stuffSelectTarget.tomselect.clear();
this.stuffSelectTarget.tomselect.sync();

But this didn't work. Could you please add the code related to operatorSelectTarget ?

Thank you!

KDederichs commented 4 months ago

Hi @KDederichs ,

Your solution is really great; thank you for sharing it! However, I'm facing an issue with refreshing the target TomSelect records. I initially thought this could be done using the following code:


this.stuffSelectTarget.tomselect.clear();
this.stuffSelectTarget.tomselect.sync();

But this didn't work. Could you please add the code related to operatorSelectTarget ?

Thank you!

Oh yeah that one's a bit tricky I actually did run into it as well and fixed it, here's the new change listener:

      target.addEventListener(
          'change',
          async () => {
            const requestBody = target.name + '=' + target.value;
            const updateFormResponse = await this.updateForm(requestBody, this.formTarget.dataset.dropdownAction, 'POST');
            const html = this.parseTextToHtml(updateFormResponse);

            const new_form_select_position = html.getElementById(this.operatorSelectTarget.id);
            if (undefined === new_form_select_position) {
              console.log('New Options are undefined, aborting');
              return;
            }
            if (undefined !== this.operatorSelectTarget.tomselect) {
              console.log('Clearing TomSelect');
              this.operatorSelectTarget.tomselect.clearOptions();
            }
            console.log('Replacing Choices');
            this.operatorSelectTarget.innerHTML = new_form_select_position.innerHTML;
            if (undefined !== this.operatorSelectTarget.tomselect) {
              console.log('Syncing TomSelect');
              this.operatorSelectTarget.tomselect.sync();
            }
          },
          false
      )
e-chauvet commented 4 months ago

Hi everyone, Has anyone found another solution? Or is @KDederichs still the best solution? In my case, I have a first select field with cities, then a second select field with unique places in those cities. Obviously, if I select a city I'd like the second field to display a select with only the places in that city.

I have two tables, one with the cities and one with the locations, in the location table there's a town_id column that allows you to go back to the parent. Do I need to implement something else to make it easier to set up the dependent field?

Last question, I'm currently using two AssocitationField, the first for towns and the second for places. Is it better to use ChoiceFields? Or something else? Or the example of @KDederichs also work with AssociationField?

Thanks in advance

maximosojo commented 3 months ago

For those who are still looking for a solution I have found this add-on that extends the functionality of EasyAdmin to solve this problem.

https://packagist.org/packages/insitaction/easyadmin-fields-bundle

e-chauvet commented 3 months ago

Thanks for your feedback @maximosojo, there's still an issue mark over the bundle and the page editing action, have you used this bundle yourself and dealt with the problem?