Skip to content

[Form] Support dependent fields #5807

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
webmozart opened this issue Oct 22, 2012 · 46 comments
Closed

[Form] Support dependent fields #5807

webmozart opened this issue Oct 22, 2012 · 46 comments
Labels
DX DX = Developer eXperience (anything that improves the experience of using Symfony) Feature Form

Comments

@webmozart
Copy link
Contributor

We long have the problem of creating fields that depend on the value of other fields now. See also:

I want to propose a solution that seems feasible from my current point of view.

Currently, I can think of two different APIs:

API 1
<?php

$builder->addIf(function (FormInterface $form) {
    return $form->get('field1')->getData() >= 1 
        && !$form->get('field2')->getData();
}, 'myfield', 'text');

$builder->addUnless(function (FormInterface $form) {
    return $form->get('field1')->getData() < 1 
        || $form->get('field2')->getData();
}, 'myfield', 'text');
API 2
<?php

$builder
    ->_if(function (FormInterface $form) {
        return $form->get('field1')->getData() >= 1 
            && !$form->get('field2')->getData();
    })
        ->add('myfield', 'text')
        ->add('myotherfield', 'text')
    ->_endif()
;

$builder
    ->_switch(function (FormInterface $form) {
        return $form->get('field1')->getData();
    })
    ->_case('foo')
    ->_case('bar')
        ->add('myfield', 'text', array('foo' => 'bar'))
        ->add('myotherfield', 'text')
    ->_case('baz')
        ->add('myfield', 'text', array('foo' => 'baz'))
    ->_default()
        ->add('myfield', 'text')
    ->_endswitch()
;

The second API obviously is a lot more expressive, but also a bit more complicated than the first one.

Please give me your opinions on what API you prefer or whether you can think of further limitations in these APIs.

Implementation

The issue of creating dependencies between fields can be solved by a lazy dependency resolution graph like in the OptionsResolver.

During form prepopulation, the conditions are invoked with a FormPrepopulator object implementing FormInterface. When FormPrepopulator::get('field') is called, "field" is prepopulated. If "field" is also dependent on some condition, that condition will be evaluated now in order to construct "field". After evaluating the condition, fields are added or removed accordingly.

During form binding, the conditions are invoked with a FormBinder object, that also implements FormInterface. This object works like FormPrepopulator, only that it binds the fields instead of filling them with default data.

In both cases, circular dependencies can be detected and reported.

@webmozart
Copy link
Contributor Author

One limitation of both APIs is that no dynamic values can be passed to add():

->add('myfield', 'entity', array(
    'query_builder' => function (EntityRepository $repo) {
        // depend on some other field here
    }
));

@daFish
Copy link

daFish commented Oct 22, 2012

Handling dynamic values would require using AJAX anyway, no?

@stof
Copy link
Member

stof commented Oct 22, 2012

Ajax ? We are talking about server side form handling here. The way you handle the frontend is not related to this.

But you probably need some JS as soon as your form is dynamic

@webmozart
Copy link
Contributor Author

Performance should also be kept in mind when implementing this. When a form is submitted, it is both prepopulated and bound in the same request. If possible, fields should only be constructed once if the output of the conditions does not change.

Furthermore, it must be possible to access the form's object in the conditions. Especially for prepopulation, FormInterface must already provide access to that object.

In respect of JS, while you need to write that by hand, it would be a bonus if the API allowed (partial) JS code generation (at least in theory).

@mablae
Copy link
Contributor

mablae commented Oct 22, 2012

@daFish No, dynamic means "dynamic to the Entity" bound to form in this case...

@bschussek Like your idea!

@daFish
Copy link

daFish commented Oct 22, 2012

@mablae Thanks. I was mixing these two when reading the issue.

@webmozart
Copy link
Contributor Author

@daFish @mablae Actually both issues are the same. You want to add a field with a configuration that depends on the data of the form or one of its children.

