iT邦幫忙

2021 iThome 鐵人賽

DAY 28
0
Modern Web

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

Day28. Rails 搭配 DataTable 寫出完美的列表頁

今天要講Stimulus & Datatable 的用法,不過不會Stimulus的讀者們不用擔心,因為在Rails可以寫 Datatable 的方式相當多種

  • inline ➡️ 寫在 slim/erb 內
  • Webpack ➡️ 寫在 DOMContentLoaded hook 執行
  • 寫在 Stimulus

我們也在 Day26, Day27介紹如何使用Stimulus,讀者都可以查閱。

config

安裝 dataTable,以及對應的 package.json

npm install datatables.net
npm install datatables.net-bs4
{
  "name": "tungrp_backend",
  "private": true,
  "dependencies": {
    // ...
    "datatables.net-bs4": "^1.10.22",
    "datatables.net-dt": "^1.10.22",
    // ...
  },
  "version": "0.1.0",
  "devDependencies": {
    "webpack-dev-server": "^3.11.0"
  }
}

在剛接觸dataTable的時候,有看過一些使用者會安裝相關的Gem,不過後來發現只要掌握些精髓,就不用使用額外的第三方來使用!

另外,dataTable 是基於JQuery的套件,因此會出現很多JQuery的語法

view

首先thead 必須要我們手動加入,我們將頁籤1加上欲加入的內容。我們將table 加入了id=blog-lists ,會在 JS 的部分使用

= tag.div data: { controller: 'admin--blogs',
        'admin--blogs-view-id-value': 40,
        'admin--blogs-reload_at-value': Time.current.strftime('%F %T') }
        
  / 卡片內容
  = card do
    = card_header(title: '部落格列表') do
      = link_to '新增', '#', class: 'ml-auto btn btn-primary',
                data: { toggle: 'modal', target: '#new-blog-modal' }
    = card_body do
      = tab_list(blog_genre)
      = tab_contents do
        / 當前顯示內容
        = tab_active_content(blog_genre.first[:id]) do
          / 頁籤1內容
          table.table.data-table#blog-lists
            thead
              tr
                th.col-2 id
                th.col-2 創建時間
                th.col-1 文章分類
                th.col-1 標題
                th.col-1

https://ithelp.ithome.com.tw/upload/images/20210923/201158542itVhB2k4d.png

表單最重要的當然是濾除功能,因此我們將欲濾除的樣式加上去

= form_tag '#', id: 'reset_usage' do

= tag.div data: { controller: 'admin--blogs',
        'admin--blogs-view-id-value': 40,
        'admin--blogs-reload_at-value': Time.current.strftime('%F %T') }
  / Stimulus 卡片內容
  = card do
    = card_header(title: 'Stimulus')
    = card_body do
      = tag.input type: 'text', data: { 'admin--blogs-target': 'name' }
      = button_tag '輸出', type: 'button', data: { action: 'click->admin--blogs#greet' },
                            class: 'btn btn-primary mx-2'
      = tag.span data: { 'admin--blogs-target': 'output' }

  / 卡片內容
  = card do
    = card_header(title: '部落格列表') do
      = link_to '新增', '#', class: 'ml-auto btn btn-primary',
                data: { toggle: 'modal', target: '#new-blog-modal' }
    = card_body do
      = tab_list(blog_genre)
      = tab_contents do
        / 當前顯示內容
        = tab_active_content(blog_genre.first[:id]) do
          = tag.div class: 'd-flex ml-1 mb-1 flex-nowrap'
            = datatable_search_field placeholder: '搜尋部落格標題/內容', form: 'reset_usage',
                                   data: { 'admin--blogs-target': 'keyword' }
            = button_tag '清除重填', type: 'reset', form: 'reset_usage', 
                  class: 'btn btn-secondary btn-sm ml-2 mb-2',
                  data: { action: 'admin--blogs#reset', 'admin--blogs-target': 'resetBtn' }
          = tag.div class: 'd-flex mb-2 flex-nowrap'
            = datatable_select_tag [['請選擇', nil], *Blog.genres.to_a], 'genre-select',
                                   controller: 'admin--blogs', target: 'genre', form: 'reset_usage'
            / 時間區間
            = search_interval(controller: 'admin--blogs', form: 'reset_usage')
          / 頁籤1內容
          table.table.data-table#blog-lists
            thead
              tr
                th.col-2 id
                th.col-2 創建時間
                th.col-1 文章分類
                th.col-1 標題
                th.col-1

