<?php
/**
* Extends Zend_Form to provide support for an id prefix and show errors by default.
*
* @copyright 2011 Perforce Software. All rights reserved.
* @license Please see LICENSE.txt in top-level folder of this distribution.
* @version <release>/<patch>
*/
class P4Cms_Form extends Zend_Dojo_Form
{
const CSRF_TOKEN_NAME = '_csrfToken';
/**
* Useful for indenting items (e.g. in select elements)
*/
const UTF8_NBSP = "\xc2\xa0";
protected $_storageAdapter = null;
protected $_idPrefix = null;
/**
* Optional form csrf protection, used to enable
* verification and generation of the csrf token
* for authenticated user.
* @var boolean
*/
protected $_csrfProtection = true;
protected $_populatedCsrfToken = '';
/**
* Zend_Session storage object.
* @var Zend_Session
*/
protected static $_session = null;
protected static $_prefixPaths = array();
protected static $_libraryPaths = array(
array(
'prefix' => 'Zend_Dojo_Form_Element',
'path' => 'Zend/Dojo/Form/Element',
'type' => self::ELEMENT
),
array(
'prefix' => 'Zend_Dojo_Form_Decorator',
'path' => 'Zend/Dojo/Form/Decorator',
'type' => self::DECORATOR
),
array(
'prefix' => 'P4Cms_Form_Element',
'path' => 'P4Cms/Form/Element',
'type' => self::ELEMENT
),
array(
'prefix' => 'P4Cms_Form_Decorator',
'path' => 'P4Cms/Form/Decorator',
'type' => self::DECORATOR
),
array(
'prefix' => 'P4Cms_Validate',
'path' => 'P4Cms/Validate',
'type' => Zend_Form_Element::VALIDATE
),
array(
'prefix' => 'P4Cms_Filter',
'path' => 'P4Cms/Filter',
'type' => Zend_Form_Element::FILTER
),
array(
'prefix' => 'P4_Validate',
'path' => 'P4/Validate',
'type' => Zend_Form_Element::VALIDATE
)
);
/**
* Extend Zend_Dojo_Form's constructor to provide our own decorators.
*
* @param array|Zend_Config|null $options Zend provides no documentation for this param.
* @return void
*/
public function __construct($options = null)
{
// combine library prefix paths with
// paths from the static registry.
$prefixPaths = static::$_libraryPaths + static::$_prefixPaths;
// add prefix paths to form instance.
foreach ($prefixPaths as $prefixPath) {
extract($prefixPath);
// add element and decorator paths to form.
if ($type === static::ELEMENT || $type === static::DECORATOR) {
$this->addPrefixPath($prefix, $path, $type);
}
// add decorator, validator and filter paths to elements.
if ($type !== static::ELEMENT) {
$this->addElementPrefixPath($prefix, $path, $type);
}
// add decorator paths to display groups.
if ($type === static::DECORATOR) {
$this->addDisplayGroupPrefixPath($prefix, $path);
}
}
// if no storage adapter specified, use default where available
if (!isset($options['storageAdapter'])
&& P4Cms_Record::hasDefaultAdapter()
) {
$this->_storageAdapter = P4Cms_Record::getDefaultAdapter();
}
parent::__construct($options);
}
/**
* Retrieve all form element values
*
* Override parent to fix an issue where form structure and form->getValues()
* are inconsistent if form has a sub-form with 'isArray' property set to false.
*
* See: http://framework.zend.com/issues/browse/ZF-12027
*
* @param bool $suppressArrayNotation zend provides no description for this param.
* @return array all form values organized by element/sub-form.
* @todo remove when issue ZF-12027 is resolved.
*/
public function getValues($suppressArrayNotation = false)
{
$values = array();
$eBelongTo = null;
if ($this->isArray()) {
$eBelongTo = $this->getElementsBelongTo();
}
foreach ($this->getElements() as $key => $element) {
if (!$element->getIgnore()) {
$merge = array();
if (($belongsTo = $element->getBelongsTo()) !== $eBelongTo) {
if ('' !== (string)$belongsTo) {
$key = $belongsTo . '[' . $key . ']';
}
}
$merge = $this->_attachToArray($element->getValue(), $key);
$values = $this->_array_replace_recursive($values, $merge);
}
}
foreach ($this->getSubForms() as $key => $subForm) {
$merge = array();
if (!$subForm->isArray()) {
$merge = $subForm->getValues();
} else {
$merge = $this->_attachToArray(
$subForm->getValues(true),
$subForm->getElementsBelongTo()
);
}
$values = $this->_array_replace_recursive($values, $merge);
}
if (!$suppressArrayNotation &&
$this->isArray() &&
!$this->_getIsRendered()) {
$values = $this->_attachToArray($values, $this->getElementsBelongTo());
}
return $values;
}
/**
* Retrieve error messages from elements failing validations.
*
* Fix for an issue where output from parent method is not consistent with the form
* structure when form contains nested sub-forms with 'isArray' flag set to false.
* See description for getValues() method where we fix the same issue.
*
* @param string $name a element or sub-form to get messages for.
* @param bool $suppressArrayNotation zend provides no description for this param.
* @return array list of error messages organized by element/sub-form
* @todo remove when issue ZF-12027 is resolved.
*/
public function getMessages($name = null, $suppressArrayNotation = false)
{
if (null !== $name) {
if (isset($this->_elements[$name])) {
return $this->getElement($name)->getMessages();
} else if (isset($this->_subForms[$name])) {
return $this->getSubForm($name)->getMessages(null, true);
}
foreach ($this->getSubForms() as $key => $subForm) {
if ($subForm->isArray()) {
$belongTo = $subForm->getElementsBelongTo();
if ($name == $this->_getArrayName($belongTo)) {
return $subForm->getMessages(null, true);
}
}
}
}
$customMessages = $this->_getErrorMessages();
if ($this->isErrors() && !empty($customMessages)) {
return $customMessages;
}
$messages = array();
foreach ($this->getElements() as $name => $element) {
$eMessages = $element->getMessages();
if (!empty($eMessages)) {
$messages[$name] = $eMessages;
}
}
foreach ($this->getSubForms() as $key => $subForm) {
$merge = $subForm->getMessages(null, true);
if (!empty($merge)) {
if ($subForm->isArray()) {
$merge = $this->_attachToArray(
$merge,
$subForm->getElementsBelongTo()
);
}
$messages = $this->_array_replace_recursive($messages, $merge);
}
}
if (!$suppressArrayNotation &&
$this->isArray() &&
!$this->_getIsRendered()) {
$messages = $this->_attachToArray($messages, $this->getElementsBelongTo());
}
return $messages;
}
/**
* Add a new element.
*
* This is a wrapper around the parent function that provides more palatable
* error messages for end users.
*
* @param string|Zend_Form_Element $element The element to add.
* @param string $name The name of the element.
* @param array|Zend_Config $options The options for the element.
* @return Zend_Form
*/
public function addElement($element, $name = null, $options = null)
{
try {
parent::addElement($element, $name, $options);
} catch (Exception $e) {
P4Cms_Log::log(
'P4Cms_Form->addElement exception ('. get_class($e) .') - '. $e->getMessage(),
P4Cms_Log::DEBUG
);
if (preg_match("/^Plugin by name '(.+)' was not found in the registry;/", $e->getMessage(), $matches)) {
throw new Zend_Form_Exception('Element plugin "'. $matches[1] .'" not found.');
} else {
throw $e;
}
}
return $this;
}
/**
* Set a string to prefix element ids with.
*
* @param string $prefix the string to prefix element ids with.
* @return P4Cms_Form_Decorator_IdPrefix the decorator instance.
*/
public function setIdPrefix($prefix)
{
$this->_idPrefix = (string) $prefix;
return $this;
}
/**
* Get the string used to prefix element ids.
*
* @return string the string used to prefix element ids.
*/
public function getIdPrefix()
{
return $this->_idPrefix;
}
/**
* Add id prefixes, then render the form.
*
* @param Zend_View_Interface $view The Zend View Interface to render.
* @return string
*/
public function render(Zend_View_Interface $view = null)
{
// prefix form element ids if id prefix is set.
if ($this->getIdPrefix()) {
static::prefixFormIds($this, $this->getIdPrefix());
}
return parent::render($view);
}
/**
* Add "Errors" and "CsrfForm" to the default set of decorators.
*
* @return void
*/
public function loadDefaultDecorators()
{
if ($this->loadDefaultDecoratorsIsDisabled()) {
return;
}
$decorators = $this->getDecorators();
$prepend = Zend_Form_Decorator_Abstract::PREPEND;
if (empty($decorators)) {
$this->addDecorator('FormElements')
->addDecorator('HtmlTag', array('tag' => 'dl', 'class' => 'zend_form_dojo'))
->addDecorator('Errors', array('placement' => $prepend))
->addDecorator('Csrf', array('placement' => $prepend))
->addDecorator('DijitForm');
}
}
/**
* Add a form plugin path to be used whenever a form is instantiated.
*
* @param string $prefix the class prefix (e.g. Foo_Form_Element)
* @param string $path the path containing the classes
* @param string $type the type of plugin (e.g. element, decorator
* validator, filter)
*/
public static function registerPrefixPath($prefix, $path, $type)
{
static::$_prefixPaths[$prefix] = array(
'prefix' => $prefix,
'path' => $path,
'type' => strtoupper($type)
);
}
/**
* Get any plugin prefix paths that are statically registered.
*
* @return array the list of registered plugin prefix paths.
*/
public static function getPrefixPathRegistry()
{
return static::$_prefixPaths;
}
/**
* Get any library prefix paths that are statically registered.
*
* @return array the list of registered library prefix paths.
*/
public static function getLibraryPathRegistry()
{
return static::$_libraryPaths;
}
/**
* Remove any registered form plugin prefix paths.
*/
public static function clearPrefixPathRegistry()
{
static::$_prefixPaths = array();
}
/**
* Helper method to get the validator of an element.
* Calls isValid on the element to load the correct validators.
* Attempts to preserve original errors and value.
*
* @param string $element the name of the element to get the validator for.
* @param string $validator the name of the validator to get.
* @return Zend_Validate_Interface the requested validator.
*/
public function getElementValidator($element, $validator)
{
// create a copy of element (to avoid polluting the element)
// and call isValid to load the validators.
$temp = clone $this->getElement($element);
$temp->isValid(true);
return $this->getElement($element)
->setValidators($temp->getValidators())
->getValidator($validator);
}
/**
* Get the values of the form flattened with array notation for keys.
*
* @return array the flattened form values.
*/
public function getFlattenedValues()
{
$filter = new P4Cms_Filter_FlattenArray;
return $filter->filter($this->getValues());
}
/**
* Populate form
*
* Records CSRF token, if enabled and present, for later use, then
* calls the parent populate method.
*
* @param P4Cms_Record|array $values the values to populate from
* @return P4Cms_Form provides fluent interface
*/
public function populate($values)
{
if ($this->hasCsrfProtection() && array_key_exists(static::CSRF_TOKEN_NAME, $values)) {
$this->_populatedCsrfToken = $values[static::CSRF_TOKEN_NAME];
}
return $this->setDefaults($values);
}
/**
* Set element values.
*
* Extended here to support setting values from a record object.
* Accepting a record object allows 'enhanced' elements to look at
* other aspects of the record and make decisions accordingly.
*
* @param P4Cms_Record|array $defaults the default values to set on elements
* @return Zend_Form provides fluent interface
*/
public function setDefaults($defaults)
{
// handle record input.
if ($defaults instanceof P4Cms_Record) {
$record = $defaults;
$defaults = $record->getValues();
// handle enhanced elements.
foreach ($defaults as $field => $value) {
$element = $this->getElement($field);
if ($element instanceof P4Cms_Record_EnhancedElementInterface) {
$element->populateFromRecord($record);
unset($defaults[$field]);
}
}
}
// Zend form has a strange behavior where sub-forms will populate
// from the entire values array, rather than their designated portion
// of the values array if there is no matching key in defaults for
// the sub-form (e.g. from $defaults instead of $defaults['subForm'])
// and parent form has no element matching the key.
//
// This can have some unfortunate side-effects (top-level values
// polluting the sub-forms) - to avoid this problem we ensure that
// there is an entry in the defaults array for every sub-form.
//
// Example:
// assuming we have a $form with element [foo] and a sub-form 'subForm'
// with elements [foo] and [bar], then
// $form->setDefaults(array('foo' => 'foo_value'))
// will populate only [foo] element, whereas
// $form->setDefaults(array('bar' => 'bar_value'))
// will populate [subForm][bar] element. This fix is to prevent the
// second case above (i.e. [subForm][bar] will be populated only if
// defaults array contains 'subForm' => array('bar' => 'bar_value').
foreach ($this->getSubForms() as $subForm) {
$key = $subForm->getElementsBelongTo();
if ($subForm->isArray() && !isset($defaults[$key])) {
$defaults[$key] = array();
}
}
return parent::setDefaults($defaults);
}
/**
* Set the storage adapter to use to access records.
*
* @param P4Cms_Record_Adapter $adapter the adapter to use for record access
* @return P4Cms_Form provides fluent interface.
*/
public function setStorageAdapter(P4Cms_Record_Adapter $adapter)
{
$this->_storageAdapter = $adapter;
return $this;
}
/**
* Get the storage adapter used by this form to access records.
*
* @return P4Cms_Record_Adapter the adapter used by this form.
*/
public function getStorageAdapter()
{
if ($this->_storageAdapter instanceof P4Cms_Record_Adapter) {
return $this->_storageAdapter;
}
throw new P4Cms_Form_Exception(
"Cannot get storage adapter. Adapter has not been set."
);
}
/**
* Enables or disables the csrf protection for this form; defaults to enabled.
*
* @param boolean $csrf Whether or not to enable csrf protection
* @return P4Cms_Form provides fluid interface
*/
public function setCsrfProtection($csrf)
{
$this->_csrfProtection = (boolean)$csrf;
return $this;
}
/**
* Returns whether or not this form has csrf protection enabled.
*
* Protection is always turned off for anonymous users. For authenticated
* users protection is on by default but can optionally be disabled.
*
* @return boolean whether or not csrf protection is enabled for the form
*/
public function hasCsrfProtection()
{
return $this->_csrfProtection
&& P4Cms_User::hasActive()
&& !P4Cms_User::fetchActive()->isAnonymous();
}
/**
* For authenticated users, returns the current session value or
* generate a new value (and set on the session) if none is present.
*
* For anonymous users this method simply returns null as they
* don't receive CSRF protection
*
* @return string|null csrf token for this form
*/
public static function getCsrfToken()
{
// skip starting a session for anonymous users and simply return null
if (!P4Cms_User::hasActive() || P4Cms_User::fetchActive()->isAnonymous()) {
return null;
}
$session = static::_getSession();
if (!$session->csrfToken) {
$session->csrfToken = (string) new P4Cms_Uuid;
// Don't let the presence of a CSRF token in the session
// prevent caching of future unrelated requests.
if (P4Cms_Cache::canCache('page')) {
P4Cms_Cache::getCache('page')->addIgnoredSessionVariable('Forms[csrfToken]');
}
}
return $session->csrfToken;
}
/**
* This method is used to retrieve a populated csrf token for use in
* validation.
*
* @param array $data Array to search for csrf token, or empty array
* @return string csrf token
*/
private function getPopulatedCsrfToken($data = array())
{
if (!empty($this->_populatedCsrfToken)) {
$csrfToken = $this->_populatedCsrfToken;
} else {
// a convenience so that module authors working with forms do
// not have to do anything special to handle csrf validations
$request = Zend_Controller_Front::getInstance()->getRequest();
// either get token or null, if the token is not set
$csrfToken = $request->getParam(static::CSRF_TOKEN_NAME);
}
return $csrfToken;
}
/**
* Validate the form, including csrf check
*
* @param array $data the data to validate.
* @return boolean
*/
public function isValid($data)
{
$isValid = parent::isValid($data);
// validate the CSRF token if protection is enabled
if ($this->hasCsrfProtection()) {
if (array_key_exists(static::CSRF_TOKEN_NAME, $data)) {
$this->_populatedCsrfToken = $data[static::CSRF_TOKEN_NAME];
}
if ($this->getPopulatedCsrfToken() != static::getCsrfToken()) {
$isValid = false;
$this->addError('Form failed security validation.');
}
}
return $isValid;
}
/**
* Helper function to adjust decorators on checkbox element
* to position the checkbox label to the right of the checkbox,
* instead of on the left hand side.
*
* @param Zend_Form_Element $element the element to adjust decorators on.
* @return Zend_Form_Element the updated element.
*/
public static function moveCheckboxLabel(Zend_Form_Element $element)
{
// adjust how the auto-label element is decorated
// to put the label immediately after the checkbox.
$decorators = array(
'Zend_Form_Decorator_ViewHelper' => null,
'P4Cms_Form_Decorator_Label' => null
);
$element->setDecorators(array_merge($decorators, $element->getDecorators()));
$element->getDecorator('label')
->setOption('placement', 'append')
->setOption('tag', null);
return $element;
}
/**
* Prefix the ids of all elements, fieldsets and sub-forms within a form.
*
* @param Zend_Form $form a specific form to prefix the ids of.
* @param string $prefix the prefix to use
*/
public static function prefixFormIds(Zend_Form $form, $prefix)
{
// prefix id of form itself.
static::_applyIdPrefix($form, $prefix);
// prefix elements in form.
foreach ($form->getElements() as $element) {
static::_applyIdPrefix($element, $prefix);
}
// prefix display groups.
foreach ($form->getDisplayGroups() as $displayGroup) {
static::_applyIdPrefix($displayGroup, $prefix);
}
// prefix sub-forms.
foreach ($form->getSubForms() as $subForm) {
$subForm->setIdPrefix($prefix);
}
}
/**
* Ensure consistent presentation of sub-forms.
*
* @param Zend_Form $form the sub-form to normalize.
* @param string $name the name of the sub-form.
* @return Zend_Form the updated form.
*/
public static function normalizeSubForm($form, $name = null)
{
$name = $name ?: $form->getName();
$form->setDecorators(
array(
'FormElements',
array(
'decorator' => 'HtmlTag',
'options' => array('tag' => 'dl')
),
'Fieldset',
array(
'decorator' => array('DdTag' => 'HtmlTag'),
'options' => array('tag' => 'dd')
),
)
);
// ensure form is identified with a css class.
$class = $name ? $name . '-sub-form' : '';
if (!preg_match("/(^| )$class( |$)/", $form->getAttrib('class'))) {
$form->setAttrib('class', trim($class . ' ' . $form->getAttrib('class')));
}
// normalized sub-forms should always put values in an array.
$form->setIsArray(true);
return $form;
}
/**
* Add prefix to id attribute of given element.
*
* @param mixed $element the element to prefix the id of - can be a
* form, fieldset or standard element.
* @param string $prefix the prefix to apply
*/
protected static function _applyIdPrefix($element, $prefix)
{
// ensure prefix ends with a dash.
$prefix = rtrim($prefix, '-') . '-';
// prefix if id is not blank and not already prefixed.
if (strlen($element->getId()) && strpos($element->getId(), $prefix) !== 0) {
$id = $prefix . $element->getId();
$element->setAttrib('id', $id);
}
}
/**
* Return the static session object, initializing if necessary.
*
* @return Zend_Session_Namespace
*/
protected static function _getSession()
{
if (!static::$_session instanceof Zend_Session_Namespace) {
static::$_session = new Zend_Session_Namespace('Forms');
}
return static::$_session;
}
}