Wrzasq.pl

Symfony2 - creating adapter-dependent sub-forms

Thursday, 16 June 2016, 10:47

I think this is quite commonly needed feature - form structure that vary depending on some other field(s). The project, on which I'm currently working, is integrated with a lot of external APIs which usually require additional per-user options for each of them. It's a SEO tool and - to pick an example - provides various indexing services integration. Some of them require API key, some require login and password, some specify different URLs. So for each of them form structure can differ. Of course you could use some conditions to pick correct structure, but it's not enough - form needs to change together with selected option. There are some partial tutorials on the web how to alternate form based on events, but it's just one part of the solution - here is more complex one.

Before you blindly follow this post: because of some dependencies we got stuck with Symfony 2.2; 2.3 provides some nice features like form buttons handling and assigning form action to form model, which could simplify things a little, but still, the logic is the same and everything should work the very same way.

First things first - the concept and flow

Let's see what we have in a standard flow: a form model (aka model type), a business model (for instance database entity), a controller that handles form submission and a view that displays the form. We don't need to re-invent the wheel, but improve it a little: we need to plug in a form alternation, which can be done with event listener; we need to delegate this alternation to adapters associated with discriminator field and just a little piece of JavaScript to handle switching between options. To keep examples provided in this post as compact as possible I will use JMSDiExtraBundle and SensioFrameworkExtraBundle to configure as many aspects as possible with annotations.

Repository for providers

Before we start with building our form we need to have all available providers aggregated to make them accessible in other services (like form type) - just a simple, hardcoded aggregation of two providers for now (note that no form handling code exists in them yet, we will cover that later):

namespace Application\Bundle\IndexersBundle\Provider;

use ArrayObject;

use JMS\DiExtraBundle\Annotation as DI;

interface ProviderInterface
{
    /**
     * @return string
     */
    public function getName();
}

class FooProvider implements ProviderInterface
{
    /**
     * {@inheritDoc}
     */
    public function getName()
    {
        return 'Foo';
    }
}

class BooProvider implements ProviderInterface
{
    /**
     * {@inheritDoc}
     */
    public function getName()
    {
        return 'Bar';
    }
}

/**
 * @DI\Service("application.indexers.providers_repository")
 */
class ProvidersRepository extends ArrayObject
{
    public function __construct()
    {
        // this is for sake of simplicity of this article
        // of course you should register all providers from outside
        // best by specialized method, like `registerProvider()`
        $this['foo'] = new FooProvider();
        $this['bar'] = new BarProvider();
    }
}

Business model

Let's continue by creating the business model. Reduced to just what we need for demonstration, it would look like that:

namespace Application\Bundle\IndexersBundle\Model;

class IndexerSettings
{
    /**
     * @var string
     */
    protected $provider;

    /**
     * @var mixed
     */
    protected $settings = array();

    /**
     * @return string
     */
    public function getProvider()
    {
        return $this->provider;
    }

    /**
     * @param string $provider
     * @return self
     */
    public function setProvider($provider)
    {
        $this->provider = $provider;

        return $this;
    }

    /**
     * @return mixed
     */
    public function getSettings()
    {
        return $this->settings;
    }

    /**
     * @param mixed $settings
     * @return self
     */
    public function setSettings($settings)
    {
        $this->settings = $settings;

        return $this;
    }
}

Settings can easily be saved in database as JSON for example (or any other serialized value).

The form

Now move to the form model - we need to put our discriminator field, based on which we will subsequently build provider-dependent fields group, all the rest will be done by event listener:

namespace Application\Bundle\IndexersBundle\Form\Type;

use JMS\DiExtraBundle\Annotation as DI;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;

use Application\Bundle\IndexersBundle\Provider\ProvidersRepository;
use Application\Bundle\IndexersBundle\Form\EventListener\ProviderChangeSubscriber;

/**
 * @DI\Service
 * @DI\Tag("form.type", attributes={"alias"="indexer_settings"})
 */
class SettingsType extends AbstractType
{
    /**
     * @var ProvidersRepository
     */
    protected $providers;

    /**
     * @DI\InjectParams({
     *      "providers"=@DI\Inject("application.indexers.providers_repository")
     *  })
     */
    public function __construct(ProvidersRepository $providers)
    {
        $this->providers = $providers;
    }

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        // generate plain map
        $providers = [];
        foreach ($this->providers as $name => $provider) {
            $providers[$name] = $provider->getName();
        }

        $builder->addEventSubscriber(new ProviderChangeSubscriber($this->providers))
            ->add(
                'provider',
                'choice',
                [
                    'choices' => $providers,
                    'label' => 'Indexer type:',
                    'empty_value' => '',
                    'attr' => ['class' => 'formReload'],
                ]
            );
    }

    public function getName()
    {
        return 'indexer_settings';
    }
}

The formReload class is important for further JavaScript snippet - you can of course pick any other class name that you want.

Handling different providers

It's show time! Now comes the most important part, that handles diversification of settings fields. We need to handle select field changes to provide proper settings fields for selected provider. But it's a little tricky - two cases need to be handled: pre-defined data (already existing settings), but also submitting data by user. While first case occurs always (at least with empty data), second one should override previous fields. We can achieve that by storing all settings within sub-form so we can easily wipe out entire fields group.

namespace Application\Bundle\IndexersBundle\Form\EventListener;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Form;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;

use Application\Bundle\IndexersBundle\Indexer\ProvidersRepository;

class ProviderChangeSubscriber implements EventSubscriberInterface
{
    /**
     * @var ProvidersRepository
     */
    protected $providers;

    /**
     * @param ProvidersRepository $providers Available providers.
     */
    public function __construct(ProvidersRepository $providers)
    {
        $this->providers = $providers;
    }

