Jobeetをやってみる 10日目

Formの作成方法について学ぶ

Form Framework

Form Frameworkは以下の3つで成り立っている

  • validation : 入力値の検証
  • widghets : HTMLタグの出力
  • forms : バリデータとウィジェットを持ったクラス

フォーム

フォームはウィジェットとバリデータを持った、フィールドで構成される
設定に利用する主なメソッド

メソッド名 説明
setWidgets() 複数のウィジェットを定義する
setValidators() 複数のバリデータを定義する
mergeForm(sfForm $form) 他のフォームとマージする
embedForm($name, sfForm $form) 対象のフィールドの設定を指定したフォームから持ってくる

Doctrineフォーム

例により、フォームクラスをsymfonyコマンドから生成することができる

$ ./symfony doctrine:build-forms

でも、これは3日目に実行した doctrine:build-all に含まれているので、改めて実行する必要はない

作成されたクラスは lib/form/doctrine に置かれている。
フロントエンドは空のconfigure()だけ定義されている単純なクラス。
詳細な実装は lib/form/doctrine/base の中にある、各ベースクラスに記述されているので、記述方法の参考に見てみるのもアリ。

フォームのカスタマイズ

フォームクラスのカスタマイズの基本として、ベースファイルは弄らずにフロントエンドのクラスに対して変更を加えること。
ファイル名が書いてないコードは JobeetJobForm::configure() に書き足していくもの

レイアウトの修正
<?php /* lib/form/doctrine/JobeetJobForm.class.php at 29 */ ?>
<a href="<?php echo url_for('@job_new') ?>">Post a Job</a>
作成日、更新日、期日、有効フラグを編集不可にする

unset()をすることで、widgetとvalidatorの両方が削除される

<?php
unset(
  $this['created_at'], $this['updated_at'],
  $this['expires_at'], $this['is_activated']
);
emailのvalidatorをsfValidatorEmailに変更する

emailカラムがvarcharなので、デフォルトではsfValidatorStringが設定されている

<?php
$this->validatorSchema['email'] = new sfValidatorEmail();
typeを選択式にする

typeカラムもvarcharに設定されているので、デフォルトがinputになっている。
選択式にするので、選択肢をJobeetJobTableクラスに持たせる。

<?php
// lib/model/doctrine/JobeetJobTable.class.php
class JobeetJobTable extends Doctrine_Table
{
  protected static $types = array(
    'full-time' => 'Full time',
    'part-time' => 'Part time',
    'freelance' => 'FreeLance',
  );

  public function getTypes()
  {
    return self::$types;
  }

  // ...
}

実装したJobeetJobTable::getTypes()を利用して、選択式に変更する。

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

sfWidgetFormChoiceはオプションのexpandedとmultipleを変更することで、選択の形式を色々変更できる。

形式 オプションの設定
普通のselect array('multiple' => false, 'expanded' => false)
複数選択式select array('multiple' => true, 'expanded' => false)
ラジオボタン array('multiple' => false, 'expanded' => true)
チェックボックス array('multiple' => true, 'expanded' => true)

また、選択式にする場合、これらでも対応できる

  • sfWidgetFormSelect
  • sfWidgetFormSelectCheckbox
  • sfWidgetFormSelectMany
  • sfWidgetFormSelectRadio

デフォルトでどれかを選択状態にするには、DBスキーマの設定で、デフォルト値を設定すればよい

typeの入力値を制限する
<?php
$this->validatorSchema['type'] = new sfValidatorChoice(array(
  'choices' => array_keys(Doctrine::getTable('JobeetJob')->getTypes()),
));
logoをファイルアップロード形式に変更する
<?php
$this->widgetSchema['logo'] = new sfWidgetFormInputFile(array(
  'label' => 'Company logo',
));
logoのバリデータを変更する

アップロードするディレクトリを作成

$ mkdir web/uploads/jobs

バリデータの変更

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

sfValidatorFileは以下の機能を持つ

  • アップロードされたファイルのMIMEタイプをチェックする
  • ユニークなファイル名に変更する
  • 与えられたパスへファイルを保存する
  • 生成された名前でlogoカラムを変更する
labelのテキストを変更する
<?php
$this->widgetSchema->setLabels(array(
  'category_id'   => 'Category',
  'is_public'     => 'Public?',
  'how_to_apply'  => 'How to apply?',
));
is_publicにヘルプメッセージを設定
<?php
$this->widgetSchema->setHelp('is_public', 'Whether the job can slso be published on affiliate websites or not.');

フォームテンプレート

フォームクラスを変更したので、テンプレート変更してやらないといけない

追加ページと編集ページを変更
<?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)) ?>
入力フォームが記述してあるパーシャルを変更
<?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()は、フォームに依存しているそれぞれのファイルを読み込む。
どちらも利用しなくとも、後日必要になった場合に記述しなければならないので、常に記述する習慣を身に付けておく。


form_tag_for()ヘルパはこんな感じ

  • 渡されたFormとRouteでformタグを生成
  • file選択フォームが存在する場合は、enctypeをmultipartに変更する

フォームの表示のカスタマイズ

Formオブジェクトで利用できるもの
メソッド名 説明
render($attributes = array()) フォームを表示(echo $formと同等)
renderHiddenFields() hiddenフィールドの表示
hasErrors() エラーがあるかどうか
hasGlobalErrors() グローバルエラーがあるかどうか
getGlobalErrors() グローバルエラーの取得
renderGlobalErrors() グローバルエラーの表示
FormFieldオブジェクトで利用できるもの
メソッド名 説明
renderRow($attributes = array(), $label = null, $help = null) フィールド行を表示
render($name, $value = null, $attributes = array(), $errors = array()) フィールドウィジェットを表示
renderLabel($label = null, $attributes = array()) フィールドラベルを表示
hasError() エラーがあるかどうか
renderError() あればエラーメッセージを表示
renderHelp() ヘルプメッセージを表示

フォームアクション

登録/変更処理がフローの通りになるように、CRUD関連のメソッドを変更。
間違ってexecuteIndexを消さないように。

<?php
// apps/frontend/modules/job/actions/actions.class.php
public function executeNew(sfWebRequest $request)
{
  $job = new JobeetJob();
  // typeのデフォルト値を設定
  $job->setType('full-time');
  // JobeetJobオブジェクトのインスタンスを渡してやると、
  // 指定したデータの操作になる
  // 空の場合は空データ(新規データ)
  $this->form = new JobeetJobForm($job);
}
 
public function executeCreate(sfWebRequest $request)
{
  $this->form = new JobeetJobForm();
  $this->processForm($request, $this->form);
  // setTemplate()を利用すると、利用するテンプレートを変更できる
  $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));
  }
}

トークンによるフォームの保護

トークンの自動生成
<?php
// lib/model/doctrine/JobeetJob.class.php
public function save(Doctrine_Connection $con = null)
{
  // ...
 
  if (!$this->getToken()) {
    $this->setToken(hash('sha256', $this->getEmail().rand(11111, 99999));
  }
 
  return parent::save($conn);
}
トークンの編集不可
<?php
// 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']
    );
 
    // ...
  }
 
  // ...
}
トークンを利用したルーティング

sfDoctrineRouteCollectionはデフォルトではPRIMARY KEYを利用する。
今回はtokenを利用するように変更するので、optionsの columnにtokenを指定する。

また、利用するカラムの書式指定はデフォルトで\dなので、\wに変更する

# apps/frontend/config/routing.yml
job:
  class:        sfDoctrineRouteCollection
  options:      { model: JobeetJob, column: token }
  requirements: { token: \w+ }
編集ページへのリンク
<?php /* apps/frontend/modules/job/templates/showSuccess.php at 39 */ ?>
<a href="<?php echo url_for('job_edit', $job) ?>">Edit</a>

確認ページの作成

管理メニューバーの設置

編集などの操作をメニューバーに集約させる。
そのため、下にある"Edit"のリンクを削除する。

<?php /* apps/frontend/modules/job/templates/showSuccess.php at 8 */ ?>
<?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>
必要になったメソッドの作成
<?php
// 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);
}
公開処理を作成
# apps/frontend/config/routing.yml
job:
  class:   sfDoctrineRouteCollection
  options:
    model:          JobeetJob
    column:         token
    object_actions: { publish: put }
  requirements:
    token: \w+
<?php /* apps/frontend/modules/job/templates/_admin.php at 6 */ ?>
<li>
  <?php echo link_to('Publish', 'job_publish', $job, array('method' => 'put')) ?>
</li>
<?php
// 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));
}
<?php
// lib/model/doctrine/JobeetJob.class.php
public function publish()
{
  $this->setIsActivated(true);
  $this->save();
}
<?php
// lib/model/doctrine/JobeetJobTable.class.php
public function addActiveJobsQuery(Doctrine_Query $q = null)
{
  // ...
 
  $q->andWhere($alias . '.is_activated = ?', 1);
 
  return $q;
}