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();
  }
}

除了上述的脚本内容,我们也可以在脚本写比较复杂的内容,例如点击按钮开启视窗,并在编辑表单上填写预设值等等。

之前曾经接过一个任务,任务内容为在点开下拉式选单时,要依据不同笔的资料填写不同的值,因此我就在点击事件的动作,触发写好的脚本。这样一来就不用动到已经大到不行的专案,只需写脚本即可。

结论

觉得写这篇需要比较多的背景常识,很怕读者看不懂我想要表达什麽? 如果有问题的欢迎在下方留言

或者私讯我的信箱 ➡️ [email protected]

或许读者的意见,可以让我补充原本过於缺漏的内容

参考资料


<<:  初学者跪着学JavaScript Day13 : 物件加字串?物件加物件?

>>:  入门魔法 - 针对 DOM 节点的简单操作

Day 8 - 目前(传统)的机器学习三步骤(3)-训练

第三步 Training : 训练并验证,找出最佳结果 挑选[学习演算法] 什麽是演算法(Algor...

Chapter3 - canvas动画续篇 加入Z轴也能使2D画面产生立体的空间感

https://jerry-the-potato.github.io/ChapterX-demo/ ...

【图解演算法教学】一次搞懂「资料结构」与「演算法」到底是什麽?

Youtube连结:https://bit.ly/35x3dih 这次我们将精确定位出,在整个演算...

Day 21 支援向量机 SVM

介绍: 支援向量机(support vector machine,简称为SVM)是一种简单的分类模型...

数位签章(digital signature)

-数位签章 使用您的私钥加密代码的指纹或对代码进行散列并使用您的私钥加密结果是生成数字签名的 改写...