Building My First Application with Zend, Part 6


Continuing along with my movies model, I discovered a few things.  First, the init I had in my movies controller is outdated (init was part of ZF1 not ZF2) and can be removed as it doesn't actually do anything. ๐Ÿ˜›  Our books arrived and I'm already seeing some interesting stuff in the ZF2 Cookbook – including a curious way of making the entire DAO a separate module.  I may explore that later, but first, we have Movies to finish! 

So now it's time for the add/edit form.  There are three basic ways of creating a form: programmatic, via factory, or factory-backed extension.  The latter is the one I used in the earlier examples, using the $this->add notation and is essentially a combination of the other two methods.

	$this->add(array(
		'name' => 'formatlabel',
		'type' => 'Text',
		'attributes' => array(
			'id' => 'formatlabel',
			'required' => 'required',
		),
		'options' => array(
			'label' => 'Format Label',
			'label_attributes' => array(
				'class' => 'required',
			),
		),
	));

Per the docs, creation forms via the Factory has the form as purely a configuration that is passed along.  The first, programmatic, has you create the individual elements and wire them into our form.  This is the method I decided to try for the Movies module. It certainly didn't make for smaller code, though to be fair, we're also dealing with a much bigger table.

<?php
	namespace MovieShelves\Movies;
	
	use MovieShelves\Movies\MovieEntity;
	use Zend\Db\Adapter\AdapterInterface;
	use Zend\Db\Sql\Select;
	use Zend\Db\Sql\Sql;
	use Zend\Form\Element;
	use Zend\Form\Form;
	use Zend\Stdlib\Hydrator\ArraySerializable;
	use DateTime;
	
	class MoviesForm extends Form {
		protected $dbAdapter;
		protected $sql;
		
		public function __construct(AdapterInterface $dbAdapter) {
			// get our DB adapter and send to the form because the form is DB dependent in this case
			$this->setDbAdapter($dbAdapter);
			$this->sql = new Sql($dbAdapter);
			
			// now to the form
			parent::__construct('movies');
			
			$this->setAttribute('method', 'post');
			$this->setAttribute('class', 'pure-form pure-form-aligned standardForm');
			$this->setHydrator(new ArraySerializable());
			$this->setObject(new MovieEntity());

			$this->setInputFilter(new MovieFilter());
			
			$element = new Element\Hidden('movieid');
			$this->add($element);
			
			$element = new Element\Text('title');
			$element->setLabel('Title')
				->setAttributes(
					array(
						'id' => 'title',
						'class' => 'size100',
						'required' => 'required',
						)
					)
				->setLabelAttributes(array('class' => 'required'));
			
			$this->add($element);
			
			$element = new Element\Text('runtime_minutes');
			$element->setLabel('Runtime (in Minutes)')
				->setAttributes(
					array(
						'id' => 'runtime_minutes',
						'class' => 'size10',
						'required' => 'required',
						)
					)
				->setLabelAttributes(array('class' => 'required'));
			
			$this->add($element);
			
			$element = new Element\Date('dateacquired');
			$element->setLabel('Date Acquired')
				->setAttributes(
					array(
						'id' => 'dateacquired',
						'class' => 'dateField',
						'required' => 'required',
						'max' => date('m/d/Y'),
						)
					)
				->setLabelAttributes(array('class' => 'required'))
				->setOptions(array(
					'format' => 'm/d/Y'
				));
			
			$this->add($element);
			
			$element = new Element\Text('num_discs');
			$element->setLabel('# of Discs')
				->setAttributes(
					array(
						'id' => 'num_discs',
						'class' => 'size3',
						'required' => 'required',
						)
					)
				->setLabelAttributes(array('class' => 'required'));
			
			$this->add($element);
			
			$element = new Element\Text('num_episodes');
			$element->setLabel('# of Episodes')
				->setAttributes(
					array(
						'id' => 'num_episodes',
						'class' => 'size5',
						)
					);
			
			$this->add($element);
			
			$element = new Element\Radio('thinpaked');
			$element->setLabel('Is it Thinpaked?')
				->setAttributes(
					array(
						'required' => 'required',
						)
					)
				->setLabelAttributes(array('class' => 'required'))
				->setValueOptions(array(
						array(
							'value' => '1',
							'label' => ' Yes',
							'attributes' => array(
								'id' => 'thinpaked_yes',
							),
							'label_attributes' => array(
								'class' => 'noAppend pure-radio',
							),
						),
						array(
							'value' => '0',
							'label' => ' No',
							'attributes' => array(
								'id' => 'thinpaked_no',
							),
							'label_attributes' => array(
								'class' => 'noAppend pure-radio',
							),
						),
					));
			
			$this->add($element);
			
			$element = new Element\Select('mpaa_rating');
			$element->setLabel('MPAA Rating')
				->setAttributes(
					array(
						'id' => 'mpaa_rating',
						'required' => 'required',
						)
					)
				->setLabelAttributes(array('class' => 'required'))
				->setValueOptions($this->getMPAAOptions());
			
			$this->add($element);
			
			$element = new Element\Radio('issubtitled');
			$element->setLabel('Is it Subtitled?')
				->setAttributes(
					array(
						'required' => 'required',
						)
					)
				->setLabelAttributes(array('class' => 'required'))
				->setValueOptions(array(
						array(
							'value' => '1',
							'label' => ' Yes',
							'attributes' => array(
								'id' => 'issubtitled_yes',
							),
							'label_attributes' => array(
								'class' => 'noAppend pure-radio',
							),
						),
						array(
							'value' => '0',
							'label' => ' No',
							'attributes' => array(
								'id' => 'issubtitled_no',
							),
							'label_attributes' => array(
								'class' => 'noAppend pure-radio',
							),
						),
					));
			
			$this->add($element);
			
			$element = new Element\Select('formats_formatid');
			$element->setLabel('Format')
				->setAttributes(array('id' => 'formats_formatid'))
				->setLabelAttributes(array('class' => 'required'))
				->setValueOptions($this->getFormatOptions());
			
			$this->add($element);
			
			$element = new Element\Select('genres_genreid');
			$element->setLabel('Genre')
				->setAttributes(array('id' => 'genres_genreid'))
				->setLabelAttributes(array('class' => 'required'))
				->setValueOptions($this->getGenreOptions());
			
			$this->add($element);
			
			$element = new Element\Select('series_seriesid');
			$element->setLabel('Series')
				->setAttributes(array('id' => 'series_seriesid'))
				->setValueOptions($this->getSeriesOptions())
				->setEmptyOption('-- Not in a Series --')
				;
			
			$this->add($element);
			
			$element = new Element\Text('series_position');
			$element->setLabel('Position in Series')
				->setLabelAttributes(array('class' => 'formIndent'))
				->setAttributes(
					array(
						'id' => 'series_position',
						'class' => 'size3',
						)
					);
			
			$this->add($element);
			
			$element = new Element\Select('studios_studioid');
			$element->setLabel('Studio')
				->setAttributes(
					array(
						'id' => 'studios_studioid',
						)
					)
				->setLabelAttributes(array('class' => 'required'))
				->setValueOptions($this->getStudioOptions());
			
			$this->add($element);
			
			$element = new Element\Text('upcode');
			$element->setLabel('UPC')
				->setAttributes(
					array(
						'id' => 'upcode',
						'class' => 'size20',
						'required' => 'required',
						)
					)
				->setLabelAttributes(array('class' => 'required'));
			
			$this->add($element);
			
			$element = new Element\Text('amazon_asin');
			$element->setLabel('Amazon ASIN')
				->setAttributes(
					array(
						'id' => 'amazon_asin',
						'class' => 'size20',
						)
					);
			
			$this->add($element);
			
			
			$element = new Element\Textarea('plotsummary');
			$element->setLabel('Plot Summary')
				->setAttributes(
					array(
						'id' => 'plotsummary',
						)
					);
			
			$this->add($element);
			
			$button = new Element\Button('saveButton');
			$button->setLabel("Save");
			$button->setAttributes(
				array(
					'id' => 'saveButton',
					'type' => 'submit',
					'value' => 'Save',
				)
			);
			$this->add($button);
			
		}
		
		public function getDbadapter() {
			return $this->dbAdapter;	
		}
		public function setDbadapter(AdapterInterface $dbAdapter) {
			$this->dbAdapter = $dbAdapter;	
		}
		
		// functions to populate our select boxes
		public function getFormatOptions() {
			$dbAdapter = $this->getDbAdapter();
			
			$select = new Select('movieshelves_formats');

			$select->quantifier('DISTINCT')
				->columns(array(
						'formatid', 
						'formatlabel'
					)
				)
				->order('formatlabel')
			;
			
			$statement = $this->sql->prepareStatementForSqlObject($select);
			$results = $statement->execute();
			
			$selectData = array();
 
			foreach ($results as $res) {
				$selectData[$res['formatid']] = $res['formatlabel'];
			}
			
			return $selectData;
		}
		public function getGenreOptions() {
			$dbAdapter = $this->getDbAdapter();
			
			$select = new Select('movieshelves_genres');

			$select->quantifier('DISTINCT')
				->columns(array(
						'genreid', 
						'genre'
					)
				)
				->order('genre')
			;
			
			$statement = $this->sql->prepareStatementForSqlObject($select);
			$results = $statement->execute();
			
			$selectData = array();
 
			foreach ($results as $res) {
				$selectData[$res['genreid']] = $res['genre'];
			}
			
			return $selectData;
		}
		public function getSeriesOptions() {
			$dbAdapter = $this->getDbAdapter();
			
			$select = new Select('movieshelves_series');

			$select->quantifier('DISTINCT')
				->columns(array(
						'seriesid', 
						'seriesname'
					)
				)
				->order('seriesname')
			;
			
			$statement = $this->sql->prepareStatementForSqlObject($select);
			$results = $statement->execute();
			
			$selectData = array();
 
			foreach ($results as $res) {
				$selectData[$res['seriesid']] = $res['seriesname'];
			}
			
			return $selectData;
		}
		public function getStudioOptions() {
			$dbAdapter = $this->getDbAdapter();
			
			$select = new Select('movieshelves_studios');

			$select->quantifier('DISTINCT')
				->columns(array(
						'studioid', 
						'studio'
					)
				)
				->order('studio')
			;
			
			$statement = $this->sql->prepareStatementForSqlObject($select);
			$results = $statement->execute();
			
			$selectData = array();
 
			foreach ($results as $res) {
				$selectData[$res['studioid']] = $res['studio'];
			}
			
			return $selectData;
		}
		public function getMPAAOptions() {
			$dbAdapter = $this->getDbAdapter();
			
			$select = new Select('movieshelves_movies');

			$select->quantifier('DISTINCT')
				->columns(array(
						'mpaa_rating', 
					)
				)
				->order('mpaa_rating')
			;
			
			$statement = $this->sql->prepareStatementForSqlObject($select);
			$results = $statement->execute();
			
			$selectData = array();
 
			foreach ($results as $res) {
				$selectData[$res['mpaa_rating']] = $res['mpaa_rating'];
			}
			
			return $selectData;
		}
	}
