Thumbnail

打造自己的 Form Builder

by 慕凡 2019-10-26 00:00:00 UTC

通常我們在編寫 HTML 表單中的輸入標籤(或稱 Input Tag)時,都會在每個 Tag 前後加入一些附帶特定樣式,通常是 CSS Class 的 HTML Tag 作點綴修飾,本篇文章的目的為透過 Step by step 的方式,介紹如何透過繼承與拓展 Ruby on Rails 的 FormBuilder 來建立適用於 Bulma CSS Framework 的 FormBuilder。

Bulma CSS Framrwork 與安裝

Bulma 是一個純 CSS 的前端網站 Framework,特色是所有樣式都基於對 HTML TAG Class 的設定即可套用,在 2019 State of CSS 的使用者調查中高居滿意度的第二位,在網路上有不少有關的介紹,官網的文件也算非常完整,詳細的使用方式這邊就不再贊述。

在已經內建 Webpacker Gem 的狀況下,我們在程式目錄下執行:

yarn add bulma
mkdir app/javascript/stylesheets

就會安裝 Bulma 的 npm 套件到你的程式中,然後建立用來放置 Bulma CSS 的檔案:

# app/javascript/stylesheets
@import '~bulma/bulma'

app/javascript/packs/application.js 加入對這個 sass 的引用:

import '../stylesheets/application'

在 Layout ERB 中加入 stylesheet_pack_tag 引用 CSS

<%= javascript_pack_tag 'application' %>
<%= stylesheet_pack_tag 'application' %>

就算完成了 Bulma 的安裝,接下來我們要實作基於 Bulma Form Builder 這個網站上提供的 Component 設定來實作 FormBuilder Class。

Bulma Form Input

根據 Bulma 官網和 Bulma Form Builder 網站上的文件,我們可以發現 Bulma 的 Input 大致上有下列的結構:

<!-- Text input-->
<div class="field">
  <label class="label" for="textinput-0">Text Input</label>
  <div class="control">
    <input id="textinput-0" name="textinput-0" type="text" placeholder="placeholder" class="input ">
    <p class="help">help</p>
  </div>
</div>

也就是 帶有 field class 的 DIV 包住 label帶有 control class 的 DIV 再包住 Input TagHelp Text Tag

自訂 FormBuilder 起手式

我們先設定一個使用者註冊表單,像這樣:

<%=form_with scope: :user, url: {action: :create}, local: true do |f|%>
  <%=f.email_field :email%>
  <%=f.password_field :passwd%>
  <%=f.password_field :passwd_confirmation%>
  <%=f.submit 'Sign Up'%>
<%end%>

我們來找個位置放這個 Class 的程式碼,目前先選在 app/controllers/concerns

# app/controllers/concerns/bulma_form_builder.rb
class BulmaFormBuilder < ActionView::Helpers::FormBuilder
end

然後在 form_with 上加上 builder 設定:

<%=form_with scope: :user, url: {action: :create}, local: true, builder: BulmaFormBuilder, class: "form-horizontal" do |f|%>
...

然後來設定 field 和 control tag 用的 helper,以下程式碼如果沒有特別說明都會在 BulmaFormBuilder 裡:

def field_container(content_or_options = nil, options = {}, &block)
  options = content_or_options if content_or_options.is_a?(Hash)
  content_or_options = @template.capture(&block) if block_given?
  options[:class] = options.dig(:class).to_s + " field"
  @template.content_tag(:div, content_or_options, options)
end

def control_container(content_or_options =nil, options = {}, &block)
  options = content_or_options if content_or_options.is_a?(Hash)
  content_or_options = @template.capture(&block) if block_given?
  options[:class] = options.dig(:class).to_s + "control"
  @template.content_tag(:div, content_or_options, options)
end

在此略作說明,根據 [ActionView::Helpers::FormBuilder] 上的定義,我們可以使用 @template 這個 Instance Variable 來呼叫實際執行時的 View Context,這個變數在之後會一直使用到,請特別注意;不過稍微注意就會發現,兩個 method 的內容除了 Dom Class 以外都一樣,讓我們來修改一下:

# Code: https://github.com/ryudoawaru/bulma_form_builder_post/tree/step1
def container_tag(base_com_class, content_or_options =nil, options = {}, &block)
  options = content_or_options if content_or_options.is_a?(Hash)
  content_or_options = @template.capture(&block) if block_given?
  options[:class] = [options.dig(:class).to_s, base_com_class].join(" ")
  @template.content_tag(:div, content_or_options, options)
end

%w[field control].each do |klass|
  define_method "#{klass}_container" do |content_or_options = nil, options = {}, &block|
    container_tag(klass, content_or_options = nil, options = {}, &block)
  end
end

於是我們試著在 View 中每個 field 前後加上 field_containercontrol_containerlabel 看看

<%=form_with scope: :user, url: {action: :create}, local: true, builder: BulmaFormBuilder, class: "form-horizontal" do |f|%>
  <%=f.field_container do%>
    <%=f.label :email, class: "label"%>
    <%=f.control_container do%>
      <%=f.email_field :email, class: "input"%>
    <%end%>
  <%end%>
  <%=f.field_container do%>
    <%=f.label :passwd, class: "label"%>
    <%=f.control_container do%>
      <%=f.password_field :passwd, class: "input"%>
    <%end%>
  <%end%>
  <%=f.field_container do%>
    <%=f.label :passwd_confirmation, class: "label"%>
    <%=f.control_container do%>
      <%=f.password_field :passwd_confirmation, class: "input"%>
    <%end%>
  <%end%>
  <%=f.submit 'Sign Up', class: "button is-primary"%>
<%end%>

這樣畫面看起來開始比較有樣子了:

Step1

不過看來每個輸入前都有一些重複的行為,我們可以把這些重複的行為也統整一下建立一些 FormBuilder 的 Method:

def bulma_email_field(method, options = {})
  options[:class] = options.delete(:class).to_s + " input "
  field_container do
    label(method, class: "label") + control_container do
      email_field(method, options)
    end
  end
end

def bulma_password_field(method, options = {})
  options[:class] = options.delete(:class).to_s + " input "
  field_container do
    label(method, class: "label") + control_container do
      password_field(method, options)
    end
  end
end

View 也可以變回比較簡潔的形狀:

<%=form_with scope: :user, url: {action: :create}, local: true, builder: BulmaFormBuilder, class: "form-horizontal" do |f|%>
  <%=f.bulma_email_field :email%>
  <%=f.bulma_password_field :passwd%>
  <%=f.bulma_password_field :passwd_confirmation%>
  <%=f.submit 'Sign Up', class: "button is-primary"%>
<%end%>

不過兩個 method 看起來實在是十分相似,我們把這兩個 methods 代換成下面這樣:

# TAG: step2
# code: https://github.com/ryudoawaru/bulma_form_builder_post/tree/step2
def self.bulma_field(field_method_name)
  define_method "bulma_#{field_method_name}" do |method, options = {}|
    options[:class] = options.delete(:class).to_s + " input "
    field_container do
      label(method, class: "label") + control_container do
        send(field_method_name, method, options)
      end
    end
  end
end

bulma_field(:email_field)
bulma_field(:password_field)

仔細看 ActionView::Helpers::FormBuilder 的 Api 文件會發現很多 FormBuilder Method 都可以比照辦理,所以我們可以把這些 methods 都加上去:

  %i[color_field date_field email_field password_field text_field datetime_field datetime_local_field number_field phone_field range_field].each do |field_name|
    bulma_field field_name
  end

接下來我們要來加上一些彈性,例如指定 Label Tag 的內容,加上 Help Text Tag 等:

  # TAG: step3
  # Code: https://github.com/ryudoawaru/bulma_form_builder_post/tree/step3
  def fetch_i18n_help_text(method)
    t_scope = ["helpers", "help", @object_name, method].join(".")
    I18n.t(t_scope, default: nil)
  end

  def self.bulma_field(field_method_name)
    define_method "bulma_#{field_method_name}" do |method, options = {}|
      options[:class] = [options.delete(:class), "input"].compact.join(" ")
      help_text = options.delete(:help) || fetch_i18n_help_text(method)
      help_tag = help_text ? @template.content_tag(:p, help_text, class: "help") : ""
      field_container do
        label(method, options.delete(:label), class: "label") + control_container do
          send(field_method_name, method, options) + help_tag
        end
      end
    end
  end

