#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)
Una form multistep, anche nota come wizard, è una form molto grande suddivisa in più pagine in sequenza che l’utente può navigare per completare l’inserimento dei dati. Non tutti amano questo tipo di approccio, alcuni preferiscono far vedere un’unica immensa form suddividendo piuttosto i dati fra più risorse e modelli. Esistono in ogni modo alcuni casi in cui il wizard rappresenta il miglior approccio, per esempio il caso di un sito per la dichiarazione dei redditi online, che richiede molti dati all’utente e che prevede molte ramificazioni a seconda dei dati immessi e che perciò rende sensato l’utilizzo di una form multistep.
Una form multistep ben fatta ricorda i dati inseriti fra i vari passi, permettendo agli utenti di andare avanti e indietro fra le pagine senza perdere nessuna delle informazioni inserite. Se il vostro obiettivo è semplicemente quello di suddividere una form grande per renderla più semplice agli occhi dell’utente, potreste considerare anche il solo utilizzo di JavaScript e CSS per mostrare e nascondere le varie parti della form quando vengono premuti i pulsanti “previous” e “next”. Un esempio di questo approccio lo si può ritrovare nell’Apple’s developer site. Se avete bisogno di qualcosa di più e orientato al lato server, allora dovrete passare necessariamente da Rails stesso e in questo episodio vi mostreremo come farlo.
Una form multistep di ordine
Per spiegare come comporre una form multistep, aggiungeremo un processo di checkout all’applicazione negozio online. Gli utenti dovranno per prima cosa inserire le loro informazioni di spedizione e poi quelle relative al pagamento ed infine, al terzo passo, dovranno vedere un riassunto del loro ordine in cui potranno dare conferma definitiva dei dati e dell’ordine stesso.
Non abbiamo ancora un modello per l’ordine e nemmeno un controller, per cui usiamo uno dei nifty generator di Ryan Bates per creare uno scaffold per la form degli ordini. Si noti come, essendo questa applicazione già stata scritta con Rails 2, si debba usare il comando script/generate
. Un modello di ordine vero e proprio dovrebbe avere molti attibuti, ma per i nostri scopi semplifichiamo, dandogliene solamente due. Creiamo anche le action index
, show
e new
per il controller:
$ script/generate nifty_scaffold order shipping_name:string billing_name:string index show new
Poi dobbiamo migrare il database per creare le nuove tabelle sul database:
$ rake db:migrate
La form dell’ordine, così come è stata generata dallo scaffold, ha tutti i campi insiemesu un unica pagina, per cui la prima cosa che dobbiamo fare è suddividere l’inserimento dei dati su più passaggi:
Il codice per la form del nuovo ordine ha tutti i campi riuniti insieme. Per cominciare a realizzare il nostro wizard, spostiamo ciascuno dei tre elementi paragrafo e i campi della form che questi racchiudono in partial distinti:
<% 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>
Prima di fare ciò, tuttavia, aggiungiamo un’intestazione a ciascuna sezione e modifichiamo la sezione finale in modo tale che mostri un riassunto dell’ordine:
<% 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>
Una volta fatto questo, possiamo spostare ogni sezione in un proprio partial. Creiamo tre nuovi partial chiamati shipping_step
, billing_step
e confirmation_step
e copiamo le rispettive parti della form all’interno di essi. Dopo aver fatto ciò, la nostra form di nuovi ordini dovrebbe apparire così:
<% 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>
e i tre partial dovrebbero essere così fatti:
<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>
Dobbiamo modificare anche la classe di modello Order
in modo tale che sia consapevole dell’esistenza dei tre passaggi e possa riconoscere quale fra questi sia il corrente. Potremmo usare il plugin state machine per questo scopo, ma dal momento che questo esempio è piuttosto semplice, scriveremo il codice da zero.
Il modello Order
dovrà sapere quali sono i vari passi e l’ordine in cui si devono susseguire, per cui scriviamo un nuovo metodo steps
per restituire una lista di passi. La nostra lista sarà un semplice array, ma per form più complesse questo metodo può facilmente essere reso più dinamico e gestire i casi in cui la lista di passi dipenda dallo storico delle scelte già fatte nei passi precedenti.
Per ottenere ed impostare il passo corrente per un ordine, creiamo un writer chiamato current_step
e un metodo accessor corrispondente che restituirà il passo corrente nel caso in cui sia stato impostato, o altrimenti il primo passo. Fatte queste modifiche, il modello Order
dovrebbe apparire così:
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
Ora che il modello Order
a che passo corrisponde il passo corrente, possiamo usare questa informazione per cambiare dinamicamente il partial da renderizzare, modificando leggermente la form del nuovo ordine, in modo che mostri solo il passo corrente:
<% 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>
Al ricaricamento della pagina con la form del nuovo ordine, ora vedremo mostrata solo la sezione relativa al primo passaggio:
Muoversi fra i vari passi
Se clicchiamo il pulsante di submit sulla form, verrà chiamata la action create
sul controller e verrà creato un nuovo ordine, ma questo non è ciò che vogliamo che accada. Vorremmo piuttosto che venisse mostrata la pagina col passo successivo al corrente: modifichiamo dunque la action sul controller per riflettere il comportamento desiderato.
Ci sono una serie di modi per approcciarsi al problema specifico. Potremmo creare una action separata per ogni passo; potremmo usare semplicemente la action create
per il passo iniziale e le action edit
e update
per gli altri passi, che vorrebbe dire che abbiamo un modello parzialmente completato sul database, oppure potremmo rimanere nelle action new
e create
e salvare i dettagli inseriti fino ad ora in sessione. Ci sono una serie di caveat a quest’utlimo approccio che verranno citati di volta in volta man mano che emergeranno, mostrandone le alternative.
La cosa più semplice che possiamo fare è modificare la action create
. Il primo passo che faremo sarà cambiarla in modo tale che non salvi l’ordine, ma che piuttosto mostri il prossimo passo della form.
def create @order = Order.new(params[:order]) @order.next_step render 'new' end
Sul controller, come si vede, chiamiamo il metodo next_step
della classe Order
che abbiamo scritto nella classe di modello:
def next_step self.current_step = steps[steps.index(current_step)+1] end
Ora verremo portati dalla maschera del primo passo a quella del secondo al click del pulsante di submit sulla form, ma al successivo click rimarremo sul secondo passo. Ciograve; a causa del fatto che non stiamo salvando lo stato relativo al passo corrente quando facciamo il submit della form. Nell’action create
dobbiamo impostare il passo corrente sul modello Order
e poi salvare tale valore. Possiamo fare tutto questo recuperando il passo corrente dalla sessione, muovendoci al passo successivo e poi salvando quest’ultimo nuovo passo in sessione:
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
Se proviamo ora la form, si potrà navigare in avanti da una pagina alla successiva come volevamo, ma si ritorna nuovamente alla prima pagina al submit dell’ultima. Quest’ultima cosa non riflette esattamente ciò che avevamo in mente per l’ultimo passo, ma sistemeremo la cosa più tardi.
Prima però, implementiamo il modo per tornare indietro nei passi precedenti. Al momento la form ci permette di spostarci in avanti, ma non possiamo tornare indietro e fare delle modifiche ai campi che abbiamo già compilato. Aggiungiamo un altro pulsante alla form per fare questo:
<% 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>
Si noti che abbiamo dato al pulsate "Back" un attributo denominato name
, in modo tale che si possa capire quale pulsante è stato utilizzato per fare il submit della form. Ora possiamo cambiare il controller per farlo andare coerentemente, a seconda di quale pulsante è stato premuto:
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
Affinchè tutto questo funzioni, dobbiamo aggiungere un metodo previous_step
alla classe di modello Order
che sia simile al metodo next_step
scritto poco fa:
def previous_step self.current_step = steps[steps.index(current_step)-1] end
Ora ci sono entrambi i pulsanti nella form per permettere di spostarsi avanti e indietro fra i vari passi del wizard:
Non ha senso tuttavia che ci sia un pulsante per andare indietro anche sulla prima pagina, per cui imponiamo che venga nascosto se l’ordine è al primo passo:
<% 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>
e ovviamente aggiungiamo il metodo first_step?
che viene riferito nel partial alla classe di modello Order
:
def first_step? current_step == steps.first end
Quando ricarichiamo la form ora, non vedremo il pulsante per andare indietro sulla prima pagina:
Fare in modo che i campi ricordino i propri valori
Ora possiamo andare indietro e avanti fra le pagine della form, ma se inseriamo un valore in uno dei campi, questo non viene ricordato se si ritorna su tale passo più tardi. Possiamo risolvere questo problema usando nuovamente la sessione. Sebbene non sia raccomandabile memorizzare oggetti complessi in variabili di sessione, per oggetti semplici come hash o array può andare.
Il primo passo per fare ciò è creare una nuova variabile di sessione nella action new
chiamata order_params
e impostarne il valore ad un hash vuoto a meno che non abbia già un valore:
def new session[:order_params] ||= {} @order = Order.new end
Nell’action create
raggruppiamo i valori dei parametri dell’ordine con i valori della variabile di sessione. Si noti che usiamo una deep merge nel caso ci siano valori innestati fra i parametri dell’ordine mentre faremo un semplice merge se esistono parametri di ordine. Possiamo creare un nuovo oggetto Order
da questo hash di merge:
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
Ora, al riempimento dei dati in form, vedremo i valori immessi nel passo finale, dato che sono stati salvati fra i vari passi:
C’è ancora un piccolo problema, comunque. Se ci spostiamo dalla form mentre è solo parzialmente compilata e subito dopo ritorniamo indietro, le informazioni inserite ed il passo sono persi. Possiamo risolvere questa cosa copiando le due linee che recuperano le informazioni sull’ordine e sul passo corrente dalla variabile di sessione che abbiamo scritto per la action create nella action new:
def new session[:order_params] ||= {} @order = Order.new(session[:order_params]) @order.current_step = session[:order_step] end
Sistemato questo codice, ora possiamo ritornare alla form ed ogni dato inserito sarà ancora là. Non solo, ma saremo anche ricondotti all’ultimo passo in cui eravamo arrivati.
Salvataggio di un ordine
Ora renderemo completamente funzionante la form, consentendole di salvare l’ordine al termine dell’ultimo passo. Possiamo fare ciò, cambiando il controller in modo tale che salvi l’ordine solo se il passo corrente è l’ultimo e se il pulsante premuto per il submit non è quello per ritornare alla pagina precedente. Dobbiamo anche cambiare il comportamento del rendering in modo che se l’ordine viene correttamente salvato, l’applicazione alla action show per tale ordine e mostri un messaggio flash:
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
Per fare funzionare il tutto, dobbiamo anche aggiungere il metodo last_step
al modello Order
:
def last_step? current_step == steps.last end
Quando andiamo nella form di inserimento di un nuovo ordine, ora, e compiliamo tutte le informazioni per tutti i passi e salviamo, l’ordine verrà salvato e saremo ridiretti alla pagina di dettaglio di tale ordine:
Non abbiamo ancora finito, però. Se proviamo a creare un nuovo ordine, la form andrà direttamente all’ultimo passo e vedremo il dettaglio dell’ordine precedente. Dobbiamo cambiare nuovamente il controller in modo tale che le informazioni dell’ordine siano cancellate dalla sessione una volta che l’ordine è stato salvato correttamente. Possiamo fare ciò svuotando le informazioni di sessione una volta salvato l’ordine, impostando entrambe le variabili di sessione, order_step
e order_params
, a nil
:
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
Aggiungere la validazione
L’ultima cosa che trattiamo è come gestire le validazioni nella form. Imponiamo una validazione per il modello Order
per gli attributi shipping_name
e billing_name
:
validates_presence_of :shipping_name validates_presence_of :billing_name
Vorremmo che l’errore di validazione comparisse solo nel passo opportuno. Aggiungiamo, per questa ragione, una condizione if
ai validatori:
validates_presence_of :shipping_name, :if => lambda { |o| o.current_step == "shipping" } validates_presence_of :billing_name, :if => lambda { |o| o.current_step == "billing" }
Vogliamo che la form transiti al passo successivo (o precedente) se l’ordine è valido, per cui dobbiamo cambiare di nuovo la action create
sul controller in modo che controlli che l’ordine sia valido. Per fare ciò, racchiudiamo il codice che cambia il passo e salva il record in un blocco if
:
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
Se proviamo ora a creare un nuovo ordine e proviamo a fare il submit, nel primo passo vedremo l’errore di validazione per il campo shipping, ma non per il billing:
Analogamente, se andiamo avanti fino al passo della fatturazione e proviamo a lasciare il campo vuoto e a fare il submit, vedremo solo l’errore per tale passo:
Possiamo ripulire un po’ la validazione, spostando le espressioni lambda nei metodi denominati shipping?
e billing?
, così:
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
E’anche utile avere un metodo che validi tutti i passi in un colpo solo. Per fare ciò, possiamo scrivere un metodo all_valid?
che iteri su ogni passo e controlli che sia valido:
def all_valid? steps.all? do |step| self.current_step = step valid? end end
In questo modo, se mai facessimo delle modifiche alle validazioni o al modo in cui funzionano i passi, potremmo essere tranquilli del fatto che nessun passo è stato invalidato nel processo. Possiamo usare questo nuovo metodo nel controller per assicurarci che l’ordine sia salvato solo se tutti i passi sono validi:
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
L’aspetto pratico del metodo all_valid?
è che cambia il passo su cui si trova in modo che se uno dei passi non è valido, quello diventi anche il passo corrente, in modo tale che all’utente sia mostrato il primo passo non valido.
E’ tutto per questo episodio sulle form multipasso. Una cosa da tenere a mente è che stiamo salvando i parametri dell’ordine in sessione, il che significa che se un utente apre più finestre o tab del browser, le stesse informazioni saranno condivise fra tali finestre. Se si vuole che più tab o finestre di un medesimo browser siano trattati in modo indipendente, allora potreste voler salvare i parametri dell’ordine sul database e salvare prima il modello dell’ordine, usando la action update per salvare le informazioni aggiuntive provenienti dai vari passi. In alternativa, i parametri potrebbero essere salvati in campi nascosti della form.
Infine, un rapido sguardo alla action create evidenzia che è diventata più corposa e complessa di quello che dovrebbe essere. Anche se per gli scopi della lezione questo può anche andare, se questa tecnica fosse applicata in produzione, necessiterebbe di un po’ di ulteriore refactoring di pulizia.