iT邦幫忙

2021 iThome 鐵人賽

DAY 27
0
Modern Web

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

Day27. Stimulus 與非同步處理 - Ajax 的更優雅寫法

  • 分享至 

  • xImage
  •  

我們在 Day21 提到 data-remote=trueDay25 提到了一些與Ajax 相關的例子,今天為正式的介紹在Rails如何完美的搭配Stimulus & Rails。今天我們會用3個情境教導讀者如何使用Stimulus Ajax

dependent select lists

相依性的下拉式表單為標準以非同步處理的標準模型之一。以下的操作為先選品牌,當選品牌的同時,右方的下拉選單的選項會隨著品牌的不同而有改變

⭐️ 下列為相依性表單的Slim & Stimulus Code

= card(controller: 'admin--blogs') do
  = card_header(title: 'Stimulus Ajax')
  = card_body do
    = tag.div class: 'mx-1 d-flex' do
      = datatable_select_tag Brand.all.pluck(:title, :id).prepend(['請選擇品牌', nil]),
                             'brand-select', controller: 'admin--blogs',
                             target: 'brand', action: 'fetchStores', 
                             style: 'max-width: 300px'
      = datatable_select_tag [['請選擇店舖', nil]], 'store-select',
                              controller: 'admin--blogs', target: 'store', 
                              style: 'max-width: 300px'
import { isEmpty, isNil, map, prepend, equals } from 'ramda';
import { Controller } from 'stimulus';
import Rails from '@rails/ujs';

/* 下拉式選單的選項 */
const selectOption = ({ value, text, select = null }) => `<option value=${value} ${equals(select, true) ? 'selected' : ''}>${text}</option>`

const fetchStores = ({ brandId, storeComp }) => {
  if(isEmpty(brandId)) {
    storeComp.innerHTML = selectOption({ value: '', text: '請選擇店舖' })
    return
  }

  Rails.ajax({
    type: 'get',
    url: `/admin/brands/${brandId}/get_stores`,
    success: (response) => {
      /* 選單內容 */
      const resContent = prepend({ value: '', text: '請選擇店舖'}, response)
      /* 找不到店櫃的下拉式選單 => 跳出 */
      if (isNil(storeComp)) return
      /* Ajax 內容 */
      storeComp.innerHTML =
        `${map((e) => selectOption({ value: e.value,
            text: e.text }),
          resContent).join('')}`
    },
    error: (error) => {
      console.log('error response:', error);
    }
  })
}

export default class extends Controller {
  static targets = ['brand', 'store']

  // 依照廠牌取得店舖
  fetchStores() {
    fetchStores({ brandId: this.brandTarget.value,
      storeComp: this.storeTarget, prependWording: '請選擇店舖'});
  }
}

可以看到打非同步的地方網址為 ⬇️

`/admin/brands/${brandId}/get_stores`

⭐️ 上面的網址列,對應的routes, controller 分別如下

resources :brands do 
  get :get_stores, on: :member
end
class Admin::BrandsController < Admin::ApplicationController
  # 下拉式選單的 Ajax
  def get_stores
    @stores = Brand.find_by_id(params[:id])&.stores

    render json: @stores&.map { |s| { value: s.id, text: s.title_zh } }
  end
end

⭐️ 透過非同步的動作取得的成功回應為底下的response,並且非同步回傳的結果前方加上{ value: '', text: '請選擇店舖'},並且使用selectOption組成下拉式選單的DOM,成為了搭配非同步處理的相依性選單。

import Rails from '@rails/ujs';

Rails.ajax({
  type: 'get',
  url: `/admin/brands/${brandId}/get_stores`,
  success: (response) => {
    /* 選單內容 */
    const resContent = prepend({ value: '', text: '請選擇店舖'}, response)
    /* 找不到店櫃的下拉式選單 => 跳出 */
    if (isNil(storeComp)) return
    /* Ajax 內容 */
    storeComp.innerHTML =
      `${map((e) => selectOption({ value: e.value,
                                  text: e.text }),
             resContent).join('')}`
  },
  error: (error) => {
    console.log('error response:', error);
  }
})

Ajax with click event

還記得Day26greet()嗎? 當時我們使用greet() 來觸發簡單的JS動作,而我們可以透過事件觸發非同步的動作,接著我們要來介紹,如何使用 Ajax 處理非同步的問題,以下為呈現的結果。

剛剛提到,只要打非同步就會需要打到後端,就需要事先設定並打通routes, controller,因此我們先將講路徑和邏輯寫出來

⭐️ 下列為routes

resources :blogs do
  #====== ajax
  post :search, on: :collection
end

⭐️ 下列為 controller , view 。順帶一提,render partial的寫法不只是view的專利,我們也可以在controller寫。

module Admin
  class BlogsController < ApplicationController
    def search
      blog = Blog.find_by_id params[:id]
      
      render partial: 'searched_blog', locals: { blog: blog }
    end
  end
end

⭐️ 在 app/views/admin/blogs/_searched_blog 中寫下欲渲染的畫面

br
= log_template do
  = log_item title: 'id' do
    = blog&.id || ''
  = log_item title: '標題' do
    = blog&.title || '找不到標題'
  = log_item title: '內文' do
    = blog&.content || '找不到內文'

⭐️ 由於想要用實際的例子讓讀者感受使用Value的用法,我們將路徑放在Value當中

= card(controller: 'admin--blogs', data: { 'admin--blogs-query-url-value': search_admin_blogs_path }) do
  = card_header(title: 'Stimulus Ajax')
  = card_body do
    // 內容省略......
    = tag.div class: 'mx-2 my-3 p-2', style: 'width: 300px; border: 1px solid black' do
      = tag.div(class: 'form-group string required admin_blogs_search_id')
        = tag.label tag.strong('搜尋id')
        = tag.input name: nil, class: "form-control string required",
                    data: { 'admin--blogs-target': 'ajaxId' }
      = button_tag '搜尋', type: 'button', data: { action: 'admin--blogs#ajaxGreet' },
                   class: 'btn btn-primary'
      = tag.div data: { 'admin--blogs-target': 'searchedContent' }
import { isEmpty, isNil, map, prepend, equals } from 'ramda';
import { Controller } from 'stimulus';
import Rails from '@rails/ujs';

/* 下拉式選單的選項 */
const selectOption = ({ value, text, select = null }) =>
  `<option value=${value} ${equals(select, true) ? 'selected' : ''}>${text}</option>`

const fetchStores = ({ brandId, storeComp }) => {
  if(isEmpty(brandId)) {
    storeComp.innerHTML = selectOption({ value: '', text: '請選擇店舖' })
    return
  }

  Rails.ajax({
    type: 'get',
    url: `/admin/brands/${brandId}/get_stores`,
    success: (response) => {
      /* 選單內容 */
      const resContent = prepend({ value: '', text: '請選擇店舖'}, response)
      /* 找不到店櫃的下拉式選單 => 跳出 */
      if (isNil(storeComp)) return
      /* Ajax 內容 */
      storeComp.innerHTML =
        `${map((e) => selectOption({ value: e.value,
            text: e.text }),
          resContent).join('')}`
    },
    error: (error) => {
      console.log('error response:', error);
    }
  })
}

export default class extends Controller {
  static targets = ["searchedContent", 'ajaxId']
  static values = { queryUrl: String }

  connect() {
    console.log('this.queryUrlValue', this.queryUrlValue)
  }

  ajaxGreet() {
    Rails.ajax({
      type: 'post',
      url: this.queryUrlValue,
      data: new URLSearchParams({
        id: this.ajaxIdTarget.value
      }),
      success: (data, status, xhr) => {
        this.searchedContentTarget.innerHTML = xhr.response;
      },
      error: (error) => {
        console.log('error response:', error);
      }
    })
  }
}

我們將後端傳過來的search_admin_blogs_path,傳到 this.queryUrlValueRails.ajax,並且將參數 this.ajaxIdTarget.value 傳進 Controller,並且將結果打回來顯示在this.searchedContentTarget

寫好之後,就可以使用了

Ajax form

過往我們常用js.erb 渲染非同步的表單,而當我們引入了Stimulus 以後,我們可以不用重新開一個js.erb的檔案。

⭐️ 此例跟上例用的是一樣的routes, controller,與上例的情境相同,只不過這邊是使用Ajax送出表單的方式進行非同步,因此使用到data-remote=truehelper

import { Controller } from 'stimulus';

export default class extends Controller {
  static targets = ["searchedContent"]

  onBlogSuccess(event) {
    let [data, status, xhr] = event.detail;
    this.searchedContentTarget.innerHTML = xhr.response;
  }

  onBlogError(event) {
    let [data, status, xhr] = event.detail;
    console.log(xhr.response);
  }
}
= tag.div data: { controller: 'admin--blogs' } do
  / 中間內容省略...
  = modal(id: 'new-blog-modal', confirm_wording: '送出文章',
          confirm_form: 'new_modal', title: '新增文章') do
    / 中間內容省略...
    = simple_form_for(@blog, url: search_admin_blogs_path, method: :post,
                      html: { data: { remote: true,
                      action: "ajax:success->admin--blogs#onBlogSuccess 
                               ajax:error->admin--blogs#onBlogError" },
                      id: 'form2' }) do
    = tag.div style: "border: 1px solid black"
      = tag.div(class: 'form-group string required admin_blogs_search_id"')
        = tag.label tag.strong('搜尋id')
        = tag.input name: 'id', class: "form-control string required", form: 'form2'
      = submit_tag "搜尋", class: "btn btn-primary btn-sm", form: 'form2',
                          data: { disable_with: '載入中...' }
      = tag.div data: { 'admin--blogs-target': 'searchedContent' }

我們在Day25 提過下列的例子,但Day25的重點是講述表單包覆表單的問題,今天的重點在於Ajax資料的打法。

對於上述的程式碼,我們來進行解析

  • 在表單加入remote: true,代表該表單需要做非同步的處理

  • ajax:success->admin--blogs#onBlogSuccess ➡️ 當非同步進入成功階段的反應

  • ajax:error->admin--blogs#onBlogError ➡️ 當非同步進入失敗階段的反應

其中onBlogSuccess, onBlogError 分別為在Stimulus自定義的兩個動作。

結論

今天介紹了三種Stimulus 搭配非同步表單的例子,還在使用React, Vue的朋友們,漢漢老師想要告訴你們,Stimulus 這種基於SSR的框架很棒,一點也不遜於主流框架,並且目前在社群上已經有很多星星數不多,但實際上已經很好用的套件。

這幾天不斷地寫文章,因此運動跟作息都變得比較不規律,偶爾會寫到懷疑人生。寫著寫著,發現自己想要分享的內容比預期的還要多很多,但總覺得時間不夠、文筆不夠、實力不夠,因此我們會在最後一天寫下遺珠之憾,為下次的IT鐵人賽做引言。

Rails 真的超棒!希望大家能夠認識Rails

今天除了寫了Day27以外,還回頭順Day1-5的文章。

參考資料


上一篇
Day26. 認識 Stimulus,與Javascript成為好朋友
下一篇
Day28. Rails 搭配 DataTable 寫出完美的列表頁
系列文
初階 Rails 工程師的養成34
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言