10. 嵌套表單(1-to-many)
10-1 情境準備
情境:管理員在後台可以編輯活動有不同票種(Ticket),一個活動會有很多個票種。
我們需要新增一個 Ticket model,並讓 Event has_many tickets。
執行 rails g model ticket
編輯 db/migrate/20170423111008_create_tickets.rb
class CreateTickets < ActiveRecord::Migration[5.0]
def change
create_table :tickets do |t|
+ t.integer :event_id, :index => true
+ t.string :name
+ t.text :description
+ t.integer :price
+ t.timestamps
end
end
end
執行 rake db:migrate
編輯 app/models/event.rb
+ has_many :tickets, :dependent => :destroy
編輯 app/models/ticket.rb
class Ticket < ApplicationRecord
+ validates_presence_of :name, :price
+ belongs_to :event
end
接下有兩種 UI 方案:
- 方案一:單獨的 resources 編輯 UI
- 方案二:嵌套(Nested)表單 UI
10-2 嵌套(Nested)表單 UI
讓我們直接進入重點,製作嵌套(Nested)表單 UI。將編輯 Event 和編輯 Ticket 的表單合並在一起。因為一個活動會有的 Ticket 票種也不是很多,而 Ticket 的欄位也不是很多,合並在一起編輯對用戶的操作會更方便,一次就可以編輯兩個 Model 的資料。
再次編輯 app/models/event.rb
,加上 accepts_nested_attributes_for
宣告:
- has_many :tickets, :dependent => :destroy
+ has_many :tickets, :dependent => :destroy, :inverse_of => :event
+ accepts_nested_attributes_for :tickets, :allow_destroy => true, :reject_if => :all_blank
某些版本的 Rails 有個 accepts_nested_attributes_for 的bug 讓 has_many 故障了,需要額外補上
inverse_of
參數,不然存儲時會找不到 tickets
編輯 app/controllers/admin/events_controller.rb
def new
@event = Event.new
+ @event.tickets.build
+ @event.tickets.build
end
# (略)
def edit
@event = Event.find_by_friendly_id!(params[:id])
+ @event.tickets.build
+ @event.tickets.build
end
# (略)
def event_params
- params.require(:event).permit(:name, :description, :friendly_id, :status, :category_id)
+ params.require(:event).permit(:name, :description, :friendly_id, :status, :category_id, :tickets_attributes => [:id, :name, :description, :price, :_destroy])
end
這裡
@event.tickets.build
兩次,等會表單中就有兩筆空的 Ticket 可以編輯。Strong Parameters 的部分,新增了tickets_attributes
陣列包含要修改的 ticket 屬性,並且額外多了一個:id
和:_destroy
是為了配合accepts_nested_attributes_for
可以編輯和刪除。
編輯 app/views/admin/events/_form.html.erb
+ <%= f.fields_for :tickets do |ff| %>
+ <fieldset style="border-left: 5px solid #bbb; margin-bottom: 10px; padding: 10px;">
+ <legend>Ticket</legend>
+ <div class="form-group">
+ <%= ff.label :name %>
+ <%= ff.text_field :name, :class => "form-control" %>
+ </div>
+
+ <div class="form-group">
+ <%= ff.label :price %>
+ <%= ff.number_field :price, :class => "form-control" %>
+ </div>
+
+ <div class="form-group">
+ <%= ff.label :description %>
+ <%= ff.text_area :description, :class => "form-control" %>
+ </div>
+ </fieldset>
+ <% end %>
這個 UI 做到這裡,我們寫死了固定新增兩筆,似乎美中不足啊 :(
有沒有辦法可以在 UI 上動態一直新增? 這會需要 JavaScript 的協助,這裡我們直接安裝一個 nested_form_fields gem:
編輯 Gemfile
+ gem "nested_form_fields"
執行 bundle 然後重啟伺服器
編輯 app/assets/javascripts/application.js
+ //= require nested_form_fields
//= require_tree .
再次編輯 app/controllers/admin/events_controller.rb
def new
@event = Event.new
@event.tickets.build
- @event.tickets.build
end
# (略)
def edit
@event = Event.find_by_friendly_id!(params[:id])
- @event.tickets.build
- @event.tickets.build
+ @event.tickets.build if @event.tickets.empty?
end
再次編輯 app/views/admin/events/_form.html.erb
- <%= f.fields_for :tickets do |ff| %>
+ <%= f.nested_fields_for :tickets do |ff| %>
<fieldset style="border-left: 5px solid #bbb; margin-bottom: 10px; padding: 10px;">
<legend>Ticket</legend>
<div class="form-group">
<%= ff.label :name %>
<%= ff.text_field :name, :class => "form-control" %>
</div>
<div class="form-group">
<%= ff.label :price %>
<%= ff.number_field :price, :class => "form-control" %>
</div>
<div class="form-group">
<%= ff.label :description %>
<%= ff.text_area :description, :class => "form-control" %>
</div>
</fieldset>
+ <%= ff.remove_nested_fields_link "移除這個票種", :class => "btn btn-danger" %>
<% end %>
+ <p class="text-right">
+ <%= f.add_nested_fields_link :tickets, "新增票種", :class => "btn btn-default" %>
+ </p>
最後成果,是個很漂亮的 UI 可以動態新增和刪除:
10-3 單獨的 resources 編輯 UI
另一個方案就是針對 Ticket 做獨立的 CRUD。
如果 has_many 的資料非常多、欄位又多的話,就不適合用嵌套(Nested)表單 UI。獨立的 CRUD 你應該已經有能力可以完成了,做為復習,這裡還是快速示範一下:
編輯 config/routes.rb
namespace :admin do
# (略)
- resources :events
+ resources :events do
+ resources :tickets, :controller => "event_tickets"
+ end
執行 rails g controller admin::event_tickets
編輯 app/controllers/admin/event_tickets_controller.rb
- class Admin::EventTicketsController < ApplicationController
+ class Admin::EventTicketsController < AdminController
+
+ before_action :find_event
+
+ def index
+ @tickets = @event.tickets
+ end
+
+ def new
+ @ticket = @event.tickets.new
+ end
+
+ def create
+ @ticket = @event.tickets.new(ticket_params)
+ if @ticket.save
+ redirect_to admin_event_tickets_path(@event)
+ else
+ render "new"
+ end
+ end
+
+ def edit
+ @ticket = @event.tickets.find(params[:id])
+ end
+
+ def update
+ @ticket = @event.tickets.find(params[:id])
+
+ if @ticket.update(ticket_params)
+ redirect_to admin_event_tickets_path(@event)
+ else
+ render "edit"
+ end
+ end
+
+ def destroy
+ @ticket = @event.tickets.find(params[:id])
+ @ticket.destroy
+
+ redirect_to admin_event_tickets_path(@event)
+ end
+
+ protected
+
+ def find_event
+ @event = Event.find_by_friendly_id!(params[:event_id])
+ end
+
+ def ticket_params
+ params.require(:ticket).permit(:name, :price, :description)
+ end
end
編輯 app/views/admin/events/index.html.erb
+ <%= link_to "Tickets", admin_event_tickets_path(event), :class => "btn btn-default" %>
<%= link_to "Edit", edit_admin_event_path(event), :class => "btn btn-default" %>
新增 app/views/admin/event_tickets/index.html.erb
<h1><%= @event.name %> / Tickets</h1>
<p class="text-right">
<%= link_to "New Ticket", new_admin_event_ticket_path(@event), :class => "btn
btn-primary" %>
</p>
<table class="table">
<tr>
<th>Name</th>
<th>Price</th>
<th>Description</th>
<th>Actions</th>
</tr>
<% @tickets.each do |ticket| %>
<tr>
<td><%= ticket.name %></td>
<td><%= ticket.price %></td>
<td><%= simple_format ticket.description %></td>
<td>
<%= link_to "Edit", edit_admin_event_ticket_path(@event, ticket), :class
=> "btn btn-default" %> <%= link_to "Delete",
admin_event_ticket_path(@event, ticket), :method => "delete", :data => {
:confirm => "Are you sure?" }, :class => "btn btn-danger" %>
</td>
</tr>
<% end %>
</table>
新增 app/views/admin/event_tickets/new.html.erb
<h1>New Ticket</h1>
<%= form_for [:admin, @event, @ticket] do |f| %> <%= render :partial => "form",
:locals => { :f => f } %>
<div class="form-group">
<%= f.submit "Create", :class => "btn btn-primary" %> <%= link_to "Cancel",
admin_event_tickets_path(@event) %>
</div>
<% end %>
新增 app/views/admin/event_tickets/edit.html.erb
<h1>Edit Ticket</h1>
<%= form_for [:admin, @event, @ticket] do |f| %> <%= render :partial => "form",
:locals => { :f => f } %>
<div class="form-group">
<%= f.submit "Update", :class => "btn btn-primary" %> <%= link_to "Cancel",
admin_event_tickets_path(@event) %>
</div>
<% end %>
新增 app/views/admin/event_tickets/_form.html.erb
<% if @ticket.errors.any? %>
<div id="error_explanation">
<ul>
<% @ticket.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="form-group">
<%= f.label :name %> <%= f.text_field :name, :class => "form-control" %>
</div>
<div class="form-group">
<%= f.label :price %> <%= f.number_field :price, :class => "form-control" %>
</div>
<div class="form-group">
<%= f.label :description %> <%= f.text_area :description, :class =>
"form-control" %>
</div>