iT邦幫忙

2021 iThome 鐵人賽

DAY 22
0
Modern Web

初階 Rails 工程師的養成系列 第 22

Day22. 誰說畫面只能寫在Erb - 一起在helper寫畫面

  • 分享至 

  • xImage
  •  

今天要開始介紹,如何做自定義helper,並使用。

https://ithelp.ithome.com.tw/upload/images/20210918/20115854ziTgqrmWvt.png

helpers依照慣例會寫在app/helpers裡面,並且不管寫在哪個檔案,view都讀得懂。我們不用知道在Rails Application 是怎麼樣載入helper,只需要知道寫在helper的東西都可以取用就好了。helperrailsmodule,我們在Day14介紹如何使用module#include 繼承裡面的方法,而我們只要有初步的認知是這些helperRails是被繼承的就好,至於是怎麼繼承的,有興趣再深入瞭解 ?

下面的樣式有部分出自sb-admin-2,讀者們可以參考下載連結內部的樣式並取用。

卡片

以下為卡片以及相關的 helper

module ApplicationHelper
  # 版型規格
  def grid_div(left = 1, right = 2, style: '')
    tag.div(style: "display:grid; grid-template-columns: #{left}fr #{right}fr; grid-auto-rows: minmax(50px, auto); #{style}") {yield if block_given?}
  end
  
  
  # card, card_header, card_body, card_footer 為卡片樣式公用規格
  def card(controller: nil, data: nil)
    data_attribute = data.present? ? 
      { controller: controller }.merge(data) : 
      { controller: controller }

    content_tag(:div, class: 'card shadow mb-4', data: data_attribute) do
      yield if block_given?
    end
  end

  def card_header(title:, &block)
    content_tag(:div, class: 'card-header py-3') do
      content_tag(:h6, class: 'h6 m-0 font-weight-bold text-primary d-flex align-items-center') do
        content_tag(:span, title) + (capture(&block) if block_given?)
      end
    end
  end

  def card_body
    content_tag(:div, class: 'card-body') { yield if block_given? }
  end

  def card_footer
    content_tag(:div, class: 'card-footer') { yield if block_given? }
  end
end
= card do
  = card_header(title: '母訂單列表') do
  = card_body do
    = tag.div '卡片內容'
  = card_footer do
    = tag.div '卡片footer'

https://ithelp.ithome.com.tw/upload/images/20210915/20115854sDPLdhm5sx.png

= grid_div(style: 'row-gap: 10px; column-gap: 10px') do
  - (1..8).each do |num|
    = card do
      = card_header(title: "標題#{num}") do
      = card_body do
        = tag.div '卡片內容'
      = card_footer do
        = tag.div '卡片footer'

https://ithelp.ithome.com.tw/upload/images/20210915/20115854WJvf2cOmBd.png

若改為左右比1:1的話,效果如下

= grid_div(1, 1, style: 'row-gap: 10px; column-gap: 10px') do
  / ...

https://ithelp.ithome.com.tw/upload/images/20210915/20115854cl58DOxzzp.png

彈跳視窗

彈跳視窗相關的 helper

module ApplicationHelper
    def modal(id: nil, confirm_wording: '確認', confirm_form:, 
              confirm_target: nil, title: nil, controller: nil, 
              close_btn: '取消')
    content_tag :div, id: id, class: 'modal fade',
                tabindex: -1, role: 'dialog', aria: { hidden: true },
                data: { "#{controller}-target": 'modal' } do
      content_tag :div, role: 'document', class: 'modal-dialog' do
        content_tag :div, class: 'modal-content' do
          # Header
          content_tag(:div, class: 'modal-header') do
            content_tag(:strong, title) +
            button_tag(type: 'button', class: 'close', 
                       data: { dismiss: 'modal' }, aria: { label: 'Close' }) do
              content_tag :span, '×', aria: { hidden: true }
            end
          end +
            # Body: 自定義內容交給 yield
            content_tag(:div, class: 'modal-body') { yield if block_given? } +
            # Footer
            content_tag(:div, class: 'modal-footer') do
              button_tag(close_btn, type: 'button', class: 'btn btn-warning',
                               data: { dismiss: 'modal' }, aria: { label: 'Close' }) +
              (confirm_wording && button_tag(confirm_wording, 
                type: 'submit', class: 'btn btn-primary',
                "data-#{controller}-target": confirm_target, 
                 form: confirm_form, data: { confirm: "是否確定要送出編輯?\n請注意!送出後無法復原!", disable_with: "載入中..." }))
            end
        end
      end
    end
  end
