phpdoctrine-ormzend-framework2zend-formzend-validate

ZF2 + Doctrine2 - Fieldset in Fieldset of a Collection in Fieldset does not validate properly


I asked a similar question a while ago, which came down to the structuring of the Forms, Fieldsets and InputFilters.

I've been thoroughly applying the principle of separation of concerns to split up Fieldsets from InputFilters as the modules they're created in will also be used in an API (Apigility based), so I would need only Entities and InputFilters.

However, I now have a problem that when I have a Fieldset, used by a Fieldset, used in a Collection in a Fieldset, that the inner-most Fieldset does not validate.

Let me elaborate with examples, and code!

The situation is that I want to be able to create a Location. A Location consists of a property name and a OneToMany ArrayCollection|Address[] association. This is because a Location could have multiple addresses (such as a visitors address and a delivery address).

An Address consists of a few properties (street, number, city, Country, etc.) and a OneToOne Coordinates association.

Now, Address has the below Fieldset:

class AddressFieldset extends AbstractFieldset
{
    public function init()
    {
        parent::init();

        // More properties, but you get the idea

        $this->add([
            'name' => 'street',
            'required' => false,
            'type' => Text::class,
            'options' => [
                'label' => _('Street'),
            ],
        ]);

        $this->add([
            'name' => 'country',
            'required' => false,
            'type' => ObjectSelect::class,
            'options' => [
                'object_manager' => $this->getEntityManager(),
                'target_class'   => Country::class,
                'property'       => 'id',
                'is_method'      => true,
                'find_method'    => [
                    'name' => 'getEnabledCountries',
                ],
                'display_empty_item' => true,
                'empty_item_label'   => '---',
                'label' => _('Country'),
                'label_generator' => function ($targetEntity) {
                    return $targetEntity->getName();
                },
            ],
        ]);

        $this->add([
            'type' => CoordinatesFieldset::class,
            'required' => false,
            'name' => 'coordinates',
            'options' => [
                'use_as_base_fieldset' => false,
            ],
        ]);
    }
}

As you can see, details for the Address Entity must entered, a Country must be selected and Coordinates could (not required) be provided.

The above is validated using the InputFilter below.

class AddressFieldsetInputFilter extends AbstractFieldsetInputFilter
{
    /** @var CoordinatesFieldsetInputFilter $coordinatesFieldsetInputFilter */
    protected $coordinatesFieldsetInputFilter;

    public function __construct(
        CoordinatesFieldsetInputFilter $filter,
        EntityManager $objectManager,
        Translator $translator
    ) {
        $this->coordinatesFieldsetInputFilter = $filter;

        parent::__construct([
            'object_manager' => $objectManager,
            'object_repository' => $objectManager->getRepository(Address::class),
            'translator' => $translator,
        ]);
    }

    /**
     * Sets AddressFieldset Element validation
     */
    public function init()
    {
        parent::init();

        $this->add($this->coordinatesFieldsetInputFilter, 'coordinates');

        $this->add([
            'name' => 'street',
            'required' => false,
            'filters' => [
                ['name' => StringTrim::class],
                ['name' => StripTags::class],
            ],
            'validators' => [
                [
                    'name' => StringLength::class,
                    'options' => [
                        'min' => 3,
                        'max' => 255,
                    ],
                ],
            ],
        ]);

        $this->add([
            'name' => 'country',
            'required' => false,
        ]);
    }
}

As you can see, the AddressFieldsetInputFilter required a few things, one of which is the CoordinatesFieldsetInputFilter. In the init() function this is then added with the name corresponding to that given in the Fieldset.

Now, all of the above works, no problem. Addresses with Coordinates everywhere. It's great.

The problem arises when we go another level further and have the LocationFieldset, as below, with it's LocationFieldsetInputFilter.

