1204 按讚功能

  • Fontawesome 找到使用方法
  • 在專案底下安裝 $ yarn add @fortawesome/fontawesome-free
  • 在app/frontend(可能有javascript而沒有frontend資料夾)建立scripts資料夾(名稱可自取),底下建立index.js
  • 到app/frontend/packs/application.js,引入require("scripts")
  • 在app/frontend/styles/index.js,引入import "@fortawesome/fontawesome-free/css/all.css",在node-modules找
  • 以上步驟完成後,在Fontawasome找所要用的圖示,可以看到標籤,加在post的show.html.erb中,就可以顯示圖示
    Image Not Showing Possible Reasons
    • The image file may be corrupted
    • The server hosting the image is unavailable
    • The image path is incorrect
    • The image format is not supported
    Learn More →

  • 不過我們要做的是可點選的按讚圖案,所以改成
    <%= link_to "<i class='far fa-thumbs-up'></i>",'#'%>
    由於link_to本身會審查連結內容,會變成字串顯示,所以要在加上.html_safe
    變成
    <%= link_to "<i class='far fa-thumbs-up'></i>".html_safe,'#'%>
    但是這寫法不好看,可以用doend抱著原本要連結的東西來改寫
<% link_to '#' do %>
  <i class="far fa-thumbs-up"></i>
<% end %>

  • 接下來想'#'的路徑,因為是要對文章按讚作收藏,所以在routes.rb裡的posts下用member做出/posts/post_id/favorite
  resources :posts, only:[] do
    resources :comments, shallow: true, only: [:create, :destroy]
    member do
      post :favorite  # POST /posts/:id/favorite
    end
  end
  • 在查表可以看到建立favorite_post_path的路徑,把它代回'#',因為還要辨認是哪篇文章再加上@post以前post方法,就變成
    <% link_to favorite_post_path(@post), method: 'post' do %>
  • 現在回到瀏覽器按下圖示會出現缺少favorite action的錯誤,同時注意網址也有變化

  • 我們希望用AJAX的形式不離開頁面就完成按讚的動作,先給予一個id
<% link_to '#',id: 'favorite_btn' do %>
​ <i class="far fa-thumbs-up"></i>
<% end %>
  • 雖然可以在show頁面直接添加<script></script>但是不推薦這種寫法
  • 按鑽屬於一種功能,所以也不適合寫在一開始建立的index.js檔裡,所以在app/frontend/scripts再建立一個post.js
  • 將功能寫在post.js並且在index.js裡require('./post')
  • 在post.js裡寫下JS功能
document.addEventListener("DOMContentLoaded", function() {
//監聽document,否則會出現抓不到圖示的錯誤,因為讀到這行時html還未建立
​ const favorite_btn = document.querySelector('#favorite_btn') //抓出圖示按鈕
​   favorite_btn.addEventListener("click", function(e) {
​     e.preventDefault() //暫停原來事件行為
​     //這裡可以打API/送資料/AJAX
​     //POST /post/:id/favorite
​     console.log(e.currentTarget)//印出發生事件的對象
​   })
​ })
  • 但是只有在文章頁面才有按鈕,所以切換到其他頁面時,在開發者工具會出現錯誤監聽到null,可以再用一個if包起來,在按鈕存在時再監聽
document.addEventListener("DOMContentLoaded", function() {
  const favorite_btn = document.querySelector('#favorite_btn')
    if(favorite_btn){ 
      favorite_btn.addEventListener("click",  function(e) {
      e.preventDefault()
      //POST /post/:id/favorite
      console.log(e.currentTarget)//印出發生事件的對象
    })}
    })

  • 接下來要想辦法讓JS可以取到文章的id,用data: { id: @post.id }(當中的id:可以自取名字name:,會形成data-name="#"),將post_id帶給link_to,寫成
    <%= link_to '#',id: 'favorite_btn', data: { id: @post.id } do %>

  • 可以做出data-id="7"

    Image Not Showing Possible Reasons
    • The image file may be corrupted
    • The server hosting the image is unavailable
    • The image path is incorrect
    • The image format is not supported
    Learn More →

  • console.log(e.currentTarget)
    可以印出發生的事件的對象,這裡是favorite_btn

  • console.log(e.currentTarget.dataset)
    加上dataset可以印出全部的data-name

  • console.log(e.currentTarget.dataset.id)
    加上.name(這裡是要id)可以印出指定的data-name


  • axios 是一個可以專門為瀏覽器和node.js發送get、post請求的套件
    安裝$ yarn add axios
  • 依照官網說明格式在post.js裡