其中有幾個之前提過的重點

  • 我們在 Day22 提過時間區間helper ➡️ search_interval

  • 我們知道按鈕一共有三個屬性 ➡️ 這裡用的是type=reset屬性

    使用該屬性,可以很輕鬆的將表單內的元素重置,但前提是要在同個表單內,所以我們需要用到Day25 提到的form屬性指定特定form的方式。我們將負責清除重填的工作交給 = form_tag '#', id: 'reset_usage' do,利用 Day25 提到的特性,將其搬往外面,而不影響其內部的樣式排列。

以下為加入濾除功能樣式 & 清除重填的功能後的結果。目前只有清除功能,尚未有顯示表單的功能,以及濾除功能。

Controller

dataTable 相關的後端邏輯我們寫在Controller,DataTable 會透過給參數、打json的方式呼叫後端,再將結果回傳給 DataTable

module Admin
  class BlogsController < ApplicationController
    def index
      respond_to do |format|
        filtered_objects = Blog.ransack(search_params).result

        results = filtered_objects.offset(params[:start])
                                  .limit(params[:length])
        # html 內容
        format.html
        # json 內容 (給 dataTable 取用)
        format.json {
          render json: {
            draw: params[:draw].to_i,
            recordsTotal: filtered_objects.count,
            recordsFiltered: filtered_objects.count,
            data: results.map do |result|
              [result.id, result.created_at.strftime('%F %T'), result.genre, result.title, result.id]
            end
          }
        }
      end
    end
    
    private
    
    def search_params
      return if params[:ransack_search].nil?

      params.require(:ransack_search)
            .permit(:genre_eq, :title_or_content_cont,
                    :created_at_gteq, :created_at_lt)
    end
  end
end

其中我們看下列這行,其實是使用搜尋功能的一個名叫ransackgem,它可以很簡單的將我們要搜尋的欄位轉換成SQL語句,而使用的Strong Paramssearch_params

filtered_objects = Blog.ransack(search_params).result

由於這篇的重點在dataTable,並且在這系列的文章內,都還沒有提過基本的Sql Statement,因此這邊,讀者就先將搜尋當作是黑盒子,等到後面的篇章介紹 model / orm / ransack的用法會再回頭講。

另外,render :json大括號包覆住的 data,即為Table內容,裡頭的陣列分別對應Table的 <thead>屬性。

Controller 的 render,除了渲染基本的html,還可以渲染其他種類型

  • render partial ➡️ 非同步處理,搭配remote=true
  • render json ➡️ 當作API使用,如 Datatable、React
  • render xlsx ➡️ 匯出表單
  • render js ➡️ 匯出 js,如舊式的 js.erb
  • ......

講完基本的controller的設定,接著開始講 JS 的部分

Basic JS

我們先將基本的 dataTable 功能上去

import { Controller } from 'stimulus';

const previousTarget = ["name", "output", "searchedContent", 'brand', 'store', 'ajaxId']
const dataTableTarget = ['genre', 'startedAt', 'endedAt', 'keyword', 'resetBtn']

export const ajaxReload = (table) => () => table.api().ajax.reload();

export default class extends Controller {
  static targets = [...previousTarget, ...dataTableTarget]
  static values = { testId: Number, viewId: Number, reloadAt: String, queryUrl: String }

  connect() {
    /* DataTable with server side render */
    let table = this.dataTable();
  }

