Link Search Menu Expand Document

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 %>

image

這個 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 可以動態新增和刪除:

image

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" %>

image

新增 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>

image

新增 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>

image


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