Creating a custom formRow view helper to filter field values on rendering in Zend Framework 2

Désolé pour les non anglophones, mais je n’ai pas le courage de traduire la solution décrite dans mon article original sur une issue du module neilime/zf2-twb-bundle sur Github.

Du coup, hors contexte, je précise que cette solution n’est viable que si vous utilisez le module précité dans votre projet zend framework 2.

Voici son contenu qui j’espère servira aux utilisateurs de zend framework 2 et du module en question. Si des améliorations pourront être faites, j’essaierai de maintenir cet article à jour en conséquence. Mais en tout cas, c’est une solution que j’utilise dans mes projets et qui est assez pratique, surtout pour les applications multi-lingues.

Description of the problem:

For example, say we have a text field that is used to store float numbers and our application is in french (yes, it’s my native language :). In database, it is stored as float, like 4112.156. In a french notation, this number should appear as 4 112,156.

In that case, if you want to do things the right way you expect the user to enter numbers in a french notation. So you add an input filter and a validator in your form like this:

'my_localized_number_field' => array(
    'allow_empty' => true,
    'required' => false,
    'filters' => array(
        array(
            'name' => 'NumberParse',
            'options' => array(
                'locale' => 'fr_FR',
                'style' => \NumberFormatter::DECIMAL,
            ),
        ),
    ),
    'validators' => array(
        array(
            'name' => 'IsFloat',
            'options' => array(
                'locale' => 'fr_FR',
            ),
        ),
    ),
),

This input filter specification verifies that the user is entering a number in the french notation. For example, if he tries to enter 4112.156, it will be rejected.
If the user enters 4 112,156, the value will be accepted (by the validator), then parsed (by the filter) to a float and finally saved in database as a real float type 4112.156.

Good!

Now the user want to modify this value and opens the form again. When the form will be populated by the database row, the field value will be written in the native float notation.
Using the original formRow view helper, as the field is filled with a native float notation, when the user will try to submit the form again without any modification, it will be rejected because the value is 4112.156 and the form expects 4 112,156.

No way!

My solution

So here is my solution, maybe things could be better but it works.

We will create our own FormRow View Helper that extends the very good existing TwbBundleFormElement view helper in order to apply filters to our value before rendering.

For this, we need our view helper to have the FilterPluginManager.

Let’s coding

So first we create an FilterPluginManagerAwareInterface interface for future classes that would use a FilterPluginManager.

<?php
namespace MyNameSpace\Zend\Form\View\Helper;

use Zend\Filter\FilterPluginManager;

interface FilterPluginManagerAwareInterface
{
    /**
     *
     * @return FilterPluginManager
     */
    public function getFilterPluginManager();

    /**
     *
     * @param FilterPluginManager $plugins
     */
    public function setFilterPluginManager(FilterPluginManager $plugins);
}

Then we create a compatible FilterPluginManagerAwareTrait to simplify the process of applying filters on form elements using the FilterPluginManager and having our main method for applying our future configured filters named applyFilters.

<?php
namespace MyNameSpace\Zend\Form\View\Helper;

use Zend\Filter\FilterPluginManager;
use Zend\Filter\FilterChain;
use Zend\Form\ElementInterface;

trait FilterPluginManagerAwareTrait
{
    /**
     *
     * @var FilterPluginManager
     */
    protected $filterPluginManager;

    /**
     * Get plugin manager instance
     *
     * @return FilterPluginManager
     */
    public function getFilterPluginManager()
    {
        if (!$this->filterPluginManager) {
            $this->setFilterPluginManager(new FilterPluginManager());
        }
        return $this->filterPluginManager;
    }

    /**
     * Set plugin manager instance
     *
     * @param  FilterPluginManager $filterPluginManager
     * @return self
     */
    public function setFilterPluginManager(FilterPluginManager $filterPluginManager)
    {
        $this->filterPluginManager = $filterPluginManager;
        return $this;
    }

    /**
     * Check presence of option 'output_filters' and filter the element value with
     * configured filters in it
     *
     * @param ElementInterface $element
     */
    protected function applyFilters(ElementInterface $element) {
        $options = $element->getOptions();
        if (isset($options['output_filters'])) {
            $chain = new FilterChain();
            $chain->setPluginManager($this->getFilterPluginManager());
            $this->populateFilters($chain, $options['output_filters']);
            $newVal = $chain->filter($element->getValue());
            $element->setValue($newVal);
        }
    }

