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

Ngày 10: Form

Tóm tắt

Tuần thứ 2 bắt đầu bằng việc giới thiệu về symfony test framework. Hôm nay chúng ta sẽ tiếp tục với form framework.

Form Framework

Bất kì website nào cũng có các form; từ form contact đơn giản đến các form phức tạp với rất nhiều trường. Viết code cho các form là một trong những việc rắc rối và kinh khủng đối với lập trình viên: bạn cần phải viết HTML form, thực hiện việc validation cho mỗi trường, lưu các giá trị vào trong database, hiển thị thông báo lỗi, hiển thị giá trị nhập lỗi, và còn rất nhiều việc nữa...

Tất nhiên, thay vì việc lặp lại tất cả những công việc trên, symfony cung cấp một framework để dễ dàng quản lý form. Form framework gồm 3 thành phần:

  • validation: validation sub-framework cung cấp các lớp để kiểm tra dữ liệu nhập vào (integer, string, email address, ...)
  • widgets: widget sub-framework cung cấp các lớp để trả về mã HTML (input, textarea, select, ...)
  • forms: các lớp form mô tả về các form được tạo từ widget và validator, và nó cũng cung cấp các phương thức để quản lý form. Mỗi form field đã bao gồm validator và widget.

Forms

Một symfony form là một lớp được tạo bởi các field. Mỗi field có tên, validator, và một widget. Một ContactForm đơn giản có thể được xác định bởi lớp sau:

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)),
    ));
  }
}

Các form field được cấu hình trong phương thức configure(), bằng các phương thức setValidators()setWidgets().

tip

Form framework có sẵn rất nhiều widgetsvalidators. Bạn có thể tham khảo API để xem tất cả các option, error, và default error message của chúng.

Tên của các lớp widget và validator rất rõ ràng: field email sẽ được render thành một HTML <input> tag (sfWidgetFormInput) và được validate như một địa chỉ email (sfValidatorEmail). Field message sẽ được render thành một <textarea> tag (sfWidgetFormTextarea), và phải là một chuỗi không có quá 255 kí tự (sfValidatorString).

Mặc định tất cả các field đều là bắt buộc, tương đương với giá trị của option requiredtrue. Vì thế, validation cho field email tương đương với cách viết new sfValidatorEmail(array('required' => true)).

tip

Bạn có thể nhúng một form vào một form khác bằng phương thức mergeForm(), hoặc embedForm():

$this->mergeForm(new AnotherForm());
$this->embedForm('name', new AnotherForm());

Doctrine Forms

Trong đa số các trường hợp, một form thường gồm các trường trong database. Symfony đã biết mọi thứ về database model, do đó nó có thể tự động tạo ra các form dựa trên những thông tin này. Khi bạn thực thi lệnh doctrine:build-all trong ngày 3, symfony đã tự động gọi lệnh doctrine:build-forms:

$ php symfony doctrine:build-forms

Lệnh doctrine:build-forms tạo ra các form classe trong thư mục lib/form/. Tổ chức các file này tương tự như trong thư mục lib/model. Mỗi model class tương ứng với một form class (ví dụ JobeetJob ứng với JobeetJobForm), các lớp này chưa có gì và thừa kế từ base class:

// lib/form/doctrine/JobeetJobForm.class.php
class JobeetJobForm extends BaseJobeetJobForm
{
  public function configure()
  {
  }
}

tip

Xem các file được tạo ra trong thư mục lib/form/doctrine/base/ , bạn sẽ thấy rất nhiều ví dụ hữu ích về các widget và validator có sẵn trong symfony..

Customizing the Job Form

Chúng ta sẽ học cách chỉnh sửa form thông qua job form. Chúng ta sẽ thực hiện nó từng bước một.

Đầu tiên, đổi link "Post a Job" ở layout:

<!-- apps/frontend/templates/layout.php -->
<a href="<?php echo url_for('@job_new') ?>">Post a Job</a>

Mặc định, một Doctrine form hiển thị tất cả các trường trong bảng. Nhưng với job form, một vài trường không được chỉnh sửa bởi người dùng. Bỏ những trường này ra khỏi form bằng cách unset chúng:

// 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']
    );
  }
}

Unset một field có nghĩa là cả field widget và validator cũng được bỏ.

Form configuration phải cụ thể hơn những gì được mô tả trong database schema. Ví dụ, cột email có kiểu là varchar trong schema, nhưng chúng ta cần cột này được validate như một email. Đổi validate mặc định sfValidatorString thành sfValidatorEmail:

// lib/form/doctrine/JobeetJobForm.class.php
public function configure()
{
  // ...
 
  $this->validatorSchema['email'] = new sfValidatorEmail();
}

