Link Search Menu Expand Document

16. 多步驟表單

16-1 需求和 Model 設計

接下來要製作的功能是:用戶可以報名活動,也就是 event has_many registrations。

首先建立 Registration model

執行 rails g model registration

編輯 db/migrate/20170429152604_create_registrations.rb

  class CreateRegistrations < ActiveRecord::Migration[5.0]
    def change
      create_table :registrations do |t|
+       t.string :status, :default => "pending"
+       t.string :uuid
+       t.integer :event_id, :index => true
+       t.integer :ticket_id, :index => true
+       t.integer :user_id, :index => true
+       t.string :name
+       t.string :email
+       t.string :cellphone
+       t.string :website
+       t.text :bio
+       t.timestamps
      end

+     add_index :registrations, :uuid,  :unique => true
    end
  end

執行 rake db:migrate

編輯 app/models/registration.rb

class Registration < ApplicationRecord

  STATUS = ["pending", "confirmed"]
  validates_inclusion_of :status, :in => STATUS
  validates_presence_of :status, :ticket_id

  belongs_to :event
  belongs_to :ticket
  belongs_to :user, :optional => true

  before_validation :generate_uuid, :on => :create

  def to_param
    self.uuid
  end

  protected

  def generate_uuid
    self.uuid = SecureRandom.uuid
  end

end

這裡設計了 status 狀態字段,以及在新建的時候亂數產生一個 UUID 來當作網址 ID。

編輯 app/models/event.rb

+  has_many :registrations, :dependent => :destroy

編輯 app/models/ticket.rb

+  has_many :registrations

編輯 app/models/user.rb

+  has_many :registrations

接下來要製作 UI,我們示範兩個解法:

  • 單一表單
  • 多步驟表單

16-2 建立報名表單

編輯 config/routes.rb

-  resources :events
+  resources :events do
+    resources :registrations
+  end

執行 rails g controller registrations

編輯 app/controllers/registrations_controller.rb

class RegistrationsController < ApplicationController

  before_action :find_event

  def new
  end

  def create
    @registration = @event.registrations.new(registration_params)
    @registration.ticket = @event.tickets.find( params[:registration][:ticket_id] )
    @registration.status = "confirmed"
    @registration.user = current_user

    if @registration.save
      redirect_to event_registration_path(@event, @registration)
    else
      render "new"
    end
  end

  def show
    @registration = @event.registrations.find_by_uuid(params[:id])
  end

  protected

  def registration_params
    params.require(:registration).permit(:ticket_id, :name, :email, :cellphone, :website, :bio)
  end

  def find_event
    @event = Event.find_by_friendly_id(params[:event_id])
  end

end

這裡針對 ticket 額外用 @event.tickets.find 再檢查確定這個票種屬於這個活動

編輯 app/views/events/show.html.erb 加上一個按鈕去報名表單

+ <p><%= link_to "開始報名", new_event_registration_path(@event), :class => "btn btn-primary" %></p>

image

新增 app/views/registrations/new.html.erb 報名頁面

<h1><%= @event.name %></h1>

<%= form_for [@event, Registration.new] do |f| %>

  <div class="form-group">
    <%= f.label :ticket_id %>
    <%= f.select :ticket_id, @event.tickets.map{ |t| [t.name, t.id] }, {}, :class => "form-control" %>
  </div>

  <div class="form-group">
    <%= f.label :name %>
    <%= f.text_field :name, :class => "form-control" %>
  </div>

  <div class="form-group">
    <%= f.label :email %>
    <%= f.email_field :email, :class => "form-control" %>
  </div>

  <div class="form-group">
    <%= f.label :cellphone %>
    <%= f.text_field :cellphone, :class => "form-control" %>
  </div>

  <div class="form-group">
    <%= f.label :website %>
    <%= f.url_field :website, :class => "form-control" %>
  </div>

  <div class="form-group">
    <%= f.label :bio %>
    <%= f.text_area :bio, :class => "form-control" %>
  </div>

  <div class="form-group">
    <%= f.submit "Submit", :class => "btn btn-primary" %>
  </div>

<% end %>

image

新增 app/views/registrations/show.html.erb 報名成功頁面

<h1><%= @event.name %></h1>

<h2>報名資料</h2>

