ZF2: Validating Form Collections


We’ve used Zend Frameworks very nifty Form Collection element in multiple applications to deal with forms where there may be one or more repeating fields.  For example, our custom contact list function allows users to add search groups and rows to those groups.  The Form Collection goodness made it eas(ier) to add that functionality without a lot of wonkiness on the backend.

Likewise, for a scheduling app where the number of dates that can be set within a schedule varies based on another setting and there are three fields related to each individual schedule “date”, Collection is a good fit.  The only issue we’ve had with the darn things is getting their associated input validators to actually be noticed and utilized!  We quickly found that despite following the ZF2 documentation, the input specifications for the form elements within a collection were not being verified.

It took a few hours of searching before finally restumbling upon the fix, and as that page is now gone (though retrievable via Archive.org), I decided I needed to get off my butt and do a post for when I forget again 😛

For the purposes here, I’m presuming anyone reading this knows how to do a basic form already, so I’m focusing purely on the collection and its related fieldset.  So first, we need to make the actual Fieldset which contains the fields that will be repeated in our form.

    namespace Schedule\Schedules;

    use Schedule\PresetDates\PresetDatesGateway;
    use Schedule\Schedules\ScheduleEntity;
    use Zend\Form\Element;
    use Zend\Form\Fieldset;
    use Zend\InputFilter\InputFilterProviderInterface;
    use Zend\Stdlib\Hydrator\ArraySerializable;

    class ScheduleFieldset extends Fieldset implements InputFilterProviderInterface
    {
        static $label_count = 1;

        public $year_id;
        public $schedule_id;

        // auto add numbers to our fields labels and ids for nice presentation
        public function __clone()
        {
            parent::__clone();
            $raw_label = $this->elements['preset_date_id']->getLabel();
            $this->elements['preset_date_id']->setLabel($raw_label . ' ' . self::$label_count++ . ':');

            $id = $this->elements['preset_date_id']->getAttribute('id');
            $this->elements['preset_date_id']->setAttribute('id', $id . '_' . self::$label_count);
        }

        public function __construct($year_id, $schedule_id)
        {
            parent::__construct('schedule_detail');

            $this->year_id = $year_id;
            $this->schedule_id = $schedule_id;

            $this
                ->setHydrator(new ArraySerializable(true))
                ->setObject(new ScheduleEntity());

            $form_element = new Element\Select('preset_date_id');
            $form_element
                ->setLabel('Add a Date') // colon added on clone
                ->setAttributes(array(
                    'id' => 'preset_date_id',
                    'class' => 'preset_date_id'
                ))
                ->setValueOptions($this->getPresetDateOptions())
                ->setEmptyOption('-- Select a Preset Date Option --')
            ;

            $this->add($form_element);

            $form_element = new Element\Text('custom_date_name');
            $form_element
                ->setLabel('Display Name:')
                ->setLabelAttributes(array('class' => 'required'))
                ->setAttributes(
                    array(
                        'class' => 'custom_date_name pixel-width-500',
                        'maxlength' => 100,
                        'required' => false,
                        )
                    );

            $this->add($form_element);

            $form_element = new Element\Text('custom_date');
            $form_element
                ->setLabel('Date:')
                ->setLabelAttributes(array('class' => 'required'))
                ->setAttributes(
                    array(
                        'class' => 'custom_date pixel-width-125 date-field',
                        'maxlength' => 10,
                        'required' => false,
                        )
                    )
                ->setOptions(array('wrapper-class' => 'display-inline'));

            $this->add($form_element);
        }

        public function getData($flag = Zend\Form\FormInterface::VALUES_NORMALIZED)
        {
            $data = parent::getData($flag);

            if (!empty($data['custom_date']) && $data['preset_date_id'] == -1) {
                $data['preset_date_id'] = null;
            }

            return $data;
        }

        public function getInputFilterSpecification()
        {
            return array(
                'preset_date_id' => array(
                    'required' => false,
                    'filters' => array(
                        array('name' => 'StripTags'),
                        array('name' => 'StringTrim'),
                        array('name' => 'NULL'),
                    ),
                    'validators' => array(
                        array(
                            'name' => 'Digits',
                            'options' => array(
                                'messages' => array(
                                    'notDigits' => 'Invalid preset date received, make sure all required fields are filled in',
                                ),
                                'break_chain_on_failure' => true,
                            ),
                        ),
                        array(
                            'name' => 'Application\Validator\ScheduleDetails',
                            'options' => array(
                                'schedule_id' => $schedule_id,
                                'disable_inarray_validator' => true,
                                'break_chain_on_failure' => true,
                            ),
                        ),
                    ),
                ),
                'custom_date_name' => array(
                    'required' => (isset($_POST['preset_date_id']) && $_POST['preset_date_id'] == -1),
                    'filters' => array(
                        array('name' => 'StripTags'),
                        array('name' => 'StringTrim'),
                        array('name' => 'NULL'),
                    ),
                    'validators' => array(
                        array(
                            'name' => 'NotEmpty',
                            'options' => array(
                                'messages' => array(
                                    'isEmpty' => 'You must indicate the name of the custom date',
                                ),
                                'break_chain_on_failure' => true,
                            ),
                        ),
                        array(
                            'name' => 'string_length',
                            'options' => array(
                                'min' => 4,
                                'max' => 30,
                                'messages' => array(
                                    'stringLengthTooShort' => 'The custom date name should be at least 4 characters longs',
                                    'stringLengthTooLong' => 'Custom date name is limited to 30 characters long'
                                ),
                                'break_chain_on_failure' => true
                            ),
                        ),
                    ),
                ),
                'custom_date' => array(
                    'required' => (isset($_POST['preset_date_id']) && $_POST['preset_date_id'] == -1),
                    'filters' => array(
                        array('name' => 'StripTags'),
                        array('name' => 'StringTrim'),
                        array('name' => 'NULL'),
                    ),
                    'validators' => array(
                        array(
                            'name' => 'NotEmpty',
                            'options' => array(
                                'messages' => array(
                                    'isEmpty' => 'You must indicate the actual custom date\'s date',
                                ),
                                'break_chain_on_failure' => true,
                            ),
                        ),
                        array(
                            'name' => 'Application\Validator\DateInYear',
                            'options' => array(
                                'fiscal_year_id' => $year_id,
                                'break_chain_on_failure' => true,
                            ),
                        ),
                    ),
                ),
            );
        }

        public function populateValues($data)
        {
            if (array_key_exists('custom_date', $data) && !empty($data['custom_date'])) {
                $checkDate = DateTime::createFromFormat('Y-m-d', $data['custom_date']);

                if ($checkDate && DateTime::getLastErrors()["warning_count"] == 0 && DateTime::getLastErrors()["error_count"] == 0) {
                    $data['custom_date'] = date_format($checkDate, 'm/d/Y');
                }

                $data['preset_date_id'] = -1;
            }
            parent::populateValues($data);
        }

        protected function getPresetDateOptions()
        {
            $preset_dates_gateway = new PresetDatesGateway();
            $preset_dates = $preset_dates_gateway->getPresetDates($this->year_id);

            $select_options = array();

            foreach ($preset_dates as $preset_date) {
                $this_option = array(
                    'value' => $preset_date->common_preset_date_id,
                    'label' => $preset_date->preset_date_name . ' - ' . $preset_date->getFormattedDate('preset_date', 'F d, Y'),
                );

                array_push($select_options, $this_option);
            }

            $this_option = array(
                'value' => -1,
                'label' => 'Other/Custom',
            );

            array_push($select_options, $this_option);

            return $select_options;
        }
    }

Then add it to our form.

$schedule_fieldset = new ScheduleFieldset($year_id, $schedule_id);

$form_fieldset = new Element\Collection('schedule_details');
$form_fieldset
    ->setOptions(array(
        'label' => '',
        'allow_add' => false,
        'count' => $max_allowed_dates,
        'should_create_template' => false,
        'target_element' => $schedule_detail_fieldset,
    ));
$this->add($form_fieldset);

Now, at this point according to Zend’s documentation, the getInputFilterSpecification() function in the Fieldset should be called when the form is validated. Except it ISN’T! At least not in this instance. Tried various tricks to make it work, but was only able to get it to work when adding the Fieldset as a regular Input, which is only useful when doing a one-to-one tie. We need multiple details. So finally stumbled back on that now archived blog post, and with that was able to fix it by adding this bit to our main form’s filter:

$schedule_fieldset = new ScheduleFieldset($year_id, $schedule_id);
$schedule_filter = $schedule_fieldset->getInputFilterSpecification();

$collection_input_filter = new \Zend\InputFilter\CollectionInputFilter();
$collection_input_filter->setInputFilter($schedule_filter);

$this->add($collection_input_filter, 'schedule_details');

With those few lines added, the input validation now properly works and we’re good to go!