Vlad Dogarescu
Vlad Dogarescu

Reputation: 173

Dynamically modify the form choices in a Custom FormType using Form Events Symfony 5

I want to build a custom AjaxEntityType that loads the options over ajax. I cannot build the form with all the choices because there are too many and the performance is greatly affected.

The problem is that if I readd the form field (like in the cookbook) from within the custom type the data doesn't get submitted at all.

I need a way to change the choices from within the Custom Type Class without readding the form field.

Here is my class:

<?php

namespace App\Form\Type;

class AjaxEntityType extends AbstractType
{


    protected $em;

    public function __construct(EntityManagerInterface $em)
    {
        $this->em = $em;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'ajax_url' => null
        ]);
    }

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        
        $builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($options) {
            $data = $event->getData();
            if ($data === null || $data === []) {
                return;
            }
            // here i get the ids from the submited data, get the entities from the database and I try to set them as choices
            $entityIds = is_array($data) ? $data : [$data];
            $entities = $this->em->getRepository($event->getForm()->getConfig()->getOptions()['class'])->findBy(["id" => $entityIds]);

            $options['choices'] = $entities;

            $event->getForm()->getParent()->add(
                $event->getForm()->getName(),
                self::class,
                $options
            );
            // the result is that the from gets submitted, but the new data is not set on the form. It's like the form field never existed in the first place.
        });

    }

    /**
     * {@inheritdoc}
     */
    public function buildView(FormView $view, FormInterface $form, array $options)
    {
        $view->vars['ajax_url'] = $options['ajax_url'];
    }

    public function getParent()
    {
        return EntityType::class;
    }
}

My controller is as simple as it gets:

public function create(Request $request, ProductService $productService, Product $product = null)
    {
        if(empty($product)){
            $product = new Product();
        }

        $form = $this->createForm(ProductType::class, $product);

        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {

            $this->getDoctrine()->getManager()->persist($product);
            $this->getDoctrine()->getManager()->flush();
            return $this->redirectToRoute('products_list');

        }

        return $this->render('admin/products/form.html.twig', [
            'page_title' => "Product form",
            'product' => $product,
            'form' => $form->createView()
        ]);
    }

Upvotes: 1

Views: 2915

Answers (3)

MarkW
MarkW

Reputation: 51

I came across your question after I encountered the same issue, with the new field data being missing from the submitted form. My solution was to conditionally add the new field, and then manually submit it within the pre submit event handler:

$data = $event->getData();
$form = $event->getForm();
$name = $form->getName();
$parent = $form->getParent();

if(!in_array($data, $form->getConfig()->getOption('choices'))) {

    $parent->add(
        $name,
        self::class,
        $options
    );
        
    $newForm = $parent->get($name);
    $newForm->submit($data);
}

Upvotes: 0

Vlad Dogarescu
Vlad Dogarescu

Reputation: 173

Altering the form in the PRE_SET_DATA (to keep the selected choices in the form) and in the PRE_SUBMIT (to add the newly selected items to the choice list) ended up being a huge headache. Always needed some adjustments with a lot of complications.

So as a final solution I removed all the choices in the buildView method of my custom type class, it works well.

// in file App\Form\Type\AjaxEntityType.php
/**
 * {@inheritdoc}
 */
public function buildView(FormView $view, FormInterface $form, array $options)
{
    $view->vars['ajax_url'] = $options['ajax_url'];

    // in case it's not a multiple select
    if(!is_array($view->vars['value'])){
        $selected = [$view->vars['value']];
    }else{
        $selected = $view->vars['value'];
    }

    foreach($view->vars['choices'] as $index => $choice){
        if(!in_array($choice->value, $selected)){
            unset($view->vars['choices'][$index]);
        }
    }

}

Upvotes: 0

Jakumi
Jakumi

Reputation: 8374

After consulting the form components code and thinking about it some time, I have a theory.

First, form submission works like this (simplified):

So it's recursive, and the sub form goes through it's entire cycle before the parent form continues.

So what happens is this:

When it is its turn, your sub form does some elaborate stuff to create a new sub form (which will not be included in the cycle of the parent form) and which will override the original form. However, since the new sub form doesn't get its own cycle it's essentially empty. (Also, the parent form removes the submitted data for the form it has processed to detect extra data). So we now have a detached original form that received the data and a new linked form that has no data.

The parent form will now call the datamapper for itself (and its subforms) to map the data back to view -> normalized -> model data. Since your old sub form isn't a child anymore and the new sub form is empty, the result is empty.

How to fix this?

You have to add the sub form before the parent form cycles through the sub forms. So one way to achieve this would be to add a PRE_SUBMIT event listener to the parent form and add the sub form there instead of the sub form overriding itself in the parent. Obviously, this isn't exactly as reusable as you might have hoped. Maybe you can add the event listener to the parent form in the buildForm method of the sub form, but that sounds a little dirty (it's not its place to edit the parent form to begin with though).

side note: I also hope you're aware that if the form produced an error your changed form would be shown to the user ... with its very limited set of options - if you have a good handling of the form response this shouldn't be a problem.

update

Okay, so apparently it should be reusable ... in that case I see two alternatives, of which either might not work:

  1. set empty_data on the new form to the entities, since the form don't receive any data, this is probably going to be used. Maybe you could set data as well.

  2. don't replace the form but actually implement event handlers that actually set the data. The SUBMIT handler should have direct access to the normData (set new values via $event->setData(...)) which then gets propagated.

  3. you could opt to not extend EntityType but instead wrap it (i.e. have ->add(..., EntityType...) in its buildForm) and apply the methods that I described for the parent modification before. This would make it reusable. if you set label to false and a convenient form theme, this should be close to indistinguishable from using the entity type directly.

Upvotes: 1

Related Questions