​ const ax = require('axios') 
​   //require套件並指定給自定義的ax
​ ax.post(`/posts/7/favorite`, {}) 
​   //ax.方法('路徑',要傳送的值)
​   //以post方法打向/posts/7/favorite,因為不需要傳值所以給一個{}
​       .then(function(resp){
​         console.log(resp)
​       })
​       .catch(function(err) {
​         console.log(err);
​       })
​   非同步處理
  • 路徑的7應該是文章id是個變數不能固定,所以
    const postId = e.currentTarget.dataset.id
    先將前面找到的data-id指定給變數postId
    /posts/${postId}/favorite,在JS中帶變數要用 'esc下方的特殊符號' 包住

  • 現在按圖示在開發者工具會出現404錯誤,因為缺少favorite action,先暫時建立
def favorite
  render html: 'hi'
end
  • 又會出現422錯誤因為缺少token(之前的token都由form_for自動建立,但目前的js是我們手刻所以沒有token,在頁面原始碼可以看到實際上頁面還是有token存在,所以可以用js抓來使用
  const ax = require('axios')
  const token = document.querySelector('[name=csrf-token]').content 
  //抓取頁面會有個name為csrf-token並且content為token的東西
  ax.defaults.headers.common['X-CSRF-TOKEN'] = token
  //將抓到的token塞入

這樣就完成token的放入


  • 建立一個新的model來處理按讚
    $ rails g model FavoritePost user:belongs_to post:belongs_to

  • 確認migration沒問題再建立表格
    $ rails db:migrate

  • 在post.rb與user.rb加上
    has_many :favorite_posts

  • 到目前為止建立了post與favorite_post以及user與favorite_post之間的關聯,但實際上我們需要的是user與post的關聯,透過favorite_post就可以建立兩者之間的關聯

  • 在post.rb加上
    has_many :users, through: :favorite_posts
    但是在post.rb已經有belongs_to :user,為了避免混淆可以改寫users
    (這裡的users只會做出一個方法並不是真的model所以可以自由改名稱)
    has_many :favorite_users, through: :favorite_posts

  • 在user.rb加上
    has_many :posts, through: :favorite_posts

  • posts也與原有的has_many :posts衝突,所以也改名稱
    has_many :my_favorites, through: :favorite_posts

  • 在rails c中測試

p7 = Post.find(7) //抓出指定文章,這裡抓7
p7.favorite_user //找出喜歡7號文章的使用者

這裡會出現錯誤,因為我們把原來的users改掉,rails無法判斷要找尋的對象,要再加上一個source給它來源變成
has_many :favorite_users, through: :favorite_posts, source: 'user'
source後面可以用字串'user'或符號:user,不能用'User'

  • 現在p7.favorite_user就會用INNER JOIN的方式去找user與favorite_post,就可以找到喜歡這篇文章的使用者
  • 同樣的user.rb也需要改成
    has_many :my_favorites, through: :favorite_posts, source: 'post'

  • 接下來在favorite action寫儲存我的最愛的功能
def favorite
  post = Post.find(params[:id]) //找出文章
  current_user.my_favorites << post  //加到我的最愛
  render html: 'hi'
end
  • 但是這樣沒有判斷這篇文章是否已經加入過我的最愛,再加上判斷式
if current_user.favorite?(post)  //如果這篇文章已喜歡
  # 移除我的最愛
else
  # 加到我最愛
end

就變成

def favorite
    post = Post.find(params[:id])
    if current_user.favorite?(post)
      # 移除我的最愛
    else
      # 加到我最愛
      current_user.my_favorites << post  
      //my_favorites是一個陣列
    end
  end
  • 因為current_user.favorite中的current_user是一個user model,所以favorite就需要寫在user.rb
def favorite?(post)
  my_favorites.include?(post)
end
  • 再回到posts_controller
def favorite
    post = Post.find(params[:id])

    if current_user.favorite?(post)
      # 移除我的最愛
      current_user.my_favorites.destroy(post)
      render json: { status: 'removed' } //給前端的狀態值
    else
      # 加到我最愛
      current_user.my_favorites << post
      render json: { status: 'added' }
    end
  end
  • 回到post.js,在status為added時執行,其他狀態執行
ax.post(`/posts/${postId}/favorite`, {})
  .then(function(resp){
  if (resp.data.status == "added") {
    icon.classList.remove("far")
    icon.classList.add("fas")
  } else {
    icon.classList.remove("fas")
    icon.classList.add("far")
          }
        })

  • 只有寫進資料庫沒有在show.html.erb做控制, 所以重新整理之後,還是會呈現fas的圖示
<%= link_to '#',id: 'favorite_btn', data: { id: @post.id } do %>
   < i class="fas fa-heart">< /i>
<% end %>

*修改show.html.erb

  <% if user_signed_in? %>
  //使用者登入時才會出現按讚圖案,較好做法是未登入時按下會提醒請先登入
    <%= link_to '#',id: 'favorite_btn', data: { id: @post.id } do %>
      <% if current_user.favorite?(@post) %>
      // 如果已經按過讚,class為fas
        < i class="fas fa-heart">< /i>
      <% else %>
      // 如果沒按過讚,class為far
        < i class="far fa-heart">< /i>
      <% end %>
    <% end %>
  <% end %>

  • 目前的post.js會在application.js中用require("scripts")引入,所以全部地方都會執行,雖然我們有檢查按鈕存在才執行,但萬一有出現一樣名稱favorite_btn的按鈕就會衝突

  • 可以在app/views/layouts/application.html.erb的<body>加上類別,讓頁面的body變得可區分
    < body class="<%= controller_name %>-<%= action_name %>">

  • 原來post.js的按鈕監聽加上.posts-show的class

const favorite_btn = document.querySelector('.posts-show #favorite_btn')

就可以避免監聽到其他的同名稱按鈕(如果有的話)


用stimulus改寫

  • 前置作業
    將app/frontend/scripts/index.js的require('./post')註解掉關閉本來post.js的功能

  • 先安裝stimulus,會出現在package.json的清單中,同時在javascripts資料夾底下增加一個controllers的資料夾,還會在app/frontend/packs/application.js裡import "controllers"
    $ rails webpacker:install:stimulus

  • 將app/views/posts/show.html.erb的h2標籤加上data-controller="名稱",會去呼叫app/frontend/controllers/名稱_controller.js
    <h2 data-controller="favorite">就會去呼叫favorite_controller.js

  • 因為post.js已經關閉,所以show.html.erb本來提供給它用的id: 'favorite_btn'就可以清掉,並為圖示加上data-target="favorite.icon,link_to加上action: 'favorite#go'

  • target用.連接目標名稱,action用#連接方法

< h2 data-controller="favorite">
  <% if user_signed_in? %>
    <%= link_to '#', data: { action: 'favorite#go',id: @post.id } do %>
      <% if current_user.favorite?(@post) %>
        < i class="fas fa-heart" data-target="favorite.icon"></i>
      <% else %>
        < i class="far fa-heart" data-target="favorite.icon"></i>
      <% end %>
    <% end %>
  <% end %>
< /h2>
  • 會呈現出以下原始碼

  • 在app/frontend/controllers/favorite_controller.js,用console.log驗證是否有成功掛載
import { Controller } from "stimulus"

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

  go() {
    console.log('go!')
  }
}
   
  • 仔細看可以發現按讚後網址其實是有變化的,如果希望不要離開網頁,可以用preventDefault阻止預設行為改寫成
