What is Nested Form (巢狀表單) ?

我們經常能看到巢狀表單的蹤影,例如:註冊使用者時會需要填寫多個收件人地址或是聯絡電話等…。 簡單來說,當需要處理兩個不同的 model 在同ㄧ個表單內執行就經常會使用到巢狀表單來開發。 有關於巢狀表單的介紹可以參考 RailsGuides,以及中文手冊

首先建立ㄧ個示範專案

  • 在終端機內輸入
    rails new dynamic-nested-form -m https://raw.githubusercontent.com/kevin0117/rails-template/master/my_template.rb -d postgresql
    

建立ㄧ個簡易的商品入庫示範系統

  • STEP 1: 在終端機內輸入:
    rails g scaffold purchase_order title
    
  • STEP 2: 建立 product model,在終端機內輸入:
    rails g model product title weight:decimal price:decimal quantity:integer storage_price:decimal purchase_order:references
    

    這樣會在 product table 建立 “purchase_order_id” 的欄位以及 index 以方便搜尋。

實作方法(ㄧ)

安裝 Cocoon Gem

Cocoon 套件主要是讓開發者能更輕易地使用巢狀表單。

以 Rails 6 為例,安裝的步驟如下:

  • Step 1: 可從 Rubygems 下載最新版本
    gem 'cocoon', '~> 1.2', '>= 1.2.15'
    

    接著記得要 bundle install !!

  • Step 2:在終端機內輸入
    yarn add @nathanvda/cocoon
    
  • Step 3:在 app/javascripts/packs/application.js 檔內,加上
    require("jquery")
    require("@nathanvda/cocoon")
    

Follow Basic Usage

  • Step 1: 在 ProductPurchaseOrder model 中修改進貨單與產品的關聯
  class Product < ApplicationRecord
    belongs_to :purchase_order
  end

  class PurchaseOrder < ApplicationRecord
    has_many :products
    accepts_nested_attributes_for :products, reject_if: :all_blank, allow_destroy: true
  end
  • Step 2: 修改 PurchaseOrder controller 裡的 Strong Parameters
  def purchase_order_params
    params.require(:purchase_order).permit(:title, products_attributes:[:id, :title,:weight, :price, :quantity, :storage_price, :_destroy])
  end

Follow SimpleForm

  • Step 1: 在 purchase_orders/_form 加入
  <%= simple_form_for(purchase_order) do |f| %>
    <div class="form-inputs">
      <%= f.input :title %>
    </div>

    <h3>產品</h3>
    <%= f.simple_fields_for :products do |product| %>
      <%= render 'product_fields', f: product %>
    <% end %>

    <div class="links">
      <%= link_to_add_association '新增產品', f, :products, class:"btn btn-info" %>
    </div>

    <div class="form-actions">
      <%= f.submit class:"btn btn-primary mb-2 mt-2", value: "送出" %>
    </div>
  <% end %>
  • Step 2: 在 purchase_orders/_product_fields 加入
  <div class="nested-fields">
    <div class="form-group">
      <%= f.input :title, label: "名稱", class: "form-control" %>
      <%= f.input :weight, label: "重量", class: "form-control" %>
      <%= f.input :price, label: "價位", class: "form-control" %>
      <%= f.input :quantity, label: "數量", class: "form-control" %>
      <%= f.input :storage_price, label: "單件入庫成本(重量 x 價位)", class: "form-control" %>
      <%= f.hidden_field :_destroy %>

      <%= link_to_remove_association "刪除", f, data: { confirm: "確認刪除嗎?" }, class:   "btn btn-sm btn-danger mt-2" %>
    </div>
  </div>
Nested Form


實作到這裡就完成的基本的巢狀表單,但是當某個產品的屬性需要作運算或是回傳資料庫,就會遇到問題了。

Nested Form

舉例來說: 在建立產品進貨單時,每次都需要手動計算『單件入庫成本』似乎是很麻煩的事情。 就計算的部分來說,基 本的 Javascript 語法就可以處理了。但是要動態綁定每個『價位』以及『重量』的欄位就沒那麼容易了。

因為每個欄位的 ID 都是依照 Rails 中的 sanitized_object_name() 和 sanitized_method_name() 方法來產生 ID。因此為了要綁定欄位的值來做計算,就會需要借助以上兩個方法。

  • Step 3: 在 app > helpers > purchase_orders_helper.rb 加入以下方法:
  def sanitized_object_name(object_name)
    object_name.gsub(/\]\[|[^-a-zA-Z0-9:.]/,"_").sub(/_$/,"")
  end

  def sanitized_method_name(method_name)
    method_name.sub(/\?$/, "")
  end

  def form_tag_id(object_name, method_name)
    "#{sanitized_object_name(object_name.to_s)}_#{sanitized_method_name(method_nameto_s) }"
  end

接著使用 JS 搭配 form_tag_id() 方法便可取得各個欄位的 ID。

  const weight_id = '<%= form_tag_id(f.object_name, :weight) %>';
  console.log(weight_id);
  const weight = document.querySelector(`#${weight_id}`);

實作方法(二)

安裝 Stimulus JS

  • 在終端機內輸入:
      rails webpacker:install:stimulus
    

安裝 Cocoon Gem

  • 請參照實作方法(ㄧ)的步驟來做

完成安裝 Stimulus 後,只要參考以下步驟,就不用再煩惱要如何綁住屬性欄位的ID。

  • 步驟一:修改 “_product_fields.html.erb” 檔案,使用 Stimulus 的語法來綁定所需欄位的值
<div data-controller="nested-product" class="nested-fields">

<%= f.input :weight, label: "重量", input_html: { data: { "nested-product-target":"weight", "action": "blur->nested-product#cal_storage_price"} }, class: "form-control"%>
<%= f.input :price, label: "價位", input_html: { data: { "nested-product-target":"price", "action": "blur->nested-product#cal_storage_price"} }, class: "form-control"%>
<%= f.input :storage_price, label: "單件入庫成本(重量 x 價位)", input_html: { data: {"nested-product-target": "storage_price"} }, class: "form-control" %>
  • 步驟二:在 app > javascript > controllers 資料夾中,新增 “nested_product_controller.js” 檔案
import { Controller } from "stimulus"

export default class extends Controller {
  static targets = [ "weight", "price", "storage_price" ]

  cal_storage_price(event) {
    event.preventDefault();
    let weight = this.weightTarget.value
    let price = this.priceTarget.value
    let storage_price = this.storage_priceTarget.value
    let result = (weight * price).toFixed(2)

    this.storage_priceTarget.value = result
  }
}