Previously on Jobeet
The second week of Jobeet got off to a flying start with the introduction of the symfony test framework. We will continue today with the form framework.
The Form Framework
Any website has forms; from the simple contact form to the complex ones with tens of fields. Writing forms is also one of the most complex and tedious task for a web developer: you need to write the HTML form, implement validation rules for each field, process the values to store them in a database, display error messages, repopulate fields in case of errors, and much more...
Of course, instead of reinventing the wheel over and over again, symfony provides a framework to ease form management. The form framework is made of three parts:
- validation: The validation sub-framework provides classes to validate inputs (integer, string, email address, ...)
- widgets: The widget sub-framework provides classes to output HTML fields (input, textarea, select, ...)
- forms: The form classes represent forms made of widgets and validators and provide methods to help manage the form. Each form field has its own validator and widget.
Forms
A symfony form is a class made of fields. Each field has a name, a validator,
and a widget. A simple ContactForm
can be defined with the following class:
class ContactForm extends sfForm { public function configure() { $this->setWidgets(array( 'email' => new sfWidgetFormInput(), 'message' => new sfWidgetFormTextarea(), )); $this->setValidators(array( 'email' => new sfValidatorEmail(), 'message' => new sfValidatorString(array('max_length' => 255)), )); } }
Form fields are configured in the configure()
method, by using the
setValidators()
and setWidgets()
methods.
The form framework comes bundled with a lot of widgets and validators. The API describes them quite extensively with all the options, errors, and default error messages.
The widget and validator class names are quite explicit: the email
field
will be rendered as an HTML <input>
tag (sfWidgetFormInput
) and validated
as an email address (sfValidatorEmail
). The message
field will be rendered as a
<textarea>
tag (sfWidgetFormTextarea
), and must be a string of no more than
255 characters (sfValidatorString
).
By default all fields are required, as the default value for the required
option is true
. So, the validation definition for email
is equivalent to
new sfValidatorEmail(array('required' => true))
.
You can merge a form in another one by using the
mergeForm()
method, or embed one by using theembedForm()
method:$this->mergeForm(new AnotherForm()); $this->embedForm('name', new AnotherForm());
Propel Forms
Most of the time, a form has to be serialized to the database. As symfony
already knows everything about your database model, it can automatically
generate forms based on this information. In fact, when you launched the
propel:build-all
task during day 3, symfony automatically called the
propel:build-forms
task:
$ php symfony propel:build-forms
The propel:build-forms
task generates form classes in the lib/form/
directory. The organization of these generated files is similar to that of
lib/model
. Each model class has a related form class (for instance
JobeetJob
has JobeetJobForm
), which is empty by default as it inherits
from a base class:
// lib/form/JobeetJobForm.class.php class JobeetJobForm extends BaseJobeetJobForm { public function configure() { } }
By browsing the generated files under the
lib/form/base/
sub-directory, you will see a lot of great usage examples of symfony built-in widgets and validators.
Customizing the Job Form
The job form is a perfect example to learn form customization. Let's see how to customize it, step by step.
First, change the "Post a Job" link in the layout to be able to check changes directly in your browser:
<!-- apps/frontend/templates/layout.php -->
<a href="<?php echo url_for('@job_new') ?>">Post a Job</a>
By default, a Propel form displays fields for all the table columns. But for the job form, some of them must not be editable by the end user. Removing fields from a form is as simple as unsetting them:
// lib/form/JobeetJobForm.class.php class JobeetJobForm extends BaseJobeetJobForm { public function configure() { unset( $this['created_at'], $this['updated_at'], $this['expires_at'], $this['is_activated'] ); } }
Unsetting a field means that both the field widget and validator are removed.
The form configuration must sometimes be more precise than what can be
introspected from the database schema. For example, the email
column is a
varchar
in the schema, but we need this column to be validated as an email.
Let's change the default sfValidatorString
to a sfValidatorEmail
:
// lib/form/JobeetJobForm.class.php public function configure() { // ... $this->validatorSchema['email'] = new sfValidatorEmail(); }
Even if the type
column is also a varchar
in the schema, we want its value
to be restricted to a list of choices: full time, part time, or freelance.
First, let's define the possible values in JobeetJobPeer
:
// lib/model/JobeetJobPeer.php class JobeetJobPeer extends BaseJobeetJobPeer { static public $types = array( 'full-time' => 'Full time', 'part-time' => 'Part time', 'freelance' => 'Freelance', ); // ... }
Then, use sfWidgetFormChoice
for the type
widget:
$this->widgetSchema['type'] = new sfWidgetFormChoice(array( 'choices' => JobeetJobPeer::$types, 'expanded' => true, ));
sfWidgetFormChoice
represents a choice widget which can be rendered by a
different widget according to some configuration options (expanded
and
multiple
):
- Dropdown list (
<select>
):array('multiple' => false, 'expanded' => false)
- Dropdown box (
<select multiple="multiple">
):array('multiple' => true, 'expanded' => false)
- List of radio buttons:
array('multiple' => false, 'expanded' => true)
- List of checkboxes:
array('multiple' => true, 'expanded' => true)
If you want one of the radio button to be selected by default (
full-time
for instance), you can change the default value in the database schema.
Even if you think nobody can submit a non-valid value, a hacker can easily bypass the widget choices by using tools like curl or the Firefox Web Developer Toolbar. Let's change the validator to restrict the possible choices:
$this->validatorSchema['type'] = new sfValidatorChoice(array( 'choices' => array_keys(JobeetJobPeer::$types), ));
As the logo
column will store the filename of the logo associated with the
job, we need to change the widget to a file input tag:
$this->widgetSchema['logo'] = new sfWidgetFormInputFile(array( 'label' => 'Company logo', ));
For each field, symfony automatically generate a label (which will be used in
the rendered <label>
tag). This can be changed with the label
option.
You can also change labels in a batch with the setLabels()
method of the
widget array:
$this->widgetSchema->setLabels(array( 'category_id' => 'Category', 'is_public' => 'Public?', 'how_to_apply' => 'How to apply?', ));
We also need to change the default validator:
$this->validatorSchema['logo'] = new sfValidatorFile(array( 'required' => false, 'path' => sfConfig::get('sf_upload_dir').'/jobs', 'mime_types' => 'web_images', ));
sfValidatorFile
is quite interesting as it does a number of things:
- Validates that the uploaded file is an image in a web format (
mime_types
) - Renames the file to something unique
- Stores the file in the given
path
- Updates the
logo
column with the generated name
You need to create the logo directory (
web/uploads/jobs
) and check that it is writable by the web server.
As the validator will save the relative path in the database, change the path
used in the showSuccess
template:
// apps/frontend/modules/job/template/showSuccess.php <img src="/uploads/jobs/<?php echo $job->getLogo() ?>" alt="<?php echo $job->getCompany() ?> logo" />
If a
generateLogoFilename()
method exists in the form, it will be called by the validator and the result will override the default generatedlogo
filename. The method is given thesfValidatedFile
object as an argument.
Just as you can override the generated label of any field, you can also define a
help message. Let's add one for the is_public
column to better explain its
significance:
$this->widgetSchema->setHelp('is_public', 'Whether the job can also be published on affiliate websites or not.');
The final JobeetJobForm
class read as follow:
// lib/form/JobeetJobForm.class.php class JobeetJobForm extends BaseJobeetJobForm { public function configure() { unset( $this['created_at'], $this['updated_at'], $this['expires_at'], $this['is_activated'] ); $this->validatorSchema['email'] = new sfValidatorEmail(); $this->widgetSchema['type'] = new sfWidgetFormChoice(array( 'choices' => JobeetJobPeer::$types, 'expanded' => true, )); $this->validatorSchema['type'] = new sfValidatorChoice(array( 'choices' => array_keys(JobeetJobPeer::$types), )); $this->widgetSchema['logo'] = new sfWidgetFormInputFile(array( 'label' => 'Company logo', )); $this->validatorSchema['logo'] = new sfValidatorFile(array( 'required' => false, 'path' => sfConfig::get('sf_upload_dir').'/jobs', 'mime_types' => 'web_images', )); $this->widgetSchema->setHelp('is_public', 'Whether the job can also be published on affiliate websites or not.'); } }
The Form Template
Now that the form class has been customized, we need to display it. The
template for the form is the same whether you want to create a new job or
edit an existing one. In fact, both newSuccess.php
and editSuccess.php
templates are quite similar:
<!-- apps/frontend/modules/job/templates/newSuccess.php --> <?php use_stylesheet('job.css') ?> <h1>Post a Job</h1> <?php include_partial('form', array('form' => $form)) ?>
The form itself is rendered in the _form
partial. Replace the content of the
generated _form
partial with the following code:
<!-- apps/frontend/modules/job/templates/_form.php --> <?php include_stylesheets_for_form($form) ?> <?php include_javascripts_for_form($form) ?> <?php echo form_tag_for($form, '@job') ?> <table id="job_form"> <tfoot> <tr> <td colspan="2"> <input type="submit" value="Preview your job" /> </td> </tr> </tfoot> <tbody> <?php echo $form ?> </tbody> </table> </form>
The include_javascripts_for_form()
and include_stylesheets_for_form()
helpers include JavaScript and stylesheet dependencies needed for the form
widgets.
Even if the job form does not need any JavaScript or stylesheet file, it is a good habit to keep these helper calls "just in case". It can save your day later if you decide to change a widget that needs some JavaScript or a specific stylesheet.
The form_tag_for()
helper generate a <form>
tag for the given form and
route and changes the HTTP methods to POST
or PUT
depending on whether the
object is new or not. It also takes care of the multipart
attribute if the
form has any file input tags.
Eventually, the <?php echo $form ?>
renders the form widgets.
Customizing the Look and Feel of a Form
By default, the
<?php echo $form ?>
renders the form widgets as table rows.Most of the time, you will need to customize the layout of your forms. The form object provides many useful methods for this customization:
Method Description render()
Renders the form (equivalent to the output of echo $form
)renderHiddenFields()
Renders the hidden fields hasErrors()
Returns true
if the form has some errorshasGlobalErrors()
Returns true
if the form has global errorsgetGlobalErrors()
Returns an array of global errors renderGlobalErrors()
Renders the global errors The form also behaves like an array of fields. You can access the
company
field with$form['company']
. The returned object provides methods to render each element of the field:
Method Description renderRow()
Renders the field row render()
Renders the field widget renderLabel()
Renders the field label renderError()
Renders the field error messages if any renderHelp()
Renders the field help message The
echo $form
statement is equivalent to:<?php foreach ($form as $widget): ?> <?php echo $widget->renderRow() ?> <?php endforeach(); ?>
The Form Action
We now have a form class and a template that renders it. Now, it's time to actually make it work with some actions.
The job form is managed by five methods in the job
module:
- new: Displays a blank form to create a new job
- edit: Displays a form to edit an existing job
- create: Creates a new job with the user submitted values
- update: Updates an existing job with the user submitted values
- processForm: Called by
create
andupdate
, it processes the form (validation, form repopulation, and serialization to the database)
All forms have the following life-cycle:
As we have created a Propel route collection 5 days ago for the job
module,
we can simplify the code for the form management methods:
// apps/frontend/modules/job/actions/actions.class.php public function executeNew(sfWebRequest $request) { $this->form = new JobeetJobForm(); } public function executeCreate(sfWebRequest $request) { $this->form = new JobeetJobForm(); $this->processForm($request, $this->form); $this->setTemplate('new'); } public function executeEdit(sfWebRequest $request) { $this->form = new JobeetJobForm($this->getRoute()->getObject()); } public function executeUpdate(sfWebRequest $request) { $this->form = new JobeetJobForm($this->getRoute()->getObject()); $this->processForm($request, $this->form); $this->setTemplate('edit'); } protected function processForm(sfWebRequest $request, sfForm $form) { $form->bind( $request->getParameter($form->getName()), $request->getFiles($form->getName()) ); if ($form->isValid()) { $job = $form->save(); $this->redirect($this->generateUrl('job_show', $job)); } }
When you browse to the /job/new
page, a new form instance is created and
passed to the template (new
action).
When the user submits the form (create
action), it is bound (bind()
method) with the user submitted values and the validation is triggered.
Once the form is bound, it is possible to check its validity using the
isValid()
method: If the form is valid (returns true
), the job is saved
to the database ($form->save()
), and the user is redirected to the job
preview page; if not, the newSuccess.php
template is displayed again with the
user submitted values and error messages.
The
setTemplate()
method changes the template used for a given action. If the submitted form is not valid, thecreate
andupdate
methods use the same template as thenew
andedit
action respectively to re-display the form with error messages.
The modification of an existing job is quite similar. The only difference
between the new
and the edit
action is that the job object to be modified
is passed as the first argument of the form constructor. This object will be
used for default widget values in the template (default values are an object
for Propel forms, but a plain array for simple forms).
You can also define default values for the creation form. One way is to
declare the values in the database schema. Another one is to pass a
pre-modified Job
object to the form constructor:
// apps/frontend/modules/job/actions/actions.class.php public function executeNew(sfWebRequest $request) { $job = new JobeetJob(); $job->setType('full-time'); $this->form = new JobeetJobForm($job); }
When the form is bound, the default values are replaced with the user submitted ones. The use submitted values will be used for form repopulation when the form is redisplayed in case of validation errors.
Protecting the Job Form with a Token
Everything must work fine by now. But there is a problem. First, the job token
must be generated automatically when a new job is created, as we don't want
the user to provide a unique token. Update the save()
method of JobeetJob
:
// lib/model/JobeetJob.php public function save(PropelPDO $con = null) { // ... if (!$this->getToken()) { $this->setToken(sha1($this->getEmail().rand(11111, 99999))); } return parent::save($con); }
We can remove the token
field from the form:
// lib/form/JobeetJobForm.class.php class JobeetJobForm extends BaseJobeetJobForm { public function configure() { unset( $this['created_at'], $this['updated_at'], $this['expires_at'], $this['is_activated'], $this['token'] ); // ... } // ... }
If you remember the user stories from day 2, a job can be edited only if the
user knows the associated token. Right now, it is pretty easy to edit or
delete any job, just by guessing the URL. That's because the edit URL is like
/job/ID/edit
, where ID
is the primary key of the job.
By default, a sfPropelRouteCollection
route generates URLs with the primary
key, but it can be changed to any unique column by passing the column
option:
# apps/frontend/config/routing.yml job: class: sfPropelRouteCollection options: { model: JobeetJob, column: token } requirements: { token: \w+ }
Now, all routes, except the job_show_user
one, embed the token. For
instance, the route to edit a job is now:
http://jobeet.localhost/job/TOKEN/edit
You will also need to change the "Edit" link in the showSuccess
template:
<!-- apps/frontend/modules/job/templates/showSuccess.php -->
<a href="<?php echo url_for('job_edit', $job) ?>">Edit</a>
We have also changed the requirements for the
token
column as the symfony default requirements is\d+
for the primary key.
The Preview Page
The preview page is the same as the job page display. Thanks to the routing,
if the user comes with the right token, it will be accessible in the token
request parameter.
If the user comes in with the tokenized URL, we will add an admin bar at the
top. At the beginning of the showSuccess
template, add a partial to host the
admin bar and remove the edit
link at the bottom:
<!-- apps/frontend/modules/job/templates/showSuccess.php --> <?php if ($sf_request->getParameter('token') == $job->getToken()): ?> <?php include_partial('job/admin', array('job' => $job)) ?> <?php endif; ?>
Then, create the _admin
partial:
<!-- apps/frontend/modules/job/templates/_admin.php --> <div id="job_actions"> <h3>Admin</h3> <ul> <?php if (!$job->getIsActivated()): ?> <li><?php echo link_to('Edit', 'job_edit', $job) ?></li> <li><?php echo link_to('Publish', 'job_edit', $job) ?></li> <?php endif; ?> <li><?php echo link_to('Delete', 'job_delete', $job, array('method' => 'delete', 'confirm' => 'Are you sure?')) ?></li> <?php if ($job->getIsActivated()): ?> <li<?php $job->expiresSoon() and print ' class=" expires_soon"' ?>> <?php if ($job->isExpired()): ?> Expired <?php else: ?> Expires in <strong><?php echo $job->getDaysBeforeExpires() ?></strong> days <?php endif; ?> <?php if ($job->expiresSoon()): ?> - <a href="">Extend</a> for another <?php echo sfConfig::get('app_active_days') ?> days <?php endif; ?> </li> <?php else: ?> <li> [Bookmark this <?php echo link_to('URL', 'job_show', $job, true) ?> to manage this job in the future.] </li> <?php endif; ?> </ul> </div>
There is a lot of code, but most of the code is simple to understand. The admin bar is different, depending on the job status:
To make the template more readable, we have added a bunch of shortcut methods
in the JobeetJob
class:
// lib/model/JobeetJob.php public function getTypeName() { return $this->getType() ? JobeetJobPeer::$types[$this->getType()] : ''; } public function isExpired() { return $this->getDaysBeforeExpires() < 0; } public function expiresSoon() { return $this->getDaysBeforeExpires() < 5; } public function getDaysBeforeExpires() { return floor(($this->getExpiresAt('U') - time()) / 86400); }
Job Activation and Publication
In the previous section, there is a link to publish the job. The link needs to
be changed to point to a new publish
action. Instead of creating a new
route, we can just configure the existing job
route:
# apps/frontend/config/routing.yml job: class: sfPropelRouteCollection options: model: JobeetJob column: token object_actions: { publish: put } requirements: token: \w+
The object_actions
takes an array of additional actions for the given
object. We can now change the link of the "Publish" link:
<!-- apps/frontend/modules/job/templates/_admin.php --> <li> <?php echo link_to('Publish', 'job_publish', $job, array('method' => 'put')) ?> </li>
The last step is to create the publish
action:
// apps/frontend/modules/job/actions/actions.class.php public function executePublish(sfWebRequest $request) { $request->checkCSRFProtection(); $job = $this->getRoute()->getObject(); $job->publish(); $this->getUser()->setFlash('notice', sprintf('Your job is now online for %s days.', sfConfig::get('app_active_days'))); $this->redirect($this->generateUrl('job_show_user', $job)); }
As we have enabled the CSRF protection, the link_to()
helper embeds a CSRF
token in the link and the checkCSRFProtection()
method of the request object
checks the validity of it on submision.
The executePublish()
method uses a new publish()
method that can be
defined as follows:
// lib/model/JobeetJob.php public function publish() { $this->setIsActivated(true); $this->save(); }
You can now test the new publish feature in your browser.
But we still have something to fix. The non-activated jobs must not be
accessible, which means that they must not show up on the Jobeet homepage, and
must not be accessible by their URL. As we have created an
addActiveJobsCriteria()
method to restrict a Criteria
to active jobs, we
can just edit it and add the new requirements at the end:
// lib/model/JobeetJobPeer.php static public function addActiveJobsCriteria(Criteria $criteria = null) { // ... $criteria->add(self::IS_ACTIVATED, true); return $criteria; }
That's all. You can test it now in your browser. All non-activated jobs have disappeared from the homepage; even if you know their URLs, they are not accessible anymore. They are, however, accessible if one knows the job's tokent URL. In that case, the job preview will show up with the admin bar.
That's one of the great advantage of the MVC pattern and the refactorisation we have done along the way. Only a single change in one method was needed to add the new requirement.
When we created the
getWithJobs()
method, we forgot to use theaddActiveJobsCriteria()
method. So, we need to edit it and add the new requirement:class JobeetCategoryPeer extends BaseJobeetCategoryPeer { static public function getWithJobs() { // ... $criteria->add(JobeetJobPeer::IS_ACTIVATED, true); return $criteria; }
See you Tomorrow
Today's tutorial was packed with a lot of new information, but hopefully you now have a better understanding of symfony's form framework.
I know that some of you noticed that we forgot something today... We have not implemented any test for the new features. Because writing tests is an important part of developing an application, this is the first thing we will do tomorrow.
I justed wanted to verify that what I did so far was ok (it works, but just wanted to do a diff) but the svn server doesn't seem accessable without username and password. If I change to https I come into the normal symfony svn.
Please somebody update the links on the Jobeet page because currently link to this day isn't there
very nice!
little typo in the template: class=" expires_soon"
there is a preceding whitespace inside the class attribute
A couple of question:
2.- after the Publish but before the Show it gives me this Error for like a second
"Warning: Cannot modify header information - headers already sent by (output started at C:\dev\sf\jobeet\apps\frontend\lib\Jobeet.class.php:1) in C:\dev\sf\jobeet\lib\vendor\symfony\lib\response\sfWebResponse.class.php on line 335
Warning: Cannot modify header information - headers already sent by (output started at C:\dev\sf\jobeet\apps\frontend\lib\Jobeet.class.php:1) in C:\dev\sf\jobeet\lib\vendor\symfony\lib\response\sfWebResponse.class.php on line 349
Warning: Cannot modify header information - headers already sent by (output started at C:\dev\sf\jobeet\apps\frontend\lib\Jobeet.class.php:1) in C:\dev\sf\jobeet\lib\vendor\symfony\lib\response\sfWebResponse.class.php on line 349"
any helps?
2 Gianko about 1-st i left doSelect too. about 2-nd take look to line 1 of C:\dev\sf\jobeet\apps\frontend\lib\Jobeet.class.php looks like here left spaces or other chars before tag <?php
2 Gianko
actually strange path for Jobeet class. expected that it will be in the C:\dev\sf\jobeet\lib\Jobeet.class.php not in the app libs, as this class probably will be used on backend too.
Doctrine version is missing, right ?
@Iking:
it seems that i have that file duplicated, but if i delete the C:\dev\sf\jobeet\apps\frontend\lib\Jobeet.class.php it throws me a fatal error.
well... i'm lost now :(
@Gianko: Have you tried a 'symfony cc' after deleting the extra one?
god...i actually now think that making a form with plain html and some php it's much simpler...i;m really happy it;s just a subframework and i can just not use it
yeah! that was it... i forget the CC
now everythng is Ok.
thanks @Iking and @Harro
This tutorial Rocks!
@Mad: For simple websites it's a bit overkill, but for big projects with over 200 forms, 2 designers and 5 programmers all working on them And then customer calling after 6 months wanting something changed that requires a whole different set of validators in a form someone else made. Then you'll be happy that you have the forms framework.
This was a lot of information at once, but it is still OK. 2 remarks :
Some of you might want to use instead of writing directly , in order to avoid validation problems in your IDE (PHPEclipse for instance checks HTML and I hate warnings :P).
Humm.. Not sure but, with this way of writing forms, it seems that the framework has all the informations needed to also provide client-side input validators (ie Javascript validation). Which would of course be great and give a lovely feeling to the users (if provided with graceful degradation...). Is there anything in symfony 1.2 that enables it without writing a lot more code ? Any plugin maybe ?
I like the sfPropelRouteCollection and the concept of admin bar when accessing the object with its token. :)
Does this limit you to having your forms in a table instead of div statements and using CSS to style them? Also what about attaching javascripts to certain fields. For example on my symfony 1.0 site, I am using auto complete in several fields, and others I create more fields based on the fact if all the fields are in use and another field is needed.
This is an awesome tutorial. Keep up the good work.
For those who want their delete function to work i've changed following things:
job: class: sfPropelRouteCollection options: model: JobeetJob column: token object_actions: { publish: put, delete: delete } requirements: token: \w+
an then in the actions.class.php (executeDelete)
$request->checkCSRFProtection(); $job = $this->getRoute()->getObject(); $job->delete(); $this->getUser()->setFlash('notice', sprintf('Your job has been deleted.')); $this->redirect('job/index');
and then override the method delete from the BaseJobeetJob in JobeetJob
$logo = sfConfig::get('sf_upload_dir').'/jobs'.$this->getLogo(); unlink($logo); return parent::delete($con);
What about Doctrine version of this article? Why Doctrine version is so out of date?