  // dataTable
  dataTable() {
    return $('#blog-lists').dataTable({
      serverSide: true,
      bStateSave: true,
      bAutoWidth: false,
      searchDelay: 1200,
      ordering: false,
      searching: false,
      language: { url: '/datatable.chinese_traditional.lang.json' },
      ajax: {
        url: '/admin/blogs.json',
        data: {
          // 先省略濾除內容...
        },
      },
      'rowCallback': (row, data) => {
        $('td:eq(4)', row).html(this.blogShowButton(data[4]));
      }
    });
  }

  // 部落格詳細頁
  blogShowButton(data) {
    return `<div class="d-flex justify-content-end">
              <a class="btn btn-info ml-2" href="/admin/blogs/${data[2]}">詳細頁</a>
            </div>`
  }
}

在Stimulusconnect hook加入下列這行,該Table就會被DataTable 所調用

let table = this.dataTable();

題外話,我們將該 Table 加入樣式display: none;

/ 頁籤1內容
table.table.data-table#blog-lists(style="display:none;")
  thead
    tr
      th.col-2 id
      th.col-2 創建時間
      th.col-1 文章分類
      th.col-1 標題
      th.col-1

會發現,有些已經被DataTable調用的地方藏不住。我曾經藏試過藏Table,發生了這種狀況,所以才有這個小提醒 ?

JS & Controller with filter

我們先看加入濾除功能後的效果如何 ⬇️

接著,我們介紹加上濾除功能的JS要如何寫。

const previousTarget = ["name", "output", "searchedContent", 'brand', 'store', 'ajaxId']
const dataTableTarget = ['genre', 'startedAt', 'endedAt', 'keyword', 'resetBtn']

/* 重新載入table */
export const ajaxReload = (table) => () => table.api().ajax.reload();

export default class extends Controller {
  static targets = [...previousTarget, ...dataTableTarget]
  static values = { testId: Number, viewId: Number, reloadAt: String, queryUrl: String }

  connect() {
    /* DataTable with server side render */
    let table = this.dataTable();
    /* addEventListener: change */
    dataTableTarget.forEach((e) => this[`${e}Target`].addEventListener('change', ajaxReload(table)))
    /* 搜尋框 */
    this.keywordTarget.addEventListener('input', ajaxReload(table))
    /* 重置按鈕 */
    this.resetBtnTarget.addEventListener('click', ajaxReload(table))
  }

  // dataTable
  dataTable() {
    return $('#blog-lists').dataTable({
      serverSide: true,
      bStateSave: true,
      bAutoWidth: false,
      searchDelay: 1200,
      ordering: false,
      searching: false,  // 搜尋關掉。
      language: { url: '/datatable.chinese_traditional.lang.json' },
      ajax: {
        url: '/admin/blogs.json',
        data: {
          'ransack_search[created_at_gteq]': () => this.startedAtTarget.value,
          'ransack_search[created_at_lt]': () => this.endedAtTarget.value,
          'ransack_search[genre_eq]': () => this.genreTarget.value,
          'ransack_search[title_or_content_cont]': () => this.keywordTarget.value,
        },
      },
      'rowCallback': (row, data) => {
        $('td:eq(4)', row).html(this.blogShowButton(data[4]));
      }
    });
  }

  reset() {
    this.resetBtnTarget.click()
  }

  // 部落格詳細頁
  blogShowButton(data) {
    return `<div class="d-flex justify-content-end">
              <a class="btn btn-info ml-2" href="/admin/blogs/${data[2]}">詳細頁</a>
            </div>`
  }
}

這裡有幾個重點想要講,首先先講解 dataTable 的參數

api().ajax.reload() # 重新載入Table
serverSide: true    # 如果此值為 false,伺服器會將資料全撈,並且濾除的功能會在客戶端做。
rowCallback         # 渲染之前的 callback
data                request.parameters

關於rowCallback,將詳細頁的按鈕先寫成一個常數,在第5個位置(從0開始算)來使用它。