end
= link_to '編輯', '#',
        class:'btn btn-primary mx-1 mb-3',
        data: { toggle: 'modal', target: '#edit-modal' }

= modal(id: 'edit-modal', confirm_wording: '送出', 
        confirm_form: 'edit_modal', title: '編輯視窗') do
  = tag.div "我是彈跳視窗"

其中 tag.div "我是彈跳視窗" 為自定義內容,其他都為 modal 方法(元件)的一部分。

https://ithelp.ithome.com.tw/upload/images/20210915/20115854fGyoGO5Acz.png

側邊欄 Helper

module ApplicationHelper
    def admin_sidebar_block(topic:, genre:, icon: 'fa-solar-panel')
    content_tag(:li, class: 'nav-item') do
      content_tag(:a, class: 'nav-link', href: '#', data: { toggle: 'collapse', 
                 target: "##{genre}" }, aria: { expanded: true, controls: genre }) do
        content_tag(:i, nil, class: "fas fa-fw #{icon}") + content_tag(:span, topic)
      end + \
      content_tag(:div, class: 'collapse', data: { parent: '#accordionSidebar' }, 
                  aria: { labelledby: genre }, id: genre) do
        content_tag(:div, class: 'bg-white py-2 collapse-inner rounded') { yield if block_given? }
      end
    end
  end

  def sidebar_link_to(path, wording = nil)
    if wording
      link_to(content_tag(:span, wording), path, class: 'collapse-item')
    else
      link_to(path, class: 'collapse-item') { yield if block_given? }
    end
  end
end

使用方式為

= admin_sidebar_block topic: '策展', genre: 'curation', icon: 'fa-solar-panel' do
  = sidebar_link_to admin_brand_home_pages_path(current_brand), '首頁管理'
  = sidebar_link_to admin_brand_scrolling_texts_path(current_brand), '跑馬燈管理'
  = sidebar_link_to admin_brand_event_pages_path(current_brand), '促銷頁管理'
  = sidebar_link_to admin_brand_top_banners_path(current_brand), '置頂Banner'
  = sidebar_link_to admin_brand_shop_the_looks_path(current_brand), '焦點商品'
= admin_sidebar_block topic: '商品管理', genre: 'products', icon: 'fa-capsules' do
  = sidebar_link_to import_export_admin_brand_products_path(current_brand), '匯入/匯出'
  = sidebar_link_to admin_brand_products_path(current_brand), '商品總覽'
  = sidebar_link_to admin_brand_series_index_path(current_brand), '系列'
  = sidebar_link_to admin_brand_categories_path(current_brand), '大類管理'
  = sidebar_link_to admin_brand_collections_path(current_brand), '中類管理'
  = sidebar_link_to admin_brand_sub_collections_path(current_brand), '小類管理'  
= admin_sidebar_block topic: '訂單管理', genre: 'orders', icon: 'fa-envelope' do
  - if can_edit?(:user)
    = sidebar_link_to admin_orders_path, '母訂單列表'
  = sidebar_link_to admin_brand_sub_orders_path(current_brand) do
    = tag.span '訂單總覽'
  = sidebar_link_to admin_unshipped_orders_path do
    = tag.span '待出貨訂單'
    = tag.span unshipped_order_count, class: 'badge badge-secondary ml-1'
  = sidebar_link_to admin_return_orders_path do
    = tag.span '退貨訂單'
    = tag.span return_orders_count, class: 'badge badge-secondary ml-1'
  - if can_edit? :user
    = sidebar_link_to admin_pay_failed_orders_path do
      = tag.span '刷退失敗訂單'
      = tag.span pay_failed_orders, class: 'badge badge-secondary ml-1'
