Symfony 2.8 dynamic ChoiceType options
Asked Answered
G

2

9

in my project I have some forms with choice types with a lot of options.

So I decided to build an autocomplete choice type based on jquery autocomplete, which adds new <option> HTML elements to the original <select> on runtime. When selected they are submitted correctly, but can't be handled within the default ChoicesToValuesTransformer, since the don't exist in my form when I create it.

How can I make symfony accept my dynamically added values?

I found this answer Validating dynamically loaded choices in Symfony 2 , where the submitted values are used to modify the form on the PRE_SUBMIT form event, but couldn't get it running for my situation. I need to change choices known to the current type instead of adding a new widget to the form

Gabbey answered 17/2, 2016 at 12:2 Comment(0)
G
22

To deal with dynamically added values use 'choice_loader' option of choice type. It's new in symfony 2.7 and sadly doesn't have any documentaion at all.

Basically it's a service implementing ChoiceLoaderInterface which defines three functions:

  • loadValuesForChoices(array $choices, $value = null)
    • is called on build form and receives the preset values of object bound into the form
  • loadChoiceList($value = null)
    • is called on build view and should return the full list of choices in general
  • loadChoicesForValues(array $values, $value = null)
    • is called on form submit and receives the submitted data

Now the idea is to keep a ArrayChoiceList as private property within the choice loader. On build form loadValuesForChoices(...) is called, here we add all preset choices into our choice list so they can be displayed to the user. On build view loadChoiceList(...) is called, but we don't load anything, we just return our private choice list created before.

Now the user interacts with the form, some additional choices are loaded via an autocomplete and put into th HTML. On submit of the form the selected values are submitted and in our controller action first the form is created and afterwards on $form->handleRequest(..) loadChoicesForValues(...) is called, but the submitted values might be completly different from those which where included in the beginning. So we replace our internal choice list with a new one containing only the submitted values.

Our form now perfectly holds the data added by autocompletion.

The tricky part is, that we need a new instance of our choice loader whenever we use the form type, otherwise the internal choice list would hold a mixture of all choices.

Since the goal is to write a new autocomplete choice type, you usually would use dependency injection to pass your choice loader into the type service. But for types this is not possible if you always need a new instance, instead we have to include it via options. Setting the choice loader in the default options does not work, since they are cached too. To solve that problem you have to write a anonymous function which needs to take the options as parameters:

$resolver->setDefaults(array(
    'choice_loader' => function (Options $options) {
        return AutocompleteFactory::createChoiceLoader();
    },
));

Edit: Here is a reduced version of the choice loader class:

use Symfony\Component\Form\ChoiceList\ArrayChoiceList;
use Symfony\Component\Form\ChoiceList\ChoiceListInterface;
use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface;

class AutocompleteChoiceLoader implements ChoiceLoaderInterface
{
    /** @var ChoiceListInterface */
    private $choiceList;

    public function loadValuesForChoices(array $choices, $value = null)
    {
        // is called on form creat with $choices containing the preset of the bound entity
        $values = array();
        foreach ($choices as $key => $choice) {
            // we use a DataTransformer, thus only plain values arrive as choices which can be used directly as value
            if (is_callable($value)) {
                $values[$key] = (string)call_user_func($value, $choice, $key);
            }
            else {
                $values[$key] = $choice;
            }
        }

        // this has to be done by yourself:  array( label => value )
        $labeledValues = MyLabelService::getLabels($values);

        // create internal choice list from loaded values
        $this->choiceList = new ArrayChoiceList($labeledValues, $value);

        return $values;
    }


    public function loadChoiceList($value = null)
    {
        // is called on form view create after loadValuesForChoices of form create
        if ($this->choiceList instanceof ChoiceListInterface) {
            return $this->choiceList;
        }

        // if no values preset yet return empty list
        $this->choiceList = new ArrayChoiceList(array(), $value);

        return $this->choiceList;
    }


    public function loadChoicesForValues(array $values, $value = null)
    {
        // is called on form submit after loadValuesForChoices of form create and loadChoiceList of form view create
        $choices = array();
        foreach ($values as $key => $val) {
            // we use a DataTransformer, thus only plain values arrive as choices which can be used directly as value
            if (is_callable($value)) {
                $choices[$key] = (string)call_user_func($value, $val, $key);
            }
            else {
                $choices[$key] = $val;
            }
        }

        // this has to be done by yourself:  array( label => value )
        $labeledValues = MyLabelService::getLabels($values);

        // reset internal choice list
        $this->choiceList = new ArrayChoiceList($labeledValues, $value);

        return $choices;
    }
}
Gabbey answered 11/3, 2016 at 13:29 Comment(4)
Oh, if only I knew what the array keys and values should be!Tribal
@IanPhillips depends on which array you mean. For return values of the functions have a look at the phpDoc of ChoiceLoaderInterface. Keys are always as in the parameters array and values either choices or values. Note that you still need a DataTransformer if you work with entities! The array used to create the internal ArrayChoiceList should contain the later <option> labels as keys and their values as values.Gabbey
@IanPhillips I added a shortened version of my autocomplete choice loaderGabbey
@Gabbey There's something weird going on with my selected values. When I send an associative array to my loader like [ values => options], the values end up displayed as options on my form, but, at least, the selected values are actually selected. Then if I flip the array, the options are correctly displayed, but they're not selected anymore.Manno
S
-1

A basic (and probably not the best) option would be to unmap the field in your form like :

->add('field', choiceType::class, array(
       ...
       'mapped' => false
    ))

In the controller, after validation, get the data and send them to the entity like this :

$data = request->request->get('field');
// OR
$data = $form->get('field')->getData();
// and finish with :
$entity = setField($data);
Sapphira answered 17/2, 2016 at 13:38 Comment(2)
Setting 'mapped' => false doesn't solve the problem. Also I am looking for a general solution within my custom type class without the need to add code to any controllerGabbey
Yep, you're right, I mixed up with another thing which is combined with an event lister : dynamic form modification where the idea is to get all the field selectioned in your form, and add it to your chocieSapphira

© 2022 - 2024 — McMap. All rights reserved.