Example 1: Add a field only if $entity->isXxx() ($entity is the form's data) returns true

Example 2: Add a province field with provinces for the selected country (= the data) of a country field

@webda2l
Copy link

webda2l commented Oct 22, 2012

I prefer personally the actual way with event..
Would not it be interesting to decide about issue_5480#issuecomment-9343882 which would generate of large changes?

@webmozart
Copy link
Contributor Author

@webda2l The current way you refer to has strong limitations. You can only rely on information stored in the underlying entity, but not on the (transformed) values of other fields. E.g., if a field is submitted, you cannot observe the value of that field for initializing a different field.

I don't think that #5480 is related to this topic.

@webda2l
Copy link

webda2l commented Oct 22, 2012

Ok I understand
About the issue_5480 (I edited my comment to see if the autolink can be remove..), it's because it seems change the way to getData but you know better the impacts.

@webmozart
Copy link
Contributor Author

@webda2l Don't worry about the links on GH ;) You're right that it's vaguely related, but it doesn't influence the underlying design decisions (as far as I can tell right now).

@jonathaningram
Copy link
Contributor

@bschussek from a cosmetic view, I prefer API 2, but a) are the underscores necessary? And b) instead of endif and endswitch can it just be end like in the DependencyInjection Configuration classes? Or at least for consistency endIf and endSwitch.

@webmozart
Copy link
Contributor Author

@jonathaningram "if", "switch" and the like are PHP keywords and cannot be used as method names, that's why the methods include an underscore. I'm ok with naming the last method just end().

@jonathaningram
Copy link
Contributor

@bschussek of course they are, duh.

@kadryjanek
Copy link

I would prefer API 2 - it's more fexible and powerfull.
The "builder" way of adding dependent fields is very nice but i also like the "EventListener" way. Do we have access to binded data in listeners? And do we have to create 2 listeners for just one filed FormEvents::PRE_SET_DATA or FormEvents::BIND?
Creating dependencies in the "builder" way is better in this case - dependent fields are created according to the default and binded data, i am right?
@bschussek what do you mean by "no dynamic values can be passed to add()"?

@Burgov
Copy link
Contributor

Burgov commented Oct 30, 2012

The limitation you describe is a big drawback, in my opinion. Most of the dynamic fields I add are entity types, which cannot be generated until the value of a specific other field is known.

How about

<?php

