読者です 読者をやめる 読者になる 読者になる

モノノフ日記

普通の日記です

Jobeet - 7日目: カテゴリーページを作りましょう

php symfony jobeet

メールで和訳に指摘をいただいたので反映させました。該当箇所は一番最後の「また明日」のところです。ご指摘いただいたGさん、ありがとうございます!しかし、自分の訳よりスマート。。こういうところでセンスの差が(´;ω;`)

前回までのJobeet

昨日はたくさんの異なったコードを変更することでsymfonyについての知識が深まりました。その変更箇所とは、Propel Criteriaオブジェクトであり、fixtures、ルーティング、デバッギング、カスタム設定などです。今日のために、少しのチャレンジを仕上げました。
今日のチュートリアルであるJobeetのカテゴリーページを作ることはあなたにとって非常に有用になることを望みます。
準備はいいですか?実装方法について話していきましょう。

カテゴリールート

最初に、カテゴリーページ用のきれいなURLを定義するため、ルートに追加します。ルーティングファイルの先頭に下記コードを追加します。

// apps/frontend/config/routing.yml
category:
  url:      /category/:slug
  class:    sfPropelRoute
  param:    { module: category, action: show }
  options:  { model: JobeetCategory, type: object }

新しい要素を実装しようとするときはいつでも、最初にURLと生成するルートを結びつけるように考えることは良い実践になります。

slugはカテゴリーテーブルのカラムでないので、ルートが動作するようにJobeetCategoryに仮想アクセサを追加する必要があります。

<?php
// lib/model/JobeetCategory.php
public function getSlug()
{
  return Jobeet::slugify($this->getName());
}

カテゴリーリンク

今度はカテゴリーページへのリンクを追加するため、jobモジュールのindexSuccess.phpテンプレートを編集します。

<!-- some HTML code -->
 
        <h1><?php echo link_to($category, 'category', $category) ?></h1>
 
<!-- some HTML code -->
 
      </table>
 
      <?php if (($count = $category->countActiveJobs() - sfConfig::get('app_max_jobs_on_homepage')) > 0): ?>
        <div class="more_jobs">
          and <?php echo link_to($count, 'category', $category) ?>
          more...
        </div>
      <?php endif; ?>
    </div>
  <?php endforeach; ?>
</div>

現在表示しているカテゴリに10個以上の仕事が存在しているなら、リンクを追加するだけです。リンクには仕事数は表示しないようにします。このテンプレートを動作させるため、JobeetCategoryにcountActiveJobs()メソッドを追加する必要があります。

<?php
// lib/model/JobeetCategory.php
public function countActiveJobs()
{
  $criteria = new Criteria();
  $criteria->add(JobeetJobPeer::CATEGORY_ID, $this->getId());
 
  return JobeetJobPeer::countActiveJobs($criteria);
}

countActiveJobs()メソッドはJobeetJobPeerにまだ存在していないメソッドであるcountActiveJobs()を使っています。

<?php
// lib/model/JobeetJobPeer.php
class JobeetJobPeer extends BaseJobeetJobPeer
{
  static public function doSelectActive(Criteria $criteria)
  {
    return self::doSelectOne(self::addActiveJobsCriteria($criteria));
  }
 
  static public function getActiveJobs(Criteria $criteria = null)
  {
    return self::doSelect(self::addActiveJobsCriteria($criteria));
  }
 
  static public function countActiveJobs(Criteria $criteria = null)
  {
    return self::doCount(self::addActiveJobsCriteria($criteria));
  }
 
  static public function addActiveJobsCriteria(Criteria $criteria = null)
  {
    if (is_null($criteria))
    {
      $criteria = new Criteria();
    }
 
    $criteria->add(self::EXPIRES_AT, time(), Criteria::GREATER_THAN);
    $criteria->addDescendingOrderByColumn(self::CREATED_AT);
 
    return $criteria;
  }
}

自分で確かめるとわかるように、コードがもっとDRYになるように新しい共有メソッドaddActiveJobsCriteria()を導入しJobeetJobPeerのコード全体をリファクタリングしました。

コードの一部を再利用するなら、コピペするだけで間に合います。しかし別のところでも使っているのを見つけたのなら、今やったように共通の関数やメソッドが使われている全ての箇所をリファクタリングする必要があります。

countActiveJobs()メソッドは、結果数をカウントするのにdoSelect()を使う代わりに、より早く動作するdoCount()メソッドを使っています。
たくさんのファイルを変更しますが、単純な要素のみです。しかしコードを追加するたびにアプリケーションの正しいレイヤに挿入したり、再利用できるコードにしたりします。そのプロセスで既存のコードもリファクタリングしています。このことはsymfonyプロジェクトを運用する上で典型的なワークフローです。

Categoryモジュールの作成

下記コマンドで、categoryモジュールを生成します。

$ symfony generate:module frontend category

モジュールが作られたなら、たいていpropel:generate-moduleが使われます。このタスクは良いのですが、生成されたコードの90%は不必要なものです。空のモジュールを作るには、generate:moduleを使います。

なぜjobモジュールにcategoryアクションを追加しないのでしょうか?可能ですが、カテゴリーページの主題はカテゴリーであり、専用のcategoryモジュールを作る方がより自然になります。

データベースの更新

カテゴリーテーブルにslugカラムを追加します。

# config/schema.yml
propel:
  jobeet_category:
    id:           ~
    name:         { type: varchar(255), required: true }
    slug:         { type: varchar(255), required: true, index: unique }

これでslugカラムを実在するカラムになったので、JobeetCategoryからgetSlug()メソッドを削除します。
カテゴリー名が変更になるたび、計算してslugを変更する必要があります。setName()メソッドをオーバーライドしてみましょう。

<?php
// lib/model/JobeetCategory.php
public function setName($name)
{
  parent::setName($name);
 
  $this->setSlug(Jobeet::slugify($name));
}

データベースを更新するためにpropel:build-all-loadタスクを使うと、fixtures込みでデータベースが再定義されます。

$ symfony propel:build-all-load

適切な位置にexecuteShow()メソッドを生成します。

<?php
// apps/frontend/modules/category/actions/actions.class.php
class categoryActions extends sfActions
{
  public function executeShow(sfWebRequest $request)
  {
    $this->category = $this->getRoute()->getObject();
    $this->jobs = $this->category->getActiveJobs();
  }
}

最後にshowSuccess.phpテンプレートを生成します。

// apps/frontend/modules/category/template/showSuccess.php
<?php use_stylesheet('jobs.css') ?>
 
<?php slot('title', sprintf('Jobs in the %s category', $category->getName())) ?>
 
<div class="category">
  <div class="feed">
    <a href="">Feed</a>
  </div>
  <h1><?php echo $category ?></h1>
</div>
 
<table class="jobs">
  <?php foreach ($category->getActiveJobs() as $i => $job): ?>
    <tr class="<?php echo fmod($i, 2) ? 'even' : 'odd' ?>">
      <td class="location"><?php echo $job->getLocation() ?></td>
      <td class="position"><?php echo link_to($job->getPosition(), 'job_show_user', $job) ?></td>
      <td class="company"><?php echo $job->getCompany() ?></td>
    </tr>
  <?php endforeach; ?>
</table>

Partials

jobモジュールのindexSuccess.phpテンプレートから仕事のリストを生成している<table>タグをコピペしているのに気づきましょう。それは悪いことです。新しいトリックを学習する機会です。テンプレートの一部を再利用する必要があるとき、partialを生成します。partialはいくつかのテンプレート間で共有できるテンプレートコードのsnippetです。partialはアンダースコア(_)で始まる別のテンプレートとなります。

// apps/frontend/modules/job/templates/_list.php
<table class="jobs">
  <?php foreach ($jobs as $i => $job): ?>
    <tr class="<?php echo fmod($i, 2) ? 'even' : 'odd' ?>">
      <td class="location"><?php echo $job->getLocation() ?></td>
      <td class="position"><?php echo link_to($job->getPosition(), 'job_show_user', $job) ?></td>
      <td class="company"><?php echo $job->getCompany() ?></td>
    </tr>
  <?php endforeach; ?>
</table>

include_partialヘルパーを使うことでpartialをテンプレートに含めることができます。

<?php include_partial('job/list', array('jobs' => $jobs)) ?>

include_partial()の第1引数はpartialの名前( モジュール名、/、アンダースコア無しのpartial名の順)です。第2引数はpartialに渡す変数の配列です。

なぜinclude_partial()ヘルパーの代わりにPHPにビルトインされているinclude()メソッドを使わないでしょうか?2つの主な違いはinclude_partial()ヘルパーはビルトインされたキャッシュに対応している点です。

両テンプレートからinclude_partial()を使って<table>HTMLコードを置きかえるには下記のようにします。

// in apps/frontend/modules/job/templates/indexSuccess.php
<?php include_partial('job/list', array('jobs' => $category->getActiveJobs(sfConfig::get('app_max_jobs_on_homepage')))) ?>
 
// in apps/frontend/modules/category/templates/showSuccess.php
<?php include_partial('job/list', array('jobs' => $category->getActiveJobs())) ?>

ページネーションリスト

2日目の必要要件から
"1ページには20個の仕事がリストされます"
Propelオブジェクトのリストをページネートするため、symfonyはsfPropelPagerという専用クラスを提供します。テンプレートへjobオブジェクトを渡す代わりに、pagerを渡します。

<?php
// apps/frontend/job/modules/category/actions/actions.class.php
public function executeShow(sfWebRequest $request)
{
  $this->category = $this->getRoute()->getObject();
 
  $this->pager = new sfPropelPager(
    'JobeetJob',
    sfConfig::get('app_max_jobs_on_category')
  );
  $this->pager->setCriteria($this->category->getActiveJobsCriteria());
  $this->pager->setPage($request->getParameter('page', 1));
  $this->pager->init();
}

getParameter()メソッドは第2引数でデフォルト値を設定します。上記のアクションだと、pageリクエストパラメータが存在しなければ、getParameter()は1を返します。

sfPropelPagerコンストラクタはモデルクラスと1ページに表示するアイテムの最大値を引数に持ちます。設定ファイルに最大値を追加します。

# apps/frontend/config/app.yml
all:
  active_days:          30
  max_jobs_on_homepage: 10
  max_jobs_on_category: 20

sfPropel::setCriteria()メソッドはデータベースからデータを取得するときに使うCriteriaオブジェクトを引数に持ちます。加えて、モデルのリファクタリングも少しやります。

<?php
// lib/model/JobeetCategory.php
public function getActiveJobsCriteria()
{
  $criteria = new Criteria();
  $criteria->add(JobeetJobPeer::CATEGORY_ID, $this->getId());
 
  return JobeetJobPeer::addActiveJobsCriteria($criteria);
}

getActiveJobsCriteria()メソッドを作ったので、これを使ってJobeetCategoryの他のメソッドがリファクタリングできます。

<?php
// lib/model/JobeetCategory.php
public function getActiveJobs($max = 10)
{
  $criteria = $this->getActiveJobsCriteria();
  $criteria->setLimit($max);
 
  return JobeetJobPeer::doSelect($criteria);
}

public function countActiveJobs()
{
  $criteria = $this->getActiveJobsCriteria();
  $criteria->add(JobeetJobPeer::CATEGORY_ID, $this->getId());
 
  return JobeetJobPeer::doCount($criteria);
}

最後にテンプレートを更新しましょう。

<!-- apps/frontend/modules/category/templates/showSuccess.php -->
<?php use_stylesheet('jobs.css') ?>
 
<div class="category">
  <div class="feed">
    <a href="">Feed</a>
  </div>
  <h1><?php echo $category ?></h1>
</div>
 
<?php include_partial('job/list', array('jobs' => $pager->getResults())) ?>
 
<?php if ($pager->haveToPaginate()): ?>
  <div class="pagination">
    <a href="<?php echo url_for('category', $category) ?>?page=1">
      <img src="/images/first.png" alt="First page" />
    </a>
 
    <a href="<?php echo url_for('category', $category) ?>?page=<?php echo $pager->getPreviousPage() ?>">
      <img src="/images/previous.png" alt="Previous page" title="Previous page" />
    </a>
 
    <?php foreach ($pager->getLinks() as $page): ?>
      <?php if ($page == $pager->getPage()): ?>
        <?php echo $page ?>
      <?php else: ?>
        <a href="<?php echo url_for('category', $category) ?>?page=<?php echo $page ?>"><?php echo $page ?></a>
      <?php endif; ?>
    <?php endforeach; ?>
 
    <a href="<?php echo url_for('category', $category) ?>?page=<?php echo $pager->getNextPage() ?>">
      <img src="/images/next.png" alt="Next page" title="Next page" />
    </a>
 
    <a href="<?php echo url_for('category', $category) ?>?page=<?php echo $pager->getLastPage() ?>">
      <img src="/images/last.png" alt="Last page" title="Last page" />
    </a>
  </div>
<?php endif; ?>
 
<div class="pagination_desc">
  <strong><?php echo $pager->getNbResults() ?></strong> jobs in this category
 
  <?php if ($pager->haveToPaginate()): ?>
    - page <strong><?php echo $pager->getPage() ?>/<?php echo $pager->getLastPage() ?></strong>
  <?php endif; ?>
</div>

このコードのほとんどは他ページへのリンクを処理します。テンプレート内で使われるsfPropelPagerのメソッド一覧は下記になります。

  • getResults(): 現在のページのPropleオブジェクトを配列で返します
  • getNbResults(): 結果の総数を返します
  • haveToPaginate(): 1ページ以上ならtrueを返します
  • getLinks(): 表示すべきページリンクの一覧を返します
  • getPage(): 現在のページ番号を返します
  • getPreviousPage(): 1つ前のページ番号を返します
  • getNextPage(): 次のページ番号を返します
  • getLastPage(): 最終ページ番号を返します

また明日

もし昨日自分で考えた実装と今日学習したことがマッチしていないならもし昨日自分で実装を考えてしまって、今日は学ぶことがほとんどなかったのなら、symfonyの哲学に慣れていることを意味します。symfonyで作られたWebサイトに新しい要素を追加するプロセスはいつも同じです。URLのことを考えて、アクションを生成し、モデルを更新してテンプレートを書きます。もしこの構成がよい開発プラクティスをもたらすのなら、早くsymfonyマスターになれるでしょう。
明日はJobeetの新しい週の始まりです。新しいトピックであるテストについて話せることをお祝いしましょう。