<dl>
  <dt>狀態</dt>
  <dd><%= t(@registration.status, :scope => "registration.status") %></dd>
  <dt>姓名</dt>
  <dd><%= @registration.name %></dd>
  <dt>E-mail</dt>
  <dd><%= @registration.email %></dd>
  <dt>電話</dt>
  <dd><%= @registration.cellphone %></dd>
  <dt>網站</dt>
  <dd><%= @registration.website %></dd>
  <dt>自我介紹</dt>
  <dd><%= simple_format @registration.bio %></dd>
</dl>

編輯 config/locales/zh-CN.yml 新增狀態的翻譯

  "zh-CN":
+   registration:
+     status:
+       pending: 報名尚未完成
+       confirmed: 報名成功

image

這樣就完成了。如果只是這樣就太小看大家了,讓我們繼續看下去…

16-3 多步驟表單

接下來的重頭戲是製作多步驟的表單(Multi Step Form,又叫做 Wizards)

Protip: 記得這些英文名詞對 google 是有幫助的,google “wizards rails” 就可以找到如何做這個功能的教程

什麽時候會用到呢? 當表單很複雜的時候,我們不希望一次就把所有字段顯示出來,這樣會嚇跑用戶。而是會拆成步驟一、步驟二、步驟三…. 一步一步讓用戶掉入這個坑完成表單,以增加表單完成的成功率。

要製作的 UI 將拆分成三個表單:

  • 第一個表單: 選票種
  • 第二個表單: 填姓名、E-mail、電話
  • 第三個表單: 填個人網站 URL、填自我介紹

其中第二個表單和第三個表單,除了有下一步之外,也可以回到上一步進行修改。如果用戶中途離開,下次再進來也可以繼續編輯。

另一種純前端的做法,例如 jQuery Steps,則是只用特效的方式拆成不同步驟,而沒有將過程儲存進到資料庫,如果中離就毫無紀錄。本章的做法是中間過程都會存進資料庫。

首先,讓我們修改路由 config/routes.rb,分別是三個步驟的表單頁面和修改動作:

   resources :events do
-    resources :registrations
+    resources :registrations do
+      member do
+        get "steps/2" => "registrations#step2", :as => :step2
+        patch "steps/2/update" => "registrations#step2_update", :as => :update_step2
+        get "steps/3" => "registrations#step3", :as => :step3
+        patch "steps/3/update" => "registrations#step3_update", :as => :update_step3
+      end
     end
   end

沒有特別看到 step1 是因為 registrations controller 本來的 new action 和 create action 就是新增時的第一步。

16-4 第一步的表單

修改 app/views/registrations/new.html.erb 報名頁面,只留下選票種:

   <h1><%= @event.name %></h1>
+  <h2>Step 1</h2>

   <%= form_for [@event, Registration.new] do |f| %>

     <div class="form-group">
       <%= f.label :ticket_id %>
       <%= f.select :ticket_id, @event.tickets.map{ |t| [t.name, t.id] }, {}, :class => "form-control" %>
     </div>

-    <div class="form-group">
-      <%= f.label :name %>
-      <%= f.text_field :name, :class => "form-control" %>
-    </div>
-
-    <div class="form-group">
-      <%= f.label :email %>
-      <%= f.email_field :email, :class => "form-control" %>
-    </div>
-
-    <div class="form-group">
-      <%= f.label :cellphone %>
-      <%= f.text_field :cellphone, :class => "form-control" %>
-    </div>
-
-    <div class="form-group">
-      <%= f.label :website %>
-      <%= f.url_field :website, :class => "form-control" %>
-    </div>
-
-    <div class="form-group">
-      <%= f.label :bio %>
-      <%= f.text_area :bio, :class => "form-control" %>
-    </div>
-
     <div class="form-group">
-      <%= f.submit "Submit", :class => "btn btn-primary" %>
+      <%= f.submit "Save and Next", :class => "btn btn-primary" %>
     </div>

   <% end %>

編輯 app/controllers/registrations_controller.rb


   def create
     @registration = @event.registrations.new(registration_params)
     @registration.ticket = @event.tickets.find( params[:registration][:ticket_id] )
-    @registration.status = "confirmed"
+    @registration.status = "pending"
     @registration.user = current_user

     if @registration.save
