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
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.