Caution: You are browsing the legacy symfony 1.x part of this website.

第十天:表单(Forms)

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表示一个选择控件,它可以根据配置选项(通过expandedmultiple参数不同搭配)的不同,被显示为不同的控件:

  • 生成下拉列表(<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),你可以在数据库架构中改变默认值。

虽然你认为不会有人提交无效值,但黑客可以利用curlFirefox 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.phpeditSuccess.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方法为POSTPUT。 如果表单中存在文件选择框,这个辅助函数也可以设置enctype属性为multipart

最后,<?php echo $form ?>显示表单控件:

sidebar

自定义表单样式

<?php echo $form ?>默认会以表格形式显示表单。

多少情况下,你可能需要自己定制表单布局。表单对象为定制提供许多有用的方法:

方法 描述
render() 显示表单(相当于echo $form
renderHiddenFields() 显示隐藏的字段
hasErrors() 如果表单有错误,返回true
hasGlobalErrors() 如果标有全局错误,返回true
getGlobalErrors() 返回全局错误数组
renderGlobalErrors() 显示全局错误

表单对象就像一个字段数组。你可以用$form['company']访问company字段,返回的对象提供显示这个字段每一个元素的方法:

方法 描述
renderRow() 显示表单域行。(包括labelerrorfield tag等全部)
render() 显示字段控件
renderLabel() 显示字段标签
renderError() 如果有子段错误则显示
renderHelp() 显示字段帮助信息

echo $form语句相当于:

<?php foreach ($form as $widget): ?>
  <?php echo $widget->renderRow() ?>
<?php endforeach; ?>

表单动作

现在我们有表单类和显示它的模板,是时候创建动作了。

工作表单由job模块中5个方法管理:

  • new: 显示新建招聘的空白表单
  • edit: 显示招聘信息编辑表单
  • create: 将提交的信息生成一条招聘信息
  • update: 用提交的信息更新一条招聘信息
  • processForm: 供createupdate调用,用来处理表单(验证、表单重填、信息入库)

所有表单对象具有下面的生命周期(life-cycle):

Form flow

因为我们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()方法可以更改动作的模板。如果提交的表单不正确,createupdate方法 使用相同的模板,因为newedit动作各自重显带有错误信息的表单。

修改已有招聘信息也是否非常相似。newedit动作之间唯一的不同是,被修改的招聘 信息对象作为表单构造函数的第一参数。这个对象会被用于模板中默认控件值(默认值一 个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:

Not activated job

Activated job

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的表单框架。

大家应该注意到了我们今天忘记做一些事情…我们没有给新功能测试。因为写测试是编程的重要部分,我们明天第一件事就做测试。