-      redirect_to event_registration_path(@event, @registration)
+      redirect_to step2_event_registration_path(@event, @registration)
     else
       render "new"
     end
   end

本來進 create action 就完成了,現在要改成進 step2 action。

image

16-5 第二步的表單

接著要編輯第二步:

編輯 app/controllers/registrations_controller.rb


+  def step2
+    @registration = @event.registrations.find_by_uuid(params[:id])
+  end
+
+  def step2_update
+    @registration = @event.registrations.find_by_uuid(params[:id])
+
+    if @registration.update(registration_params)
+      redirect_to step3_event_registration_path(@event, @registration)
+    else
+      render "step2"
+    end
+  end

新增 app/views/registrations/step2.html.erb

<h1><%= @event.name %></h1>

<h2>Step 2</h2>

<%= form_for @registration, :url => update_step2_event_registration_path(@event, @registration) do |f| %>

  <div class="form-group">
    <%= f.label :name %>
    <%= f.text_field :name, :class => "form-control" %>
  </div>

  <div class="form-group">
    <%= f.label :email %>
    <%= f.email_field :email, :class => "form-control" %>
  </div>

  <div class="form-group">
    <%= f.label :cellphone %>
    <%= f.text_field :cellphone, :class => "form-control" %>
  </div>

  <div class="form-group">
    <%= f.submit "Save & Next", :class => "btn btn-primary" %>
  </div>

<% end %>

image

16-6 第三步的表單

接著要編輯第三步:

編輯 app/controllers/registrations_controller.rb

+  def step3
+    @registration = @event.registrations.find_by_uuid(params[:id])
+  end
+
+  def step3_update
+    @registration = @event.registrations.find_by_uuid(params[:id])
+    @registration.status = "confirmed"
+
+    if @registration.update(registration_params)
+      flash[:notice] = "報名成功"
+      redirect_to event_registration_path(@event, @registration)
+    else
+      render "step3"
+    end
+  end

新增 app/views/registrations/step3.html.erb

<h1><%= @event.name %></h1>

<h2>Step 3</h2>

<%= form_for @registration, :url => update_step3_event_registration_path(@event, @registration) do |f| %>

  <div class="form-group">
    <%= f.label :website %>
    <%= f.url_field :website, :class => "form-control" %>
  </div>

  <div class="form-group">
    <%= f.label :bio %>
    <%= f.text_area :bio, :class => "form-control" %>
  </div>

  <div class="form-group">
    <%= f.submit "Submit", :class => "btn btn-primary" %>
  </div>

<% end %>

image

這樣基本上就完成了,在 step3_update action 中,最後成功後會轉去 show 成功畫面。

16-7 回到上一步

接下來讓我們加上「回到上一步」的按鈕。

首先是第三步回第二步,修改 app/views/registrations/step3.html.erb

   <div class="form-group">
+    <%= link_to "Previous", step2_event_registration_path(@event, @registration), :class => "btn btn-default" %>
     <%= f.submit "Submit", :class => "btn btn-primary" %>
   </div>

這個簡單,一個連結回去 step2 即可。

image

接下來是第二步回到第一步,編輯 config/routes.rb

   resources :events do
     resources :registrations do
       member do
+        get "steps/1" => "registrations#step1", :as => :step1
+        patch "steps/1/update" => "registrations#step1_update", :as => :update_step1
         get "steps/2" => "registrations#step2", :as => :step2
         patch "steps/2/update" => "registrations#step2_update", :as => :update_step2
         get "steps/3" => "registrations#step3", :as => :step3
         patch "steps/3/update" => "registrations#step3_update", :as => :update_step3
       end
     end
   end

修改 app/views/registrations/step2.html.erb

   <div class="form-group">
+    <%= link_to "Previous", step1_event_registration_path(@event, @registration), :class => "btn btn-default" %>
     <%= f.submit "Save & Next", :class => "btn btn-primary" %>
   </div>

這裡就很不一樣了,當初新增時的第一步是 new_event_registration_path,而不是這裡寫的 step1_event_registration_path。這是因為如果已經開始編輯了,那麽「回頭編輯時」的第一步跟「新增當時」的第一步,雖然表單長得一樣,但是其實是不同頁面,操作也不一樣,前者是更新資料(update_step1 action),後者新建資料(create action)。

