--- title: Astro課程 0807.0810 - Rails (Day6.Day7) tags: astro, rails --- [第一篇筆記:Astro課程 0723-0731 - Rails (Day1-Day4)](https://hackmd.io/uSW5LI6aS1GyCsEMtGBnCg?view) [第二篇筆記:Astro課程 0803 - Rails (Day5)](https://hackmd.io/uSW5LI6aS1GyCsEMtGBnCg?view) # 幫文章加上使用者 ### 想法: 先建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? ![](https://i.imgur.com/23gxeY3.png) 進看板的時候, - 看板查詢一次 - 版主也查詢 - 每篇文章也都查詢一次 `http://localhost:3004/boards/1` ![](https://i.imgur.com/v3UwdVr.png) 查詢看板的時候,把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](https://webpack.js.org/) [webpacker](https://github.com/rails/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](https://api.rubyonrails.org/classes/ActiveRecord/Inheritance.html) 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-有什麼不同](https://medium.com/@anneju/rails%E5%9C%A8%E5%BB%BA%E7%AB%8B%E8%A1%A8%E5%96%AE%E7%9A%84%E6%99%82%E5%80%99-form-for-%E8%B7%9F-form-with-%E6%9C%89%E4%BB%80%E9%BA%BC%E4%B8%8D%E5%90%8C-ec45cebbbf92) Q: 如何讓使用者知道自己的留言已經被存進去,又不重新送出表單? 祕技:偷懶的做法 ``` views/comments/create.js.erb ``` ``` alert('hi!<%= @comment.content %>'); ``` XHR (XML HTTP Request) ## 把`加到我的最愛`換成Ajax寫法 ### 第一個方式: 搜尋gem [fontawesome gem](https://github.com/FortAwesome/font-awesome-sass) 但是不要等別人打包好再用 ### npm [NPM](https://www.npmjs.com/package/font-awesome) 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](https://fontawesome.com/how-to-use/on-the-web/setup/using-package-managers) ``` 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](https://stimulusjs.org/) 架構 ``` <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`資料夾 ```javascript import { Controller } from "stimulus" export default class extends Controller { static targets = [ "output" ] connect() { console.log('hi!') // this.outputTarget.textContent = 'Hello, Stimulus!' } } ``` ```htmlmixed <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](https://github.com/axios/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` ```javascript 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](https://stimulusjs.org/reference/data-maps) 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){ }) } ``` ![](https://i.imgur.com/rwSM3qt.png) ## 如果失敗的話印出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](https://github.com/rails/webpacker/issues/1015) 參考一下別人的解法 ``` // 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) ``` 改寫成自己的 ```javascript 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手冊舉例](https://api.rubyonrails.org/v5.2.4/classes/ActionController/MimeResponds.html#method-i-respond_to) ``` 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`會出錯 => 改成箭頭函式 ```javascript 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](https://www.rubyguides.com/2019/03/rails-ajax/) ``` 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> ``` [自訂事件](https://developer.mozilla.org/zh-TW/docs/Web/Guide/Events/Creating_and_triggering_events) [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> ```