class LocationFieldset extends AbstractFieldset
{
    public function init()
    {
        parent::init();

        $this->add([
            'name' => 'name',
            'required' => true,
            'type' => Text::class,
            'options' => [
                'label' => _('Name'),
            ],
        ]);

        $this->add([
            'type' => Collection::class,
            'name' => 'addresses',
            'options' => [
                'label' => _('Addresses'),
                'count' => 1,
                'allow_add' => true,
                'allow_remove' => true,
                'should_create_template' => true,
                'target_element' => $this->getFormFactory()->getFormElementManager()->get(AddressFieldset::class),
            ],
        ]);
    }
}

In the class below, you might notice a bunch of commented out lines, these have been different attempts to modify the DI and/or setup of the InputFilter so that it works.

class LocationFieldsetInputFilter extends AbstractFieldsetInputFilter
{
    /** @var AddressFieldsetInputFilter $addressFieldsetInputFilter */
    protected $addressFieldsetInputFilter;

//    /** @var CoordinatesFieldsetInputFilter $coordinatesFieldsetInputFilter */
//    protected $coordinatesFieldsetInputFilter;

    public function __construct(
        AddressFieldsetInputFilter $filter,
//        CoordinatesFieldsetInputFilter $coordinatesFieldsetInputFilter,
        EntityManager $objectManager,
        Translator $translator
    ) {
        $this->addressFieldsetInputFilter = $filter;
//        $this->coordinatesFieldsetInputFilter = $coordinatesFieldsetInputFilter;

        parent::__construct([
            'object_manager' => $objectManager,
            'object_repository' => $objectManager->getRepository(Location::class),
            'translator' => $translator,
        ]);
    }

    /**
     * Sets LocationFieldset Element validation
     */
    public function init()
    {
        parent::init();

        $this->add($this->addressFieldsetInputFilter, 'addresses');
//        $this->get('addresses')->add($this->coordinatesFieldsetInputFilter, 'coordinates');

        $this->add([
            'name' => 'name',
            'required' => true,
            'filters' => [
                ['name' => StringTrim::class],
                ['name' => StripTags::class],
            ],
            'validators' => [
                [
                    'name' => StringLength::class,
                    'options' => [
                        'min' => 3,
                        'max' => 255,
                    ],
                ],
            ],
        ]);
    }
}