接著讓我們新增「回頭編輯時」的第一步動作,請編輯 app/controllers/registrations_controller.rb

+  def step1
+    @registration = @event.registrations.find_by_uuid(params[:id])
+  end
+
+  def step1_update
+    @registration = @event.registrations.find_by_uuid(params[:id])
+
+    if @registration.update(registration_params)
+      redirect_to step2_event_registration_path(@event, @registration)
+    else
+      render "step1"
+    end
+  end

新增 app/views/registrations/step1.html.erb

<h1><%= @event.name %></h1>
<h2>Step 1</h2>

<%= form_for [@event, @registration], :url => update_step1_event_registration_path(@event, @registration) do |f| %>

  <%= render :partial => "step1_form", :locals => { :f => f } %>

<% end %>

因為 new.html.erbstep1.html.erb 的表單是一模一樣的,所以讓我們拆出來 partial 樣板:

新增 app/views/registrations/_step1_form.html.erb

<div class="form-group">
  <%= f.label :ticket_id %>
  <%= f.select :ticket_id, @event.tickets.map{ |t| [t.name, t.id] }, {}, :class => "form-control" %>
</div>

<div class="form-group">
  <%= f.submit "Save & Next", :class => "btn btn-primary" %>
</div>

編輯 app/views/registrations/new.html.erb,改用這個 partial 樣板。

   <h1><%= @event.name %></h1>
   <h2>Step 1</h2>

   <%= form_for [@event, Registration.new] do |f| %>

-    <div class="form-group">
-      <%= f.label :ticket_id %>
-      <%= f.select :ticket_id, @event.tickets.map{ |t| [t.name, t.id] }, {}, :class => "form-control" %>
-    </div>

-    <div class="form-group">
-      <%= f.submit "Save and Next", :class => "btn btn-primary" %>
-    </div>

+    <%= render :partial => "step1_form", :locals => { :f => f } %>
   <% end %>

image

你可以試試看這個上一步的功能了。

16-8 有條件的表單驗證

接下來加上一些資料驗證好了,編輯 app/models/registration.rb

+  validates_presence_of :name, :email, :cellphone, :bio

乍看之下沒有問題,但是你會發現我們連第一步都做不下去。因為第一步的表單只有選票種,而沒有其他資料。這樣存儲時驗證會失敗。

怎麽辦呢?Rails 可以根據條件來做表單驗證,叫做 Conditional Validations。我們需要根據用戶實際在做哪一步,來決定要啟用哪些驗證。

再次編輯 app/models/registration.rb


-  validates_presence_of :name, :email, :cellphone, :bio

+  attr_accessor :current_step
+  validates_presence_of :name, :email, :cellphone, :if => :should_validate_basic_data?
+  validates_presence_of :name, :email, :cellphone, :bio, :if => :should_validate_all_data?

  # 略

   protected

+  def should_validate_basic_data?
+    current_step == 2  # 只有做到第二步需要驗證
+  end
+
+  def should_validate_all_data?
+    current_step == 3 || status == "confirmed"  # 做到第三步,或最後狀態是 confirmed 時需要驗證
+  end

解說:

  • :if 這個參數可以設定調用那一個方法來決定要不要啟用這個驗證,回傳 true 就是要,回傳 false 就是不要
  • 透過 attr_accessor :current_step 我們增加一個虛擬屬性(也就是資料庫中並沒有這個字段)來代表目前做到哪一步

編輯 app/controllers/registrations_controller.rb

   def create
     @registration = @event.registrations.new(registration_params)
     @registration.ticket = @event.tickets.find( params[:registration][:ticket_id] )
     @registration.status = "pending"
     @registration.user = current_user
+    @registration.current_step = 1

   # 略

   def step1_update
     @registration = @event.registrations.find_by_uuid(params[:id])
+    @registration.current_step = 1

   # 略

   def step2_update
     @registration = @event.registrations.find_by_uuid(params[:id])
+    @registration.current_step = 2

   # 略

   def step3_update
     @registration = @event.registrations.find_by_uuid(params[:id])
     @registration.status = "confirmed"
+    @registration.current_step = 3

   # 略

我們在每次調用 save 存進資料庫前,設定一下 current_step 的值,這樣就可以有條件的觸發對應的資料驗證了。


Copyright © 2010-2021 Wen-Tien Chang All Rights Reserved.