?>

Got all that?  Cool, moving on then…just kidding ๐Ÿ™‚   I'm sure you guys can probably follow most of it, but since I'm verbose, I'll highlight a few bits. Starting with the top.

namespace MovieShelves\Movies;

use MovieShelves\Movies\MovieEntity;
use Zend\Db\Adapter\AdapterInterface;
use Zend\Db\Sql\Select;
use Zend\Db\Sql\Sql;
use Zend\Form\Element;
use Zend\Form\Form;
use Zend\Stdlib\Hydrator\ArraySerializable;
use DateTime;

class MoviesForm extends Form {
	protected $dbAdapter;
	protected $sql;
	
	public function __construct(AdapterInterface $dbAdapter) {
		// get our DB adapter and send to the form because the form is DB dependent in this case
		$this->setDbAdapter($dbAdapter);
		$this->sql = new Sql($dbAdapter);
		
		...

I have more use statements here than I had in my other forms, though this is primarily because several fields of this particular form need to pull a select list from the database.  So we need the to be sure we can call the database and make the necessary queries.  This is also why we have the two protected variables of dbAdapter and sql, which are set as soon as the __construct function is started (i.e. on call of the form).

Also, because we need to be able to get/set the DB adapter, we must add to functions after the __construct function to act as the getting and setter. Both are very basic.

public function getDbadapter() {
	return $this->dbAdapter;	
}
public function setDbadapter(AdapterInterface $dbAdapter) {
	$this->dbAdapter = $dbAdapter;	
}

From there, we get on to actually making our form. 

...

// now to the form
parent::__construct('movies');

$this->setAttribute('method', 'post');
$this->setAttribute('class', 'pure-form pure-form-aligned standardForm');
$this->setHydrator(new ArraySerializable());
$this->setObject(new MovieEntity());

$this->setInputFilter(new MovieFilter());
...

The parent::__construct('movies') creates our starting form object with the name of movies.  Similar to what we did previously, we set the method and class attributes.  The new bits are the setting of the Hydrator and Object.  The Hydrator will function to connect data with the form, while the object creates the Entity that will store the data to be hydrated.  Finally we let it know where to find our input filter, which will handle any sanitizing and validating of our data.

With that done, we can begin creating each form element. Hidden elements are pretty basic since they really need no configuration. Most of our text elements are like the one below, with just basic attributes and options.  Notice that for this form, we use chaining of method calls to set each data point about a form element rather than having our sets be an array of configurations.

...
$element = new Element\Hidden('movieid');
$this->add($element);

$element = new Element\Text('title');
$element->setLabel('Title')
	->setAttributes(
		array(
			'id' => 'title',
			'class' => 'size100',
			'required' => 'required',
			)
		)
	->setLabelAttributes(array('class' => 'required'));

$this->add($element);
...

For Date Acquired, we use the date element, which allows to set the max date (in this case today) and what format to expect the date in. 

...
$element = new Element\Date('dateacquired');
$element->setLabel('Date Acquired')
	->setAttributes(
		array(
			'id' => 'dateacquired',
			'class' => 'dateField',
			'required' => 'required',
			'max' => date('m/d/Y'),
			)
		)
	->setLabelAttributes(array('class' => 'required'))
	->setOptions(array(
		'format' => 'm/d/Y'
	));

$this->add($element);
...

Because we use the date type, Zend will also automatically make this element an HTML date element in our output. However, as some browsers, i.e. Firefox (even 29) and IE do not support HTML 5's date element, we need to be sure users till get the same general experience on all browsers.  So I added this to my core.js file, which will only run if the browser doesn't support dates.

// alternate for date field type where stupid browsers don't support yet
var el = document.createElement('input');
el.setAttribute('type','date');

//if type is text then and only then should you call the fallback
if(el.type === 'text'){
	$(".dateField").datepicker({
		showOtherMonths: true,
		selectOtherMonths: true,
		showOn: "button",
		buttonImage: "/img/icon_calendar.png",
	}).next(".ui-datepicker-trigger").addClass("datePickerIcon");
}

We also need a few radios for this form, such as this one which asks if the title is thinpaked or not. 

...
$element = new Element\Radio('thinpaked');
$element->setLabel('Is it Thinpaked?')
	->setAttributes(
		array(
			'required' => 'required',
			)
		)
	->setLabelAttributes(array('class' => 'required'))
	->setValueOptions(array(
			array(
				'value' => '1',
				'label' => ' Yes',
				'attributes' => array(
					'id' => 'thinpaked_yes',
				),
				'label_attributes' => array(
					'class' => 'noAppend pure-radio',
				),
			),
			array(
				'value' => '0',
				'label' => ' No',
				'attributes' => array(
					'id' => 'thinpaked_no',
				),
				'label_attributes' => array(
					'class' => 'noAppend pure-radio',
				),
			),
		));

$this->add($element);
...

The main difference with the radios is we need to set the ValueOptions, to generate each radio.  This is where that custom form and formRow I mentioned in my last post came in.  See by default, with radios (and multiple check boxes), Zend will take the outer label and make it a fieldset, then use the individual value labels (properly) as the labels for the radio.  With my custom formRow, the fieldset and legend become a more usable div.  ๐Ÿ™‚

Something totally new for this form (for me) is adding select elements. In some ways, they are similar to radios, at least if you are using a hard coded list.  But these need to be populated from a DB, so we set the value options to a function call instead.

...
$element = new Element\Select('mpaa_rating');
$element->setLabel('MPAA Rating')
	->setAttributes(
		array(
			'id' => 'mpaa_rating',
			'required' => 'required',
			)
		)
	->setLabelAttributes(array('class' => 'required'))
	->setValueOptions($this->getMPAAOptions());

$this->add($element);

Then, after our __construction function, we add the referred to new function to query our DB, get the list of MPAA options, and then return the results as a simple array.  In the case of the MPAA, since there is no base table, it simple pulls the unique existing values.  So basically you can only use ones that have been used before.

public function getMPAAOptions() {
	$dbAdapter = $this->getDbAdapter();
	
	$select = new Select('movieshelves_movies');

	$select->quantifier('DISTINCT')
		->columns(array(
				'mpaa_rating', 
			)
		)
		->order('mpaa_rating')
	;
	
	$statement = $this->sql->prepareStatementForSqlObject($select);
	$results = $statement->execute();
	
	$selectData = array();

	foreach ($results as $res) {
		$selectData[$res['mpaa_rating']] = $res['mpaa_rating'];
	}
	
	return $selectData;
}

If you look back at the full code above, you'll see the others do more basic select statements to pull from their child tables, but otherwise they are functionally the same.  For the plot summary, I added a text area.  The textarea element is pretty basic – the label and ID.  I used CSS to define the size though you could also enter the rows/cols here as attributes if desired.  For HTML5, you can also set placeholder and maxlength, options.  Also notice this one is not required. ๐Ÿ™‚

...
$element = new Element\Textarea('plotsummary');
$element->setLabel('Plot Summary')
	->setAttributes(
		array(
			'id' => 'plotsummary',
			)
		);

$this->add($element);

The last form element is the saveButton, but it's pretty much the same as it was before, so nothing really new to add here.  Now we just need to put in our code in the view/movie-shelves/movies/add.phtml file. Big form….ah well, no point procrastinating.

<?php
	$title = 'Add New Movie';
	$this->headTitle($title);
	$this->layout()->pageTitle = $title;
	$this->layout()->needsDataTables = true;
	
	$form = $this->form;
	
	$form->setAttribute('action', $this->url('movies', array('action' => 'add')));
	$form->prepare();
	
	echo $this->form($form);
?>

That's it.  That's the ENTIRE add.phtml file.  And that produces:

Seriously!  That is kind of awesome.  Basically the form function iterates through all the form elements and performs a formRow call on each, saving you from having to add each one line by line.  The only minor "catch" is a pretty obvious one – you have to add the form elements to the form in the order you want them to appear. ๐Ÿ™‚ 

Dang…this post ended up being really long from all the code, so I'll break here.  Coming up next time, I'll go through the input filter that goes with this form, and space permitting the saving process.