Try   HackMD

第一篇筆記:Astro課程 0723-0731 - Rails (Day1-Day4)
第二篇筆記:Astro課程 0803 - Rails (Day5)

幫文章加上使用者

想法: 先建migration,在posts加user欄位

為了把user id 指定給posts

rails g migration add_user_to_post

null: false 先空著(原本應該文章有作者才合理)

class AddUserToPost < ActiveRecord::Migration[5.2]
  def change
    add_reference :posts, :user, null: false
  end
end

編按: 08010的migrate我是這樣寫(預設之前文章都是user id為 6的作者)

class AddUserToPost < ActiveRecord::Migration[6.0]
  def change
    add_reference :posts, :user, null: false, foreign_key: true, default: 6
  end
end

修改model

user.rb
has_many :posts

post.rb
belongs_to :user

修改controller

  • 權限控管: 登入才可發文

有兩種方式可以指定post給user

# 用看板的角度新增文章,然後再指定給current_user
    @post = @board.posts.new(post_params)
    @post.user = current_user

# 從current_user建posts,再指定看板
    # @post = current_user.posts.new(post_params)
    # @post.board = @board

另一種:直接帶入user

比對虛擬欄位: 在post_controller清洗參數時,把application controller定義好的method current_user加進去
因為model已經建了post belongs_to :user

 
  private
  def post_params
    params.require(:post)
    .permit(:title, :content)
    .merge(user: current_user)
    # .merge(user_id: current_user.id)
  end

所以merge完之後指定current_user這行就可以刪掉了

    @post = @board.posts.new(post_params)
    #@post.user = current_user

使用者只能編輯自己的文章

編輯完後送出更新
成功更新的話,回到原來那篇文章

  def edit
    # 用find :找不到文章的話就會出現404
    @post = current_user.posts.find(params[:id])
  end

  def update
    @post = current_user.posts.find(params[:id])

    if @post.update(post_params)
      redirect_to @post, notice: '文章更新成功'
    else
      render :edit
    end
  end

在看板列表,列出所有文章的作者

post.user.display_name

如果沒有名字,顯示未知

  def display_name
    account || "未知"
  end

方法寫在helper還是model比較好?

  • 如果希望物件有這個方法(有直接關聯),寫在model
  • 如果泛用的情況,可以寫在helper
  def display_name
    user.nil? ? "未知" : user.account
  end

post.rb

  def display_name
    if user.nil?
      "未知"
    else
      user.account
    end
  end

解決N + 1問題

這個頁面有幾次SQL?

進看板的時候,

  • 看板查詢一次
  • 版主也查詢
  • 每篇文章也都查詢一次

http://localhost:3004/boards/1

查詢看板的時候,把user加進去

board controller

  def show
    @posts = @board.posts.includes(:user)
  end

其他使用者相關權限設定功能

0810假日實作:

  • 只有版主群才可以修改和刪除看板

user.rb

  def self.has_this_id?(id)
    one?{ |user| user.id == id }
  end

boards#index 看板列表

<% @boards.each do |board|%> 
<tr>
  <td><%= link_to board.title, board_path(board)%></td>
  <%# byebug %>
  <td><%= link_to '編輯', edit_board_path(board) if authorized_user_for_board?(board)%></td>
  <td><%= link_to '刪除', board_path(board), method: 'delete', data: { confirm: '確認刪除?' } if authorized_user_for_board?(board)%></td>
</tr>
<% end %> 

boards_helper

  def authorized_user_for_board?(board)
    user_signed_in? && board.users.has_this_id?(current_user.id)
  end
  • 只有文章作者才可以編輯和刪除自己的文章

boards#show 文章列表

    <% @board.posts.each do |post| %>  
    <tr>
      <td class=""><%= link_to post.title, post_path(post) %></td>
      <%# byebug %>
      <td><%= link_to "編輯", edit_post_path(post) if authorized_user_for_post?(post.user.id) %></td>
      <td><%= link_to "刪除", post_path(post), method: 'delete', data: { confirm: '確認刪除?' } if authorized_user_for_post?(post.user.id) %></td>
    </tr>
    <% end %> 

posts_helper.rb

  def authorized_user_for_post?(user_id)
    user_signed_in? && (user_id == current_user.id)
  end

javascript和css

webpack
webpacker

打包的目的:減少空格的數量、壓縮檔案的大小

Rails 5: assets pipeline (ruby寫的)
Rails 6: 加上了webpacker (js寫的)

有些東西放在public資料夾
不用經過routes就可以取用檔案

webpack,你去旁邊跑吧!

由於修改JS檔內容可能會造成reload時間過長
所以要另外開一個單獨給webpack的server

  1. 另開terminal分頁
    執行bin/webpack-dev/server以節省開發時間

  2. 直接裝 foreman gem,在專案目錄下建立 Procfile