import { Controller } from "stimulus"

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

  go(e) {
    e.preventDefault()
    console.log('go!')
  }
}
   

  • 將文章id從action移動到controller,方便我們在controller抓取id
  • data-favorite-id中間規定要放controller的名稱,不能寫data-id,否則會抓不到東西
< h2 data-controller="favorite" data-favorite-id="<%= @post.id %>">
  <% if user_signed_in? %>
    <%= link_to '#', data: { action: 'favorite#go' } do %>
      <% if current_user.favorite?(@post) %>
        < i class="fas fa-heart" data-target="favorite.icon">< /i>
      <% else %>
        < i class="far fa-heart" data-target="favorite.icon">< /i>
      <% end %>
    <% end %>
  <% end %>
< /h2>
  • 在favorite_controller.js可以用this.data.get去抓取controller的資料,這裡我們要的是id,同樣用console.log印出id來檢查
import { Controller } from "stimulus"

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

  go() {
    const id = this.data.get('id')
    console.log('id')
  }
}
   
  • 從post.js複製以下程式碼
// 打 API / 送資料 / AJAX
      const ax = require('axios')
      const token = document.querySelector('[name=csrf-token]').content
      ax.defaults.headers.common['X-CSRF-TOKEN'] = token

      const postId = e.currentTarget.dataset.id
      const icon = e.target

      ax.post(`/posts/${postId}/favorite`, {})
        .then(function(resp){
          if (resp.data.status == "added") {
            icon.classList.remove("far")
            icon.classList.add("fas")
          } else {
            icon.classList.remove("fas")
            icon.classList.add("far")
          }
        })
        .catch(function(err) {
          console.log(err);
        })

