Creating custom dependent form elements in Zend Framework 2-3

Overview

For this tutorial I wanted to demonstrate how to make a custom dependent form element. What makes this tutorial stand out from most “custom form element tutorials” is that sometimes, a standalone form element isn’t good enough and I want to show how more can be achieved. Sometimes there are dependencies between elements both on the backend and on the frontend. For this tutorial, my example is a linked country/state dropdown set. Not just linked on the frontend, but also linked in the InputFilter validation.

Countries Element (independent)

Let’s start with the easy one. The Countries element is independent so it doesn’t need much work or explanation. Don’t forget to add it to your ServiceManager under form_elements.

class Countries extends \Application\Table\CountriesTable {
  protected $countriesTable;
  // This is injected in the ServiceFactory
  public function __construct(CountriesTable $countries_table) {
    $this->countriesTable = $countries_table;
    $this->value = 'USA';
  }

  public function getValueOptions() {
    if(!$this->valueOptions) {
      $this->valueOptions = [
        '' => $this->getAttribute('placeholder')
      ];
      $this->valueOptions += $this->countriesTable->fetchCountries();
    }
    return $this->valueOptions;
  }
}

States Element (dependent)

The States element is more complicated because of the nature of this tutorial but still fairly self-explanatory. Again, don’t forget to add it to your ServiceManager under form_elements.

class States extends \Application\Table\StatesTable {
  protected $statesTable;
  protected $country = 'USA';

  // This is injected in the ServiceFactory
  public function __construct(StatesTable $states_table) {
    $this->statesTable = $states_table;
  }

  public function getValueOptions() {
    if(!$this->valueOptions) {
      $country = $this->getCountry();

      // We may have been passed an element
      if($country instanceof \Zend\Form\Element) {
        $country = $country->getValue() ?: 'USA';
      }

      $this->valueOptions = [
        '' => $this->getAttribute('placeholder')
      ];
      
      $states = $this->statesTable->fetchStates($country);
      if(empty($states)) {
        $this->valueOptions = ['n/a' => 'N/A'];
      }
      else {
        $this->valueOptions += $states;
      }
    }
    return $this->valueOptions;
  }

  public function setOptions($options) {
    parent::setOptions($options);
    if(isset($options['country'])) {
      $this->setCountry($options['country']);
    }
    return $this;
  }

  public function setCountry($country) {
    $this->country = $country;
    return $this;
  }

  public function getCountry() {
    return $this->country;
  }
}

Let’s take a look at the meat of this code in getValueOptions:

// This was passed in as an option
$country = $this->getCountry();

// We may have been passed an element so let's extract the value
if($country instanceof \Zend\Form\Element) {
  $country = $country->getValue() ?: 'USA';
}

// Create the base of the valueOptions
$this->valueOptions = [
  '' => $this->getAttribute('placeholder')
];

// Get the states from the DB using the selected country
$states = $this->statesTable->fetchStates($country);

// The selected country may not have any states
if(empty($states)) {
  $this->valueOptions = ['n/a' => 'N/A'];
}
else {
  // Use the union operator to append the states to the list
  $this->valueOptions += $states;
}

We are simply creating the list of states to display. The option list contains the placeholder as the first item and then adds injects a key->value list of states where applicable.

FormRow View Helper

We will override the FormRow helper to allow us to inject what we need to target our element in JavaScript. Don’t forget to add it to your ServiceManager under view_helpers, if you are using ZF3, you will need to set the invokable Zend\Form\View\Helper\FormRow to your helper (or to be more ZF3 compliant, make a factory for your helper, add it as a factory using the same key instead) and then set the alias FormRow to Zend\Form\View\Helper\FormRow.

class FormRow extends \Zend\Form\View\Helper\FormRow {
  public function render(ElementInterface $element,$labelPosition=null) {
    // Only modify our target element
    if($element instanceof \Application\Form\Element\States) {
      // $country is our Country form element
      $country = $element->getCountry();
      
      // There are other ways of doing this but we will target the element by ID
      $id = $country->getAttribute('id');
      $id = $id ?: $this->createId($country->getName());
      
      // Set the element up for JS targeting
      $element->setAttribute('data-country-id',$id);
      $element->setAttribute('class',$element->getAttribute('class') . ' form__states-helper');
    }
    return parent::render($element);
  }
}

As you can see, our helper still uses the default rendering return parent::render($element), we are just going to modify the element if it is our element type if($element instanceof \Application\Form\Element\States) {. All we are doing is adding our custom class for JS targeting $element->setAttribute('class',/*snip*/);, and setting the country field’s element ID as a data attribute $element->setAttribute('data-country-id',/*snip*/)so we can link them via JS.

JavaScript

The JavaScript for this helper is outside the scope of this tutorial. It would control how the states dropdown changes based on the value of the country dropdown. This can be done in a variety of ways including an in-memory array or through AJAX. However you choose to implement it, it would in your template, AMD module, or somehow otherwise injected and run, and would look something like this if you are using jQuery:

$('.form__states-helper').each(function(index,element) {
  var $states = $(this);

  // Use the data field that we set in the helper
  var $country = $('#' + $states.data('country-id'));
  $country.on('change',function(e) {
    // ..
    // Load new states into $states.
    // ..
  });
});

Usage

Once they have been created, the usage is actually pretty-standard. Also, it is now super-easy to add other features like setting defaults based on the user’s location.

The form

The usage below assumes you are adding your form to the FormElementManager and retrieving it from there (as seen in “the controller” usage) as well.

class ContactForm extends Form {
  public function init() {
    $this->setPreferFormInputFilter(false);
    $this->add([
      'name' => 'country',
      'type' => 'Countries',
      'attributes' => [
        'placeholder' => 'Country'
      ]
    ]);
    $this->add([
      'name' => 'state',
      'type' => 'States',
      'attributes' => [
        'placeholder' => 'State'
      ],
      'options' => [
        'country' => $this->get('country')
      ]
    ]);
  }
}

The potentially unique usage is that we can pass our country element into the state element as an 'country' => $this->get('country') option. This is what allows the view helper and States element to do it’s thing.

The view:

You may be rendering your form differently but if you are using anything that triggers a render through FormRow (which includes the Form view helper), it will be sufficient.

<!-- [Inside your <form>] -->
<?php echo $this->FormRow($form->get('country'))?>
<?php echo $this->FormRow($form->get('state'))?>
<!-- [/Inside your <form>] -->

The controller:

There’s no special treatment in the controller, just use the form as you normally would:

$form = $sl->get('FormElementManager')->get(\Application\Form\ContactForm::class);
$request = $this->getRequest();
if($request->isPost()) {
  $form->setData($request->getPost());
  if($form->isValid()) {
    // Hurray!
  }
}

That’s it!

That’s pretty much all there is to it.

Leave a Reply