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

Community
  • 1
  • 1
SBH
  • 1,787
  • 3
  • 22
  • 27

2 Answers2

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;
    }
}
SBH
  • 1,787
  • 3
  • 22
  • 27
  • Oh, if only I knew what the array keys and values should be! – Ian Phillips Mar 18 '16 at 23:06
  • @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](http://symfony.com/doc/current/cookbook/form/data_transformers.html) if you work with entities! The array used to create the internal `ArrayChoiceList` should contain the later ` – SBH Mar 19 '16 at 09:02
  • 1
    @IanPhillips I added a shortened version of my autocomplete choice loader – SBH Mar 19 '16 at 20:52
  • @SBH 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. – vctls Jan 25 '17 at 12:26
-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);
Aridjar
  • 301
  • 1
  • 5
  • 14
  • 3
    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 controller – SBH Feb 17 '16 at 13:45
  • Yep, you're right, I mixed up with another thing which is combined with an event lister : [dynamic form modification](http://symfony.com/doc/current/cookbook/form/dynamic_form_modification.html#adding-an-event-listener-to-a-form-class) where the idea is to get all the field selectioned in your form, and add it to your chocie – Aridjar Feb 17 '16 at 14:34