    /**
     * @param  FilterChain       $chain
     * @param  array|Traversable $filters
     * @throws Exception\RuntimeException
     * @return void
     */
    protected function populateFilters(FilterChain $chain, $filters)
    {
        foreach ($filters as $filter) {
            if (is_object($filter) || is_callable($filter)) {
                $chain->attach($filter);
                continue;
            }

            if (is_array($filter)) {
                if (!isset($filter['name'])) {
                    throw new \RuntimeException(
                        'Invalid filter specification provided; does not include "name" key'
                    );
                }
                $name = $filter['name'];
                $priority = isset($filter['priority']) ? $filter['priority'] : FilterChain::DEFAULT_PRIORITY;
                $options = array();
                if (isset($filter['options'])) {
                    $options = $filter['options'];
                }
                $chain->attachByName($name, $options, $priority);
                continue;
            }

            throw new \RuntimeException(
                'Invalid filter specification provided; was neither a filter instance nor an array specification'
            );
        }
    }
}

The key thing here is the string output_filters that will be set in our form field declaration, we will see that later.

We can new create the new TwbBundleFormElement extending the original one, implementing previous interface and using our trait.

<?php

namespace MyNameSpace\TwbBundle\Form\View\Helper;

use TwbBundle\Form\View\Helper\TwbBundleFormElement as OriginalTwbBundleFormElement;
use TwbBundle\Options\ModuleOptions;
use MyNameSpace\Zend\Form\View\Helper\FilterPluginManagerAwareInterface;
use MyNameSpaceZend\Form\View\Helper\FilterPluginManagerAwareTrait;
use Zend\Form\ElementInterface;

class TwbBundleFormElement extends OriginalTwbBundleFormElement implements FilterPluginManagerAwareInterface
{

    use FilterPluginManagerAwareTrait;

    public function __construct(ModuleOptions $options, $pluginManager = null)
    {
        parent::__construct($options);
        if (!is_null($pluginManager)) {
            $this->setFilterPluginManager($pluginManager);
        }
    }

    public function render(ElementInterface $element) {
        $this->applyFilters($element);
        $retour = parent::render($element);
        return $retour;
    }
}

We just override __construct and render methods.

The constructor is important here because we are waiting for a new member, the FilterPluginManager. But to make things smooth, I decided that it would be optional and the class can work without, it’s a matter of custom filter visibility.

The render method calls the $this->applyFilters($element); of the trait. The render method is then responsible for applying configured output_filters on element before rendering.

To instanciate correctly our new TwbBundleFormElement, as we defined a new constructor, we have to create an appropriate factory, here it is:

<?php

namespace MyNameSpace\TwbBundle\Form\View\Helper\Factory;

use Zend\ServiceManager\FactoryInterface;
use Zend\ServiceManager\ServiceLocatorInterface;
use MyNameSpace\TwbBundle\Form\View\Helper\TwbBundleFormElement;

/**
 * Factory to inject the ModuleOptions hard dependency from original TwbBundleFormElement
 * and the FilterManager in order to provide all defined filters in the application
 */
class TwbBundleFormElementFactory implements FactoryInterface
{
    public function createService(ServiceLocatorInterface $serviceLocator)
    {
        $options = $serviceLocator->getServiceLocator()->get('TwbBundle\Options\ModuleOptions');
        $filterPluginManager = $serviceLocator->getServiceLocator()->get('FilterManager');
        return new TwbBundleFormElement($options, $filterPluginManager);
    }
}

The last thing to do is to inform our MyNameSpace module to use our own custom TwbBundleFormElement instead of the default one, so in our module.conf.php, in vew_helpers section, we add:

'view_helpers' => array(
    'factories' => array(
        // any existing view helper factories are here,
        // the new one bellow:
        'formElement' => 'MyNameSpace\TwbBundle\Form\View\Helper\Factory\TwbBundleFormElementFactory',
    ),

How to use?

Here, we just added a very useful feature for our forms but how to use it?

We have our new custom formRow view helper. It expects an output_filters option in our form field declaration to be able to filter form field value before rendering. This output_filters option has exactly the same structure as the filter option in the input filter specifications. So we can write a NumberFormat filter for our float field like this (I assume you declare your form using Zend Form object and its fields in the init() method):

$this->add(array(
        'name' => 'my_localized_number_field',
        'options' => array(
            'label' => $label,
            'output_filters' => array(
                array(
                    'name' => 'NumberFormat',
                    'options' => array(
                    'locale' => 'fr_FR
                ),
            ),
        ),
        'attributes' => array(
            'placeholder' => 'Pleas enter a decimal number in the french notation',
        ),
));

Then, in the view script, you can use this simple instruction:

$this->formRow($form->get('my_localized_number_field'));

The field will be filled with your float value formatted in french notation (or any other specified locale) on view script rendering.

Vous aimerez aussi...

Laisser un commentaire