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

ソート可能なリストを作る方法

1.1
Symfony version Language

概要

多くのWebアプリケーションはアイテムを並べ替えるインターフェイスを提供する必要があります。weblogのカテゴリ、CMSの記事、EコマースWebサイトのウィッシュリストなどを考えてみましょう。旧式の方法はリストで1つのアイテムを上げ下げする矢印を提供することです。AJAXによる方法はサーバーのサポートで直接ドラッグアンドドロップをして並べ替えできることです。このチュートリアルでは、オブジェクトモデルを強化する方法とCreoleを使って複雑なクエリを行う方法の両方といくつかのティップを説明します。

古典的でAJAXなソート可能なリスト

あなたが必要なもの

データ構造

この記事のために、使用される例ではItemテーブルが未定義で、テーブルの名前は好きなようにつけてください。ソート可能にするために、レコードは少なくともrankフィールドが必要です、ソートはコンピュータではなくユーザーによって行われるのでここでは[ヒープ]は必要ありません。データ構造(schema.ymlで書かれています)はシンプルです:

propel:
  test_item:
    _attributes: { phpName: Item }
    id:
    name:        varchar(255)
    rank:        { type: integer, required: true }

データ構造が定義されたらコマンドラインインターフェイスでモデルを必ずビルドしてください:

$ symfony propel-build-model

同じ構造を持つデータベースも1つ必要です。最速の方法は次のコマンドを実行することです:

$ symfony propel-build-sql
$ symfony propel-insert-sql

モデルを拡張する

ユーザーインターフェイスを考える前に、lib/model/ItemPeer.phpに次のメソッドを追加することで、ランクによってアイテムを取得する、ランクによって並べ替えられたアイテムのリストを取得する、現在の最高ランクを取得する方法があることを確認してください:

static function retrieveByRank($rank = 1)
{
  $c = new Criteria;
  $c->add(self::RANK, $rank);
  return self::doSelectOne($c); 
}
 
static function getAllByRank()
{
  $c = new Criteria;
  $c->addAscendingOrderByColumn(self::RANK);
  return self::doSelect($c); 
}
 
static function getMaxRank()
{
  $con = Propel::getConnection(self::DATABASE_NAME);
  $sql = 'SELECT MAX('.self::RANK.') AS max FROM '.self::TABLE_NAME; 
  $stmt = $con->prepareStatement($sql);
  $rs = $stmt->executeQuery();
 
  $rs->next();
  return $rs->getInt('max');
}

これらのメソッドは両方のソート機能のインターフェイスのために多いに役立ちます。オブジェクトモデルがデータベースを扱う方法に関してもっと詳しい情報が必要でしたら、Propelの基本的なCRUDの章を確認してください。

lib/model/Item.phpに追加する必要のあるメソッドがさらに2つあります。これらはここでは必要ありませんが、テーブルにアイテムを追加したり削除する実在のアプリケーションではおそらく必要でしょう:

public function save($con = null)
{
  // 新しいレコードは rank = maxRank +1 で初期化する必要がある
  if(!$this->getId())
  {
    $con = Propel::getConnection(ItemPeer::DATABASE_NAME);
    try
    {
      $con->begin();
 
      $this->setRank(ItemPeer::getMaxRank()+1);
      parent::save();
 
      $con->commit();
    }
    catch (Exception $e)
    {
      $con->rollback();
      throw $e;
    }
  }
  else
  {
    parent::save(); 
  }
} 
 
public function delete($con = null)
{  
  $con = Propel::getConnection(PagePeer::DATABASE_NAME);
  try
  {
    $con->begin();
 
    // より高いランクで同じカテゴリのページレコードのランクのすべてを減らす
    $sql = 'UPDATE '.ItemPeer::TABLE_NAME.' SET '.ItemPeer::RANK.' = '.ItemPeer::RANK.' - 1 WHERE '.ItemPeer::RANK.' > '.$this->getRank();
    $con->executeQuery($sql);
    // アイテムを削除する
    parent::delete();
 
    $con->commit();
  }
  catch (Exception $e)
  {
    $con->rollback();
    throw $e;
  }
}

レコードの追加と削除はrankフィールドの整合性のために注意深く管理されなければなりません。専用のsave()delete()メソッドがあるのはそういうわけです。これらのメソッドは複雑な読み込み/書き込みの実行を行い、同時並行処理の問題を作るので、これらの実行はトランザクションで行われます(symfonyにおけるトランザクションについての詳細な情報はPropelのドキュメントを参照してください)。

モデルを準備する

このチュートリアルで説明されたインタラクションはitemモジュールで行われます。次のコードを呼び出して初期化します(frontendアプリケーションであることを前提とします):

$ symfony init-module frontend item

好きなブラウザを通して新しいモジュールへのアクセスをテストしてWebサーバーの環境設定がうまくいっていることを確認します。サンドボックスでこのチュートリアルに従った場合に確認するURLは以下の通りです:

http://localhost/sf_sandbox/web/frontend_dev.php/item

最後に、アイテムの並べ替えをテストしたいのであれば、アイテムが必要です。CRUDインターフェイスもしくは投入ファイルを通して一連のテスト項目を作ります。

すべての準備が整いました。始めましょう。

ソート可能で古典的なリスト

ソート可能で古典的なリストは順序を変更するためにコントロール機能を持つリストです。最初に、リストを表示するアクションとテンプレートを作ります:

// modules/item/actions/actions.class.phpに追加する
public function executeList()
{
  $this->items = ItemPeer::getAllByRank();
  $this->max_rank = ItemPeer::getMaxRank();
}
 
// modules/item/templates/にあるlistSuccess.phpテンプレートを作る
<h1>Ordered list of items</h1>
<ul>
<?php foreach($items as $item): ?>
  <li>
    <?php 
      echo $item->getName().' ';
      if($item->getRank() > 0): 
        echo link_to('Move up ', 'item/up?id='.$item->getId()); 
      endif;
      if($item->getRank() != $max_rank):
        echo link_to('Move down', 'item/down?id='.$item->getId());
      endif;
    ?>
  </li>
<?php endforeach ?>
</ul>

アイテムを上げ下げするリンクは再度並べ替えが可能であるときのみ表示されます。このことは最初のアイテムをさらに上げることができず、最後のアイテムはさらに下げることはできないことを意味します。正しくページが表示されるか確認してください:

http://localhost/sf_sandbox/web/frontend_dev.php/item/list

では、item/upitem/downアクションを見てましょう。upアクションはパラメータとして渡されるページのランクを減らし、以前のページのランクを増やします。downアクションはパラメータとして渡されるページのランクを増やし、次のページのランクを減らします。両方ともデータベースで2つの書き込み実行を行うので、これらのアクションはトランザクションを使用します。

2つのアクションはとても似たようなロジックを持ち、D.R.Yを保ちたい場合、コードを繰り返さずに書くスマートな方法が見つかります。これはItem.phpモデルクラスにswapWith()メソッドを追加することです:

public function swapWith($item)
{
  $con = Propel::getConnection(ItemPeer::DATABASE_NAME);
  try
  {
    $con->begin();
 
    $rank = $this->getRank();  
    $this->setRank($item->getRank());
    $this->save();
    $item->setRank($rank);
    $item->save();
 
    $con->commit();
  }
  catch (Exception $e)
  {
    $con->rollback();
    throw $e;
  }
} 

そして、updownアクションはとてもシンプルです:

public function executeUp()
{
  $item = ItemPeer::retrieveByPk($this->getRequestParameter('id'));
  $this->forward404Unless($item);
  $previous_item = ItemPeer::retrieveByRank($item->getRank() - 1);
  $this->forward404Unless($previous_item);
  $item->swapWith($previous_item);
 
  $this->redirect('item/list');
}  
 
public function executeDown()
{
  $item = ItemPeer::retrieveByPk($this->getRequestParameter('id'));
  $this->forward404Unless($item);
  $next_item = ItemPeer::retrieveByRank($item->getRank() + 1);
  $this->forward404Unless($next_item);
  $item->swapWith($next_item);
 
  $this->redirect('item/list');
}

セキュリティのためにforward404Unless()へのコールによってチェックが行われない場合、これらのアクションはよりシンプルになりますが、間違ったリクエスト - URLを直接入力することで行われます - に対してアプリケーションを守らなければなりません。

リストはこれで十分に機能します。リストのアイテム宇を上げ下げしてみてください。

AJAXによるソート可能なリスト

基本

Ajaxによる基本的なソート可能なリストの開発は古典的なリストよりも難しくありません。たいていの仕事はsortable_element()と呼ばれる特別なJavaScriptヘルパーによって取り扱われます:

// modules/item/actions/actions.class.phpに追加する
public function executeAjaxList()
{
  $this->items = ItemPeer::getAllByRank();
}
 
// modules/item/templates/にajaxListSuccess.phpテンプレートを作成する
<?php use_helper('Javascript') ?>
<style>
  .sortable { cursor: move; }
</style>
<h1>Ordered list of items - AJAX enabled</h1>
<ul id="order">
  <?php foreach($items as $item): ?>
  <li id="item_<?php echo $item->getId() ?>" class="sortable">
    <?php echo $item->getName() ?>
  </li>
  <?php endforeach ?>
</ul>
<div id="feedback"></div>
<?php echo sortable_element('order', array(
  'url'    => 'item/sort',
  'update' => 'feedback',
)) ?>

次のURLを入力して結果を確認します:

http://localhost/sf_sandbox/web/frontend_dev.php/item/ajaxlist

sortable_element() JavaScriptヘルパーのマジックによって、<ul>要素はソート可能になります。このことはその子要素はドラッグドロップによって再度並べ替えできることを意味します。ユーザーがリストを並べ替えするためにアイテムをドラッグして放すたびに、AJAXリクエストが次のパラメータで行われます:

POST /sf_sandbox/web/frontend_dev.php/item/sort HTTP/1.1
  order[]=1&order[]=3&order[]=2&order[]=4&order[]=5&order[]=6&_=

並べ替えられたリストのすべては配列として渡されます(idプロパティのリスト要素にあるアンダースコア(_)の後で来たものに基づいて0と$idで始まるorder[$rank]=$id$orderのフォーマット)。ソート可能な要素(この例ではorder)のidプロパティはパラメータの配列を命名するために使用されます。JavaScriptヘルパーはXMLHttpRequestをurlアクション(例ではitem/sort)に変え、POSTモードで並べられたリストを渡し、id update(この例ではfeedbackdiv)の要素を更新するアクションの結果を使用します。

AJAXリクエストを扱う

item/sortアクションを書き、アイテムのリストの並べ替えがどのように行われるのか見てみましょう:

// modules/item/actions/actions.class.phpに追加する
public function executeSort()
{
  $order = $this->getRequestParameter('order');
  $flag = ItemPeer::doSort($order);
  return $flag ? sfView::SUCCESS : sfView::ERROR;
}

リスト全体を並べ替える機能はモデルの一部です。ItemPeerクラスのスタティックメソッドが実行される理由はそういうわけです。繰り返しますが、このメソッドがitemテーブルの多くのレコードを更新するという事実があるのでデータベーストランザクションで更新を含めることが必要です。

static function doSort($order)
{
  $con = Propel::getConnection(self::DATABASE_NAME);
  try 
  {
    $con->begin();
 
    foreach ($order as $rank => $id) 
    {
      $item = ItemPeer::retrieveByPk($id);
      if($item->getRank() != $rank)
      {
        $item->setRank($rank);
        $item->save();
      }
    }
 
    $con->commit();
    return true;    
  }
  catch (Exception $e)
  {
    $con->rollback();
    return false;
  }
}

このメソッドによって返された値はアクションが表示するテンプレートを決定します。modules/item/templates/フォルダに次のテンプレートを追加します:

// sortSuccess.php
Ok
 
// sortError.php
<strong>A problem occurred. Please refresh and try again.</strong>

リストを並べ替えた後にF5を押してサーバーのハンドリングをテストします。サーバーが理解しAJAXリクエストが送信した内容を正しく保存したのであれば、順序は変更されません。

sortable_element()オプションに注目する

JavaScriptヘルパーの章はリモート機能のコールの一般的なオプションを説明していますが、この例はsortable_element()の詳細を見る良い機会です。

hoverclassパラメータでホーバーされたリストの要素に他の要素をドラッグしているとき異なる外見を定義できます:

<?php use_helper('Javascript') ?>
<style>
  .sortable { cursor: move; }
  .hovered  { font-weight: bold; }
</style>
...
<?php echo sortable_element('order', array(
  'url'        => 'item/sort',
  'hoverclass' => 'hovered',
)) ?>

リストにソートできない要素を追加しonlyパラメータによってのみ単独のクラスにドラッグアンドドロップのふるまいを制限することができます:

...
<ul id="order">
  <?php foreach($items as $item): ?>
  <li id="item_<?php echo $item->getId() ?>" class="sortable">
    <?php echo $item->getName() ?>
  </li>
  <?php endforeach ?>
  <li>This element is not part of the ordered list</li>
</ul>
<?php echo sortable_element('order', array(
  'url'    => 'item/sort',
  'only'   => 'sortable',
)) ?>

前の例においてリスト要素が上下に表示されない場合、overlapパラメータをhorizontalに設定しなければなりません:

<?php use_helper('Javascript') ?>
<style>
  .sortable { cursor: move; float: left; }
</style>
...
<?php echo sortable_element('order', array(
  'url'     => 'item/sort',
  'overlap' => 'horizontal',
)) ?>

並べ替えるリストが<li>要素のセットではない場合、ドラッグ可能にするソート可能な要素の子要素を定義しなければなりません:

...
<div id="order">
  <?php foreach($items as $item): ?>
  <div id="item_<?php echo $item->getId() ?>" class="sortable">
    <?php echo $item->getName() ?>
  </div>
  <?php endforeach ?>
  <p>This cannot be dragged</p>
</div>
<?php echo sortable_element('order', array(
  'url'    => 'item/sort',
  'tag'    => 'div',
)) ?>

すべてのAJAXアクションのために、バックグラウンドの活動とリクエストの成功を可視化するフィードバックを用意するのは良いことです:

<div id="feedback"></div>
<div id="indicator" style="display:none;"><img src="/legacy/images/activity_indicator.gif" style="display:none;"/></div>
<?php echo sortable_element('order', array(
  'url'      => 'item/sort',
  'update'   => 'feedback',
  'loading'  => "Element.show('indicator')",
  'complete' => "Element.hide('indicator')",
  'success'  => visual_effect('highlight', 'feedback'),
)) ?>

これらのパラメータについて詳細な内容とここでは説明していないその他の内容については、script.aculo.usのSortableのマニュアルを参照してください。

比較

リストを並べ替えるために2つの方法の両方が効果的ですが、制限と欠点があります。

アイテムの大きな配列のために、おそらくはページ分割されたリストが必要です。古典的な方法はページ単位のリストで立派に動作しますが、AJAXによる方法では適用が必要で、独自ページの外側から要素を再び並べ替えることが不可能です。AJAXの並べ替えインターフェイスに加えて'アイテムを位置に移動させる'機能を提供するのはそういうわけです。

古典的なアクションと同様にAJAXアクションは間違ったリクエストに対して保護されません。データベースの矛盾のリスクを避けるために、itemActionsクラスにvalidateSort()メソッドを追加します。このメソッドはすべてのアイテムのidとそれらだけが、受け取る配列に一度だけ登場することをチェックします。

AJAXソート機能で使用されるItemPeer::doSort()メソッドの欠点の一つはリストを並べ替えるときに必要なクエリの回数です。n個のアイテムにおいてそれぞれの活動によって少なくともデータベースにn+2回のクエリが行われます。AJAXリストは長いリストに適用されないので、主要な問題ではないのかもしれませんが、パフォーマンスが問題になる場合、たとえば、UPDATE table SET CASE/WHEN SQL構文を使用するといった一つのクエリだけでランクを更新するようにこのメソッドをリファクタリングしなければなりません。

2つの動作の間のサーバーのリフレッシュは義務ではないので、AJAXインターフェイスはとりわけ長いタスクの並べ替えといったことにとてもユーザーフレンドリです。しかし、要素をドラッグする機能はWebインターフェイスでは新しく、その機能を使っていないユーザーは驚くかもしれません。さらに、AJAXインターフェイスを選ぶ場合、ドラッグ可能な要素のサイズについて考えなければならない場合(それらは掴むのに十分な大きさが必要です)、それらの外見、運動の自由度・・・古典的な方法で解決する必要が無かった多くの人間とコンピュータの間のインタラクションの問題があります。

ターゲットとしているユーザーがブラウザでJavaScriptsをオフにしている場合はAJAXインタラクションには常に問題があります。JavaScriptインターフェイスのデザインに加えて、フェイルセーフのために(degraces gracefully)代替の古典的なインターフェイスを提供しなければなりません。

すべてにおいて、AJAXバージョンは本当にルックアンドフィールが優れますが、少なくとも開発期間は2倍長くなります。