Jobeet第二周我们引入的symfony测试框架,这是一个良好的开端。我们今天将学习表单。
表单框架
几乎所有网站都有使用表单,从简单的联系表单到有十几个字段的复杂表单。对开发者 来说编写表单也是一项复杂而单调的工作:你需要写HTML代码,为每个自动添加验证, 将字段值存储到数据库,显示错误信息,出错后自动重载表单内容等等。
为了不一遍一遍的重复制造轮子,symfony提供一个表单框架简化表单管理。form框架 由3部分组成:
- 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.
验证(validation):validation子框架提供验证表单输入(比如 验证email格式是否正确)的类
- 控件(widgets): widget子框架提供生成字段HTML(如生成一个input 输入框)的类
- 表单类(forms): form类表示由控件和验证器组成的表单,提供帮助 管理表单的方法。每个表单字段都有自己验证器和和控件。
表单
symfony表单是由field字段组成的类,每个字段都有一个名字、一个验证器(validator) 和一个控件(widget)组成,一个简单的联系表单可以定义为下面的类:
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)), )); } }
表单的字段在configure()
方法中通过setValidators()
和setWidgets()
两个方法配置。
tip
表单框架附带很多控件(widgets) 和验证器(validators), 这个API内容十分广泛,包含选项、错误和默认错误信息。
控件和验证器类的命名十分明了: email
字段显示一个HTML<input>
标记(sfWidgetFormInput
),
验证是否符合email格式(sfValidatorEmail
);message
字段显示一个HTML<textarea>
标记(sfWidgetFormTextarea
),
并且必须是长度小于255的字符串(sfValidatorString
)。
默认所有字段都是必填的,因为required
选项的默认值为true
。所以email
字段的
验证定义等同于sfValidatorEmail(array(’required’ => true))
。
tip
可以通过mergeForm()
方法将一个表单与另一个表单合并,或用embedForm()
方法进行表单嵌套:
$this->mergeForm(new AnotherForm()); $this->embedForm('name', new AnotherForm());
Doctrine表单
大多数情况下,表单会存储到数据库。因为symfony了解你数据库模型的全部信息,
它可以基于这些信息自动生成表单。事实上,在第3天启动doctrine:build-all
命令,
symfony自动调用了doctrine:build-forms
:
$ php symfony doctrine:build-forms
启动doctrine:build-forms
命令会在lib/form/
目录下生成表单类。这些生成的
文件组织结构有点像lib/model
目录(下文件结构)。每个model类都有一个对应的
表单类(例如JobeetJob
对应一个JobeetJobForm
),这些表单类默认是空的,
因为它继承自base类:
// lib/form/doctrine/JobeetJobForm.class.php class JobeetJobForm extends BaseJobeetJobForm { public function configure() { } }
tip
浏览lib/form/doctrine/base/
子目录下生成的文件,你会看见许多关于symfony内建控件和验证器的、非常棒的应用实例。
定制工作信息表单
工作表单是学习表单定制(Customization)的非常好的例子。下面我们一步一步学习如何定制一个工作表单。
首先,修改layout.php
中”Post a Job”链接:
<!-- apps/frontend/templates/layout.php -->
<a href="<?php echo url_for('@job_new') ?>">Post a Job</a>
默认情况下,Doctrine表单显示的字段对应全部数据表字段,我们要清除(unset)其中不可以由终端用户编辑的字段:
// lib/form/doctrine/JobeetJobForm.class.php class JobeetJobForm extends BaseJobeetJobForm { public function configure() { unset( $this['created_at'], $this['updated_at'], $this['expires_at'], $this['is_activated'] ); } }
清除一个字段意味着这个字段的控件和验证器都会被移除。
这个表单配置有时必须比那些可以从数据架构中内省出来的东西更加精确。例如,
数据表中email
字段是varchar
类型(自动使用了sfValidatorString
字符串验证方法),
但我们需要的是sfValidatorEmail
(验证email),让我们修改一下:
// lib/form/doctrine/JobeetJobForm.class.php public function configure() { // ... $this->validatorSchema['email'] = new sfValidatorEmail(); }
Replacing the default validator is not always the best solution, as the
default validation rules introspected from the database schema are lost
(new sfValidatorString(array('max_length' => 255))
). It is almost always
better to add the new validator to the existing ones by using the special
sfValidatorAnd
validator:
// lib/form/doctrine/JobeetJobForm.class.php public function configure() { // ... $this->validatorSchema['email'] = new sfValidatorAnd(array( $this->validatorSchema['email'], new sfValidatorEmail(), )); }
The sfValidatorAnd
validator takes an array of validators that must pass for
the value to be valid. The trick here is to reference the current validator
($this->validatorSchema['email']), and to add the new one.
note
You can also use the sfValidatorOr
validator to force a value to
pass at least one validator. And of course, you can mix and match
sfValidatorAnd
and sfValidatorOr
validators to create complex
boolean based validators.
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.
还有,type
字段虽然也是varchar
类型,但我们希望它的值被限制在一定的选择范围内:
full time、part time或 freelance。
首先,我们修改JobeetJobTable
:
// lib/model/doctrine/JobeetJobTable.class.php class JobeetJobTable extends Doctrine_Table { static public $types = array( 'full-time' => 'Full time', 'part-time' => 'Part time', 'freelance' => 'Freelance', ); public function getTypes() { return self::$types; } // ... }
然后,使用sfWidgetFormChoice
:
$this->widgetSchema['type'] = new sfWidgetFormChoice(array( 'choices' => Doctrine::getTable('JobeetJob')->getTypes(), 'expanded' => true, ));
sfWidgetFormChoice
表示一个选择控件,它可以根据配置选项(通过expanded
和
multiple
参数不同搭配)的不同,被显示为不同的控件:
- 生成下拉列表(
<select>
):array(’multiple’ => false, ‘expanded’ => false)
- 生成下拉框(
<select multiple=”multiple”>
):array(’multiple’ => true, ‘expanded’ => false)
- 生成单选框(
<input type=radio>
):array(’multiple’ => false, ‘expanded’ => true)
- 生成复选框(
<input type=checkbox>
):array(’multiple’ => true, ‘expanded’ => true)
note
如果你想单选框中的一个默认选中(如full-time
),你可以在数据库架构中改变默认值。
虽然你认为不会有人提交无效值,但黑客可以利用curl或 Firefox Web Developer Toolbar一类工具, 轻易地绕过控件设置的选项。让我们更换验证器限制可接受的选项:
$this->validatorSchema['type'] = new sfValidatorChoice(array( 'choices' => array_keys(Doctrine::getTable('JobeetJob')->getTypes()), ));
因为logo
字段将存储与工作关联的logo文件名,我们需要将控件更换为文件选择框控件:
$this->widgetSchema['logo'] = new sfWidgetFormInputFile(array( 'label' => 'Company logo', ));
symfony自动为每个字段生成一个标签(label
,用于显示<label>
标记),这个可以通过label
选项更改。
你也可以用setLabels()
方法批量更改标签:
$this->widgetSchema->setLabels(array( 'category_id' => 'Category', 'is_public' => 'Public?', 'how_to_apply' => 'How to apply?', ));
我们同样也要修改logo默认验证器:
$this->validatorSchema['logo'] = new sfValidatorFile(array( 'required' => false, 'path' => sfConfig::get('sf_upload_dir').'/jobs', 'mime_types' => 'web_images', ));
sfValidatorFile
非常有趣,它可以做许多事:
- 验证上传的文件是不是web格式(
mime_types
)图片 - 给文件重命名(唯一)
- 存储文件到指定路径
- 用生成的名更新
logo
字段
note
你需要创建logo目录(web/uploads/jobs
),并保证目录可写。
因为验证器会保存相对路径到数据库,更换showSuccess.php
模板中路径:
// apps/frontend/modules/job/templates/showSuccess.php <img src="/uploads/jobs/<?php echo $job->getLogo() ?>" alt="<?php echo $job->getCompany() ?> logo" />
tip
如果表单中存在generateLogoFilename()
,它会先被验证器调用,它返回值将覆盖
默认生成的logo名。这个方法作为sfValidatedFile
对象的一个参数。
就像覆盖生成的标签一样,你可以定义一个帮助(help)信息,我们给is_public
添加一条帮助信息:
$this->widgetSchema->setHelp('is_public', 'Whether the job can also be published on affiliate websites or not.');
经过上面的修改的JobeetJobForm
:
// lib/form/doctrine/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 sfValidatorAnd(array( $this->validatorSchema['email'], new sfValidatorEmail(), )); $this->widgetSchema['type'] = new sfWidgetFormChoice(array( 'choices' => Doctrine::getTable('JobeetJob')->getTypes(), 'expanded' => true, )); $this->validatorSchema['type'] = new sfValidatorChoice(array( 'choices' => array_keys(Doctrine::getTable('JobeetJob')->getTypes()), )); $this->widgetSchema['logo'] = new sfWidgetFormInputFile(array( 'label' => 'Company logo', )); $this->widgetSchema->setLabels(array( 'category_id' => 'Category', 'is_public' => 'Public?', 'how_to_apply' => 'How to apply?', )); $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.'); } }
表单模板
现在表单类已经定制好了,我们需要显示它。无论你想创建一个招聘信息还是想
编辑一个已有的信息,表单的模板都是一样的。事实上,newSuccess.php
和
editSuccess.php
两个模板非常相似:
<!-- apps/frontend/modules/job/templates/newSuccess.php --> <?php use_stylesheet('job.css') ?> <h1>Post a Job</h1> <?php include_partial('form', array('form' => $form)) ?>
note
If you have not added the job
stylesheet yet, it is time to do so in both
templates (<?php use_stylesheet('job.css') ?>
).
表单本身呈现在_form
局部模板中。用下面的代码替换生成的_form.php
局部模板内容:
<!-- 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>
include_javascripts_for_form()
和include_stylesheets_for_form()
这两个
辅助函数引用表单控件需要的JavaScript和CSS文件。
tip
即使工作表单需要任何JavaScript或CSS文件,保留这两个辅助函数仍不失为一个好习惯, “以防万一”。如果你日后决定更换一个需要JavaScript或CSS支持的控件,它可以 节约你的时间。
form_tag_for()
辅助函数为给定的表单和路由生成一个HTML<form>
标记,并依据
对象是否为新来更改HTTP方法为POST
或PUT
。
如果表单中存在文件选择框,这个辅助函数也可以设置enctype
属性为multipart
。
最后,<?php echo $form ?>
显示表单控件:
表单动作
现在我们有表单类和显示它的模板,是时候创建动作了。
工作表单由job
模块中5个方法管理:
- new: 显示新建招聘的空白表单
- edit: 显示招聘信息编辑表单
- create: 将提交的信息生成一条招聘信息
- update: 用提交的信息更新一条招聘信息
- processForm: 供
create
和update
调用,用来处理表单(验证、表单重填、信息入库)
所有表单对象具有下面的生命周期(life-cycle):
因为我们5天前已经给job
模块创建Doctrine路由集,我们可以简化表单管理方法:
// 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'); } public function executeDelete(sfWebRequest $request) { $request->checkCSRFProtection(); $job = $this->getRoute()->getObject(); $job->delete(); $this->redirect('job/index'); } 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)); } }
当你访问/job/new
时,一个新的表单实例被创建并传送给模板(new
动作)。
当用户提交表单(create
动作)时,用户提交数据绑定到表单对象(bind()
方法),触发验证。
一旦表单对象被绑定,就可以通过isValid()
检查它的正确性:如果正确(返回true
),
这条招聘信息保存到数据库($form->save()
),用户被重定向到信息预览页面。如果不正确,
newSuccess.php
模板会重写显示将用户提交的内容和错误信息。
tip
setTemplate()
方法可以更改动作的模板。如果提交的表单不正确,create
和update
方法
使用相同的模板,因为new
和edit
动作各自重显带有错误信息的表单。
修改已有招聘信息也是否非常相似。new
和edit
动作之间唯一的不同是,被修改的招聘
信息对象作为表单构造函数的第一参数。这个对象会被用于模板中默认控件值(默认值一
个Doctrine表单对象,而不是简单表单的普通数组)。
你也可以为创建的表单定义默认值。一种方法是在数据库架构中声明(修改数据库默认值), 另一种是将一个预修改(pre-modified)的招聘信息对象作为表单构造器函数第一个参数。
Change the executeNew()
method to define full-time
as the default value
for the type
column:
// 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); }
note
对象被绑定时,默认值将被用户提交的内容取代。当验证未通过时,重新显示在 表单中的将是用户值而不是默认值。
用标识保护工作表单
现在我们的代码可以正常工作了。但还有一个问题。首先,标识(token)必须在 招聘信息创建时自动生成,我们不希望用户填写特别的标识,修改JobeetJob.php中 的save()方法:
Update the save()
method of JobeetJob
to add the logic that generates the
token before a new job is saved:
// lib/model/doctrine/JobeetJob.class.php public function save(Doctrine_Connection $conn = null) { // ... if (!$this->getToken()) { $this->setToken(sha1($this->getEmail().rand(11111, 99999))); } return parent::save($conn); }
我们可以从表单中移除标识:
// lib/form/doctrine/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'] ); // ... } // ... }
如果你还记得第2天的User Stories,只有知道标识(token)的用户可以编辑相应的
招聘信息。而现在,任何用户只通过猜测URL,就可以编辑或删除任何招聘。这是因为
编辑招聘信息的URL都类似/job/ID/edit
形式,这里的ID是招聘信息的主键(primany key)。
默认的sfDoctrineRouteCollection
使用主键生成URL,但我们可以通过column
选项,
将它设置为其它任何的唯一字段:
# apps/frontend/config/~routing|Routing~.yml job: class: sfDoctrineRouteCollection options: { model: JobeetJob, column: token } requirements: { token: \w+ }
我们同样要修改requirements的token
值,因为symfony默认requirements是为主键设置的\d+
。
现在,除job_show_user
外所有的路由都嵌入了标识。例如,现在edit的路由形式:
http://jobeet.localhost/job/TOKEN/edit
showSuccess.php
中的“Edit”链接也要修改:
<!-- apps/frontend/modules/job/templates/showSuccess.php -->
<a href="<?php echo url_for('job_edit', $job) ?>">Edit</a>
预览页面
预览页面和显示页面相同。感谢路由,如果用户使用正确的标识,将被允许访问。
如果用户使用带标记的URL,我们将页面顶部添加管理栏。在showSuccess
模板开始处,添加局部模板控制管理栏,删除底部edit
链接:
<!-- 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; ?>
然后,创建_admin.php
局部模板:
<!-- 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>
这里有很多代码,但大部分都比较容易理解。随着招聘信息状态的不同,管理栏显示的内容也不同:
为了让模板更易读,我们在JobeetJob
类中添加了一组快捷方法:
// lib/model/doctrine/JobeetJob.class.php public function getTypeName() { $types = Doctrine::getTable('JobeetJob')->getTypes(); return $this->getType() ? $types[$this->getType()] : ''; } public function isExpired() { return $this->getDaysBeforeExpires() < 0; } public function expiresSoon() { return $this->getDaysBeforeExpires() < 5; } public function getDaysBeforeExpires() { return floor((strtotime($this->getExpiresAt()) - time()) / 86400); }
The admin bar displays the different actions depending on the job status:
note
You will be able to see the "activated" bar after the next section.
招聘信息激活与发布
在上一部分,有一个发布job的链接。这个链接需要指向一个新的发布(publish
)动作。
我们不需要添加新的路由规则,而可以通过修改已有的job
路由规则实现:
# apps/frontend/config/routing.yml job: class: sfDoctrineRouteCollection options: model: JobeetJob column: token object_actions: { publish: put } requirements: token: \w+
object_actions
选项将动作数组传递给对象。我们现在修改"Publish"链接:
<!-- apps/frontend/modules/job/templates/_admin.php --> <li> <?php echo link_to('Publish', 'job_publish', $job, array('method' => 'put')) ?> </li>
最后,创建publish
动作:
// 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)); }
The astute reader will have noticed that the "Publish" link is submitted with the HTTP put method. To simulate the put method, the link is automatically converted to a form when you click on it.
我们激活了CSRF保护,link_to()
辅助函数将CSRF标识(token)嵌入链接中,request对象
的checkCSRFProtection()
方法会在提交的时候验证其合法性。
executePublish()
中的新的publish()
方法可以定义为:
// lib/model/doctrine/JobeetJob.class.php public function publish() { $this->setIsActivated(true); $this->save(); }
你现在可以在浏览器中测试一下新的发布功能。
另外,我们还有些东西要进行修改,未激活招聘信息必须不允许访问,也就是说它们既不能显示在Jobeet主页上,
也不能通过普通URL访问。因为我们已经创建了addActiveJobsQuery()
方法来限制Doctrine_Query
激活招聘信息,
所以我们只需要在它尾部添加新的条件即可:
// lib/model/doctrine/JobeetJobTable.class.php public function addActiveJobsQuery(Doctrine_Query $q = null) { // ... $q->andWhere($alias . '.is_activated = ?', 1); return $q; }
好了,你现在可以在浏览器中测试一下。所有未激活的job已经从首页消失;即使你知道 它们的URL也不会显示内容。但使用带有标识(token)的Url的用户仍然可以访问这些信息, 同时预览页面会显示管理栏。
这是MVC模式(pattern)和重构(refactorization)的优点之一。当需要增加新需求时, 只要单独修改一个方法。
note
当我们创建了getWithJobs()
方法时,我们忘记用addActiveJobsQuery()
方法,所以我们需要给它添加一个条件:
class JobeetCategoryTable extends Doctrine_Table { public function getWithJobs() { // ... $q->andWhere('j.is_activated = ?', 1); return $q->execute(); }
明天见
今天的课程有很多新的知识,但希望你现在已经很好的理解了symfony的表单框架。
大家应该注意到了我们今天忘记做一些事情…我们没有给新功能测试。因为写测试是编程的重要部分,我们明天第一件事就做测试。
This work is licensed under the Creative Commons Attribution-Share Alike 3.0 Unported License license.