homeASCIIcasts

255: PaperTrailで操作の取り消し 

(view original Railscast)

Other translations: En Es

Other formats:

Written by Naomi Fujimoto

確認のためのダイアログボックスはウェブアプリケーションではよく使われます。ほぼすべてのRailsアプリケーションで「削除(delete)」リンクをクリックすると、その項目を本当に削除したいかを確認するダイアログが表示されます。項目を削除したいから削除リンクをクリックしたわけなので、ほとんどの場合これらの警告は必要ないはずですが、もちろん間違ってリンクをクリックしてしまう場合もあるでしょう。しかし、確認をする代わりに直前の変更を取り消すことができれば、より便利ではないでしょうか? その方がよりスムーズなユーザ体験を提供できるでしょう。

今回のエピソードでは、ActiveRecordのための汎用的な世代管理用ライブラリであるPaperTrailというgemを利用して、この機能を実装してみます。Rails向けには、例えばエピソード177[動画を見る, 読む]で取り上げたVestal Versionsのように取り消し機能専用のフレームワークも存在します。ですが、ここでは取り消し機能を付加する場合により便利なPaperTrailを使用することにします。

PaperTrailをインストールする

PaperTrailをインストールするには、アプリケーションのGemfileに参照情報を追加し、bundleコマンドを実行してインストールします。

/Gemfile

# Edit this Gemfile to bundle your application's dependencies.
source 'http://gemcutter.org'

gem "rails", "3.0.5"
gem "sqlite3-ruby", :require => "sqlite3"
gem "paper_trail"

gemをインストールしたら、PaperTrailのinstallジェネレータを実行します。

$ rails g paper_trail:install

このジェネレータが生成するmigrationファイルに対してrake db:migrateを実行すると、versions(世代)テーブルが作成されます。

PaperTrailを使う

このアプリケーションのProductモデルに世代管理の機能を追加するために、モデルを定義するファイル内でhas_paper_trailを呼び出します。

/app/models/product.rb

class Product < ActiveRecord::Base
  attr_accessible :name, :price, :released_at
  has_paper_trail
end

これでProductモデルは世代管理できるようになるので、どのような変更も取り消しが可能です。

取り消し機能を追加する

PaperTrailのインストールはとても簡単でしたが、アプリケーションに取り消し機能を付加するにはどうすればいいでしょうか? まず初めに、取り消しのリンクで実行されるコントローラアクションが必要です。ProductsControllerに新しいアクションを追加することもできますが、コードをよりきれいに保つために新たにversionsというコントローラを作成します。このアプローチをとることによって、後ほど他のモデルにも簡単に世代管理機能を付加できるようになります。

$ rails g controller versions

このコントローラに必要なものは、世代を一つ戻すためのアクションだけです。

/app/controllers/versions_controller.rb

class VersionsController < ApplicationController
  def revert
    @version = Version.find(params[:id])
    @version.reify.save!    redirect_to :back, :notice => "Undid #{@version.event}"
  end
end

このアクションは、URLから渡される引数idに一致するVersionを返します。次にモデルオブジェクトの特定の世代を得るために、versionインスタンスのreifyメソッドを呼び出します。これが、今回の例では、指定した世代に対応するProductインスタンスを返します。これに対してsave!メソッドを呼び出して、その商品を指定する世代に戻します。その世代を保存したら前のページに戻って、実行された内容をフラッシュメッセージで表示します。@version.eventを呼び出せば、直前に完了したイベントを得ることができます。これによって返されるのは直前に取り消された「作成(create)」、「更新(update)」、「削除(destory)」のいずれかのアクションで、それをメッセージに表示させます。

この新しく作成したアクションにアクセスできるように、routesファイルに新しいルートを追加します。

/config/routes.rb

Store::Application.routes.draw do |map|
  post "versions/:id/revert" => "versions#revert", :as => ?     
       "revert_version"
  resources :products
  root :to => "products#index"
end

ここでpostメソッドを使用していることに注目してください。取り消し処理はデータベースに変更を加える破壊的操作であるため、GETリクエストを受け付けるmatchは使用しません。名前が示すように、postはPOSTリクエストのみに応答します。

更新を取り消す

取り消し処理を行うアクションができたので、ProductsController内でフラッシュ通知にリンクを追加する部分を作成しましょう。まずupdateアクションを作成しましょう。これにより商品情報を更新した人がリンクをクリックすることで変更を取り消すことができるようになります。

