Creating custom Symfony (2 and 3) Form Types puts the developpers in front of problems regarding the loading of front-end dependencies (or assets) such as JavaScript and CSS files. In this article, we will present the problems we frequently encounter and how to fix them with the IdciAssetLoader Bundle. To have a full understanding of this article, you should be comfortable with Symfony 2's Form Types
Let's use a typical example : let's imagine we want to create a custom form type, which would be based on the Text type, allowing us to handle a system of tags.
We'll use Taggle.js in order to generate the tags and JQuery to implement an autocompletion system.
Therefore, we have :
We could be tempted to include the dependencies in the widget script (in <script>
tags), which would not be an issue if we only use one instance of it at a time. This effectively becomes problematic when we wish to use multiple ones in the same page : the dependencies are loaded in the DOM as many times as there are instances of the widget.
We could avoid this problem of multiple dependency loading by including the dependencies in the {% block javascripts %}
of the base.html.twig
file, but this introduces two further problems :
Another problem appears if our widget uses globally loaded dependencies, e.g. JQuery, which would then be registered in the {% block javascripts %}
of the base.html.twig
file. There is then a risk that the widget script runs before these dependencies are loaded, being placed above in the code.
A possible solution to avoid this issue would be to delay the execution of the widget script with the following native function :
window.addEventListener('load', function () {
// code goes here ...
});
With this technique, the script only runs when the window is entirely loaded, meaning our dependencies should be loaded and available. But this doesn't solve the issue of dependency duplication.
Furthermore, scripts loaded at the middle of the page, above the fold, introduce minor page speed optimisation issues. More.
Thus, we want to be able to :
Fortunately, our bundle does that all at once
First, we need to add AssetLoaderBundle as a dependency of our project. To do so, we simply need to add it to the composer.jscon
file.
"require": {
...
"idci/asset-loader-bundle": "dev-master"
},
Then, let's install this dependency with composer :
$ php composer.phar update
Finally let's register the bundle in the application kernel:
<?php
// app/AppKernel.php
public function registerBundles()
{
$bundles = array(
// ...
new IDCI\Bundle\AssetLoaderBundle\IDCIAssetLoaderBundle(),
);
}
Adding assets to our custom form type is pretty simple :
twig
files, with the other template filesPlease note that you can add assets this way for any service, and not only form types. You only need a class implementing the AssetProviderInterface and to register it as a service with the idci_asset_loader.asset_provider tag.
Our AbstractType will therefore look like this :
<?php
namespace AppBundle\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormView;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;
use IDCI\Bundle\AssetLoaderBundle\AssetProvider\AssetProviderInterface;
use IDCI\Bundle\AssetLoaderBundle\Model\Asset;
use IDCI\Bundle\AssetLoaderBundle\Model\AssetCollection;
class TagType extends AbstractType implements AssetProviderInterface
{
/**
* @var AssetCollection
*/
private $assetCollection;
/**
* Constructor
*/
public function __construct()
{
$this->assetCollection = new AssetCollection();
}
/**
* {@inheritDoc}
*/
public function getAssetCollection()
{
return $this->assetCollection;
}
/**
* {@inheritdoc}
*/
public function buildView(FormView $view, FormInterface $form, array $options)
{
$this->assetCollection->add(new Asset('AppBundle:Form:tags_type_assets.html.twig'));
// argument passed
$this->assetCollection->add(new Asset('Form/tags_type_script.html.twig', ['form' => $view]));
// Option added to define the tag separator
$view->vars['separator'] = $options['separator'];
// Option added to format the autocompletion data from the API
$view->vars['jsTransformFunction'] = $options['jsTransformFunction'];
// The URL of the Json autocompletion API
if (isset($options['url'])) {
$view->vars['url'] = $options['url'];
}
return $view->vars;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver
->setDefaults(array(
'separator' => ',',
'jsTransformFunction' => 'function (tags) { return tags; };'
))
->setOptional(array(
'url'
))
->setAllowedTypes(array(
'separator' => array('string'),
'jsTransformFunction' => array('string'),
'url' => array('string')
))
;
}
/**
* {@inheritdoc}
*/
public function getParent()
{
return 'textarea';
}
/**
* {@inheritdoc}
*/
public function getBlockPrefix()
{
return 'extra_form_tags';
}
/**
* {@inheritdoc}
*
* @deprecated
*/
public function getName()
{
return $this->getBlockPrefix();
}
}
}
Our script uses elements from view
in order to work, so we pass it as an argument of the Asset.
In our case, because our script needs dependencies to work properly, we need to load these before the actual widget script.
Thus, we include the two elements (dependencies and actual script) in two twig
files :
```twig {# app/Resources/views/Form/tags_type_assets.html.twig #}
```twig
{# app/Resources/views/Form/tags_type_script.html.twig #}
<script type="text/javascript">
(function () {
var formId = '{{ form.vars.id }}';
var separator = '{{ form.vars.separator }}';
var textarea = document.getElementById(formId);
var tags = [];
if (textarea.value !== '') {
tags = textarea.value.split(separator);
}
var updateTags = function () {
var taggleTags = taggle.getTags();
textarea.value = taggleTags.values.join(separator);
};
textarea.style.display = 'none';
var taggle = new Taggle('tags_' + formId, {
tags: tags,
onTagAdd: updateTags,
onTagRemove: updateTags
});
{% if form.vars.url is defined %}
var container = taggle.getContainer();
var input = taggle.getInput();
var request = $.ajax({
url: '{{ form.vars.url }}',
method: 'GET'
});
var formatTags = {{ form.vars.jsTransformFunction }};
request.done(function (tags) {
$(input).autocomplete({
source: formatTags(tags),
appendTo: container,
position: { at: 'left bottom', of: container },
select: function(event, data) {
event.preventDefault();
//Add the tag if user clicks
if (event.which === 1) {
taggle.add(data.item.value);
}
}
});
});
{% endif %}
})();
</script>
Don't forget to declare your form type as a service in the services.yml
file :
# app/config/services.yml
services:
tags_type:
class: AppBundle\Form\Type\TagType
tags:
- { name: form.type, alias: tags_type}
- { name: idci_asset_loader.asset_provider, alias: tags_type}
If you have multiple assets which have to be loaded in a precise order, you can add a priority to the asset as 3rd argument (-1 by default). The higher the priority, the sooner the asset will be loaded in the DOM.
<?php
$this->assetCollection->add(new Asset('MyBundle:Form:form_type_asset_1.html.twig', [], 0));
$this->assetCollection->add(
new Asset(
'MyBundle:Form:form_type_asset_2.html.twig',
[
'options' => $options,
'form' => $view,
],
1
)
);
You can use the idci_asset_loader.asset_dom_loader service in order to load the assets of one or more providers.
<?php
// Loads the assets from all the providers
$this->get('idci_asset_loader.asset_dom_loader')->loadAll();
// Loads the assets from one provider identified by its alias
$this->get('idci_asset_loader.asset_dom_loader')->load('tags_type');
To allow the subscriber to load the dependencies automatically (recommended), add the following parameter to the config.yml
file :
# app/config/config.yml
idci_asset_loader:
auto_load: true
Here you can see the final outcome of the Bundle :
Thank you for reading. Don't hesitate to contact us for further information.