#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)
Los diálogos de confirmación son comunes en las aplicaciones web. Casi todas las aplicaciones Rails lo muestran cuando hacemos clic en un vínculo “eliminar”, preguntando si estamos seguros que queremos eliminar un elemento. La mayoría de las veces estas alertas son innecesarias, hicimos clic en el vínculo porque queremos eliminar el elemento, pero hay, por supuesto, momentos cuando hacemos clic por error. ¿No sería mejor, sin embargo, si no hay una confirmación sino un enlace ofreciendo deshacer el cambio que hemos realizado? Esto proporcionaría una mejor experiencia de usuario.
En este episodio vamos a implementar este comportamiento utilizando una gema llamada PaperTrail que es una librería genérica de versionado para ActiveRecord. En Rails hay frameworks especificos para deshacer, por ejemplo Vestal Versions que fue cubierto en el episodio 177 [ver, leer]. Usaremos PaperTrail aquí, ya que funciona mejor cuando añadimos soporte para deshacer.
Instalando PaperTrail
Para instalar PaperTrail necesitamos agregar una referencia en nuestro archivo Gemfile
y luego ejecutar el comando bundle
para instalar la gema.
# 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"
Luego de instalar la gema, ejecutamos el generador instalador de PaperTrail.
$ rails g paper_trail:install
Este generador crea una migración que creará una tabla versions
una vez que ejecutemos rake db:migrate
.
Utilizando PaperTrail
Para agregar versionado a un modelo, como al modelo Product
de esta aplicación, agregamos una llamada a has_paper_trail
en el archivo del modelo.
class Product < ActiveRecord::Base attr_accessible :name, :price, :released_at has_paper_trail end
El modelo Product
será ahora versionado, por lo que cualquier cambio en él puede ser deshecho.
Agregando Funcionalidad de Deshacer
Instalar PaperTrail es bastante simple, pero ¿cómo hacemos para agregar la funcionalidad de deshacer a nuestra aplicación? Para empezar vamos a necesitar una acción a la cual apunte nuestro vínculo de deshacer. Podemos agregar una nueva acción al controlador ProductsController
, pero para mantener el código limpio, crearemos un nuevo controlador llamado versions
. De esta forma podemos luego añadir fácilmente versionado a otros modelos.
$ rails g controller versions
Sólo necesitamos una acción en este controlador para poder revertir una versión.
class VersionsController < ApplicationController def revert @version = Version.find(params[:id]) @version.reify.save! redirect_to :back, :notice => "Undid #{@version.event}" end end
En esta acción obtenemos la Version
que coincide con el parámetro id
de la URL. Luego queremos obtener el objeto del modelo específico a esa versión llamando reify
sobre el objeto version. En este caso retornará la instancia de Product
a la que se refiere la versión. Podemos llamar luego a save!
para revertir el producto a esa versión. A continuación redirigimos a la página anterior y asignamos un mensaje flash que nos describa que acaba de suceder. Podemos obtener el evento que se ha completado llamando a @version.event
. Este retornará “create”, “update” or “destroy” dependiendo de la acción que se ha revertido y lo pondremos en el mensaje
Necesitamos poder acceder a esta nueva acción asi que agregamos una nueva ruta al archivo routes
.
Store::Application.routes.draw do |map| post "versions/:id/revert" => "versions#revert", :as => "revert_version" resources :products root :to => "products#index" end
Nota que hemos utilizado el metodo post
. Estamos realizando algo potencialmente destructivo en la acción revert ya que modifica la base de datos, por lo tanto no usaremos match
porque aceptaría pedidos GET. Como el nombre implica post únicamente responde a pedidos POST.
Deshaciendo Actualizaciones
Ahora que tenemos una acción que manejará el comportamiento de deshacer, es momento de agregar un vínculo a los mensajes flash dentro de ProductsController
. Comenzaremos con la acción update
asi cuando alguien actualiza un producto, puede hacer clic en el vínculo y deshacer los cambios realizados.
Necesitamos crear el vínculo deshacer, pero ¿cómo lo hacemos en el controlador? Podríamos colocar HTML dentro de un string pero sería mejor poder utilizar link_to
. No podemos utilizar métodos como link_to
directamente en los controladores, pero podemos acceder a ellos a través de view_context
asi que llamamos a view_context.link_to
para crear un vínculo. Este vínculo apuntará a la acción revert
y le pasará el id
de la última versión guardada de ese producto. Podemos llamar @product.versions
para obtener todas las versiones de un producto y llamar a last
para obtener la versión más reciente. Conociendo esto podemos ahora crear el vínculo a deshacer. Notar que incluimos :method => :post
pues la acción revert
sólo responderá a pedidos POST. Luego de construir el vínculo, podemos agregar el mismo al mensaje flash.
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
Tenemos suficiente código para probar. Si editamos uno de los productos de la lista, por ejemplo cambiando “1 Pint of Milk” por “2 Pints of Milk”, obtendremos el siguiente resultado.
El producto ha cambiado pero el vínculo no ha funcionado aún. El vínculo esta ahí, pero el HTML ha sido escapado. Para arreglar esto debemos ir al lugar de la aplicación que muestra los mensajes flash y modificarlo, de modo que no escape su contenido. En esta aplicación esto sucede en el archivo de layout. Todo lo que debemos hacer es llamar al método raw
de forma que el contenido no sea escapado.
<div id="container"> <h1><%=h yield(:title) %></h1> <%- flash.each do |name, msg| -%> <%= content_tag :div, raw(msg), :id => "flash_#{name}" %> <%- end -%> <%= yield %> </div>
Debemos ser cuidadosos haciendo esto. Si cualquier entrada del usuario es desplegada en el mensaje flash, no será escapada por lo que debemos escaparla antes de mostrarla.
Si editamos nuevamente el producto, cambiando a “4 Pints of Milk” esta vez el vínculo a deshacer se muestra correctamente.
Cuando hacemos clic en el vínculo de deshacer el producto es revertido a su versión anterior.
Deshaciendo Luego de Eliminar un Elemento
Ahora agregaremos el vínculo de deshacer a la acción destroy
asi podemos recuperar un elemento luego de eliminarlo. Construiremos el vínculo de la misma forma que hicimos en la acción update
por lo que primeramente moveremos el código que crea el vínculo a un método separado al que podamos llamar update
y destroy
. Haremos privado al método de forma de que no sea considerado una acción y lo llamaremos 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
Hay una pequeña trampa aquí. Cuando estamos eliminando un registro product.versions
aun refiere a una lista de versiones que no incluye la versión más reciente (la versión que fue eliminada por destroy
). Parece que las versiones son cacheadas en un array. En este caso deberíamos poder llamar a @product.versions(true)
para evitar el cache pero esto no funciona como esperamos cuando eliminamos registros. Para evitar esto podemos llamar scoped
en el array de veriones antes de llamar a last
y esto significa que siempre nos estamos refiriendo a la versión más reciente.
Vamos a probar esto. Hacemos clic en el vínculo “Destroy” para “2 Pints of Milk” y confirmamos la eliminación del elemento.
Cuando hacemos click en el vínculo “undo” el elemento eliminado es reintegrado.
Deshaciendo un Create
Hay una acción más que implementar: create
. Probemos añadir el vínculo al mensaje flash en la acción create
tal como lo realizamos en update
y destroy
y veamos que pasa.
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
Cuando agregamos un nuevo producto y tratamos de deshacerlo, obtenemos un mensaje de error.
El código esta tratando de guardar un registro pero nosotros estamos tratando de deshacer la creación de un producto, pero lo que queremos es destruir el elemento creado en lugar de guardarlo. Necesitamos modificar el codigo en VersionsController
y cambiar su comportamiento.
El problema es que cuando llamamos reify
en un objeto recientemente creado, retorna nil
ya que no hay versiones previas. Necesitamos modificar en código en la acción revert
de forma que compruebe si esa versión previa existe, y si lo hace, guardarla. En otro caso elimina el elemento.
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
Si no existen versiones previas llamamos a @version.item
que retornará el nuevo Product
de la tabla products. Podemos luego llamar a destroy
para eliminarlo.
Vamos a probar. Creamos un nuevo producto llamado “Chips”.
Cuando hacemos clic en vínculo “undo” el nuevo objeto es eliminado.
Rehaciendo el Deshacer
Ahora que podemos deshacer luego de crear, actualizar o eliminar un producto, seria muy útil agregar la habilidad de reahacer la acción que hemos deshecho. Para agregar el comportamiento de rehacer debemos ir a VersionsController
y agregar un vínculo al mensaje flash que se muestra cuando deshacemos una acción.
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
La mayor parte de la lógica aquí tiene que ver con cambiar el texto en el vínculo dependiendo de la acción previa. Luego generamos el vínculo que utiliza revert_version_path
y el que apunta a la siguiente versión del elemento en la lista de versiones. Selecciona la siguiente versión porque cuando guardamos o eliminamos un elemento creará una nueva versión y esa es la que necesitamos para el comportamiento de rehacer.
Vamos a probar estos cambios. Editamos “Flat Screen TV” y cambiamos su nombre a “Flat Screen Television”. Como es de esperar, nos muestra un vínculo a deshacer.
Cuando hacemos clic en el vínculo de deshacer el producto se revierte a su estado anterior.
Haciendo clic en el vínculo rehacer, reahace los cambios realizados en el título.
Podemos pasar entre “undo” y “redo” cuantas veces queramos. Ahora que tenemos la capacidad de deshacer y reahacer podemos finalmente eliminar el dialogo de confirmación de los vínculos de eliminar.
Administrar Versiones Antigüas
Con la utilización de la aplicación los datos de las versiones crearán mucha información. Guardar todas las versiones en una única tabla hace fácil eliminar versiones antigüas con un comando como el siguiente:
Version.delete_all["created_at < ?", 1.week.ago]
Podemos colocar el comando anterior en una tarea rake y ejecutarla de forma regular con la gema Whenever. Hecha un vistazo al episodio 164 [ver, leer] para más información.
Otra excelente funcionalidad de PaperTrail es que hace fácil guardar información adicional en la tabla de versiones. Todo lo que debemos hacer es agregar una nueva columna a la tabla de versiones. Podemos luego usar la opción :meta
del método has_paper_trail
o utilizar el método info_for_paper_trail
en el controlador y suministrar las opciones adicionales ahí. Si deseamos agregar información adicional en la tabla de versiones, como el nombre del modelo modificado de forma de mostrarlo en el mensaje flash podemos agregar esa información aquí y desplegarla en la acción revert
.