然後修改 View 的內容:

<%=form_with scope: :user, url: {action: :create}, local: true, builder: BulmaFormBuilder, class: "form-horizontal" do |f|%>
  <%=f.bulma_email_field :email, help: "請勿用 + ", label: "電子郵件"%>
  <%=f.bulma_password_field :passwd%>
  <%=f.bulma_password_field :passwd_confirmation%>
  <%=f.submit 'Sign Up', class: "button is-primary"%>
<%end%>

就可以看到出現了 help Tag:

Step3

這邊的修改增加兩個功能,一個是在 options 增加 label 選項讓我們可以用選項指定 Label Tag 的文字內容,以及 help 選項手動指定 Help Tag 的文字,類似於 FormHelper 的 label method,藉由定義 fetch_i18n_help_text 這個 method,讓我們可以在 options 未指定 help 的情況下,從 I18n 語系檔中直接設定 Help Tag 的文字,例如:

helpers:
  help:
    user:
      email: 請輸入常用的 E-Mail

就可以在前面的 email 欄位的 Input Tag 後產生對應的 Help Tag。

那些無法使用通用格式的 FormBuilder Methods

看到這邊應該會發現,目前能直接套用 bulma_field 都是有相同參數的 method,那麼那些不一樣的 methods 呢?

select

這邊還算單純,只要定義新的 method 就可以了,在開始之前我們先把現有的 method 整理一下,減少之後實作其它 input method 時重複的程式碼:

class BulmaFormBuilder < ::ActionView::Helpers::FormBuilder
  def container_tag(base_dom_class, content_or_options = nil, options = {}, &block)
    options = content_or_options if content_or_options.is_a?(Hash)
    content_or_options = @template.capture(&block) if block_given?
    options[:class] = [options.dig(:class), base_dom_class].compact.join(" ")
    @template.content_tag(:div, content_or_options, options)
  end

  def field_container(content_or_options = nil, options = {}, &block)
    container_tag "field", content_or_options, options, &block
  end

  def around_input(method, options = {}, &block)
    options[:class] = [options.delete(:class), "input"].compact.join(" ")
    help_text = options.delete(:help) || fetch_i18n_help_text(method)
    help_tag = help_text ? @template.content_tag(:p, help_text, class: "help") : ""
    input_html = @template.capture(&block)
    label_tag = options[:hide_label] ? @template.raw("") : label(method, options.delete(:label), class: "label")
    label_tag + container_tag("control", input_html + help_tag)
  end

  def self.bulma_field(field_method_name)
    define_method "bulma_#{field_method_name}" do |method, options = {}|
      field_container do
        around_input(method, options) do
          send(field_method_name, method, options)
        end
      end
    end
  end

  %i[color_field date_field email_field password_field text_field datetime_field datetime_local_field number_field phone_field range_field].each do |field_name|
    bulma_field field_name
  end

  def bulma_select(method, choices = nil, options = {}, html_options = {}, &block)
    field_container do
      around_input(method, options) do
        @template.content_tag(:div, select(method, choices, options, html_options, &block), class: "select")
      end
    end
  end
  private

  def fetch_i18n_help_text(method)
    t_scope = ["helpers", "help", @object_name, method].join(".")
    I18n.t(t_scope, default: nil)
  end
end

在這一步將原本用 define_method 動態建立的兩個 container method 拆回各自定義,並將 control_container 改成 around_input,把原本在 bulma_field 裡設定 Label、Help Tag 與 Input Tag Class 的行為搬過去,然後順便加上 hide_label 這個 option 用來隱藏 label,這樣之後在新增其它 Input Method 時就只要經過 field_containeraround_input 這兩個 method,不需要其它手續就可以產生必要的 Tag;然後加上 bulma_select 來建立 select input。

collectionradiobuttons & collectioncheckboxes

BulmaFormBuilder 裡加入這兩個方法:

# Code: https://github.com/ryudoawaru/bulma_form_builder_post/tree/step4
def bulma_collection_radio_buttons(method, collection, value_method, text_method, options = {}, html_options = {})
  field_container do
    label_tag(method, options) + container_tag("control") do
      collection_radio_buttons(method, collection, value_method, text_method, options, html_options) do |b|
        b.radio_button + b.label(class: "radio")
      end
    end
  end
end

def bulma_collection_check_boxes(method, collection, value_method, text_method, options = {}, html_options = {})
  field_container do
    label_tag(method, options) + container_tag("control") do
      collection_check_boxes(method, collection, value_method, text_method, options, html_options) do |b|
        b.check_box + b.label(class: "radio")
      end
    end
  end
end

然後在 View 裡加上對應的欄位測試:

<%=form_with scope: :user, url: {action: :create}, local: true, builder: BulmaFormBuilder, class: "form-horizontal" do |f|%>
  <%=f.bulma_email_field :email, help: "請勿用 + ", label: "電子郵件"%>
  <%=f.bulma_select :age_range, %w[20歲以下 21~30歲 31~40歲 41歲以上]%>
  <%=f.bulma_collection_radio_buttons :sex, %w[男 女 其它], :to_s, :to_s, label: '性別'%>
  <%=f.bulma_collection_check_boxes :from, [%w[SNS sns], %w[朋友告知 friend], %w[平面媒體 media], %w[其它 others]], ->(x){x[1]}, :first, label: '從何處得知我們'%>
  <%=f.bulma_password_field :passwd%>
  <%=f.bulma_password_field :passwd_confirmation%>
  <%=f.submit 'Sign Up', class: "button is-primary"%>
<%end%>

Step4

這樣基本的機能就算是完成了。

同化:將原有的表單快速遷移

到這邊為止主要的功能都差不多了,其它的變化形都可以用上述的功能拼湊出來,不過如果想要整合既有的使用原始 FormBuilder 的表單並且不做大幅修改的話,我們要讓原有的這些 FormBuilder Helper 具備同樣的能力才行,這邊可以使用 Ruby 內建的 alias_method 達成,讓我們在 BulmaFormBuilder 加上這個方法:

def self.bulma_alias(method_name)
  alias_method :"#{method_name}_without_bulma", method_name
  alias_method field_name, "bulma_#{method_name}"
end

當我們執行 bulma_alias :text_field 的時候會發生以下的事情:

  • 將原本的 text_field 建立別名 text_field_without_bulma
  • bulma_text_field 建立別名 text_field

也就是指我們可以把 text_field 當成 bulma_text_field 來用,原本的變成 text_field_without_bulma,然後在 self.bulma_field 最後加上:

def self.bulma_field(field_method_name)
  define_method "bulma_#{field_method_name}" do |method, options = {}|
    field_container do
      around_input(method, options) do
        send("#{field_method_name}_without_bulma", method, options)
      end
    end
  end
  bulma_alias field_method_name
end

並且把另外定義的幾個 method 套用 bulma_alias

%i[select collection_radio_buttons collection_check_boxes].each{|fn| bulma_alias(fn)}

然後把 view 裡的 bulma_ 去掉:

<%=form_with scope: :user, url: {action: :create}, local: true, builder: BulmaFormBuilder, class: "form-horizontal" do |f|%>
  <%=f.email_field :email, help: "請勿用 + ", label: "電子郵件"%>
  <%=f.select :age_range, %w[20歲以下 21~30歲 31~40歲 41歲以上]%>
  <%=f.collection_radio_buttons :sex, %w[男 女 其它], :to_s, :to_s, label: '性別'%>
  <%=f.collection_check_boxes :from, [%w[SNS sns], %w[朋友告知 friend], %w[平面媒體 media], %w[其它 others]], ->(x){x[1]}, :first, label: '從何處得知我們'%>
  <%=f.password_field :passwd%>
  <%=f.password_field :passwd_confirmation%>
  <%=f.submit 'Sign Up', class: "button is-primary"%>
<%end%>

會發現出來的結果和前一個 Step 完全一樣,這樣是不是非常方便呢?

以上的範例程式碼放在我個人的 GitHub 上,歡迎大家參考。

«回文章列表