#217 Multistep Forms
- Download:
- source codeProject Files in Zip (94.3 KB)
- mp4Full Size H.264 Video (22.7 MB)
- m4vSmaller H.264 Video (15.7 MB)
- webmFull Size VP8 Video (44.8 MB)
- ogvFull Size Theora Video (31.4 MB)
Un formulario multipaso, también conocido como wizard o asistente, es un formulario que debido a su tamaño ha sido dividido en una serie de páginas o pantallas por las cuales los usuarios deben navegar hasta completarlo. Este enfoque tiene sus detractores: hay quien prefiere mostrar el formulario completo o quienes prefieren dividir dicho formulario en diferentes recursos y modelos. Pero hay casos en los que son la mejor solución, por ejemplo un formulario de declaración de impuestos que necesita que el usuario introduzca muchos datos y donde el camino de pasos a seguir no es lineal sino que depende de los datos introducidos.
Un formulario multipaso debe recordar los datos introducidos de un paso a otro de forma que si el usuario se mueve adelante y atrás a lo largo de sus páginas no debería perder la información que ha introducido. Si tan sólo queremos separar un formulario largo para simplificar la interfaz de usuario podríamos usar una combinación de JavaScript y CSS para mostrar y ocultar las diferentes partes del formulario según se pulsen los botones “anterior” y “siguiente”. En el sitio de desarrolladores de Apple se puede encontrar un ejemplo de esto . Si nos hace falta algo más dinámico y orientado al servidor no nos quedará más remedio que pasar por Rails. En este episodio veremos cómo se hace.
Un formulario de pedido multipaso
Como demostración de formulario multipaso vamos a añadir un proceso de compra a una aplicación de tienda. Los usuarios rellenarán primero los datos de envío, luego la dirección de facturación y por último, en el tercer paso, verán un resumen del pedido para su confirmación.
Como no tenemos todavía un modelo para el pedido ni su controlador usaremos uno de los nifty generators de Ryan Bates para crear un andamiaje para el formulario del pedido. Nótese que esta aplicación está escrita con Rails 2, por lo que usaremos script/generate
. Para los propósitos de nuestro ejemplo, el pedido tendrá dos datos (en el mundo real un modelo para un pedido tendría muchos más atributos). También crearemos las acciones index
, show
y new
de este controlador.
$ script/generate nifty_scaffold order shipping_name:string billing_name:string index show new
A continuación migraremos la base de datos para crear la nueva tabla.
$ rake db:migrate
El formulario de pedido que se generará tendrá todos los campos amontonados en una sóla página, por lo que lo primero que tenemos que hacer es separarlo en múltiples pasos.
Empezaremos moviendo cada uno de los tres elementos de párrafo y sus campos de formulario en sus propios parciales.
<% title "New Order" %> <% form_for @order do |f| %> <%= f.error_messages %> <p> <%= f.label :shipping_name %><br /> <%= f.text_field :shipping_name %> </p> <p> <%= f.label :billing_name %><br /> <%= f.text_field :billing_name %> </p> <p><%= f.submit "Submit" %></p> <% end %> <p><%= link_to "Back to List", orders_path %></p>
Antes de hacerlo tendremos que añadir una cabecera a cada sección y modificar la sección final para que muestre un resumen del pedido.
<% title "New Order" %> <% form_for @order do |f| %> <%= f.error_messages %> <h2>Shipping Information</h2> <p> <%= f.label :shipping_name %><br /> <%= f.text_field :shipping_name %> </p> <h2>Billing Information</h2> <p> <%= f.label :billing_name %><br /> <%= f.text_field :billing_name %> </p> <h2>Confirm Information</h2> <p> <strong>Shipping Name:</strong> <%= h @order.shipping_name %> </p> <p> <strong>Billing Name:</strong> <%= h @order.billing_name %> </p> <p><%= f.submit "Submit" %></p> <% end %> <p><%= link_to "Back to List", orders_path %></p>
Con esto podemos empezar a mover cada seccion a su propio parcial. Crearemos tres nuevos parciales llamados shipping_step
, billing_step
y confirmation_step
, copiando lo que haga falta del formulario. Después de esto nuestro formulario de nuevo pedido tendrá este aspecto:
<% title "New Order" %> <% form_for @order do |f| %> <%= f.error_messages %> <%= render 'shipping_step', :f => f %> <%= render 'billing_step', :f => f %> <%= render 'confirmation_step', :f => f %> <p><%= f.submit "Submit" %></p> <% end %> <p><%= link_to "Back to List", orders_path %></p>
Y los tres parciales serán así:
<h2>Billing Information</h2> <p> <%= f.label :billing_name %><br /> <%= f.text_field :billing_name %> </p>
<h2>Shipping Information</h2> <p> <%= f.label :shipping_name %><br /> <%= f.text_field :shipping_name %> </p>
<h2>Confirm Information</h2> <p> <strong>Shipping Name:</strong> <%= h @order.shipping_name %> </p> <p> <strong>Billing Name:</strong> <%= h @order.billing_name %> </p>
También tendremos qie modificar el modelo Order
para que sepa acerca de cada uno de los pasos y pueda identificar cuál es el paso actual. Una posibilidad sería utilizar un plugin de máquina de estados, pero dado que este ejemplo es muy sencillo podemos escribir el código desde cero.
El modelo Order
tendrá que saber cuáles son los pasos y el orden en que deberían aparecer, para eso escribiremos un méotod steps
que devolverá la lista de pasos. Esta lista no será más que un array pero en el caso de formularios más complejos podríamos hacer que este método fuese más dinámico y controlase casos en los que la lista de pasos cambiase dependiendo de algún dato.
Para obtener y cambiar el paso actual de un pedido crearemos un método de escritura llamado current_step
y su correspondiente método de acceso que devolverá el paso actual (o el primero si es que hemos empezado). Tras estos cambios, el modelo Order
tiene lo siguiente:
class Order < ActiveRecord::Base attr_accessible :shipping_name, :billing_name attr_writer :current_step def current_step @current_step || steps.first end def steps %w[shipping billing confirmation] end end
Ahora que sabemos en qué paso estamos, podemos utilizar dicha información para cambiar el parcial que se muestra modificando el formulario de nuevo pedido:
<% title "New Order" %> <% form_for @order do |f| %> <%= f.error_messages %> <%= render "#{@order.current_step}_step", :f => f %> <p><%= f.submit "Submit" %></p> <% end %> <p><%= link_to "Back to List", orders_path %></p>
Si ahora recargamos el formulario veremos que se muestra tan sólo el primer paso.
Movimiento por los pasos
Si ahora pulsamos el botón de envío el formulario invocará la acción create
, creando un nuevo pedido. No queremos que sea esto lo que ocurra sino que se muestre el siguente paso del formulario, así que tenemos que cambiar el comportamiento del controlador.
Podemos enfocar este problema de distintas maneras. Por ejemplo podríamos crear una acción separada para cada uno de los pasos y utilizar create
para el paso inicial y las acciones edit
y update
para los otros pasos, lo que significaría que tendríamos en la base de datos un modelo a medio completar o podríamos usar solo las acciones new
y create
, guardando los datos de entrada del usuario en la sesión. Esta técnica no está libre de problemas, los veremos (y sus alternativas) poco a poco.
La solución más sencilla parasaría por modificar la acción create
. Lo primero que haremos será cambiarlo de forma que no guarde el pedido sino que en su lugar muestre el primer paso del formulario.
def create @order = Order.new(params[:order]) @order.next_step render 'new' end
En el controlador, invocaremos el método next_step
sobre Order
:
def next_step self.current_step = steps[steps.index(current_step)+1] end
Cuando ahora pulsemos el botón de envío iremos del primer al segundo paso pero si volvemos a pulsar en el botón permaneceremos en el segundo paso, porque no estamos guardando el paso en el que estamos cuando se envía el formulario. En la acción create
tenemos que establecer el paso actual en el modelo Order
y luego guardar ese valor. Esto lo haremos recuperando el paso actual de la sesión, pasando al siguiente y guardando el valor de nuevo en la sesión.
def create @order = Order.new(params[:order]) @order.current_step = session[:order_step] @order.next_step session[:order_step] = @order.current_step render 'new' end
Si ahora volvemos al formulario veremos que recorre las página como debería y vuelve al primer paso cuando se pulsa el botón en el último paso, lo que no es precisamente lo que queremos pero ya lo corregiremos más adelante.
Antes de eso vamos a implementar la forma de volver al paso anterior. Ahora mismo podemos movernos hacia adelante pero no hacia atrás, para hacer cambios en los campos que ya hemos completado. Vamos a añadirle otro botón al formulario para poder hacerlo.
<% title "New Order" %> <% form_for @order do |f| %> <%= f.error_messages %> <%= render "#{@order.current_step}_step", :f => f %> <p><%= f.submit "Continue" %></p> <p><%= f.submit "Back", :name => "previous_button" %></p> <% end %> <p><%= link_to "Back to List", orders_path %></p>
Nótese que al botón para volver atrás le hemos dato un atributo name
para poder determinar qué botón se ha pulsado cuando se envíe el formulario. Ya podemos modificar el controlador para que se comporte adecuadamente según el botón que se haya pulsado.
def create @order = Order.new(params[:order]) @order.current_step = session[:order_step] if params[:back_button] @order.previous_step else @order.next_step end session[:order_step] = @order.current_step render 'new' end
Para que esto funcione necesitamos un método previous_step
en el modelo Order
que será muy parecido al método next_step
que escribimos antes.
def previous_step self.current_step = steps[steps.index(current_step)-1] end
Con estos botones ya podemos movernos a lo largo de todos los pasos en ambas direcciones.
Como no tiene sentido tener un botón para volver atrás en la primera pantalla tendremos que ocultarlo si la orden está en el primer paso.
<% title "New Order" %> <% form_for @order do |f| %> <%= f.error_messages %> <%= render "#{@order.current_step}_step", :f => f %> <p><%= f.submit "Continue" %></p> <p><%= f.submit "Back", :name => "previous_button" unless @order.first_step? %></p> <% end %> <p><%= link_to "Back to List", orders_path %></p>
Para esto tenemos que añadirle al modelo Order
el método first_step?
.
def first_step? current_step == steps.first end
Si ahora recargamos el formulario veremos que en el primer paso ya no aparece el botón para volver atrás.
Cómo hacer que los campos recuerden sus valores
Ya podemos movernos por los pasos de nuestro formulario pero si introducimos un valor en uno de los campos y abandonamos dicho paso el valor no será recuperado si posteriormente regresamos al paso. Nuevamente, podemos resolver este problema utilizando la sesión. Si bien no es recomendable guardar objetos complejos en variables de sesión, sí que podemos hacerlo con objetos sencillos como hashes y listas.
El primer paso es crear una nueva variable de sesión en la acción new
llamada order_params
y darle un valor vacío a no ser que ya exista.
def new session[:order_params] ||= {} @order = Order.new end
En la acción create
mezclamos los valores de los parámetros del pedido con los valores que tiene la variable de sesión. Nótese que estamos efectuando una copia en profundidad (deep merge) por si hay valores anidados en los parámetros, y además sólo lo haremos si existen dichos parámetros A partir de este hash combinado ya podemos crear nuestro objeto Order
.
def create session[:order_params].deep_merge!(params[:order]) if params[:order] @order = Order.new(session[:order_params]) @order.current_step = session[:order_step] if params[:back_button] @order.previous_step else @order.next_step end session[:order_step] = @order.current_step render 'new' end
Si ahora rellenamos el formulario veremos los valores que introducimos en el paso final, lo que muestra que se han guardado.
Todavía nos queda un pequeño problema. Si cambiamos de paso mientras el formulario está a medio rellenar luego volvemos la información que habíamos introducido se perderá. Esto se puede resolver copiando a la acción new
las dos líneas de la acción create
que recuperan la información del pedido y el paso actual desde las variables de sesión.
def new session[:order_params] ||= {} @order = Order.new(session[:order_params]) @order.current_step = session[:order_step] end
Ya podemos volver al formulario, y veremos que los datos que vayamos introduciendo seguirán estando ahí. Y no sólo eso, además iremos al último paso en el que estábamos.
Almacenamiento del Pedido
Por último haremos que el formulario sea totalmente funcional haciendo que guarde el pedido cuando se finalice el último paso. Para ello vamos a modificar el controlador de forma que guarde el pedido sólo si el paso en el que estamos es el último y si el botón pulsado no era el de “paso anterior”. También tendremos que cambiar el comportamiento de la respuesta de forma que si se guarda el pedido la aplicación redirige a la acción show
del pedido y muestra un mensaje informativo.
class OrdersController < ApplicationController def index @orders = Order.all end def show @order = Order.find(params[:id]) end def new session[:order_params] ||= {} @order = Order.new(session[:order_params]) @order.current_step = session[:order_step] end def create session[:order_params].deep_merge!(params[:order]) if params[:order] @order = Order.new(session[:order_params]) @order.current_step = session[:order_step] if params[:back_button] @order.previous_step elsif @order.last_step? @order.save else @order.next_step end session[:order_step] = @order.current_step if @order.new_record? render 'new' else flash[:notice] = "Order saved." redirect_to @order end end end
Para que esto funcione tendremos que añadir el método last_step
al modelo Order
.
def last_step? current_step == steps.last end
Cuando ahora rellenemos el último paso se guardará el pedido y seremos redirigidos a la página del nuevo pedido.
Pero aún no hemos terminado. Si intentamos crear un nuevo pedido el formulario nos llevará directamente al último paso y veremos los detalles del pedido anterior. Una vez más, tenemos que cambiar el controlador para que la información del pedido que estamos guardando en la sesión se elimine una vez que el pedido haya sido creado correctamente. Lo haremos poniendo a nil
las variables de sesión order_step
y order_params
.
def create session[:order_params].deep_merge!(params[:order]) if params[:order] @order = Order.new(session[:order_params]) @order.current_step = session[:order_step] if params[:back_button] @order.previous_step elsif @order.last_step? @order.save else @order.next_step end session[:order_step] = @order.current_step if @order.new_record? render 'new' else session[:order_step] = session[:order_params] = nil flash[:notice] = "Order saved." redirect_to @order end end
Validaciones
Lo último que nos queda por ver es cómo hacer las validaciones del formulario. Pondremos una validación que compruebe la presencia de los atributos shipping_name
y billing_name
.
validates_presence_of :shipping_name validates_presence_of :billing_name
Queremos que los errores de validación de cada atributo sólo aparezcan en su paso apropiado, lo que haremos añadiendo una condición en los validadores.
validates_presence_of :shipping_name, :if => lambda { |o| o.current_step == "shipping" } validates_presence_of :billing_name, :if => lambda { |o| o.current_step == "billing" }
Claro está, sólo queremos que el formulario cambie al paso siguiente (o anterior) si el pedido es válido, así que tenemos que modificar la acción create
del controlador para que compruebe que el pedido es válido, cosa que haremos poniendo una sentencia if
al código que cambia el paso y almacena el registro.
def create session[:order_params].deep_merge!(params[:order]) if params[:order] @order = Order.new(session[:order_params]) @order.current_step = session[:order_step] if @order.valid? if params[:back_button] @order.previous_step elsif @order.last_step? @order.save else @order.next_step end session[:order_step] = @order.current_step end if @order.new_record? render 'new' else session[:order_step] = session[:order_params] = nil flash[:notice] = "Order saved." redirect_to @order end end
Si ahora creamos un nuevo pedido e intentamos enviar el primer paso veremos que aparece el error de un campo pero no del otro.
Igualmente si intentamos movernos al siguiente paso y dejamos su campo vacío, sólo veremos el error correspondente.
Podemos organizar un poco la validación cambiando las expresiones lambda por métodos llamados shipping?
y billing?
.
class Order < ActiveRecord::Base attr_accessible :shipping_name, :billing_name attr_writer :current_step validates_presence_of :shipping_name, :if => :shipping? validates_presence_of :billing_name, :if => :billing? # other methods omittted. def shipping? current_step == "shipping" end def billing? current_step == "billing" end end
También resulta útil tener un método que valide todos los pasos a la vez. Podemos escribir un método llamado all_valid?
que recorra los pasos y compruebe que sean válidos.
def all_valid? steps.all? do |step| self.current_step = step valid? end end
De esta forma si alguna vez hacemos cambios en las validaciones o en la forma en que funcionan los pasos podremos asegurarnos de que no hemos invalidado ninguno de ellos. Podemos usar este nuevo método en el controlador para garantizar qué sólo se guarde el pedido si todos sus pasos son válidos.
def create session[:order_params].deep_merge!(params[:order]) if params[:order] @order = Order.new(session[:order_params]) @order.current_step = session[:order_step] if @order.valid? if params[:back_button] @order.previous_step elsif @order.last_step? @order.save if @order.all_valid? else @order.next_step end session[:order_step] = @order.current_step end if @order.new_record? render 'new' else session[:order_step] = session[:order_params] = nil flash[:notice] = "Order saved." redirect_to @order end end
Lo más útil del método all_valid?
es que si uno de los pasos no valida entonces se convierte en el paso actual por lo que al usuario se le mostrará el primer paso que no ha validado.
Y eso es todo por este episodio. Tenemos que tener siempre en mente que estamos almacenando los parámetros del pedido en la sesión, esto quiere decir que si un usuario tiene múltiples ventanas o pestañas abiertas estas compartirán la información de sesión. Si quisiéramos tratar las ventanas o pestañas de forma indepnediente tendríamos que almacenar los parámetros del pedido en la base de datos y guardar el modelo al principio, utilizando la acción update
para ir grabando cada paso. Alternativamente se podrían guardar los parámetros como campos ocultos en el formulario.
Por último, si le echamos un vistazo a la acción create
veremos que es bastante más larga y complicada de lo que debería ser. Si bien para nuestros propósitos esto está bien así, si quisiéramos usar esta técnica para una aplicación de producción tendríamos que refactorizarla un poco.