file-content自己寫:
名稱: bin/rails server -p (port:3000)
名稱: bin/webpack-dev-server

foreman start -f Procfile.dev`

webpacker gem

幫忙設定webpack的gem

/assets/javascripts/application.js

新增我的最愛看板

rails g model FavoriteBoard user:belongs_to board:belongs_to position:integer

路徑設計

POST -> /boards/2/favorite 
member/collection (collection必須帶id)


在這個情境下
動詞和路徑沒有絕對的關聯

  resources :favorites, only: [:index]

  resources :boards do
    member do
      post :favorite
    end
    resources :posts, shallow: true
  end
<h1>看板 <%= @board.title %>
  | <%= link_to "我的最愛", favorite_board_path(@board), method: 'POST' %>
</h1>

做一個toggle_favorite

board conctroller

  def favorite
    #current_user.favorited_boards << @board
    current_user.toggle_favorite_board(@board)
    redirect_to favorites_board_path, notice: 'OK!'
  end

user.rb

  def toggle_favorite_board(b)
    if favorited_boards.exists?(b.id)
      favorited_boards.destroy(b)
    else
      favorited_boards << b
    end
  end

rails 加上javascript

GET /api/users.json

讓ruby語法產生json

創一個index.html

json.array! @boards

eg. 可以輕易地做到api改版

GET /api/v1/boards.json 
GET /api/v2/boards.json

加上文章推文功能

做個表單,按下送出後不會離開這一頁

Comment
- post:belongs_to
- user:belongs_to
- content:string
- ip_address:string
rails g model Comment post:belongs_to user:belongs_to content ip_address

不要使用type關鍵字。STI

routes

/post/5
-> POST 
/posts/5/comments
對資源做新增
  resources :boards do
    member do
      post :favorite
    end
    resources :posts, shallow: true do
      resources :comments, shallow: true, only: [:create]
    end
  end

post#show加一個表單

<%= form_with(model: @comment, url: post_comments_path(@post)) do |form| %>
 <%= form.text_field :content %>
 <%= form.submit "送出" %>
<% end %>

接下來的步驟

建立comments controller
檢查登入
建立create action
redirect_to 原本的文章

comments controller

class CommentsController < ApplicationController
  before_action :authenticate_user!

  def create
    @post = Post.find(params[:post_id])
    @comment = @post.comments.new(comment_params)
    redirect_to @post
  end

  private
  def comment_params
    params.require(:comment).permit(:content).merge(user: current_user)
  end
end

post controller

  def show
    @post = Post.find(params[:id])
    @comment = @post.comments.new
    # 排序最新留言的在上面
    @comments = @post.comments.order(id: :desc)
    #@comments = @post.comments
  end

@comments讓board的show.html.erb用


  <% @comments.each do |comment| %>
      <i><%= comment.id %></i>
    <i><%= comment.content %></i><br>
  <% end %>

重點:剛剛的comment controller要記得把@comment存起來~

  def create
    @post = Post.find(params[:post_id])
    @comment = @post.comments.new(comment_params)
    if @comment.save
      redirect_to @post
    else
      redirect_to @post
    end 
  end

非同步處理

表單畫面是否有重新refresh的差異

form with的預設值 
使用form_with時,建立時就已經預設了remote: true
有了這個屬性之後,表單會透過 Ajax 提交,而不是瀏覽器平常的提交機制。
如果不要-form-for-跟-form-with-有什麼不同設定Ajax提交,則要另訂 local: true。

form for 
要寫記得寫:remote true (預設 local: false)

Ref: form-for-跟-form-with-有什麼不同

Q: 如何讓使用者知道自己的留言已經被存進去,又不重新送出表單?

祕技:偷懶的做法

views/comments/create.js.erb
alert('hi!<%= @comment.content %>');

XHR (XML HTTP Request)

加到我的最愛換成Ajax寫法

第一個方式: 搜尋gem

fontawesome gem

但是不要等別人打包好再用

npm

NPM

eg:

npm i bootstrap

yarn

yarn add boostrap
yarn install --check-files

yarn add boostrap

yarn add v1.22.4
[1/4] 🔍  Resolving packages...
warning boostrap@2.0.0: Package no longer supported. Contact support@npmjs.com for more info.
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependencies...
warning " > webpack-dev-server@3.11.0" has unmet peer dependency "webpack@^4.0.0 || ^5.0.0".
warning "webpack-dev-server > webpack-dev-middleware@3.7.2" has unmet peer dependency "webpack@^4.0.0".
[4/4] 🔨  Building fresh packages...
success Saved lockfile.
success Saved 1 new dependency.
info Direct dependencies
└─ boostrap@2.0.0
info All dependencies
└─ boostrap@2.0.0
✨  Done in 10.90s.

如何把boostrap require進rails專案?

require("scripts")

對 webpacker來說 所有東西都是js

下載font-awoesome

fontawesome

yarn add @fortawesome/fontawesome-free

/stylesheets/index.js

import "bootstrap/dist/css/bootstrap";
import "@fortawesome/fontawesome-free/css/all";
import "./frontend/home.scss";
import "./frontend/form.scss";

愛心加到我的最愛

/scripts/index.js

function hello() {
  alert('hhh');
}

html.erb

  <a href="javascript:hello();">

這段js不會動的原因是因為webpacker把函數包起來了

IIFE (Immediately Invoked Function Expression) 立即呼叫函式表達式

javascript 套件: stimulus js

stimulus js

架構

<div data-controller="hello">
  <input data-target="hello.name" type="text">

  <button data-action="click->hello#greet">
    Greet
  </button>

  <span data-target="hello.output">
  </span>
</div>

在rails專案安裝stimulus

webpacker:install:stimulus

安裝後,會在前端的資料夾長出controller資料夾

import { Controller } from "stimulus"

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

  connect() {
    console.log('hi!')
    // this.outputTarget.textContent = 'Hello, Stimulus!'
  }
}
    <p class="m-1"  data-controller="heart">
      <%= link_to "加到我的最愛", favorite_board_path(@board), method: 'POST', class: "fa fa-heart" %> 
        <button><i class="fa fa-heart" data-target="heart.heart"></i></button>
    </p>

把愛心換掉

import { Controller } from "stimulus"
import ax from "axios"

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

  favorite() {

    this.heartTarget.classList.add("fa")
    this.heartTarget.classList.remove("fas")
    // console.log(this.heartTarget);
    // far -> fas
  }
}

axios:Promise based HTTP client for the browser and node.js

yarn add axios

安裝好後,import

import { Controller } from "stimulus"
import ax from "axios";

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

  favorite() {
    ax.post
  }
}

axios的寫法then

const axios = require('axios');

// Make a request for a user with a given ID
axios.get('/user?ID=12345')
  .then(function (response) {
    // handle success
    console.log(response);
  })
  .catch(function (error) {
    // handle error
    console.log(error);
  })
  .then(function () {
    // always executed
  });

透過erb把看板的數字塞進來

  favorite() {
    // 用某個動作、打去某個地方,然後做什麼事情
    ax.post("/boards/2/favorite").then(function(result){

    })

  }

透過chrome dev印出值

const heart = document.querySelector('.far')
> undefined

heart
> <i class=​"far fa-heart fa fas" data-target=​"heart.heart">​…​</i>​

heart.dataset
> DOMStringMap {target: "heart.heart"}

要怎麼把資料塞進去? 放在data-heart-id
參考stimulus Reference
Eg.

<h1 data-controller="heart" 
data-heart-id="<%= @board.id %>"></h1>
  favorite() {
    let board_id = this.data.get("board");
    
    // 用某個動作、打某個地方,然後做什麼事情
    ax.post(`/boards/${board_id}/favorite`)
    .then(function(result){

    })

  }

如果失敗的話印出catch

chrome console出現422訊息

xhr.js:175 POST http://localhost:3000/boards/1/favorite 422 (Unprocessable Entity)

dispatchXhrRequest @ xhr.js:175
xhrAdapter @ xhr.js:18
dispatchRequest @ dispatchRequest.js:40
Promise.then (async)
request @ Axios.js:64
Axios.<computed> @ Axios.js:88
wrap @ bind.js:11
favorite @ heart_controller.js:19
Binding.invokeWithEvent @ binding.js:60
Binding.handleEvent @ binding.js:33
EventListener.handleEvent @ event_listener.js:40
heart_controller.js:24 Error: Request failed with status code 422
    at createError (createError.js:17)
    at settle (settle.js:19)
    at XMLHttpRequest.handleLoad (xhr.js:63)

axios rails authenticity token關鍵字找解答

Specify XSRF config defaults for axios

參考一下別人的解法

// app/javascript/setupCSRFToken.js
import axios from 'axios'

export default function() {
  const csrfToken = document.querySelector("meta[name=csrf-token]").content
  axios.defaults.headers.common[‘X-CSRF-Token’] = csrfToken
}

// app/javascript/packs/application.js
import setupCSRFToken from '../setupCSRFToken'
setupCSRFToken()
// or
window.addEventListener('DOMContentLoaded', setupCSRFToken)

改寫成自己的

import { Controller } from "stimulus"
import ax from "axios";

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

  favorite() {
    let board_id = this.data.get("board");

    const token = document.querySelector("meta[name=csrf-token]").content;
    ax.defaults.headers.common['X-CSRF-Token'] = token;

    ax.post(`/boards/${board_id}/favorite`)
      .then(function(result) {
        console.log(result);
      })
      .catch(function(err) {
        console.log(err);
      })
  }
}

以上方式可以成功通過POST的驗證
但是要如何將成功的結果顯示在頁面?

如果是
json - > render (愛心按鈕改變,但是該打的資料都有打)
html - > redirect (我的最愛連結)

修改board controller

respond_to 一個block
rails手冊舉例

def index
  @people = Person.all

  respond_to do |format|
    format.html
    format.js
    format.xml { render xml: @people }
  end
end
  def favorite

    current_user.toggle_favorite_board(@board)

    respond_to do |format|
      format.html { redirect_to favorites_path, notice: 'OK!' }
      format.json { render json: {status: 1} }
      
    end
  end

Eg.

# 找到show後,根據格式分發到不同地方
# 預設是回傳html, 如果請求json, 回傳json格式的資料

  def show
    @posts = @board.posts.includes(:user)
    respond_to do |f|
      f.html { render :show }
      f.json { render json: {aa: 1}}
    end
  end

json格式:如果這個版被喜歡過,回傳喜歡的使用者

    respond_to do |format|
      format.html { redirect_to favorites_path, notice: 'OK!' }
      format.json { status: @board.favorited_by?(current_user) }
      
      # @board.favorited
    end
  favorite() {
    let board_id = this.data.get("board");

    const token = document.querySelector("meta[name=csrf-token]").content;
    ax.defaults.headers.common['X-CSRF-Token'] = token;

    ax.post(`/boards/${board_id}/favorite.json`)
      .then(function(result) {
        if (result.data["status"] == true) {
          this.heartTarget.classList.remove("far");
          this.heartTarget.classList.add("fas");
        } else {
          this.heartTarget.classList.remove("fas");
          this.heartTarget.classList.add("far");
        }
      })
      .catch(function(err) {
        console.log(err);
      })
  }

this會出錯 => 改成箭頭函式

import { Controller } from "stimulus"
import ax from "axios";

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

  favorite() {
    let board_id = this.data.get("board");

    const token = document.querySelector("meta[name=csrf-token]").content;
    ax.defaults.headers.common['X-CSRF-Token'] = token;

    ax.post(`/boards/${board_id}/favorite.json`)
      .then((result) => {
        if (result.data["status"] == true) {
          this.heartTarget.classList.remove("far");
          this.heartTarget.classList.add("fas");
        } else {
          this.heartTarget.classList.remove("fas");
          this.heartTarget.classList.add("far");
        }
      })
      .catch(function(err) {
        console.log(err);
      })
  }
}

  • 如何讓js controller知道這個版被喜歡

目前後端只做了response_to
也可以用 SSR(Server Side Rendering) 伺服器端渲染

Rails 的ajax

Ref

Rails.ajax({
  url: "/books",
  type: "get",
  data: "",
  success: function(data) {},
  error: function(data) {}
})

改寫

import { Controller } from "stimulus"
import Rails from "@rails/ujs";

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

  favorite() {
    let board_id = this.data.get("board");

    Rails.ajax({
      url: `/boards/${board_id}/favorite.json`,
      type: 'post',
      success: (result) => {
        if (result["status"] == true) {
          this.heartTarget.classList.remove("far");
          this.heartTarget.classList.add("fas");
        } else {
          this.heartTarget.classList.remove("fas");
          this.heartTarget.classList.add("far");
        }
      },
      error: (err) => {
        console.log(err);
      }
    })
  }

自訂事件:按下按鈕時,讓其他的js controller收到事件


<ul>
<% @posts.each do |post| %>
  <li data-controller="post">
    <%= post.display_username %>
    <%= link_to post.title, post, data: { target: 'post.title' } %>
    <%= link_to "編輯", edit_post_path(post) %>
  </li>
<% end %>
</ul>

自訂事件

stimulus js custom event

heart_controller.js

import { Controller } from "stimulus"
// import Rails from "@rails/ujs";

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

  favorite() {
    // 發出事件
    const event = new CustomEvent("cat", {
      detail: {
        hazcheeseburger: true
      }
    });
    window.dispatchEvent(event);
  }
}

post_controller.js

import { Controller } from "stimulus"

export default class extends Controller {
  static targets = [ 'title' ]

  something(e) {

    #把標題換成cheeseburger
    this.titleTarget.textContent = "cheeseburger";

     console.log(e.detail["hazcheeseburger"]);
    //console.log(e);
  }
}

hi.js

puts "aaa"
 ~/Documents/projects/astro_js   master  ruby hi.js                  
aaa

rails 的vue

app.vue

css的作用範圍被固定 (不用再想名字)

<template>
  <div id="app">
    <p>{{ message }}</p>
  </div>
</template>

<script>
export default {
  data: function () {
    return {
      message: "Hello Vue!"
    }
  }
}
</script>

<style scoped>
p {
  font-size: 2em;
  text-align: center;
}
</style>