今回は、初回アルファバージョンリリースに向けて
一気に実装をしていきます。

一覧ページ(/vote)

まず、質問一覧のページャを取得するメソッドを、VoteQuestionTableに実装します。

VoteQuestionやVoteQuestionTableのような、doctrineが作ったモデルに新たなメソッドを追加したり、オーバーライドをする場合は、前回のForm同様な仕組みで拡張を行うことになります。
plugins/opVotePlugin/lib/model/doctrine/PluginVoteQuestionTable.class.php

  public function getListPager($page = 1, $size = 20) 
  {
    $query = $this->createQuery()
      ->orderBy('updated_at DESC');

    $pager = new sfDoctrinePager('VoteQuestion', $size);
    $pager->setQuery($query);
    $pager->setPage($page);
    $pager->init();

    return $pager;
  }

このように、簡単にページャーを取得できるメソッドを用意しておくと便利です。

plugins/opVotePlugin/lib/action/opVotePluginVoteActions.class.php

  public function executeIndex(sfWebRequest $request)
  {
    $this->pager = Doctrine::getTable('VoteQuestion')->getListPager($request->getParameter('page'));
  }

現在の質問リストの表示は、ひとまずページャが取得できればいいので、アクションはこれだけです。

plugins/opVotePlugin/apps/pc_frontend/modules/vote/templates/indexSuccess.php

<?php op_include_box('vote_question_create_box', '選択肢つきの質問を作成することができます!', array(
  'title' => '質問作成',
  'moreInfo' => array(link_to('質問作成', '@vote_new'))
)); ?>

<?php if ($pager->getNbResults()): ?>

<?php slot('pager'); ?>
<?php op_include_pager_navigation($pager, '@vote_list?page=%d'); ?>
<?php end_slot() ?>

<div class="dparts recentList"><div class="parts">
<div class="partsHeading">
<h3>質問リスト</h3>
</div>
<?php include_slot('pager'); ?>
<?php foreach ($pager->getResults() as $item): ?>
<dl>
<dt><?php echo op_format_date($item->getUpdatedAt(), 'f') ?></dt>
<dd><?php echo link_to(sprintf("%s(%d)", $item->getTitle(), count($item->getVoteAnswers())), '@vote_show?id='.$item->getId()) ?></dd>
</dl>
<?php endforeach; ?>
<?php include_slot('pager'); ?>
</div>
</div>

<?php endif; ?>

前回に加え、新たなパーツを利用しました。

op_include_box()は、簡単なメッセージの表示などに役立つパーツを埋め込む関数です。

op_include_box() の引数は以下の通りです

第一引数 $id
パーツのIDを指定します。
第二引数 $body
ボックスの本文を指定します。この内容が大きくなる場合は、symfonyのslotを使うと便利でしょう。
第三引数 $options
オプションです。前回のフォームパーツ同様titleが指定できます。また、moreInfoに配列をしていすることにより、ボックスの下に追加情報のリストを設置することができます。今回は、新規作成画面へのリンクとして使いました。

また、ページャーナビゲージョンの埋込みは、op_include_pager_navigation()を使うと便利でしょう。

op_include_pager_navigation()は以下のような引数を持ちます。

第一引数 $pager
ページャオブジェクトを指定します。
第二引数 $internal_url
ページ変更に使うURIを指定します。文字列中の %d はページ数に変換します。
第三引数 $options
オプションです。検索画面などで検索クエリを引き継ぐ必要がある場合はuse_current_query_stringをtrueにしてください。また、ページ合計を表示させたくない場合は is_total を false にすると表示されなくなります。