其中這兩行不需要

const postId = e.currentTarget.dataset.id
const icon = e.target

另外id已經抓出來了,所以${postId}可以改為${id}

  • favorite_controller.js,註解原本要做的動作,先印出resp來檢查
import { Controller } from "stimulus"

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

  go() {
    const id = this.data.get('id')
    console.log('id')
    
    // 打 API / 送資料 / AJAX
      const ax = require('axios')
      const token = document.querySelector('[name=csrf-token]').content
      ax.defaults.headers.common['X-CSRF-TOKEN'] = token

      ax.post(`/posts/${id}/favorite`, {})
        .then(function(resp){
          console.log(resp)
          //if (resp.data.status == "added") {
            //icon.classList.remove("far")
            //icon.classList.add("fas")
          //} else {
            //icon.classList.remove("fas")
            //icon.classList.add("far")
          }
        })
        .catch(function(err) {
          console.log(err);
        })
  }
}
   
  • 可以在最上面用import ax from 'axios'來取代const ax = require('axios')
import { Controller } from "stimulus"
import ax from 'axios'

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

  go() {
    const id = this.data.get('id')
    console.log('id')
    
    // 打 API / 送資料 / AJAX
      //刪除這行const ax = require('axios')
      const token = document.querySelector('[name=csrf-token]').content
      ax.defaults.headers.common['X-CSRF-TOKEN'] = token

      ax.post(`/posts/${id}/favorite`, {})
        .then(function(resp){
          console.log(resp)
          //if (resp.data.status == "added") {
            //icon.classList.remove("far")
            //icon.classList.add("fas")
          //} else {
            //icon.classList.remove("fas")
            //icon.classList.add("far")
          }
        })
        .catch(function(err) {
          console.log(err);
        })
  }
}
   

注意 以下兩行程式碼寫法相似,但是意義不同

  • import { Controller } from "stimulus"
    有{}意思為stimulus中import叫Controller的東西,stimulus裡有叫Controller的東西
  • import ax from 'axios'
    意思為將axios預設的模組取名叫ax後取出,axios沒有叫ax的東西

  • 原本在post.js中標的物用icon,在 favorite_controller.js中要用stimulus的寫法this.iconTarget
import { Controller } from "stimulus"
import ax from 'axios'

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

  go() {
    const id = this.data.get('id')
    console.log('id')
    
    // 打 API / 送資料 / AJAX
      //刪除這行const ax = require('axios')
      const token = document.querySelector('[name=csrf-token]').content
      ax.defaults.headers.common['X-CSRF-TOKEN'] = token

      ax.post(`/posts/${id}/favorite`, {})
        .then(function(resp){
          if (resp.data.status == "added") {
            this.iconTarget.classList.remove("far")
            this.iconTarget.classList.add("fas")
          } else {
            this.iconTarget.classList.remove("fas")
            this.iconTarget.classList.add("far")
          }
        })
        .catch(function(err) {
          console.log(err);
        })
  }
}
   

現在執行會出現iconTarget為undefind

因為現在的this指的是function(resp)本身,也就是

(function(resp){
          console.log(resp)
          if (resp.data.status == "added") {
            this.iconTarget.classList.remove("far")
            this.iconTarget.classList.add("fas")
          } else {
            this.iconTarget.classList.remove("fas")
            this.iconTarget.classList.add("far")
          }

iconTarget其實不存在這之中,所以會undefind

解法

function(resp)改成fat arrow(胖箭頭)寫法,因為function本身會有scope會擋住,fat arrow沒有自己的this所以會向外找iconTarget

ax.post(`/posts/${id}/favorite`, {})
  .then((resp) => {
  //fat arrow
    if (resp.data.status == "added")