#255 Undo with Paper Trail
- Download:
- source codeProject Files in Zip (124 KB)
- mp4Full Size H.264 Video (21.6 MB)
- m4vSmaller H.264 Video (14.4 MB)
- webmFull Size VP8 Video (37 MB)
- ogvFull Size Theora Video (31 MB)
確認のためのダイアログボックスはウェブアプリケーションではよく使われます。ほぼすべてのRailsアプリケーションで「削除(delete)」リンクをクリックすると、その項目を本当に削除したいかを確認するダイアログが表示されます。項目を削除したいから削除リンクをクリックしたわけなので、ほとんどの場合これらの警告は必要ないはずですが、もちろん間違ってリンクをクリックしてしまう場合もあるでしょう。しかし、確認をする代わりに直前の変更を取り消すことができれば、より便利ではないでしょうか? その方がよりスムーズなユーザ体験を提供できるでしょう。
今回のエピソードでは、ActiveRecordのための汎用的な世代管理用ライブラリであるPaperTrailというgemを利用して、この機能を実装してみます。Rails向けには、例えばエピソード177[動画を見る, 読む]で取り上げたVestal Versionsのように取り消し機能専用のフレームワークも存在します。ですが、ここでは取り消し機能を付加する場合により便利なPaperTrailを使用することにします。
PaperTrailをインストールする
PaperTrailをインストールするには、アプリケーションのGemfile
に参照情報を追加し、bundle
コマンドを実行してインストールします。
# 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
を呼び出します。
class Product < ActiveRecord::Base attr_accessible :name, :price, :released_at has_paper_trail end
これでProduct
モデルは世代管理できるようになるので、どのような変更も取り消しが可能です。
取り消し機能を追加する
PaperTrailのインストールはとても簡単でしたが、アプリケーションに取り消し機能を付加するにはどうすればいいでしょうか? まず初めに、取り消しのリンクで実行されるコントローラアクションが必要です。ProductsController
に新しいアクションを追加することもできますが、コードをよりきれいに保つために新たにversions
というコントローラを作成します。このアプローチをとることによって、後ほど他のモデルにも簡単に世代管理機能を付加できるようになります。
$ rails g controller versions
このコントローラに必要なものは、世代を一つ戻すためのアクションだけです。
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
ファイルに新しいルートを追加します。
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
を指定していることに注目してください。リンクを作成したので、フラッシュメッセージに追加します。
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
メソッドで囲めば内容がエスケープされません。
<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
アクションのときと同じようにリンクを組み立てるので、まず最初にリンクを生成するコードを別のメソッドとして切り離し、update
とdestroy
の両方から呼び出せるようにします。このメソッドは、アクションとして扱われないようプライベートメソッドにし、undo_link
という名前にします。
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
です。update
とdestroy
でおこなったのと同じように、create
アクション内のフラッシュメッセージに取り消しリンクを追加して、どうなるか見てみましょう。
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
アクションのコードを修正して、前の世代が存在するかをチェックし、もしあればそれを保存するように変更します。そうしないと、項目を削除してしまうことになるからです。
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
内で、操作を取り消したときに表示されるフラッシュメッセージにリンクを追加します。
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
アクション内で表示することができます。