Cột type cũng có kiểu là varchar trong schema, nhưng chúng ta muốn giá trị của nó chỉ là: full time, part time, hoặc freelance.

Đầu tiên, hãy định nghĩa các giá trị trong 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;
  }
 
  // ...
}

Sau đó, dùng sfWidgetFormChoice cho widget type:

$this->widgetSchema['type'] = new sfWidgetFormChoice(array(
  'choices'  => Doctrine::getTable('JobeetJob')->getTypes(),
  'expanded' => true,
));

sfWidgetFormChoice mô tả một choice widget có thể được render thành các widget khác nhau tùy thuộc vào một vài configuration options (expandedmultiple):

  • 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)

note

Nếu bạn muốn một radio button được selecte mặc định (ví dụ full-time ), bạn có thể đổi giá trị mặc định trong database schema.

Mặc dù bạn nghĩ rằng không thể submit một giá trị khác, nhưng một hacker có thể bypass dễ dàng một widget choices bằng cách sử dụng các công cụ như curl hoặc Firefox Web Developer Toolbar. Hãy sửa lại validator để bắt buộc giá trị phải trong các lựa chọn đưa ra:

$this->validatorSchema['type'] = new sfValidatorChoice(array(
  'choices' => array_keys(Doctrine::getTable('JobeetJob')->getTypes()),
));

Cột logo sẽ chứa tên của file ảnh logo, chúng ta cần đổi widget thành một file input tag:

$this->widgetSchema['logo'] = new sfWidgetFormInputFile(array(
  'label' => 'Company logo',
));

Với mỗi field, symfony tự động tạo ra một label (được dùng khi render <label> tag). Ta có thể thay đổi nó với label option.

Bạn cũng có thể thay đổi nhiều label một lúc bằng cách cung cấp một mảng cho phương thức setLabels():

$this->widgetSchema->setLabels(array(
  'category_id'    => 'Category',
  'is_public'      => 'Public?',
  'how_to_apply'   => 'How to apply?',
));

Chúng ta cũng cần thay đổi validator mặc định:

$this->validatorSchema['logo'] = new sfValidatorFile(array(
  'required'   => false,
  'path'       => sfConfig::get('sf_upload_dir').'/jobs',
  'mime_types' => 'web_images',
));

sfValidatorFile thực sự thú vị, nó thực hiện một số công việc:

  • Validates file upload lên phải là ảnh (mime_types)
  • đổi tên file thành duy nhất
  • lưu file vào path được chỉ ra
  • cập nhật cột logo với tên file tạo ra

note

Bạn cần tạo thư mục logo (web/uploads/jobs) và chắc rằng web server có quyền ghi vào thư mục này.

Validator sẽ lưu tên file vào database, sửa lại logo hiển thị trong template showSuccess:

// apps/frontend/modules/job/template/showSuccess.php
<img src="/uploads/jobs/<?php echo $job->getLogo() ?>" alt="<?php echo $job->getCompany() ?> logo" />

tip

Nếu một phương thức generateLogoFilename() đã tồn tại trong form, validator sẽ gọi phương thức này và override kết quả mặc định. Phương thức này nhận sfValidatedFile object làm tham số.

Tương tự như label, bạn cũng có thể tạo một help message. Thêm một help message cho cột is_public để mô tả ý nghĩa của cột này:

$this->widgetSchema->setHelp('is_public', 'Whether the job can also be published on affiliate websites or not.');

Lớp JobeetJobForm sau khi đã chỉnh sửa:

// 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 sfValidatorEmail();
 
    $this->widgetSchema['type'] = new sfWidgetFormChoice(array(
      'choices' => array_keys(Doctrine::getTable('JobeetJob')->getTypes()),
      '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.');
  }
}

Form Template

Bây giờ form class đã được chỉnh sửa. chúng ta cần hiển thị nó. Template cho form này là nơi bạn muốn tạo một công việc mới hoặc sửa một công việc đã có. Cả 2 template newSuccess.phpeditSuccess.php đều tương tự nhau:

<!-- apps/frontend/modules/job/templates/newSuccess.php -->
<?php use_stylesheet('job.css') ?>
 
<h1>Post a Job</h1>
 
<?php include_partial('form', array('form' => $form)) ?>

Form được render trong partial _form. Thay nội dung được tạo ra trong partial _form bằng đoạn code sau:

<!-- 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>

2 helper include_javascripts_for_form()include_stylesheets_for_form() include JavaScript và stylesheet cần thiết cho form widgets.

tip