= admin_sidebar_block topic: '通知中心', genre: 'notifications', icon: 'fa-carrot' do
  = sidebar_link_to admin_brand_push_notifications_path(current_brand), '推播列表'
= admin_sidebar_block topic: '行銷管理', genre: 'promotions', icon: 'fa-cart-plus' do
  = sidebar_link_to admin_brand_promotions_path(current_brand), '品牌折扣'
  = sidebar_link_to admin_target_price_discounts_path, '全館滿額'

https://ithelp.ithome.com.tw/upload/images/20210915/20115854ZfYcuKW1Gu.png

時間區間

以下為時間區間的helper

https://ithelp.ithome.com.tw/upload/images/20210918/201158540W6TbZFINB.png

表單內常見的搜尋時間區間,我也有做相對應的helper 以方便好作取用。

關於裡面的參數內容我們先忽略。一個與表單有關、另外一個為stimulus所取用的框架。

= search_interval(controller: 'orders', form: 'export-orders')

以下為 search_interval的內容,而我們用了datatable_date_tag 來對原本的date_field_tagy 做加工。

module ApplicationHelper
    def search_interval(controller: nil, form: nil)
      tag.div class: 'input-group mb-3' do
          # 開始時間
          datatable_date_tag(controller: controller, target: 'startedAt',
                             value: Date.today - 1.year, form: form,
                             name: 'ransack_search[created_at_gteq]') +
          # 到
          tag.div(class: 'input-group-append',
                  style: 'height: 100%;') { tag.label '至', class: 'input-group-text' } +
          # 結束時間
          datatable_date_tag(controller: controller, target: 'endedAt',
                             value: Date.today + 1.day, form: form,
                             name: 'ransack_search[created_at_lt]')
    end
  
    def datatable_date_tag(options = {})
      stimulus_data = (options[:controller] && 
        { "#{options[:controller]}-target": options[:target] }).presence || {}

      date_field_tag((options[:name] || options[:target]&.to_sym), 
        options[:value], class: 'form-control', style: 'height: 100%; max-width: 220px;',
        data: stimulus_data.merge(options[:data].presence || {}), 
        form: options[:form],
        disabled: options[:disabled] || false)
    end
  end
end

頁籤

雖然bootstrap 本身已經做足了優化,但將其作為helper使用,可以讓我們更便利使用。

= title '自我介紹'

= card do
  = card_header(title: '關於我')
  = card_body do
    = tab_list(me_genre)
    = tab_contents do
      / 當前顯示內容
      = tab_active_content(me_genre.first[:id]) do
        / 頁籤1內容
        = tag.span "我的興趣"
      / 隱藏內容  
      = tab_content(me_genre.second[:id]) do
        / 頁籤2內容
        = tag.span "我的專長"
      / 隱藏內容    
      = tab_content(me_genre.third[:id]) do
        / 頁籤3內容
        = tag.span "如何聯絡我"

而上述的helper 於下方

module ApplicationHelper
    # 頁籤列表與頁籤內容
  # @param [Hash] list
  # @example: tab_list([{id: 'han001', wording: '漢漢1號'}, {id: 'han002', wording: '漢漢2號'}])
  def tab_list(list)
    data_attr = -> (content) { content.try(:[], :data).presence || {} }

    content_tag :ul, class: 'nav nav-tabs', role: 'tablist' do
      list.each_with_index.map do |content, index|
        if index.zero?
          content_tag(:li,
                      content_tag(:a, content[:wording], href: "##{content[:id]}-tab",
                             class: 'nav-link active', data: { toggle: 'tab', 
                               **data_attr.(content) },
                             aria: { controls: "#{content[:id]}-tab", selected: 'true' }),
                      class: 'nav-item', role: 'presentation')
        else
          content_tag(:li,
                      content_tag(:a, content[:wording], href: "##{content[:id]}-tab",
                             class: 'nav-link', data: { toggle: 'tab', 
                               **data_attr.(content) },
                             aria: { controls: "#{content[:id]}-tab", selected: 'false' }),
                      class: 'nav-item', role: 'presentation')
        end
      end .join.html_safe
    end
  end

  def tab_contents
    content_tag(:div, class: 'tab-content') { yield }
  end
  
  # 隱藏內容
  def tab_content(id, options = {})
    active_class, basic_class = 'show active', 'tab-pane fade'

    content_tag(:div, 
      class: ((options[:active] || false) ? 
      (basic_class + active_class) : basic_class),
      id: "#{id}-tab", role: 'tabpanel', data: options[:data]) { yield }
  end

  # 當前顯示內容
  def tab_active_content(id, options = {})
    options = options.merge(active: true)

    tab_content(id, options) { yield }
  end