    /**
     * {@inheritDoc}
     */
    public static function getSubscribedEvents()
    {
        return [
            FormEvents::PRE_SET_DATA => 'preSetData',
            FormEvents::PRE_BIND => 'preBind',
        ];
    }

    /**
     * This method handles initial structure.
     *
     * @param FormEvent $event Form event.
     */
    public function preSetData(FormEvent $event)
    {
        $data = $event->getData();
        $form = $event->getForm();

        // note that form data is now our entity object
        $this->buildProviderSettingsForm($form, $data->getProvider());
    }

    /**
     * This method handles changing structure after form submit.
     *
     * @param FormEvent $event Form event.
     */
    public function preBind(FormEvent $event)
    {
        $data = $event->getData();
        $form = $event->getForm();

        // first remove old structure
        $form->remove('settings');
        // this time data is just a plain array as parsed from request
        $this->buildProviderSettingsForm($form, $data['provider']);
    }

    /**
     * @param Form $form Main form.
     * @param string $provider Provider.
     */
    protected function buildProviderSettingsForm(Form $form, $provider)
    {
        // create sub-form wrapper
        $subForm = $form->getConfig()->getFormFactory()->createNamed(
            'settings',
            'form',
            null,
            ['label' => false]
        );

        // you don't need this condition if you don't allow empty value
        // in my example I allow user to select "no provider" (empty) option
        if ($provider) {
            $provider = $this->providers->getProvider($provider);

            // delegate form structure building for specific provider
            $provider->buildForm($subForm);
        }

        $form->add($subForm);
    }
}

Delegating structure building to provider

So… we delegated sub-form creation to buildForm() method of provider. It would be nice to have such method ;). When implementing it in providers keep one thing in mind - it's not FormBuilder anymore, it's particular Form instance!

namespace Application\Bundle\IndexersBundle\Provider;

use Symfony\Component\Form\Form;

interface ProviderInterface
{
    /**
     * @param Form $form Provider data form.
     */
    public function buildForm(Form $form);
}

class FooProvider implements ProviderInterface
{
    /**
     * {@inheritDoc}
     */
    public function buildForm(Form $form)
    {
        $form->add('login', 'text', ['label' => 'Login:'])
            ->add('password', 'text', ['label' => 'Password:']);
    }
}

class BooProvider implements ProviderInterface
{
    /**
     * {@inheritDoc}
     */
    public function buildForm(Form $form)
    {
        $form->add('apikey', 'text', ['label' => 'API key:']);
    }
}

The controller (together with routing)

Time for the final piece! Controller which will glue it all together. There are some caveats about form handling action:

  • there need to be two routes for the same action which will distinguish between regular form submission, and reloading after provider change;
  • you need to explicitly specify route for form action as after provider change current browser URL (and default form action) will still point to reload route;
  • order of conditions in if () {} block matters - we don't want to validate the form after reload as it will obviously contain invalid data for new provider.

Ok, let's proceed to code that includes all of these points:

/**
 * @Route(
 *      "/settings",
 *      name="application_indexers_account_settings",
 *      defaults={"submit"=true}
 *  )
 * @Route(
 *      "/settings/reload",
 *      name="application_indexers_account_settings_reload",
 *      defaults={"submit"=false}
 *  )
 * @param Request $request
 * @param bool $submit
 * @return \Symfony\Component\HttpFoundation\Response
 */
public function settingsAction(Request $request, $submit)
{
    // here we create new object, but you can pick existing model
    // let's say settings of user which is currently logged in
    $data = new IndexerSettings();
    $form = $this->createForm('indexer_settings', $data);

    // only handle POST form submits
    if ($request->isMethod('POST')) {
        $form->bind($request);

        // validate form
        if ($submit && $form->isValid()) {
            // here your data should be already updated
            // do something with it - eg. persist in database
            // and redirect somewhere, or display form again
            // with confirmation message
        }
    }

    // render form view
    return $this->render(
        'ApplicationIndexersBundle:Account:settings.html.php',
        [
            'form' => $form->createView(),
            'action' => $this->generateUrl('application_indexers_account_settings'),
        ]
    );
}

The view

We're done with most of the stuff, now just a simple view to display this form - remember to explicitly use action specified by controller:

<form action="<?php echo $view->escape($action); ?>" method="post" <?php echo $view['form']->enctype($form) ?>>
    <?php echo $view['form']->widget($form) ?>

    <input type="submit" value="Save"/>
</form>

JavaScript to handle option change

The last thing is making it all automatic. We need a JavaScript code that will re-create the form after user selects new provider:

document.on("dom:loaded", function() {
        $("select.formReload").each(function(field) {
                field.on("change", function() {
                        this.form.action += "/reload";
                        this.form.submit();
                });
        });
});

Of course my code bases on Prototype, but it's not bound to any particular piece of PHP code and you can just use your own replacement.

XDebug limits

When messing with nested forms you can very easily encounter problem with too deep function call nesting if you have XDebug enabled. Default limit is set to 100 which is far too small value for such cases. You need to increase it in your PHP configuration (with 1000 I didn't experience any problems so far - neither with too deep nesting, nor with performance):

xdebug.max_nesting_level = 1000

Fields for improvements

Didn't want to blow this note with non-essential aspects, so I restricted it to server-side stuff, related to Symfony. The only small JavaScript snippet simply reloads the form page - you can use this as an entry point to making your form more dynamic by handling option switch with AJAX.

Another thing is grabbing all possible providers - to reduce codebase I hardcoded two of them, but the proper way is to use DI tags to mark all providers and process them with custom DI compiler.

Tags: , , , ,