// 部落格詳細頁
blogShowButton(data) {
  return `<div class="d-flex justify-content-end">
            <a class="btn btn-info ml-2" href="/admin/blogs/${data[2]}">詳細頁</a>
          </div>`
}

關於data為傳到後端的參數,這裡指的是負責濾除的值,也就是前面提到的ransack

清除重填的地方。我們來分別看對應的Slim & JS

= button_tag '清除重填', type: 'reset', form: 'reset_usage', 
      class: 'btn btn-secondary btn-sm ml-2 mb-2',
      data: { action: 'admin--blogs#reset', 'admin--blogs-target': 'resetBtn' }
reset() {
  this.resetBtnTarget.click()
}

這邊的reset 只做一件事情,就是點擊的時候會再點擊一次。會做這件事情的目的為,當我們按下了清除重填,因為生命週期的關係,送出新的DataTable的時間會比清除資料的時間還要早,所以再按下清除重填的時候,非同步的資料還停留在清除以前的狀態。

因此我們再按按鈕的時候會再觸發一次點擊,等於是兩次點擊。這樣一來就會變成 點擊 ➡️ Ajax表單送出(非預期的結果) ➡️ 輸入框清除 ➡️ 再點擊一次 ➡️ Ajax表單送出(預期結果)➡️ 輸入框再清除一次

對於寫糞code的工程師來說,我發現click() 還蠻好用的

event.target.click()

目前部落格頁面如下

https://ithelp.ithome.com.tw/upload/images/20210923/20115854fCK66JWVyy.png

如果想要變成點擊新增為active,讀者要怎麼變 ⬇️

https://ithelp.ithome.com.tw/upload/images/20210923/20115854oIMFX6Y4Dn.png

除了直接改變畫面以外,我們還可以寫一個點擊特殊新增 的腳本,只要我們這樣寫即可 ⬇️

首先我們先將頁籤新增data屬性

def blog_genre
  [
    { id: 'index', wording: '列表頁', data: { 'admin--blogs-target': 'datatable' } },
    { id: 'special', wording: '特殊新增', data: { 'admin--blogs-target': 'multiform' } },
  ]
end

對應的頁籤畫面為

<ul class="nav nav-tabs" role="tablist">
  <li class="nav-item" role="presentation">
    <a
      href="#index-tab"
      class="nav-link"
      data-toggle="tab"
      data-admin--blogs-target="datatable"
      aria-controls="index-tab"
      aria-selected="true"
      >列表頁</a
    >
  </li>
  <li class="nav-item" role="presentation">
    <a
      href="#special-tab"
      class="nav-link active"
      data-toggle="tab"
      data-admin--blogs-target="multiform"
      aria-controls="special-tab"
      aria-selected="false"
      >特殊新增</a
    >
  </li>
</ul>

接著我們在 connect() 執行腳本。

export default class extends Controller {
  static targets = ["datatable", "multiform"]
  static values = { testId: Number, viewId: Number, reloadAt: String, queryUrl: String }

  connect() {
    /* 腳本內容 */
    this.multiformTarget.click();
  }
}

除了上述的腳本內容,我們也可以在腳本寫比較複雜的內容,例如點擊按鈕開啟視窗,並在編輯表單上填寫預設值等等。

之前曾經接過一個任務,任務內容為在點開下拉式選單時,要依據不同筆的資料填寫不同的值,因此我就在點擊事件的動作,觸發寫好的腳本。這樣一來就不用動到已經大到不行的專案,只需寫腳本即可。

結論

覺得寫這篇需要比較多的背景常識,很怕讀者看不懂我想要表達什麼? 如果有問題的歡迎在下方留言

或者私訊我的信箱 ➡️ k445566778899k@gmail.com

或許讀者的意見,可以讓我補充原本過於缺漏的內容

參考資料


上一篇
Day27. Stimulus 與非同步處理 - Ajax 的更優雅寫法
下一篇
Day29. Rails MVC 的 Model - 與資料庫聯絡的橋樑
系列文
初階 Rails 工程師的養成34

尚未有邦友留言

立即登入留言