ページャの埋込みに使うテンプレートは OpenPNE3本体の apps/*/_pagerNavigation.php にあります。

http://#セットアップしたSNSのURL#/vote
は以下のようになるはずです。
vote_list

質問の編集

plugins/opVotePlugin/lib/actions/opVotePluginVoteActions.class.php

  public function executeEdit(sfWebRequest $request)
  {
    // モデルオブジェクトをルートから取得
    $object = $this->getRoute()->getObject();
    // もし、作成者でない場合は404画面に飛ばす
    $this->forward404Unless($this->getUser()->getMemberId() == $object->getMemberId());
    $this->form = new VoteQuestionForm($object);
  }

  public function executeUpdate(sfWebRequest $request)
  {
    $object = $this->getRoute()->getObject();
    $this->forward404Unless($this->getUser()->getMemberId() == $object->getMemberId());
    $this->form = new VoteQuestionForm($object);
    if ($this->form->bindAndSave($request->getParameter('vote_question')))
    {
      $this->getUser()->setFlash('notice', '編集しました');
      $this->redirect('@vote_list');
    }
    $this->setTemplate('edit');
  }

編集後フォームからのリクエストを受け取り、保存が完了したときに、setFlash()でフラッシュを設定しています。OpenPNEではフラッシュにより、次の画面限定でメッセージを表示させることができます。
その機能を利用する場合は、 ‘notice’ もしくは、’error’ というキーでメッセージを設定してください。また、i18nに対応したプラグインを作る際は、翻訳元のメッセージを設定してください。フラッシュの出力時に翻訳がされます。

plugins/opVotePlugin/apps/pc_frontend/modules/vote/templates/editSuccess.php

<?php op_include_form('vote_question_from', $form, array(
  'title' => '質問編集',  
  'url' => url_for('@vote_update?id='.$form->getObject()->getId()),
)); ?>

http://#セットアップしたSNSのURL#/edit/:id
は以下のようになるはずです。
vote_edit

保存に成功すると、以下のようにメッセージが表示されます。
flash

質問/投票ページ

plugins/opVotePlugin/lib/actions/opVotePluginVoteActions.class.php

  public function executeShow(sfWebRequest $request)
  {
    $this->question = $this->getRoute()->getObject();

    // ログイン者の回答済み内容
    $yourAnswer = Doctrine::getTable('VoteAnswer')->findOneByMemberIdAndVoteQuestionId(
      $this->getUser()->getMemberId(),
      $this->question->getId()
    );  

    if ($yourAnswer || $this->question->getMemberId() == $this->getUser()->getMemberId())
    {   
      // 結果出力のためのデータ集計
      $answers = Doctrine::getTable('VoteAnswer')->findByVoteQuestionId($this->question->getId());
      $options = Doctrine::getTable('VoteQuestionOption')->findByVoteQuestionId($this->question->getId());
      $this->options = $options->toKeyValueArray('id', 'body');
      $this->answerTotal = array();
      $this->total = 0;
      foreach ($answers as $answer)
      {   
        $this->total++;
        if (isset($this->answerTotal[$answer->getVoteQuestionOptionId()]))
        {   
          $this->answerTotal[$answer->getVoteQuestionOptionId()]++;
        }   
        else
        {   
          $this->answerTotal[$answer->getVoteQuestionOptionId()] = 1;
        }   
      }   
      arsort($this->answerTotal);
    }   
    else
    {   
      // 回答済みでないかつ作成者でないときフォームオブジェクト作成
      $voteAnswer = new VoteAnswer();
      $voteAnswer->setVoteQuestion($this->question);
      $voteAnswer->setMember($this->getUser()->getMember());
      $this->form = new VoteAnswerForm($voteAnswer);
    }
  }

  public function executePost(sfWebRequest $request)
  {
    $question = $this->getRoute()->getObject();

    $yourAnswer = Doctrine::getTable('VoteAnswer')->findOneByMemberIdAndVoteQuestionId(
      $this->getUser()->getMemberId(),
      $question->getId()
    );

    // 回答済みであったり、作成者であった場合404
    $this->forward404If($yourAnswer || $question->getMemberId() == $this->getUser()->getMemberId());

    $voteAnswer = new VoteAnswer();
    $voteAnswer->setVoteQuestion($question);
    $voteAnswer->setMember($this->getUser()->getMember());
    $this->form = new VoteAnswerForm($voteAnswer);
    if ($this->form->bindAndSave($request->getParameter('vote_answer')))
    {
      $this->redirect('@vote_show?id='.$question->getId());
    }

    // 保存失敗のときは show のテンプレート使い回し
    $this->setTemplate('show');
  }


plugins/opVotePlugin/apps/pc_frontend/modules/vote/templates/showSuccess.php

<?php slot('question_body'); ?>
<h4 style="font-weight:bold;"><?php echo $question->getTitle(); ?></h4>
<?php if ($question->getMemberId() == $sf_user->getMemberId()): ?>
<div>
<?php echo link_to('[編集]', '@vote_edit?id='.$question->getId()) ?>&nbsp;
<?php echo link_to('[削除]', '@vote_delete_confirm?id='.$question->getId()) ?>
</div>
<?php endif; ?>
<div>質問者: <?php echo link_to($question->getMember()->getName(), '@obj_member_profile?id='.$question->getMemberId()); ?></div>
<div><?php echo nl2br($question->getBody()); ?></div>
<?php end_slot(); ?>
<?php op_include_box('vote_question', get_slot('question_body'), array(
  'title' => '質問'
)) ?>

<?php if (isset($form)): ?>

<?php op_include_form('vote_form', $form, array(
  'title' => '回答',
  'url' => url_for('@vote_post?id='.$question->getId())
)) ?>

<?php else: ?>

<?php slot('result_body'); ?>
<?php if (count($answerTotal)): ?>
<table>
<tbody>
<?php foreach ($answerTotal as $key => $t): ?>
<tr>
<th><?php echo $options[$key] ?></td>
<td><div style="width:<?php echo round($t / $total * 100) ?>%;background-color:#8888FF;">
<?php echo $t ?>票 (<?php echo round($t / $total * 100) ?>%)
</div></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<tbody>
<?php foreach ($answerTotal as $key => $t): ?>
<tr>
<th><?php echo $options[$key] ?></td>
<td><div style="width:<?php echo round($t / $total * 100) ?>%;background-color:#8888FF;">
<?php echo $t ?>票 (<?php echo round($t / $total * 100) ?>%)
</div></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
まだ投票がありません。
<?php endif; ?>
<?php end_slot(); ?>

<?php op_include_box('vote_result', get_slot('result_body'), array(
  'title' => '結果'
)); ?>
<?php endif; ?>

このテンプレートでは、スロットを多く活用しています。
スロットに関しては以下を御覧下さい。
Practical symfony – 4日目: Controller と View

http://#セットアップしたSNSのURL#/show/:id
は以下のようになります。(質問作成者でなく、投票済みの場合)
vote_show

質問の削除

plugins/opVotePlugin/lib/actions/opVotePluginVoteActions.class.php

  public function executeDeleteConfirm(sfWebRequest $request)
  {
    $this->question = $this->getRoute()->getObject();
  }

  public function executeDelete(sfWebRequest $request)
  {
    $request->checkCSRFProtection();
    $this->getRoute()->getObject()->delete();
    $this->getUser()->setFlash('notice', '削除しました');
    $this->redirect('@vote_list');
  }

plugins/opVotePlugin/apps/pc_frontend/modules/vote/templates/deleteConfirmSuccess.php

<?php slot('body') ?>
<div>以下の質問を削除します。投票結果も削除されますがよろしいですか?</div>
<div><?php echo $question->getTitle() ?></div>
<?php end_slot() ?>

<?php op_include_yesno('delete_confirm', new BaseForm(), new BaseForm(array(), array(), false), array(
  'title' => '削除確認',
  'body' => get_slot('body'),
  'yes_url' => url_for('@vote_delete?id='.$question->getId()),
  'no_method' => 'get',
  'no_url' => url_for('@vote_show?id='.$question->getId()),
)) ?>

op_include_yesno() は、はい/いいえ という二択のフォームを表示するヘルパー関数です。
なぜ、ここでフォームとしてボタンを表示しなくてはいけないかというと、質問削除時のアクションでCSRF対策を行いたいためです。
op_include_yesno()は以下の引数を持ちます。

第一引数 $id
パーツのIDです。
第二引数 $yesForm
「はい」選択時に利用するフォームです。hiddenなフィールドのみを出力します。
第三引数 $noForm
「いいえ」選択時に利用するフォームです。
第四引数 $options
オプションです。yes_url, no_urlで「はい」「いいえ」それぞれのアクション先を指定できます。yes_method, no_methodはそれぞれのメソッド、yes_button, no_buttonでそれぞれのキャプションを指定できます。ほかのパーツ同様titleやbodyに対応しています。

ここで、第三引数に new BaseForm(array(), array(), false) としている理由は、「いいえ」を選択したときはただ単に、質問表示画面に戻りたいだけなので、CSRF Tokenを必要としないため、BaseFormのコンストラクタの第三引数の$CSRFSecretにはfalseを指定して、パラメータにCSRF Tokenを含ませないようにするためです。

アクションでCSRF Tokenをチェックしたいときは sfOpenPNEWebRequest::checkCSRFProtection() を使うと短くてクールになるでしょう。(このメソッドは、sfWebRequest::checkCSRFProtection() をオーバーライドしたもので、過去のバージョン化で作られたプラグインとの互換性を保つための拡張が行われています。具体的には BaseForm でなく、sfFormでもCSRF Tokenのチェックが正常に完了するようになっています。)

http://#セットアップしたSNSのURL#/deleteConfirm/:id
は以下のようになります。
delete_confirm

初期データの挿入

最後に、グローバルナビに @vote_list へのリンクを最初から設定するための初期データを用意します。

plugins/opVotePlugin/data/fixtures/001_navigation.yml を作成して、以下のようにしてください。

Navigation:
  secure_global_navigation_vote:
    type: "secure_global"
    uri: "@vote_list"
    sort_order: 45
    Translation:
      ja_JP:
        caption: "投票"

その後、一度データを再挿入します。(初期化されます。)

$ symfony doctrine:build --all --and-load
$ symfony cc

ナビが追加されているはずです。

menu

なお、このようなデータ挿入の動作も実環境では初期化というわけにはいきません。初期化せずに追加データをDBに挿入する方法は、第11回のマイグレートスクリプトで説明します。

さて、こういった具合で一通りのことはできるようになりました。
次回この段階で、リリースしてみようと思います。