取り消しのリンクを作らなくてはいけないのですが、コントローラにどのように記述すればいいでしょうか? 文字列にHTMLタグを埋め込むこともできるのですが、コントローラ内でlink_toを使えればその方がずっときれいでしょう。link_toなどのビューメソッドを直接コントローラ内で使用することはできませんが、view_contextを介してアクセスできるので、view_context.link_toを呼び出してリンクを作成します。このリンクは、revertアクションを実行し、その商品の最後に保存されたバージョンのidを渡します。@product.versionsを呼び出せばある商品のすべての世代を、またlastを呼び出すことでもっとも最近保存された世代を得ることができます。これらの情報を元に、取り消しのリンクを作成してみましょう。revertアクションはPOSTリクエストのみに応答するため、:method => :postを指定していることに注目してください。リンクを作成したので、フラッシュメッセージに追加します。

/app/controllers/products_controller.rb

def update
  @product = Product.find(params[:id])
  if @product.update_attributes(params[:product])
    undo_link = view_context.link_to("undo", ?
      revert_version_path(@product.versions.last), ? 
      :method => :post)
    redirect_to products_url, :notice => ?
      "Successfully updated product."
  else
    render :action => 'edit'
  end
end

ここまでで作成したコードを実際に試してみましょう。リスト中の項目、例えば「牛乳1本」を「牛乳2本」に変更すると、以下のような結果が得られます。

取り消しリンクが表示されたがエスケープされている

項目は変更されましたが、リンクは正しく表示されていません。リンクが生成されましたが、HTMLがエスケープされています。ここでフラッシュメッセージの表示を操作している部分を修正して、内容をエスケープしないようにします。このアプリケーションでは、レイアウトファイルに記述されています。ここでは、メッセージをrawメソッドで囲めば内容がエスケープされません。

/app/views/layouts/application.html.erb

<div id="container">
  <h1><%=h yield(:title) %></h1>
  <%- flash.each do |name, msg| -%>
    <%= content_tag :div, raw(msg), :id => "flash_#{name}" %>
  <%- end -%>      
  <%= yield %>
</div>

ここで気をつけなくてはいけないことがあります。ユーザからの入力をフラッシュメッセージに表示する場合は、忘れずに表示の前にエスケープする必要があります。

再度項目を編集して「牛乳4本」に変更すると、今度は取り消しリンクが正しく表示されました。

リンクが正しく表示された

取り消しリンクをクリックすると、商品は前の世代に戻されました。

更新が取り消された

項目を削除した後の取り消し

次にdestroyアクションに取り消しリンクを追加し、項目の削除後に元に戻せるようにします。updateアクションのときと同じようにリンクを組み立てるので、まず最初にリンクを生成するコードを別のメソッドとして切り離し、updatedestroyの両方から呼び出せるようにします。このメソッドは、アクションとして扱われないようプライベートメソッドにし、undo_linkという名前にします。

/app/controllers/products_controller.rb

class ProductsController < ApplicationController

  #other actions omitted.
  
  def update
    @product = Product.find(params[:id])
    if @product.update_attributes(params[:product])
      redirect_to products_url, ?
        :notice => "Successfully updated product. #{undo_link}"
    else
      render :action => 'edit'
    end
  end
  
  def destroy
    @product = Product.find(params[:id])
    @product.destroy
    redirect_to products_url, ?
      :notice => "Successfully destroyed product. #{undo_link}"
  end
  
  private
  def undo_link
    view_context.link_to("undo", ? 
            revert_version_path(@product.versions.scoped.last), ?
     :method => :post)
  end
end

ここで小さな落とし穴に気をつけてください。レコードを削除しても、product.versionsで参照できる世代リストにはもっとも最近の世代(destroyモデルによって削除された世代)は含まれません。世代はどうも配列にキャッシュされているようです。今回の例では、通常の場合のように@product.versions(true)を呼び出すことでキャッシュをクリアしたいのですが、レコード削除の場合は期待通りに動いてくれません。この問題への解決策として、lastの前にversions配列のscopedを呼び出すようにすれば、常に最新の世代を参照していることになります。

試してみましょう。「牛乳2本」の「削除(Destroy)」リンクをクリックして確認ボタンを押すと、項目が削除されます。

項目が削除された

「取り消し」リンクをクリックすると、削除された項目が復元されます。

削除された項目が復元された

新規作成を取り消す

最後に残ったのはcreateです。updatedestroyでおこなったのと同じように、createアクション内のフラッシュメッセージに取り消しリンクを追加して、どうなるか見てみましょう。

/app/controllers/products_controller.rb

def create
  @product = Product.new(params[:product])
  if @product.save
    redirect_to products_url, :notice => "Successfully created ?
      product. #{undo_link}"
  else
    render :action => 'new'
  end
end

項目を新しく追加してそれを取り消そうとすると、エラーメッセージが表示されました。

新しい項目を取り消そうとするとエラーが発生する

このコードではレコードを保存しようとしていますが、実際に行いたいのは項目の作成を取り消すことです。つまり作成された項目を保存するのではなく、削除したいのです。VersionsControllerのコードを修正して、動作を変更します。

問題は、新規作成された項目に対してreifyを呼び出すと、前の世代が存在しないためnilが返されます。revert アクションのコードを修正して、前の世代が存在するかをチェックし、もしあればそれを保存するように変更します。そうしないと、項目を削除してしまうことになるからです。

/app/controllers/versions_controller.rb

class VersionsController < ApplicationController
  def revert
    @version = Version.find(params[:id])
    if @version.reify
      @version.reify.save!    else
      @version.item.destroy
    end
    redirect_to :back, :notice => "Undid #{@version.event}"
  end
end

前の世代が存在しない場合、@version.itemを呼び出すと商品テーブルから新規作成されたProductが返されます。それに対してdestroyメソッドを呼び出して、それを削除します。

試してみましょう。「ポテトチップ(Chips)」という新規項目を作成します。

新規項目を作成する

「取り消し」リンクをクリックすると、新規作成された項目が削除されます。

新規項目が正しく削除された

取り消した操作をやり直す

これで、商品の作成、更新、削除の後に取り消しができるようになったので、さらに取り消した操作をやり直す機能を追加すればより便利でしょう。やり直しの動作を追加するには、VersionsController内で、操作を取り消したときに表示されるフラッシュメッセージにリンクを追加します。

/app/controllers/versions_controller.rb

class VersionsController < ApplicationController
  def revert
    @version = Version.find(params[:id])
    if @version.reify
      @version.reify.save!    else
      @version.item.destroy
    end
    link_name = params[:redo] == "true" ?"undo" : "redo"
    link = view_context.link_to(link_name, ?
      revert_version_path(@version.next, ?
      :redo => !params[:redo]), :method => :post)
    redirect_to :back, :notice => ?
     ”Undid #{@version.event}. #{link}"
  end
end

ここに記述されているロジックの大部分は、直前の操作が何かによって表示するテキストを変更する処理に関連します。その次にrevert_version_pathを使用してリンクを生成します。リンクは一連の世代のうちの次の世代を参照します。次の世代を選択する理由は、項目を保存あるいは削除すると新しい世代レコードが作成され、それを指定することでやり直し処理を実行できるからです。

ここで動作を試してみましょう。「Flat Screen TV(薄型TV)」を編集し「Flat Screen Television」に名称を変更します。想定どおり、取り消しリンクが表示されます。

商品名を修正してやり直し機能を試す

取り消しリンクをクリックすると前の商品名に戻りますが、今回は同時にやり直しのリンクも表示されます。

変更が取り消される

やり直しのリンクをクリックすることで、変更が再実行され、タイトルが修正された版に再度変わります。

今度は変更がやり直された

「取り消し」と「やり直し」を何度でも繰り返すことができ、タイトルは最後の2つの世代の間を行き来することになります。取り消しとやり直しの機能が完成したので、最後に「削除」リンクから確認のダイアログボックスを取り除きます。

古い世代情報を管理する

このアプリケーションを使用していくうちに、versionsテーブルには世代情報が大量に貯まっていきます。ひとつのテーブルにすべての世代情報を蓄積していれば、次のようなコマンドで簡単に古い世代情報を削除できます。

Version.delete_all["created_at < ?", 1.week.ago]

このコマンドをrakeタスク内に置いて、Whenever gemを利用して定期的に実行することができます。具体的な設定方法は、エピソード164 [動画を見る, 読む]を参照してください。

PaperTrailの優れた特徴として、versionsテーブルに簡単に追加情報を保存することも可能です。作業としては、versionsテーブルに新しい列を追加するだけです。その上でhas_paper_trailメソッドの:metaオプションを使うか、コントローラのinfo_for_paper_trailメソッドに追加のオプションを指定します。versionsテーブルに追加の情報、例えばフラッシュメッセージに表示するために修正されたモデル名など保存したければ、ここにその情報を追加してrevertアクション内で表示することができます。