Skip to content
This repository has been archived by the owner on Jul 10, 2020. It is now read-only.

Providing Form View Helpers with i18n value rendering #144

Closed
berturion opened this issue May 7, 2015 · 3 comments
Closed

Providing Form View Helpers with i18n value rendering #144

berturion opened this issue May 7, 2015 · 3 comments

Comments

@berturion
Copy link

Hello,
I am not sure if this module is the right one to provide such functionnality but, as I use it and love it, I post my thoughts here.
I am french so when my form includes Float elements, I implement a Float validator and set locale to fr_FR:

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

All is ok for value input.

But I am very disappointed by the Zend Form View Helpers because none of them provide options to render i18n values.

When a simple $this->formRow($form->get($price)) could be enough, I have to write:

$price = $form->get('price');
$origValue = $price->getValue();
$price->setValue($this->numberFormat($origValue, \NumberFormatter::DECIMAL, \NumberFormatter::TYPE_DEFAULT, 'fr_FR'));
echo $this->formRow($price);

Why couldn't we set an option for filters in the form element declaration and render it correctly in the FormRow View Helper (applying the filters before rendering)?

    $this->add(array(
        'name' => 'price',
        'type' => 'text',
        'options' => array(
            // 'label' => $this->translate('Price'),
            'twb-layout' => \TwbBundle\Form\View\Helper\TwbBundleForm::LAYOUT_INLINE,
            // 'column-size' => 'sm-10',
            'label_attributes' => array(
                // 'class' => 'col-sm-2 required'
            )
        ),
        'attributes' => array(
            'type' => 'text',
            'placeholder' => $this->translate('Price')
        ),
        'filters' => array(
            array(
                'name' => 'NumberFormat',
                'locale' => 'fr_FR',
                'style' => \NumberFormatter::DECIMAL
            ),
            array(
                'name' => 'NumberParse',
                'locale' => 'fr_FR',
                'style' => \NumberFormatter::DECIMAL
            )
        ),
    ));

What do you think ?

@berturion
Copy link
Author

Hello, I am answering my own question with a solution I made myself: a class that extends TwbBundleFormElement and use a new option output_filters that is set on form field declaration.

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, then parsed 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.

@neilime
Copy link
Owner

neilime commented Aug 17, 2015

I think that you may suggest this behaviour to zend-form project

@berturion
Copy link
Author

I created the issue, hoping that it will grab some attention :)
zendframework/zend-form#10

If you want to close the issue in your project, no problem.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants