Jobeet - 10日目: フォーム
Day 10: The Forms (1_2) - Symfony
今回、結構ボリュームありました。。。しかしフォームフレームワークは1.0から思いっきり変わったところなので押さえておきたいところです。
フォームフレームワーク
どんなWebサイトでもフォームを持ちます。それはシンプルなコンタクトフォームから、たくさんのフィールドを持つ複雑なフォームまでいろいろあります。フォームを記述することはWeb開発者にとって最も複雑でうんざりするタスクの1つです。そのタスクはHTMLでフォームを記述し、各フィールドに対しバリデーションルールを実装し、データベースへ値を保存するプロセスを実装し、エラーメッセージを表示し、エラーが起こったフィールドは値を再設定し、などたくさんあります。
もちろん、何度も何度も車輪の再発明をする代わりに、symfonyはフォームを楽に管理するためのフレームワークを用意しています。フォームフレームワークは3つのパーツから構成されます。
フォーム
symfonyのフォームはフィールドから作られたクラスになっています。各フィールドは名前、バリデータ、ウィジェットを持っています。下記にシンプルなコンタクトフォームのクラスを定義しています。
<?php 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()メソッドのsetValidator()やsetWidgets()メソッドを使って設定されます
フォームフレームワークにはたくさんのウィジェットとバリデータがバンドルされています。APIドキュメントではそれらのオプションやエラー、標準のエラーメッセージについて幅広く説明しています。
ウィジェットとバリデータクラスの名前は非常に明確です。emailフィールドはHTMLの<input>タグとして表示され(sfWidgetFormInput)、メールアドレスとしてバリデートされます(sfValidatorEmail)。messageフィールドは<textarea>タグとして表示され(sfWidgetTextarea)、最大文字数は255文字でなければなりません(sfValidatorString)。
デフォルトでは全てのフィールドは必須項目であり、デフォルト値としてrequiredオプションにはtrueがセットされます。よって、emailのバリデーション定義は new sfValidatorEmail(array('required' => true))と同等です。
mergeForm()メソッドを使ってあるフォームと別のフォームをまとめることができます、またembedForm()メソッドを使うと別のフォームを組み込むことができます。
Propelフォーム
ほとんどの場合、フォームはデータベースへシリアライズされます。symfonyはデータベースモデルについてすでに全部知っているので、それに基づいてフォームを自動で生成できます。実際には3日目でpropel:build-allタスクを実行したとき、symfonyは自動でpropel:build-formsタスクを実行しています。
$ symfony propel:build-forms
propel:build-formsタスクは lib/form/ディレクトリにフォームクラスを生成します。この生成されたファイルの構成はlib/modelディレクトリ下の構成と似ています。各モデルクラスはフォームクラスと関連があり(例えば、JobeetJobクラスとJobeetJobFormクラス)、
基本クラスを継承しているためデフォルトは空です。
<?php // lib/form/JobeetJobForm.class.php class JobeetJobForm extends BaseJobeetJobForm { public function configure() { } }
lib/form/base/下のサブディレクトリに生成されたファイルを見ると、symfonyのビルトインウィジェットやバリデータの良い利用例がたくさんあります。
仕事のフォームのカスタマイズ
仕事のフォームはカスタマイズのやり方を学習するには完璧なサンプルです。段階的にフォームをカスタマイズしていきましょう。
最初にレイアウト内の"Post a Job"リンクを変更しブラウザで直接変更がチェックできるようにします。
<!-- apps/frontend/templates/layout.php --> <a href="<?php echo url_for('@job_new') ?>">Post a Job</a>
デフォルトでは、Propelフォームは全てのテーブルカラム用のフィールドを表示します。しかし仕事のフォームでは、いくつかエンドユーザに編集させてはいけない箇所があります。フォームからフィールドを削除するには単純にunsetします。
<?php // 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'] ); } }
フィールドをunsetするということはウィジェットとバリデータの両方とも削除する、という事を意味します。
フォームの設定はデータベーススキーマから読み込んだものより正確になるでしょう。デフォルトのsfValidatorStringをsfValidatorEmailへ変更してみましょう。
<?php // lib/form/JobeetJobForm.class.php public function configure() { // ... $this->validatorSchema['email'] = new sfValidatorEmail(); }
もしtypeカラムがスキーマ内でvarcharであったとしても、選択時の項目一覧は制限された値にしたいと考えます。この場合、full timeか、part timeか、freelanceです。
まず、JobeetJobPeerに利用可能な値を定義します。
<?php // lib/model/JobeetJobPeer.php class JobeetJobPeer extends BaseJobeetJobPeer { static public $types = array( 'full-time' => 'Full time', 'part-time' => 'Part time', 'freelance' => 'Freelance', ); // ... }
それから、typeウィジェット用にsfWidgetFormChoiceを使います。
<?php $this->widgetSchema['type'] = new sfWidgetFormChoice(array( 'choices' => JobeetJobPeer::$types, 'expanded' => true, ));
sfWidgetFormChoiceは設定オプション(expandedとmulitiple)によって、異なった選択ウィジェットを表示します。
- Dropdown list(<select>)
- array('mulitiple' => false, 'expanded' => false)
- Dropdown box(<select multiple="muletiple">)
- array('multiple' => true, 'expanded' => false)
- List of radio buttons
- array('multiple' => false, 'expanded' => true)
- List of checkboxes
- array('multiple' => true, 'expanded' => true)
もしデフォルトでラジオボタンの1つを選択状態にしたいのであれば(例えばfull-timeを)、データベーススキーマのデフォルト値を変更すればできます。
誰も有効でない値をサブミットできないだろうと思ったとしても、ハッカーはcurlやFirefoxのWeb Developmentツールバーのようなツールを使ってウィジェットによる選択方法を簡単に回避してきます。有効な選択肢を制限するように変更しましょう。
<?php $this->validatorSchema['type'] = new sfValidatorChoice(array( 'choices' => array_keys(JobeetJobPeer::$types), ));
logoカラムは仕事と関連のあるロゴのファイル名が保存されるので、file inputタグにウィジェットを変更します。
<?php $this->widgetSchema['logo'] = new sfWidgetFormInputFile(array( 'label' => 'Company logo', ));
各フィールドごとにsymfonyは自動でラベルを生成します(<label>タグで表示されます)。これはlabelオプションを使って変更することができます。
ウィジェット配列のsetLabel()メソッドでラベルの一括変更もできます。
<?php $this->widgetSchema->setLabels(array( 'category_id' => 'Category', 'is_public' => 'Public?', 'how_to_apply' => 'How to apply?', ));
デフォルトバリデータも変更が必要です。
<?php $this->validatorSchema['logo'] = new sfValidatorFile(array( 'required' => false, 'path' => sfConfig::get('sf_upload_dir').'/jobs', 'mime_types' => 'web_images', ));
sfValidatorFileはたくさんの事をこなすので非常に興味深いものです。
- アップロードされたファイルがWebフォーマットの画像であるかチェックします(mime_type)
- ユニークになるようファイルをリネームします
- 得られるパスへファイルを保存します
- 生成された名前でlogoカラムを更新します。
logoディレクトリ(web/uploads/jobs)を生成し、そこにWebサーバが書き込めるかチェックする必要があります。
バリデータがデータベースに相対パスを保存するのであれば、showSuccessテンプレートで使われているパスも変更します。
<?php // apps/frontend/modules/job/template/showSuccess.php <img src="/uploads/jobs/<?php echo $job->getLogo() ?>" alt="<?php echo $job->getCompany() ?> logo" />
もしgenerateLogoFileName()メソッドがフォーム内に存在するなら、バリデータから呼ばれて、デフォルトで生成されたlogoのファイル名を上書きするでしょう。このメソッドは引数としてsfValidatedFileオブジェクトから取得されます。
あるフィールドの自動生成されたラベルを上書きするとき、ヘルプメッセージも定義することができます。その有意性をより説明するため、is_publicカラム用のフィールドを追加しましょう。
<?php $this->widgetSchema->setHelp('is_public', 'Whether the job can also be published on affiliate websites or not.');
最終的に、JobeetJobFormクラスは下記のようになります。
<?php // 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.'); } }
フォームテンプレート
これでフォームクラスはカスタマイズされたので、表示処理が必要となります。フォーム用のテンプレートは新しく仕事を生成する場合と既存の仕事を編集する場合であっても同じです。実際にはnewSuccess.phpとeditSuccess.phpの2つのテンプレートは非常に似ています。
<!-- 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パーシャルで表示されます。生成されている_formパーシャルを下記コードに置き換えてください。
<!-- 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とスタイルシートを含みます。
たとえ仕事のフォームがJavaScriptとスタイルシートを必要としなかったとしても、念の為これらのヘルパーを呼ぶのは良い習慣です。もし後日、JavaScriptやスタイルシートが必要になった場合でも対応できます。
form_tag_forヘルパーは取得されたフォームやルート用の<form>タグを生成し、オブジェクトが新しいかそうでないかに依存してPOSTまたはPUTにHTTPメソッドを変更します。もしフォームがfile inputタグを持っているなら、multipart属性で処理されます。
結局のところ、<?php echo $form ?>はフォームウィジェットを表示します。
フォームの見た目のカスタマイズ
デフォルトでは、<php echo form ?>はテーブル行としてフォームウィジェットを表示します。
ほとんどの場合で、フォームのレイアウトをカスタマイズする必要が出てくるでしょう。フォームオブジェクトはカスタマイズに便利な多くのメソッドを用意しています。
メソッド 説明 render() フォームを表示(echo $formで出力するのと同じ) renderHiddenFields() hiddenフィールドを表示 hasErrors() フォームがエラーを持つ場合、trueを返す hasGlobalErrors() フォームがグローバルエラーを持つ場合、trueを返す getGlobalErrors() グローバルエラーの配列を返す renderGlobalErrors() グローバルエラーを表示 フォームはフィールドの配列のようにも振る舞います。$form['company']でcompanyフィールドにアクセスできます。返されるオブジェクトはフィールドの各要素を表示するメソッドを用意しています。
メソッド 説明 renderRow() フィールド行を表示 render() フィールドウィジェットを表示 renderLabel() フィールドラベルを表示 renderError() あればエラーメッセージを表示 renderHelp() ヘルプメッセージを表示 echo $form文は下記コードと同じ意味になります。
<?php foreach ($form as $widget): ?> <?php echo $widget->renderRow() ?> <?php endforeach(); ?>
フォームアクション
現在、表示可能なフォームクラスとテンプレートがあります。そこで実際にはどう動作しているのか見ていきます。
仕事のフォームはjobモジュールの5種類のメソッドで管理されます。
- new: 新しい仕事を作るため、空のフォームを表示
- edit: 登録済みの仕事を編集するためのフォームを表示
- create: ユーザが入力してきた値で新しい仕事を生成
- update: ユーザが入力した値で登録済みの仕事を更新
- processForm: createdとupdateによって呼ばれ、フォームを処理します(バリデーション、フォームの再利用、データベースへのシリアライズ処理など)。
jobモジュール用に5日前にPropelルートコレクションを作ったので、単純にフォームを管理するメソッドを書けます。
<?php // 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)); } }
/job/newページを開いたとき、新しいフォームのインスタンスは生成され、テンプレートに渡されています(newアクション)。
ユーザがフォームをサブミットすると(createアクション)、ユーザが入力した値とバリデーションが動作するのが結び付けられます(bind()メソッド)。
フォームがいったん結び付けられると、isValid()メソッドを使って正当であるかチェックすることができます。もしフォームが正当であれば(trueを返す)、仕事はデータベースへ保存され($form->save())、ユーザは仕事のプレビューページへリダイレクトされます。もしそうでなければ、newSuccess.phpテンプレートがユーザの入力値とエラーメッセージと一緒に再び表示されます。
setTemplate()メソッドはアクションから得られた結果が使われるテンプレートを変更します。もしサブミットされたフォームが正当でなければ、個々にエラーメッセージ付きのフォームを再表示するためのnew、editアクションとして、createとupdateメソッドを同じテンプレートで使います。
登録済みの仕事の変更は非常に似ています。newとeditアクション間の唯一の相違点はフォームコンストラクタの第1引数に修正されたjobオブジェクトを渡せるかだけです。このオブジェクトはテンプレートではデフォルトのウィジェット値として使われます(デフォルト値はPropelフォームではオブジェクトですが、シンプルなフォームでは配列となります)。
作ったフォーム用のデフォルト値も定義できます。1つはデータベーススキーマで値を宣言する方法です。もう1つはフォームコンストラクタへ修正済みのjobオブジェクトを渡す方法です。
<?php // 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); }
フォームがバインドされたとき、デフォルト値はユーザが入力された値に置き換えられます。バリデーションエラーが起こりフォームが再表示されるときにユーザ入力値はフォームの再生成用に使われます。
トークンで仕事フォームを保護
そろそろ、全体が上手く動くようになっていなければなりません。しかし問題があります。まず、新しい仕事が生成されるとき、ユーザが必要としないようなユニークなトークンが自動で生成されなければなりません。JobeetJobのsave()メソッドを更新します。
<?php // 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); }
フォームからトークンフィールドを削除します。
<?php // 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'] ); // ... } // ... }
もし2日目のユーザストーリーを覚えているなら、仕事はユーザが関連したトークンを知っている場合に限り編集可能となります。今のところ、URLを推測することでどの仕事も編集や削除することは非常に簡単です。これは編集用のURLが /job/ID/editのようであるからであり、IDは仕事のプライマリキーであるためです。
デフォルトでsfPropelRouteCollectionルートはプライマリキーに結びつけたURLを生成しますが、columnオプションを渡してあげることでユニークなカラムに変更することができます。
# apps/frontend/config/routing.yml job: class: sfPropelRouteCollection options: { model: JobeetJob, column: token } requirements: { token: \w+ }
今現在、job_show_userルートを除く全てのルートはトークンが組み込まれました。編集するためのルートは下記のようになります。
http://jobeet.localhost/job/TOKEN/edit showSuccessテンプレートの"Edit"リンクを変更する必要もあります。<!-- apps/frontend/modules/job/templates/showSuccess.php --> <a href="<?php echo url_for('job_edit', $job) ?>">Edit</a>
symfonyのデフォルトではプライマリキー向けに\d+となっているので、tokenカラムに対する必要要件も変更します。
プレビューページ
プレビューページは仕事ページを表示するのと同じことです。ルーティングのおかげで、ユーザが正しいトークンを持っているなら、トークンリクエストパラメータ内でアクセス可能になります。 ユーザがトークン化されたURLを持っているなら、トップにAdminバーを追加します。showSuccessテンプレートの先頭に、adminへのリンクコードを追加し、末尾の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パーシャルを生成します。
<!-- 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>たくさんのコードですが、コードのほとんどはシンプルで理解するのは簡単です。 adminバーは仕事のステータスに依存した異なったものです。 テンプレートをもっと読みやすくするため、JobeetJobクラスにショートカットメソッドを追加します。
<?php // 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); }
仕事の有効化と公開
前のセクションで、仕事を公開するリンクがあります。リンクが新しいpublishアクションを指すように変更します。新しいルートを生成する代わりに、既存のjobルートを設定するだけです。# apps/frontend/config/routing.yml job: class: sfPropelRouteCollection 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アクションを生成します。
<?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)); }CSRFプロテクションが組み込まれているので、link_to()ヘルパーはリンクにCSRFトークンを埋め込み、リクエストオブジェクトのcheckCSRFProtection()メソッドでフォームのサブミットの正当性をチェックします。 executePublish()メソッドは新しくpublish()メソッドを使っているので定義します。
<?php // lib/model/JobeetJob.php public function publish() { $this->setIsActivated(true); $this->save(); }ブラウザでpublishのテストをすることができます。 しかし、解決とするには心にわだかまりがあります。有効でない仕事にはアクセスさせてはいけません。それはホームページ上には表示させていけないですし、URLで直アクセスさせてもいけないということを意味します。有効な仕事をCriteriaで制限するためにaddActiveJobsCriteria()メソッドを作っているので、新しい必要条件をコードの最後に追加します。
<?php // lib/model/JobeetJobPeer.php static public function addActiveJobsCriteria(Criteria $criteria = null) { // ... $criteria->add(self::IS_ACTIVATED, true); return $criteria; }これだけです。ブラウザでテストしてみてください。有効でない全ての仕事はホームページから消えました。もしURLを知っていて直アクセスしようとしてももう出来ません。しかしながらトークン化されたURLを知っているなら、アクセスは可能です。その場合、仕事のプレビューページはadminバーと一緒に表示されます。 このことはMVCパターンの素晴らしいアドバンテージの1つであり、この先リファクタリングもしやすいです。1メソッドの1部分を変更するだけで新しい必要条件を追加することができます。
getWithJobs()メソッドを作ったとき、addActiveJobsCriteria()メソッドを使うのを忘れていました。だからgetWithJobs()メソッドにも新しい条件を追加する必要があります。<?php class JobeetCategoryPeer extends BaseJobeetCategoryPeer { static public function getWithJobs() { // ... $criteria->add(JobeetJobPeer::IS_ACTIVATED, true); return $criteria; }