$builder
    ->_if(function (FormInterface $form) {
        return $form->get('field1')->getData() !== null;
    }, function(FormInterface $form) use ($builder) {
        $data = $form->get('field1')->getData();
        $builder->add('myfield', 'text', array('label' => 'Choose a relation for ' . $data));
        $builder->add('myotherfield', 'entity', array('query_builder' => function (EntityRepository $repo) use ($data) {
            return $repo->createQueryBuilder('s')->where('s.relation = ?1')->setParameter(1, $data);
        });
    })
;

This, of course, would not work for the switch method, but elseif and else could probably be implemented the same way

@webmozart
Copy link
Contributor Author

@Burgov The problem with this code is that we don't know in advance which fields this if-statement potentially adds ("myfield" and "myotherfield"), which is necessary in order to build the dependency graph. For example:

$builder
    // (a)
    ->_if(
        function (...) { ... $form->get('myfield')->... },
        function (...) { ... }
    )
    // (b)
    ->_if(
        function (...) { ... },
        function (...) { ... $builder->add('myfield', ...) ... }
    )

When the condition (a) is evaluated, the field "myfield" is accessed and needs to be initialized. Thus we need to know that (b) must be executed, which is impossible if the call to add() is hidden in the closure.

@Burgov
Copy link
Contributor

Burgov commented Oct 30, 2012

That makes sense, too bad... However I feel that this feature will only come to full potential if the matter at hand can somehow be tackled... Just throwing some ideas out there:

$builder
    ->_if(function (FormInterface $form) {
        return $form->get('field1')->getData() >= 1 
            && !$form->get('field2')->getData();
    })
        ->add('myfield', 'entity', function(FormInterface $form) {
            return array('query_builder' => function (EntityRepository $repo) use ($data) {
                return $repo->createQueryBuilder('s')->where('s.relation = ?1')->setParameter(1, $data);
            });
        })
        ->add('myfield', 'text', array('label' => 'something not dependant'));
    ->_endif()
;

If the add method is called on an _if (or, in this example also possible: a _case), the third argument can be a callback accepting a FormInterface, which can be examined for data, and returning the actual options. Is this something that could be realised?

@webmozart
Copy link
Contributor Author

@Burgov Yes, that makes much more sense.

@webmozart
Copy link
Contributor Author

We could even allow this syntax for normal add() calls:

$builder->add('province', 'entity', function (FormInterface $form) {
    $country = $form->get('country')->getData();

    return array(
        'query_builder' => function (EntityRepository $repo) use ($country) {
            return $repo->createQueryBuilder('s')->where('s.country = ?1')->setParameter(1, $country);
        },
    );
});

@webmozart
Copy link
Contributor Author

Assuming that we always want to access the data of the child (and not some other property of the FormInterface object), the following simplification would be possible:

$builder->add('province', 'entity', function (Country $country) {
    return array(
        'query_builder' => function (EntityRepository $repo) use ($country) {
            return $repo->createQueryBuilder('s')->where('s.country = ?1')->setParameter(1, $country);
        },
    );
});

Using reflection, we can map the parameter names of the closure to the data of the fields with the same names.

@Burgov
Copy link
Contributor

Burgov commented Nov 2, 2012

That would be pretty neat. I suppose this would still work for embedded forms, virtual forms and non-mapped forms? What will happen when the entity is completely new? Will an exception be thrown because $country is null, or will the field simply not be generated? I'd vote for the first one, allowing the creation of a listbox without choices but with an empty value "Select a country first" (the function would then become function (Country $country = null)

If you really want to skip the generation of this field, you'd have to fall back on the _if()'s

@ardianys
Copy link

Is this proposal have been accepted ?

@LeioDos
Copy link

LeioDos commented Feb 14, 2013

Good morning friend. I get this error when I deploy to a particular case:

An exception has been thrown during the rendering of a template ("Route "select_provinces" does not exist.") in YLBcombosBundle:Ubicacion:ubicacion.html.twig at line 13.

What should I check? I have:

$(function (){
$('#Ubicacion_estado').change(function(){
var data = {
estado: $(this).val()
};
$.ajax({
type: 'post',
url: '{{ path('select_provinces') }}',
data: data,
success: function(data) {
$('#Ubicacion_municipio').html(data);
}
});
});

Other part:

  • @route("/municipios", name="select_provinces")
  • @template()
    */
    public function municipiosAction()
    {
    $estado= $this->getRequest()->request->get('estado');
    $em = $this->getDoctrine()->getManager();
    $municipios = $em->getRepository('YLBcombosBundle:Municipio')->encontrarPorEstado($estado);
    return array(
    'municipios' => $municipios
    );
    }

thank you....

@stof
Copy link
Member

stof commented Feb 14, 2013

@LeioDos This has strictly nothing to do with this issue. Why are you posting this in the conversation ?

@LeioDos
Copy link

LeioDos commented Feb 14, 2013

As simple as that is NOT RESPOND if your interest or want to help, I'm following the recommendation of the scheduled discussion in: http://showmethecode.es/php/symfony/symfony2-selects-dependientes-mediante-eventos/ @stof

@gnutix
Copy link
Contributor

gnutix commented Mar 6, 2013

Is anyone currently working on this ? Is there any chance to see it become true in 2.2.x / 2.3 ?
I'm surely not the only one badly needing this since Symfony has first been released. ;)

@gnutix
Copy link
Contributor

gnutix commented Apr 10, 2013

Any chance to see this done for 2.3 ?

@lwc
Copy link

lwc commented Jul 18, 2013

The main problem I see in using closures to define these dependencies instead of something more declarative is that it becomes hard to, for example, bake the dependencies into data attributes so one could use js to show / hide the dependent fields.

I have another solution proposed that would allow for this: #8513

Feedback welcome / desired.

@xzyfer
Copy link

xzyfer commented Jul 24, 2013

@stof @bschussek thoughts in @lwc alternative approach in #8513?

@webmozart
Copy link
Contributor Author

An important use case for dynamic fields is to support fields that depend on the built object of the form. For example, take the following two classes:

class Contact
{
    public function addEmail(Email $email)
    {
        $this->emails->add($email);
    }
}

class Email
{
    public function __construct(Contact $contact, $text)
    {
        $this->contact = $contact;
        $this->text = $text;

        $contact->addEmail($this);
    }
}

Currently, this setting can only be made to work using a POST_SET_DATA listener, because the Email constructor depends on the Contact object, which is only created after submitting the fields of the contact form.

@ioleo
Copy link

ioleo commented Oct 21, 2014

@webmozart If possible, I'd go with API 2. When dealing with multiple fields added in each scenario, the API 2 approach remains readable.

@webdevilopers
Copy link

👍

@webdevilopers
Copy link

For instance you have a radio button addFoo that was checked with the value 1 resp. true.
The form now expects the user to select a value from a choice widget selectFoo that is disabled by default.
After submitting the form should be invalid - because a constraint is used - and the error states that a choice has to be selected from selectFoo. Of course now selectFoo should no longer be disabled.

I created such an dependency in this example:
https://gist.github.com/webdevilopers/5306ee3417791227abf3

Will the API2 approach also take care of dynamically disabling elements?

@webdevilopers
Copy link

webdevilopers commented Dec 11, 2014

I have moved my issue here:
#12946

It focusses on my general understanding of the disabled option.

@Tobion
Copy link
Contributor

Tobion commented Mar 26, 2015

Just read the ticket and you can see yourself what the status is.

@Glideh
Copy link

Glideh commented Apr 29, 2016

+1 for the API 2
Dependent fields are still a pain in the ass today.

@CyrilleGuimezanes
Copy link

+1 for the API 2
Still blocked by dependent field....

@DavidBadura
Copy link
Contributor

DavidBadura commented May 12, 2016

I am still wondering. What do we need this API for? Dynamic forms can be done with events. I solved complex Forms (Multi-Step-Forms etc.) with events simply.

@Glideh
Copy link

Glideh commented Mar 14, 2017

Well, events allow powerful things to be done indeed.
But this dependent fields (lists most of the time) feature is a common need and would deserve some shortcut.
However the event subscriber still requires at least 50 lines of code for each dependent list:
Each time we have to implement at least the 3 postSetData, preSetData, preSubmit events with the preSubmit parameters "convertion"
And since we can't modify a field from the subscriber, it have to be configured entirely in the subscriber, which is not very readable from the related form definition (I mean if the field config is not repeated there).
This is without speaking about the usual ajax needed.

@psyray
Copy link

psyray commented Jun 4, 2018

You coud try the choice_loader option
https://symfony.com/doc/current/reference/forms/types/choice.html#choice-loader

use Symfony\Component\Form\ChoiceList\Loader\CallbackChoiceLoader;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
// ...

$builder->add('constants', ChoiceType::class, array(
    'choice_loader' => new CallbackChoiceLoader(function() {
        return StaticClass::getConstants();
    }),
));

@vudaltsov
Copy link
Contributor

@xabbuh , @stof , @HeahDude , this could be closed, I guess?
It's too late to implement. Otherwise one can do a PR on this.

@AdamReece-WebBox
Copy link

The event listener approach does not work when you have a chain of field dependency.

Example chain: (Entity)Project --(1-M)--> (Entity)ProjectManager --(Property)--> (array)GroupNames

In the form the user must pick a project first. They must then pick a project manager within that project. Thirdly they must pick a group name for that project manager. (If the project manager has no group names a text input is to be used instead.)

The dynamic generation for submitted forms guide works great for populating the "ProjectManager" entity field based in the "Project" entity, but the "groupName" choice field based on $projectManager->getGroupNames() never populates because apparently the "projectManager" field is null. (Even though the value for it is present in the HTTP POST data.)

This appears to happen because when the form modifier for the "project" field runs it replaces the "projectManager" field, thus the "POST_SUBMIT" event listener you had bound to it is lost. That will then mean the "POST_SUBMIT" for "projectManager" never runs to handle the "groupName" choice field.

I did try re-binding this event listener during the "PRE_SET_DATA" stage right after (involves exposing the FormBuilderInterface to the callable) but this has no impact. I also tried adding the TBD fields (projectManager, groupName) as HiddenType before the modifiers happen, but obviously they're overwritten so that won't matter.

--

Here's an example snippet of attaching my modifiers at the end of the buildForm() function.
($this->modifiers is an array of field keys containing an array of callable form modifier functions.)

// << Attach event listeners for registered form modifiers
if (is_array($formModifiers = $this->modifiers) && count($formModifiers) >= 1) {
   // var_dump(sprintf("Adding PRE_SET_DATA event for form."));
   $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($builder, $formModifiers) {

       foreach ($formModifiers as $field => $formModifiersForField) {
           if (!$field = trim($field)) {
               throw new \Exception("Field not specified.");
           }

           if (!is_array($formModifiersForField) || count($formModifiersForField) < 1) {
               throw new \Exception("Form modifiers not an array or empty.");
           }

           $getField = sprintf("get%s", ucfirst($field));

           foreach ($formModifiersForField as $formModifier) {
               if (!is_callable($formModifier)) {
                   throw new \Exception("Form modifier not callable.");
               }

               // var_dump(sprintf("Running PRE_SET_DATA event for \"%s\".", $field));
               $formModifier($event->getForm(), $event->getData()->$getField());

               if (!$builder->has($field)) {
                   $builder->add($field, HiddenType::class, [
                       "required"      => false,
                       "label"         => false,
                   ]);
               }

               // Tried re-binding the POST_SUBMIT per-field here, did not work.
               /*
               // var_dump(sprintf("Adding POST_SUBMIT event for \"%s\".", $field));
               $builder->get($field)->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) use ($field, $formModifier) {
                   // var_dump(sprintf("Running POST_SUBMIT event for \"%s\".", $field));
                   $formModifier($event->getForm()->getRoot(), $event->getForm()->getData());
               });
                */
           }
       }

   });

   foreach ($formModifiers as $field => $formModifiersForField) {
       if (!$field = trim($field)) {
           throw new \Exception("Field not specified.");
       }

       if (!is_array($formModifiersForField) || count($formModifiersForField) < 1) {
           throw new \Exception("Form modifiers not an array or empty.");
       }

       foreach ($formModifiersForField as $formModifier) {
           if (!is_callable($formModifier)) {
               throw new \Exception("Form modifier not callable.");
           }

           // Tried calling the form modifier for the entire form instead of per-field, did not work.
           // $formModifier($event->getForm(), $event->getForm()->getData());

           if (!$builder->has($field)) {
               $builder->add($field, HiddenType::class, [
                   "required"      => false,
                   "label"         => false,
               ]);
           }

           // var_dump(sprintf("Adding POST_SUBMIT event for \"%s\".", $field));
           $builder->get($field)->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) use ($field, $formModifier) {
               // var_dump(sprintf("Running POST_SUBMIT event for \"%s\".", $field));
               $formModifier($event->getForm()->getRoot(), $event->getForm()->getData());
           });
       }
   }

   /* Tried POST_SUBMIT per-field, did not work.
   // var_dump(sprintf("Adding POST_SUBMIT event for form."));
   $builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) use ($builder, $formModifiers) {

       foreach ($formModifiers as $field => $formModifiersForField) {
           if (!$field = trim($field)) {
               throw new \Exception("Field not specified.");
           }

           if (!is_array($formModifiersForField) || count($formModifiersForField) < 1) {
               throw new \Exception("Form modifiers not an array or empty.");
           }

           $getField = sprintf("get%s", ucfirst($field));

           foreach ($formModifiersForField as $formModifier) {
               if (!is_callable($formModifier)) {
                   throw new \Exception("Form modifier not callable.");
               }

               // var_dump(sprintf("Running PRE_SET_DATA event for \"%s\".", $field));
               $formModifier($event->getForm(), $event->getData()->$getField());
           }
       }

   });
    */
}
// >> Attach event listeners for registered form modifiers

If I were to uncomment line // var_dump(sprintf("Running POST_SUBMIT event for \"%s\".", $field)); I would see "Running POST_SUBMIT event for "project"." twice, but never for "projectManager".

@xabbuh
Copy link
Member

xabbuh commented Jul 21, 2019

I am closing here as I don't see this happening anytime unless someone comes up with a PR implementing this feature.

@xabbuh xabbuh closed this as completed Jul 21, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
DX DX = Developer eXperience (anything that improves the experience of using Symfony) Feature Form
Projects
None yet
Development

No branches or pull requests