Custom Widgets and Validators
by Thomas Rabaix
This chapter explains how to build a custom widget and validator for use in the form framework. It will explain the internals of sfWidgetForm
and sfValidator
, as well as how to build both a simple and complex widget.
Widget and Validator Internals
sfWidgetForm
Internals
An object of the ~sfWidgetForm
~ class represents the visual implementation of how related data will be edited. A string value, for example, might be edited with a simple text box or an advanced WYSIWYG editor. In order to be fully configurable, the sfWidgetForm
class has two important properties: options
and attributes
.
-
options
: used to configure the widget (e.g. the database query to be used when creating a list to be used in a select box) -
attributes
: HTML attributes added to the element upon rendering
Additionally, the sfWidgetForm
class implements two important methods:
-
configure()
: defines which options are optional or mandatory. While it is not a good practice to override the constructor, theconfigure()
method can be safely overridden. -
render()
: outputs the HTML for the widget. The method has a mandatory first argument, the HTML widget name, and an optional second argument, the value.
An
sfWidgetForm
object does not know anything about its name or its value. The component is responsible only for rendering the widget. The name and the value are managed by ansfFormFieldSchema
object, which is the link between the data and the widgets.
sfValidatorBase Internals
The ~sfValidatorBase
~ class is the base class of each validator. The ~sfValidatorBase::clean()
~ method is the most important method of this class as it checks if the value is valid depending on the provided options.
Internally, the clean()
method perform several different actions:
- trims the input value for string values (if specified via the
trim
option) - checks if the value is empty
- calls the validator's
doClean()
method.
The doClean()
method is the method which implements the main validation logic. It is not good practice to override the clean()
method. Instead, always perform any custom logic via the doClean()
method.
A validator can also be used as a standalone component to check input integrity. For instance, the sfValidatorEmail
validator will check if the email is valid:
$v = new sfValidatorEmail(); try { $v->clean($request->getParameter("email")); } catch(sfValidatorError $e) { $this->forward404(); }
When a form is bound to the request values, the
sfForm
object keeps references to the original (dirty) values and the validated (clean) values. The original values are used when the form is redrawn, while the cleaned values are used by the application (e.g. to save the object).
The options
Attribute
Both the sfWidgetForm
and sfValidatorBase
objects have a variety of options: some are optional while others are mandatory. These options are defined inside each class's configure()
method via:
addOption($name, $value)
: defines an option with a name and a default valueaddRequiredOption($name)
: defines a mandatory option
These two methods are very convenient as they ensure that dependency values are correctly passed to the validator or the widget.
Building a Simple Widget and Validator
This section will explain how to build a simple widget. This particular widget will be called a "Trilean" widget. The widget will display a select box with three choices: No
, Yes
and Null
.
class sfWidgetFormTrilean extends sfWidgetForm { public function configure($options = array(), $attributes = array()) { $this->addOption('choices', array( 0 => 'No', 1 => 'Yes', 'null' => 'Null' )); } public function render($name, $value = null, $attributes = array(), $errors = array()) { $value = $value === null ? 'null' : $value; $options = array(); foreach ($this->getOption('choices') as $key => $option) { $attributes = array('value' => self::escapeOnce($key)); if ($key == $value) { $attributes['selected'] = 'selected'; } $options[] = $this->renderContentTag( 'option', self::escapeOnce($option), $attributes ); } return $this->renderContentTag( 'select', "\n".implode("\n", $options)."\n", array_merge(array('name' => $name), $attributes )); } }
The configure()
method defines the option values list via the choices
option. This array can be redefined (i.e. to change the associated label of each value). There is no limit to the number of option a widget can define. The base widget class, however, declares a few standard options, which function like de-facto reserved options:
-
id_format
: the id format, default is '%s' -
is_hidden
: boolean value to define if the widget is a hidden field (used bysfForm::renderHiddenFields()
to render all hidden fields at once) -
needs_multipart
: boolean value to define if the form tag should include the multipart option (i.e. for file uploads) -
default
: The default value that should be used to render the widget if no value is provided -
label
: The default widget label
The render()
method generates the corresponding HTML for a select box. The method calls the built-in renderContentTag()
function to help render HTML tags.
The widget is now complete. Let's create the corresponding validator:
class sfValidatorTrilean extends sfValidatorBase { protected function configure($options = array(), $messages = array()) { $this->addOption('true_values', array('true', 't', 'yes', 'y', 'on', '1')); $this->addOption('false_values', array('false', 'f', 'no', 'n', 'off', '0')); $this->addOption('null_values', array('null', null)); } protected function doClean($value) { if (in_array($value, $this->getOption('true_values'))) { return true; } if (in_array($value, $this->getOption('false_values'))) { return false; } if (in_array($value, $this->getOption('null_values'))) { return null; } throw new sfValidatorError($this, 'invalid', array('value' => $value)); } public function isEmpty($value) { return false; } }
The sfValidatorTrilean
validator defines three options in the configure()
method. Each option is a set of valid values. As these are defined as options, the developer can customize the values depending on the specification.
The doClean()
method checks if the value matches a set a valid values and returns the cleaned value. If no value is matched, the method will raise an sfValidatorError
which is the standard validation error in the form framework.
The last method, isEmpty()
, is overridden as the default behavior of this method is to return true
if null
is provided. As the current widget allows null
as a valid value, the method must always return false
.
If
isEmpty()
returns true, thedoClean()
method will never be called.
While this widget was fairly straightforward, it introduced some important base features that will be needed as we go further. The next section will create a more complex widget with multiple fields and JavaScript interaction.
The Google Address Map Widget
In this section, we are going to build a complex widget. New methods will be introduced and the widget will have some JavaScript interaction as well. The widget will be called "GMAW": "Google Map Address Widget".
What do we want to achieve? The widget should provide an easy way for the end user to add an address. By using an input text field and with google's map services we can achieve this goal.
Use case 1:
- The user types an address.
- The user clicks the "lookup" button.
- The latitude and longitude hidden fields are updated and a new marker is created on the map. The marker is positioned at the location of the address. If the Google Geocoding service cannot find the address an error message will popup.
Use case 2:
- The user clicks on the map.
- The latitude and longitude hidden fields are updated.
- Reverse lookup is used to find the address.
The following fields need to be posted and handled by the form:
latitude
: float, between 90 and -90longitude
: float, between 180 and -180address
: string, plain text only
The widget's functional specifications have just been defined, now let's define the technical tools and their scopes:
- Google map and Geocoding services: displays the map and retrieves address information
- jQuery: adds JavaScript interactions between the form and the field
- sfForm: draws the widget and validates the inputs
sfWidgetFormGMapAddress
Widget
As a widget is the visual representation of data, the configure()
method of the widget must have different options to tweak the Google map or modify the styles of each element. One of the most important options is the template.html
option, which defines how all elements are ordered. When building a widget it is very important to think about reusability and extensibility.
Another important thing is the external assets definition. An sfWidgetForm
class can implement two specific methods:
-
getJavascripts()
must return an array of JavaScript files; -
getStylesheets()
must return an array of stylesheet files (where the key is the path and the value the media name).
The current widget only requires some JavaScript to work so no stylesheet is needed. In this case, however, the widget will not handle the initialization of the Google JavaScript, though the widget will make use of the Google geocoding and map services. Instead, it will be the developer's responsibility to include it on the page. The reason behind this is that Google's services may be used by other elements on the page, and not only by the widget.
Let's jump to the code:
class sfWidgetFormGMapAddress extends sfWidgetForm { public function configure($options = array(), $attributes = array()) { $this->addOption('address.options', array('style' => 'width:400px')); $this->setOption('default', array( 'address' => '', 'longitude' => '2.294359', 'latitude' => '48.858205' )); $this->addOption('div.class', 'sf-gmap-widget'); $this->addOption('map.height', '300px'); $this->addOption('map.width', '500px'); $this->addOption('map.style', ""); $this->addOption('lookup.name', "Lookup"); $this->addOption('template.html', ' <div id="{div.id}" class="{div.class}"> {input.search} <input type="submit" value="{input.lookup.name}" id="{input.lookup.id}" /> <br /> {input.longitude} {input.latitude} <div id="{map.id}" style="width:{map.width};height:{map.height};{map.style}"></div> </div> '); $this->addOption('template.javascript', ' <script type="text/javascript"> jQuery(window).bind("load", function() { new sfGmapWidgetWidget({ longitude: "{input.longitude.id}", latitude: "{input.latitude.id}", address: "{input.address.id}", lookup: "{input.lookup.id}", map: "{map.id}" }); }) </script> '); } public function getJavascripts() { return array( '/sfFormExtraPlugin/js/sf_widget_gmap_address.js' ); } public function render($name, $value = null, $attributes = array(), $errors = array()) { // define main template variables $template_vars = array( '{div.id}' => $this->generateId($name), '{div.class}' => $this->getOption('div.class'), '{map.id}' => $this->generateId($name.'[map]'), '{map.style}' => $this->getOption('map.style'), '{map.height}' => $this->getOption('map.height'), '{map.width}' => $this->getOption('map.width'), '{input.lookup.id}' => $this->generateId($name.'[lookup]'), '{input.lookup.name}' => $this->getOption('lookup.name'), '{input.address.id}' => $this->generateId($name.'[address]'), '{input.latitude.id}' => $this->generateId($name.'[latitude]'), '{input.longitude.id}' => $this->generateId($name.'[longitude]'), ); // avoid any notice errors to invalid $value format $value = !is_array($value) ? array() : $value; $value['address'] = isset($value['address']) ? $value['address'] : ''; $value['longitude'] = isset($value['longitude']) ? $value['longitude'] : ''; $value['latitude'] = isset($value['latitude']) ? $value['latitude'] : ''; // define the address widget $address = new sfWidgetFormInputText(array(), $this->getOption('address.options')); $template_vars['{input.search}'] = $address->render($name.'[address]', $value['address']); // define the longitude and latitude fields $hidden = new sfWidgetFormInputHidden; $template_vars['{input.longitude}'] = $hidden->render($name.'[longitude]', $value['longitude']); $template_vars['{input.latitude}'] = $hidden->render($name.'[latitude]', $value['latitude']); // merge templates and variables return strtr( $this->getOption('template.html').$this->getOption('template.javascript'), $template_vars ); } }
The widget uses the generateId()
method to generate the id
of each element. The $name
variable is defined by the sfFormFieldSchema
, so the $name
variable is composed of the name form, any nested widget schema names and the name of the widget as defined in the form configure()
.
For instance, if the form name is
user
, the nested schema name islocation
and the widget name isaddress
, the finalname
will beuser[location][address]
and theid
will beuser_location_address
. In other words,$this->generateId($name.'[latitude]')
will generate a valid and uniqueid
for thelatitude
field.
The different element id
attributes are quite important as there are passed to the JavaScript block (via the template.js
variable), so the JavaScript can properly handle the different elements.
The render()
method also instantiates two inner widgets: an sfWidgetFormInputText
widget, which is used to render the address field, and an sfWidgetFormInputHidden
widget, which is used to render the hidden fields.
The widget can be quickly tested with this small piece of code:
$widget = new sfWidgetFormGMapAddress(); echo $widget->render('user[location][address]', array( 'address' => '151 Rue montmartre, 75002 Paris', 'longitude' => '2.294359', 'latitude' => '48.858205' ));
The output result is:
<div id="user_location_address" class="sf-gmap-widget"> <input style="width:400px" type="text" name="user[location][address][address]" value="151 Rue montmartre, 75002 Paris" id="user_location_address_address" /> <input type="submit" value="Lookup" id="user_location_address_lookup" /> <br /> <input type="hidden" name="user[location][address][longitude]" value="2.294359" id="user_location_address_longitude" /> <input type="hidden" name="user[location][address][latitude]" value="48.858205" id="user_location_address_latitude" /> <div id="user_location_address_map" style="width:500px;height:300px;"></div> </div> <script type="text/javascript"> jQuery(window).bind("load", function() { new sfGmapWidgetWidget({ longitude: "user_location_address_longitude", latitude: "user_location_address_latitude", address: "user_location_address_address", lookup: "user_location_address_lookup", map: "user_location_address_map" }); }) </script>
The JavaScript part of the widget takes the different id
attributes and binds jQuery listeners to them so that certain JavaScript is triggered when actions are performed. The JavaScript updates the hidden fields with the longitude and latitude provided by the google geocoding service.
The JavaScript object has a few interesting methods:
-
init()
: the method where all variables are initialized and events bound to different inputs -
lookupCallback()
: a static method used by the geocoder method to lookup the address provided by the user -
reverseLookupCallback()
: is another static method used by the geocoder to convert the given longitude and latitude into a valid address.
The final JavaScript code can be viewed in Appendix A.
Please refer to the Google map documentation for more details on the functionality of the Google maps API.
sfValidatorGMapAddress
Validator
The sfValidatorGMapAddress
class extends sfValidatorBase
which already performs one validation: specifically, if the field is set as required then the value cannot be null
. Thus, sfValidatorGMapAddress
need only validate the different values: latitude
, longitude
and address
. The $value
variable should be an array, but as the user input should not be trusted, the validator checks for the presence of all keys so that the inner validators are passed valid values.
class sfValidatorGMapAddress extends sfValidatorBase { protected function doClean($value) { if (!is_array($value)) { throw new sfValidatorError($this, 'invalid'); } try { $latitude = new sfValidatorNumber(array( 'min' => -90, 'max' => 90, 'required' => true )); $value['latitude'] = $latitude->clean(isset($value['latitude']) ? $value['latitude'] : null); $longitude = new sfValidatorNumber(array( 'min' => -180, 'max' => 180, 'required' => true )); $value['longitude'] = $longitude->clean(isset($value['longitude']) ? $value['longitude'] : null); $address = new sfValidatorString(array( 'min_length' => 10, 'max_length' => 255, 'required' => true )); $value['address'] = $address->clean(isset($value['address']) ? $value['address'] : null); } catch(sfValidatorError $e) { throw new sfValidatorError($this, 'invalid'); } return $value; } }
A validator always raises an
sfValidatorError
exception when a value is not valid. That's why the validation is surrounded by atry/catch
block. In this validator, the validator re-throws a newinvalid
exception, which equates to aninvalid
validation error on thesfValidatorGMapAddress
validator.
Testing
Why is testing important? The validator is the glue between the user input and the application. If the validator is flawed, the application is vulnerable. Fortunately, symfony comes with lime
which is a testing library that is very easy to use.
How can we test the validator? As stated before, a validator raises an exception on a validation error. The test can send valid and invalid values to the validator and check to see that the exception is thrown in the correct circumstances.
$t = new lime_test(7, new lime_output_color()); $tests = array( array(false, '', 'empty value'), array(false, 'string value', 'string value'), array(false, array(), 'empty array'), array(false, array('address' => 'my awesome address'), 'incomplete address'), array(false, array('address' => 'my awesome address', 'latitude' => 'String', 'longitude' => 23), 'invalid values'), array(false, array('address' => 'my awesome address', 'latitude' => 200, 'longitude' => 23), 'invalid values'), array(true, array('address' => 'my awesome address', 'latitude' => '2.294359', 'longitude' => '48.858205'), 'valid value') ); $v = new sfValidatorGMapAddress; $t->diag("Testing sfValidatorGMapAddress"); foreach($tests as $test) { list($validity, $value, $message) = $test; try { $v->clean($value); $catched = false; } catch(sfValidatorError $e) { $catched = true; } $t->ok($validity != $catched, '::clean() '.$message); }
When the sfForm::bind()
method is called, the form executes the clean()
method of each validator. This test reproduces this behavior by instantiating the sfValidatorGMapAddress
validator directly and testing different values.
Final Thoughts
The most common mistake when creating a widget is to be overly focused on how the information will be stored in the database. The form framework is simply a data container and validation framework. Therefore, a widget must only manage its related information. If the data is valid then the different cleaned values can then be used by the model or in the controller.
インデックス
Document Index
関連ページリスト
Related Pages
- Introduction
- Advanced Routing
- Enhance your Productivity
- Emails
- Custom Widgets and Validators
- Advanced Forms
- Extending the Web Debug Toolbar
- Advanced Doctrine Usage
- Taking Advantage of Doctrine Table Inheritance
- Symfony Internals
- Windows and symfony
- Developing for Facebook
- Leveraging the Power of the Command Line
- Playing with symfony's Config Cache
- Working with the symfony Community
- Appendix A - JavaScript code for sfWidgetFormGMapAddress
- About the Authors
- Appendix B - Custom Installer Example
- Appendix C - License
日本語ドキュメント
Japanese Documents
- 2011/01/18 Chapter 17 - Extending Symfony
- 2011/01/18 The generator.yml Configuration File
- 2011/01/18 Les tâches
- 2011/01/18 Emails
- 2010/11/26 blogチュートリアル(8) ビューの作成