Mặc dù job form không cần bất kì file JavaScript hay stylesheet nào, nhưng ta vẫn gọi những helper phòng khi cần tới. Ví dụ khi bạn muốn thay đổi một widget yêu cầu một vài JavaScript hay stylesheet.

Helper form_tag_for() tạo ra một <form> tag ứng với các tham số form và route và thay đổi HTTP methods thành POST hoặc PUT phụ thuộc vào object là new hay không. Nó cũng tự thêm multipart attribute nếu form có file input tags.

Cuối cùng, <?php echo $form ?> render các form widget.

sidebar

Customizing the Look and Feel of a Form

Mặc định, <?php echo $form ?> render các form widget theo dạng table.

Trong nhiều trường hợp, bạn cần thay đổi giao diện của forms. Form object cung cấp nhiều phương thức hữu ích cho việc này:

Method Mô tả
render() Renders the form (equivalent to the output of
echo $form)
renderHiddenFields() Renders the hidden fields
hasErrors() Returns true if the form has some errors
hasGlobalErrors() Returns true if the form has global errors
getGlobalErrors() Returns an array of global errors
renderGlobalErrors() Renders the global errors

Form cũng giống như một mảng các field. Bạn có thể truy cập field company với $form['company']. Đối tượng trả về cung cấp các phương thức đế render từng thành phần của field:

Method Mô tả
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

Câu lệnh echo $form tương đương với:

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

Form Action

Chúng ta đã có một form class và một template được render. Bây giờ chúng ta cần thực hiện các công việc xử lý với một vài action.

Job form được quản lý bởi 5 phương thức trong module job:

  • new: hiển thị form để tạo công việc
  • edit: hiển thị form để sửa công việc
  • create: tạo công việc mới khi user submit
  • update: sửa công việc đã có khi user submit
  • processForm: gọi bởi createupdate, thực thi các công việc: validation, form repopulation, and serialization to the database

Các form có vòng đời như sau:

Form flow

Chúng ta đã tạo một Doctrine route collection ở ngày 5 cho module job, code của các phương thức quản lý form như sau:

// 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));
  }
}

KHi bạn truy cập trang /job/new, một form instance được tạo ra và truyền cho template (new action).

Khi user submit form (create action), giá trị user cung cấp được truyền vào (bind() method) và validation.

Khi form ở trạng thái bound, nó được validate và qua phương thức isValid() ta biết được form được valid (returns true) hay không, nếu valid job sẽ được lưu vào database ($form->save()), và user được chuyển sang trang job preview; nếu không, template newSuccess.php sẽ được hiển thi với giá trị user đã submit kèm thông báo lỗi.

tip

Phương thức setTemplate() thay đổi template dùng cho action. Nếu submitted form không valid, phương thức createupdate dùng các template của newedit action để hiển thị lại form với thông báo lỗi.

Chỉnh sửa một công việc có sẵn hoàn toàn tương tự. Chỉ có một điểm khác duy nhất giữa newedit action là job object để sửa phải được cung cấp trong phương thức khởi tạo của form. Đối tượng này sẽ được sử dụng cho các giá trị mặc định của widget trong template (các giá trị mặc định là một object đối với Doctrine forms, nhưng là một mảng trong trường hợp form đơn giản).

Bạn cũng có thể tự xác định các giá trị mặc định trong creation form. Cách đầu tiên là khai báo các giá trị mặc định này trong database schema. Cách khác là cung cấp Job object cho phương thức khởi tạo form:

// 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

Khi form ở trạng thai bound, giá trị mặc định được thay thế bởi giá trị user submitted. Giá trị user submit sẽ được dùng để hiển thị lại trong trường hợp có lỗi.

Bảo vệ Job Form với một Token

Mọi thứ hiện đều hoạt động tốt. Nhưng có một vấn đề. Đầu tiên, job token phải được tạo tự động khi một job mới được tạo, chứ chúng ta không muốn user cung cấp giá trị này. Update phương thức save() của JobeetJob:

// 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($con);
}

Chúng ta cũng bỏ token field ra khỏi form:

// 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']
    );
 
    // ...
  }
 
  // ...
}

Nếu bạn nhớ lại kịch bản trong ngày 2, một job có thể được chỉnh sửa chỉ cần user biết được token của nó. Đúng vậy, đó là cách đơn giản để chỉnh sửa hay xoá một công việc dựa trên URL.s

Mặc định, sfDoctrineRouteCollection route tạo ra URLs với primary key, nhưng ta có thể đổi thành bất kì unique column nào bằng cách cung cấp column option:

# apps/frontend/config/routing.yml
job:
  class:        sfDoctrineRouteCollection
  options:      { model: JobeetJob, column: token }
  requirements: { token: \w+ }

Bây giờ, tất cả các route, ngoại trừ job_show_user, đều chưas token. Ví dụ, route đề sửa một jobs:

http://jobeet.localhost/job/TOKEN/edit

Bạn cũng cần sửa lại link "Edit" trong template showSuccess:

<!-- apps/frontend/modules/job/templates/showSuccess.php -->
<a href="<?php echo url_for('job_edit', $job) ?>">Edit</a>

note

Chúng ta cũng thay đổi requirements cho cột token thay cho requirements mặc định \d+ đối với primary key.

Trang Preview

Trang preview tương tự như trang hiển thị một job. Nhờ có routing, một user có thể truy cập nếu cung cấp đúng tokens.

Nếu user truy cập dựa trên tokenized URL, chúng ta sẽ thêm một admin bar ở đầu. Ở đầu showSuccess template, thêm một partial để chứa admin bar và bỏ link edit ở cuối đi:

<!-- 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; ?>

Sau đó, tạo _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>

Có rất nhiều code, nhưng phần lớn đều đơn giản và dễ hiểu. Admin bar thay đối phụ thuộc vào trang thái công việc:

Not activated job

Activated job

Để template dễ đọc, ta thêm các methods vào JobeetJob class:

// 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(($this->getExpiresAt('U') - time()) / 86400);
}

Job Activation and Publication

Trong mục trước, có một link để publish một công việc. Link cần thay đổi để trỏ tới publish action. Thay vì tạo một route mới, chúng ta có thể sửa lại route job:

# apps/frontend/config/routing.yml
job:
  class:   sfDoctrineRouteCollection
  options:
    model:          JobeetJob
    column:         token
    object_actions: { publish: put }
  requirements:
    token: \w+

object_actions nhận một mảng các action thêm vào. Bây giờ, chúng ta sửa lại link "Publish":

<!-- apps/frontend/modules/job/templates/_admin.php -->
<li>
  <?php echo link_to('Publish', 'job_publish', $job, array('method' => 'put')) ?>
</li>

Cuối cùng tạo 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));
}

Chúng ta đã enabled CSRF protection, helper link_to() chứa một CSRF token và phương thức checkCSRFProtection() của request object kiểm trae validity của giá trị submit.

Phương thức executePublish() sử dụng publish() method:

// lib/model/doctrine/JobeetJob.class.php
public function publish()
{
  $this->setIsActivated(true);
  $this->save();
}

Bây giờ bạn có thể test tính năng publish trên trình duyệt.

Nhưng chúng ta vẫn có một vài thứ cần sửa. Các công việc non-activated phải không được truy cập, nghĩa là chúng phải không được hiển thị ở trang chủ, và không được truy cập thông qua URL. Chúng ta đã tạo phương thức addActiveJobsQuery để lấy các active jobs, chúng ta cần sửa lại nó:

// lib/model/doctrine/JobeetJobTable.class.php
public function addActiveJobsQuery(Doctrine_Query $q = null)
{
  // ...
 
  $q->andWhere($alias . '.is_activated = ?', 1);
 
  return $q;
}

Bây giờ, bạn có thể kiểm tra lại trên trình duyệt. Tất cả các công việc non-activated đã biến mất khỏi trang chủ; và ngay cả khi chúng ta biết URLs của chúng, chúng ta cũng không thể truy cập được. Tuy nhiên, chúng ta vẫn có thể truy cập được nếu dựa trên token URL. Khi đó công việc sẽ được hiển thị kèm với admin bar.

Đó là một trong những lợi ích của MVC pattern. Chúng ta chỉ cần một thay đổi nhỏ trong một phuơng thức để thêm một tính năng mới.

note

khi tạo phương thức getWithJobs(), chúng ta đã không sử dụng addActiveJobsQuery. Do đó, chúng ta cần sửa lại phương thức này:

class JobeetCategoryTable extends Doctrine_Table
{
  public function getWithJobs()
  {
    // ...
 
    $q->andWhere('j.is_activated = ?', 1);
 
    return $q->execute();
  }

Hẹn gặp lại ngày mai

Hôm nay chúng ta đã học rất nhiều kiến thức mới, hi vọng bạn đã hiểu về symfony's form framework.

Có thể bạn đã nhận thấy rằng chúng ta đã quên một vài thứ hôm nay... Chúng ta đã không viết bất kì test nào cho tính năng mới. Bởi vì viết test là một phần quan trọng trong việc phát triển một ứng dụng, chúng ta sẽ làm nó vào ngày mai.