end

頁籤內容在下方

module MeHelper
  def me_genre
    [
      { id: 'interest', wording: '我的興趣' },
      { id: 'skillSet', wording: '我的專長' },
      { id: 'contactMe', wording: '與我聯繫' },
    ]
  end
end

編譯完的html 如下

<div class="card shadow mb-4">
  <div class="card-header py-3">
    <h6 class="h6 m-0 font-weight-bold text-primary d-flex align-items-center">
      <span>關於我</span>
    </h6>
  </div>
  <div class="card-body">
    <ul class="nav nav-tabs" role="tablist">
      <li class="nav-item" role="presentation">
        <a
          href="#interest-tab"
          class="nav-link"
          data-toggle="tab"
          aria-controls="interest-tab"
          aria-selected="true">我的興趣</a>
      </li>
      <li class="nav-item" role="presentation">
        <a
          href="#skillSet-tab"
          class="nav-link"
          data-toggle="tab"
          aria-controls="skillSet-tab"
          aria-selected="false">我的專長</a>
      </li>
      <li class="nav-item" role="presentation">
        <a
          href="#contactMe-tab"
          class="nav-link active"
          data-toggle="tab"
          aria-controls="contactMe-tab"
          aria-selected="false">與我聯繫</a>
      </li>
    </ul>
    <div class="tab-content">
      <div class="tab-pane fadeshow" id="interest-tab" role="tabpanel">
        <span>我的興趣</span>
      </div>
      <div class="tab-pane fade" id="skillSet-tab" role="tabpanel">
        <span>我的專長</span>
      </div>
      <div class="tab-pane fade active show" id="contactMe-tab" role="tabpanel">
        <span>如何聯絡我</span>
      </div>
    </div>
  </div>
</div>

Partial

除了寫在helper以外,另外常見的用法為將畫面抽換成 partial。我們習慣會在被抽離的模板前面加底線,並且使用render方法,使用前面為底線的檔案。例如,我們最常使用的方式為將新增、編輯的畫面輸入框的部分抽共用成_form.html.slim,並且在編輯/新增使用。

= render partial: 'form', locals: { path: <路徑>, method: :<方法> }

再來介紹使用區塊的例子。下列為 shared/_export_xlsx.html.slim,一共有兩個yield,各會被插去客製化的區塊

.row
  .col-lg-12
    .ibox.float-e-margins
      .ibox-title
        h5
          | 福利中心
      .ibox-content
        / 預設區塊 
        = yield
      .ibox-content
        = yield :a_section

若我們渲染畫面時要使用 block 時,使用render 字眼會產生錯誤,因此如果有block出現要改用layout

'nil' is not an ActiveModel-compatible object. It must implement :to_partial_path.

以下為使用上述partial檔案的方法

= render layout: 'shared/export_xlsx',
         locals: { path: "/import_example.xlsx" } do
  // 插入 a_section      
  = content_for :a_section do
    a.btn.btn-info.m-l-sm href="export.xlsx" 匯出商品
  // 插入預設區塊   
  = tag.span "預設區塊"

結尾

今天介紹了自定義的helper,以及partail的用法,這些Rails小技巧能夠讓我們的畫面更簡潔,而畫面的部分大致上介紹到這裡。明天開始,會開始介紹表單。


上一篇
Day21. 用 Rails helper 省去更多開發時間
下一篇
Day23. 在講表單之前,先來談談routes和mvc - 表單 part1
系列文
初階 Rails 工程師的養成34
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言