You might have noticed that the LocationFieldset and LocationFieldsetInputFilter make use of the existing AddressFieldset and `AddressFieldsetInputFilter.

Seeing as how they work, I cannot figure out why it's going wrong.

But what goes wrong?

Well, to create a Location, it appears that entering Coordinates is always required. If you look in the AddressFieldset (at the top), you'll notice a 'required' => false,, so this makes no sense.

However, when I DO enter values in the inputs, they do not get validated. When debugging, I get into the \Zend\InputFilter\BaseInputFilter, line #262 where it specifically validates the input, and I notice that it has lost it's data along the way of validation.

I've confirmed the presence of the data at the start, and during the validation, up until it tries to validate the Coordinates Entity, where it seems to lose it (haven't found out why).

If someone could point me in the right direction to clean this up, that help would be greatly appreciated. Have been banging against this issue for way too many hours now.

EDIT

Added in view partial code to show method for printing, in case that should/could help:

address-form.phtml

<?php
/** @var \Address\Form\AddressForm $form */

$form->prepare();
echo $this->form()->openTag($form);
echo $this->formRow($form->get('csrf'));

echo $this->formRow($form->get('address')->get('id'));
echo $this->formRow($form->get('address')->get('street'));
echo $this->formRow($form->get('address')->get('city'));
echo $this->formRow($form->get('address')->get('country'));

echo $this->formCollection($form->get('address')->get('coordinates'));

echo $this->formRow($form->get('submit'));
echo $this->form()->closeTag($form);

location-form.phtml

<?php
/** @var \Location\Form\LocationForm $form */

$form->prepare();
echo $this->form()->openTag($form);
echo $this->formRow($form->get('csrf'));

echo $this->formRow($form->get('location')->get('id'));
echo $this->formRow($form->get('location')->get('name'));
//echo $this->formCollection($form->get('location')->get('addresses'));

$addresses = $form->get('location')->get('addresses');
foreach ($addresses as $address) {
    echo $this->formCollection($address);
}

echo $this->formRow($form->get('submit'));
echo $this->form()->closeTag($form);

And just in case it makes it all even more clear: a debug picture to help out isEmpty errors, when clearly set


Solution

  • After another day of debugging (and swearing), I found the answer!

    This SO question helped me out by pointing me towards the Zend CollectionInputFilter.

    Because the AddressFieldset is added to the LocationFieldset within a Collection, it must be validated using a CollectionInputFilter which has the specific InputFilter for the Fieldset specified.

    To fix my application I had to modify both the LocationFieldsetInputFilter and the LocationFieldsetInputFilterFactory. Below the updated code, with the old code in comments.

    LocationFieldsetInputFilterFactory.php

    class LocationFieldsetInputFilterFactory extends AbstractFieldsetInputFilterFactory
    {
        /**
         * @param ServiceLocatorInterface|ControllerManager $serviceLocator
         * @return InputFilter
         */
        public function createService(ServiceLocatorInterface $serviceLocator)
        {
            parent::setupRequirements($serviceLocator, Location::class);
    
            /** @var AddressFieldsetInputFilter $addressFieldsetInputFilter */
            $addressFieldsetInputFilter = $this->getServiceManager()->get('InputFilterManager')
                ->get(AddressFieldsetInputFilter::class);
    
            $collectionInputFilter = new CollectionInputFilter();
            $collectionInputFilter->setInputFilter($addressFieldsetInputFilter); // Make sure to add the FieldsetInputFilter that is to be used for the Entities!
    
            return new LocationFieldsetInputFilter(
                $collectionInputFilter,         // New
                // $addressFieldsetInputFilter, // Removed
                $this->getEntityManager(),
                $this->getTranslator()
            );
        }
    }
    

    LocationFieldsetInputFilter.php

    class LocationFieldsetInputFilter extends AbstractFieldsetInputFilter
    {
        // Removed
        // /** @var AddressFieldsetInputFilter $addressFieldsetInputFilter */
        // protected $addressFieldsetInputFilter ;
    
        // New
        /** @var CollectionInputFilter $addressCollectionInputFilter */
        protected $addressCollectionInputFilter;
    
        public function __construct(
            CollectionInputFilter $addressCollectionInputFilter, // New
            // AddressFieldsetInputFilter $filter, // Removed
            EntityManager $objectManager,
            Translator $translator
        ) {
            // $this->addressFieldsetInputFilter = $filter; // Removed
            $this->addressCollectionInputFilter = $addressCollectionInputFilter; // New
    
            parent::__construct([
                'object_manager' => $objectManager,
                'object_repository' => $objectManager->getRepository(Location::class),
                'translator' => $translator,
            ]);
        }
    
        /**
         * Sets LocationFieldset Element validation
         */
        public function init()
        {
            parent::init();
    
            // $this->add($this->addressFieldsetInputFilter, 'addresses'); // Removed
            $this->add($this->addressCollectionInputFilter, 'addresses'); // New
    
            $this->add([
                'name' => 'name',
                'required' => true,
                'filters' => [
                    ['name' => StringTrim::class],
                    ['name' => StripTags::class],
                ],
                'validators' => [
                    [
                        'name' => StringLength::class,
                        'options' => [
                            'min' => 3,
                            'max' => 255,
                        ],
                    ],
                ],
            ]);
        }
    }
    

    The way this works is that, during the validation of the data, it will apply the singular AddressFieldsetInputFilter to every "element" received from the client-side. Because a Collection, from the client, may be 0 or more of these elements (as adding/removing them is done using JavaScript).

    Now that I've figured that out, it does actually make perfect sense.