# RL-06 會員系統/AJAX/多對多/fetch ## 功能說明 ### Route: shallow nesting 淺槽狀 (專案必用) 路徑上的差別,看起來比較不囉唆 看3號 comment 不用從2號 note 裡面找 因為 **:index :new :create 三個路徑沒有 id** 其他的有,才需要這樣分 ![](https://i.imgur.com/WxCP6Yy.png) - 原效果: ```ruby= resources :notes do resources :comments, only: [:index, :new, :create] end resources :comments, only: [:show, :edit, :update, :destroy] ``` - shallow nesting: ```ruby= resources :notes do # shallow nesting resources :comments, shallow: true, except: [:new, :edit, :update] end ``` - 以上寫法會自動變成以下效果: ```ruby= resources :notes do resources :comments, only: [:index, :create] end resources :comments, only: [:show, :destroy] ``` ## 建立留言欄(comment Model) 在Model 預定有4個欄位: * user_id 誰寫的 * content * deleted_at * note_id 屬於誰 將原本給予integer型態的user_id與note_id,改寫成用FK給**belongs_to型態**的話 * 會自動長出user與note的id * 且會在Comment model中建立belongs_to。 * 但has_many還要再補。 新增欄位: > rails g model Comment user_id:integer content:text deleted_at:datetime:index note_id:integer **可以改寫成** > rails g model Comment **user:belongs_to** content:text deleted_at:datetime:index **note:belongs_to** 記得rails db:migrate 用:index的話,在新建的migrate(_create_comments.rb)可以中看到,自動建立了一行索引 add_index: comments, :deleted_at **增加FK關聯** 在migrate與Model相連。 首先note.rb加入(一篇note應該有has many複數留言) ```ruby= has_many :comments ``` 在Route新增路徑 resources :comments * 包在resources :notes裡面路徑會更長 * 在note中個別文章comment的列表(index)要顯示index與新增(create) * 在總列表,只要顯示全部(show)與刪除(destory)就好 ![](https://i.imgur.com/E2M4gSv.png) 但是,若每篇文有很多comment,就用**shallow nesting**拆開來寫 不要連的東西,再接 except: 就好。 ```ruby= resources :notes do resources :comments, shallow: true, except: [:new, :edit, :update] end ``` 在Controller,在def show增加預定使用的 **@comment**。 ```ruby= @comment = @note.comments.new ``` **在View** 新增表單會**在show**,等於讓comment.new在現在note的show * 由於在Route新增了路徑,因此可得到helper格式的POST連結,要帶mote的流水編號(@note) * 為了使用remote非同步方式傳輸資料,改用form_with取代form_for。 ```ruby= <%= form_with(model: @comment, url: note_comments_path(@note), local: false) do |f| %> <%= f.text_area :content %> <%= f.submit "新增留言" %> <% end %> ``` **在Controller** 接著會前往comment_controller,尚不存在,須製作。 * 每個行為都要登入,故注意先放 before_action * 放入需要的def create * 要知道替哪篇文章加留言,定義find_user_note,並加掛 before_action * 接著在create以note角度新增留言(設來自comments_params) * 設定清洗資料的comments_params * if存檔成功: 失敗:回首頁。 ```ruby= before_action :check_login! def create @note.comments.new(comment_params) if @note.save @content = comment_params[:content] else redirect_to "/" end end private def comment_params params.require(:comment) .permit(:content) .merge(user_id: current_user.id) end def find_user_note @note = current_user.notes.find(params[:note_id]) end ``` #### 留言不成功 因為有2個東西必填,寫在Model(comment.rb)的belongs_to(user與note),而Controller只給note(@note.comments.new)應該追加 user_id: current_user.id 的指定: ```ruby= @note.user = currrent_user #或 @note.user_id = currrent_user_id ``` **另一方法(merge)(可能會出錯)** 或是直接令既有的def create中的 @note.comments.new(comment_params)的 comment_params(雜湊)也包括current_user.id。在 def comment_params追加merge: ```ruby= def comment_params params.require(:comment) .permit(:content) .merge(user_id: current_user.id) end ``` 也順便在Model加content必填 ```ruby= validates: content, present: true ``` #### 顯示新增留言 **在Controller** 加上第二行,去拿到所有的留言 comments(注意有s 新取的名字)。用order將方向反過來(新的在最上面)。 注意:@note.comments源於Model(note.rb)中has_many :comments ```ruby= def show @comment = @note.comments.new @comments = @note.comments.order(id: :desc) end ``` **在View**的show網頁,因此需要迴圈把每一筆comment印出來 多給class名稱,方便js用querySelector抓取 ```htmlmixed= <ul class="comments"> <% @comments.each do |comment| %> <li><%= comment.content %></li> <% end %> ``` ## 新增文章時? 由於belongs_to的要求,被要求填入User。因此要在create加註note擁有的欄位 user.id並賦予值(current_user.id )。即目前的筆記屬於某人。 ```ruby= def create @note = Note.new(note_params) @note.user_id = current_user.id #由於belongs_to的要求 ``` 或從Model的角度,就結合以上2行(太不直覺 先擱置) ```ruby= @note = current_user.notes.new(note_params) ``` 其中「notes」是從has_many方法來的(創造了4個方法) > notes > notes = > notes.new > notes.create def new 改為指定到current_user ```ruby= @note = Note.new(user_id: current_user.id) #或 @note = current_user.notes.new ``` 列表要顯示是誰寫的,在index加入一條 > <%= note.user.email %> ## 區別編輯權 在Controller (note_controller) 原本find_note方法,登入後也能夠看到/編輯/刪除「別人的」文章: > def find_note > @note = Note.find(params[:id]) 更改寫法,以使用者角度(current_user)找出自己的文章: ```ruby= def find_user_note @note = Note.find_by(id: params[:id], user_id: current_user.id) #或 @note = current_user.note_find(params[:id]) ``` 區隔後,編輯自己的文章。 但會讓第一個before_action 的show找不到文章,所以將它獨立出來: (find_note索性改名 find_user_note 了) ```ruby= def show @note = Note.find(params[:id]) end ``` 也就是說,包括def create(建立/編輯文章)在內,與使用者權限有關的,都改為current_user。 ### 只能看到自己的 在def index的 notes.order前增加 current_user. 更改為: ```ruby= def index @notes = current_user.notes.order(id: :desc) end ``` 把def show 塞回before_action。 ### 筆記列表 搜尋次數(n+1問題) 若新增4筆筆記,實際上搜尋了幾次資料庫? @notes = current_user.notes.order(id: :desc) 等同於SQL中的 ```sql= select * from notes where user_id = 5 ``` 也就是一次。 而整個網頁加上nav(view/layout)出現的 <%= current_user.email %> ,共兩次。 然而,index網頁的each迴圈 <% @notes.each do |note| %> 遇到 user.email會觸發資料庫查詢,造成N + 1多次查詢問題(可以是面試題),消耗效能(若有1萬篇/人就要轉1萬次),因此要改變寫法。 方法一:JOIN 方法二:在def index加上includes,會讓查詢過程轉為一次(見下圖) ```ruby= def index @notes = current_user.notes .includes(:user) .order(id: :desc) end ``` ![](https://i.imgur.com/R3KSX2U.png) ## AJAX 非同步呼叫 按下去時往server送資料,畫面先動。非同步JavaScript,演給使用者看 用 XHR 送資料 render js:"alert:123" 將字串用 js 格式執行它 格式只能是瀏覽器有支援的 controller沒用render會去找同名檔案 js.erb 只有在Rails適用 正常是不行的,不是業界標準 在Show 在**form_for末尾加 remote:true** 在瀏覽器會產生date-remote,在背後偷偷送資料。 ```ruby= <%= form_for(@comment, url: note_comments_path(@note), remote: true) do |f| %> ``` 小試驗:若comments#create內容刪除(Controller不指定動作),新增comments/create.js.erb 內容 alert('hi <%= note.id %>')會被印出。 ### insertAdjacentHTML 在子層最前面新增物件。 接著,正式以js中控制Show的清單,在清單(\<ul class="comments">)最前面增加內容(本例:插入Controller中的 @content)。 利用JS的 insertAdjacentHTML功能 ```javascript= const comment_area = document.querySelector(".comments") const content = "<li><%= @content %></li>" comment_area.insertAdjacentHTML('afterbegin', "<li><%= @content %></li>") ``` 這樣就進行了一次xhr方式送資料到後端,再以JS方式(而非HTML)顯示回View。這樣才有很快的視覺效果。 ### 一發屋問題 但只第一次點擊有效。comment_area已經宣告一次,不能重來。 改為var宣告即可。 若索性寫成一行,就不用宣告了 ```javascript= document.querySelector(".comments") .insertAdjacentHTML('afterbegin', "<li><%= @content %></li>") ``` ### 功能: form helper * **form_for (model)** 建立model 像是 contect (name, email, content) 新增/修改/刪除 * 例 form_for(@user) * form_tag 建立一般表單 * **form_with** 都可以 兩個都可以當 有參數就用form_for,沒有就用form_tag 第三個參數預設**local:false = remote:true**,預設以xhr送資料 - 例 form_with(model: @user) - form_with() ## 多對多關係(必用) 例:醫生的約診簿,同時儲存兩邊資訊,Join Table 例:複數人(user)喜歡不同的複數文章(note) 接著製作第三份table(喜好表),取名bookmark > $rails g model Bookmark **user:belongs_to** **note:belongs_to** Bookmark 屬於(belongs to) User與Note User與Note擁有很多(has many)Bookmark ![](https://i.imgur.com/TbVSDjz.png) ### 建立HM關係 回到model **user.rb與note.rb** ```ruby= has_many :bookmarks ``` note再透過bookmark了解user的喜好程度 ```ruby= has_many :bookmarks has_many: users, through: :bookmarks ``` user再透過bookmark了解有多少筆記 ```ruby= has_many :bookmarks has_many: favorite_notes, through: :bookmarks, source: :note ``` 但notes名稱打架,所以改為favorite_notes,但**用source指名真正table** ,否則會去找FavoriteNote的Model。 注意has_many會生4出個方法。 ### 調整 **回到notes controller** 恢復顯示方式,登入觀看不受限,但編輯仍受限 其中 def find_user_note 應該改回(currentUser改回user) ```ruby= @note = Note.includes(:user).order(id: :desc) ``` def show 應該改回,並從before_action移除 ```ruby= @note = Note.find(params[:id]) ``` 此時,可在自己的文章留言,但到別人的不行。原因出在Controller(comment)用current_user去找評論。先改回來: ```ruby= def find_user_note @note = Note.find(params[:note_id]) end ``` 但送出留言後,框內仍有文字,須清除。在控制此欄的/comments/create.js.erb 加一行抓取指令,用value讓他歸零 ```ruby= document.querySelector("#comment_content").value = "" ``` ## fetch * 使用非同步,會去 Web API 排隊,預設用get去抓東西 * fetch 抓網站的 json ,會得到 Promise 物件 * 抓網址之後,用 then 來做事(callback function),繼續往下 * retuen結果用 json 函式解讀,會回傳 promise * 可以再繼續接 then 做事 * resp 是 fetch 的結果 * data 是 then 的結果* ```javascript= fetch("http://jsonplaceholder.typicode.com/posts") .then(resp => resp.json) .then((data) => { data.forEach(d() => { console.log(d.username); }); }); //印出data中的username //也能用for迴圈取代each for (let d of data){ console.log(d) } //可再解構 for (let {username} of data){ console.log(usrename); } ``` ### fetch時迴避CORS 跨來源資源共用CORS,只會阻擋瀏覽器中的JavaScript。 * 非同源(不是同個網站)或是沒開放CORS政策都會被擋掉 * 用Ruby程式碼拿、請後端拿即可。 * **就是form_for** 會帶token ![](https://i.imgur.com/dbsNsNJ.png) ### 套件:axios [axios](https://github.com/axios/axios) 因為 JS 的 fetch 太陽春了,用 axios 會比較方便 安裝:終端機跑 yarn add axios 網站上線後還是要抓東西,所以 axios 會放在 dependencies - package.json - dependencies:會上線的放這 - devDependencies:不會上線的放這 ### 連結:csrf 跨站請求偽造 對方寄連結給你,借你的權限來做事 csrf token:防止 csrf 的發生,因為對方沒有 token 還是一樣沒用,不能借你的權限來做事 ## API 應用程式介面 不等於JSON,但(網路服務的)回傳值大多是JSON(像是物件)。 讓兩個介面互通,例 * Word的 interface,表格可以貼到Excel * Excel的 interface,表格可以貼到Word 網址尾端斜線後,叫做 Endpoint端點 > http://...com/user ### 路徑:加到最愛 因此,加到我的最愛,可設計成? * 以下語法,可創造路徑 **POST /api/v1/notes/:id/favorite(.:format)** * 寫到ROUTE * 可以用 namespace 去包,不會產生新路徑 * 可以產生/api/v1/notes/:id/favorite 路徑 * 用 member 不用 collection 是因為針對個別文章做事,非全體 * 路徑可以多產生 /:id ```ruby= namespace :api do namespace :v1 do resources :notes, only: [] do #只要post :favorite member do post :favorite end end end end ``` 若以後要改版,就再做一個 namespace :v3 do ,依此類推。 ### 後台界面 進入後台,一般是分開,進/admin之類的路徑。 路徑依功能做區隔如 /admin/ 或 /admin/blog/ ....等等 * 後台管理路徑 /admin/notes ```ruby= namespace :admin do resources: notes end ``` 在app/controller/api/v1/目錄下 **新增Controller**(notes_controller.rb)時,將namespace建成目錄 > rails g controller app/controller/api/v1/ 內容填寫 * 把東西存到 bookmarks,只放 note 的 id 進去讓他查找就好 * 如果 note id 存在就移除最愛,不存在就新增到最愛 * user has_many :favorite_notes,所以可以用 current_user.favorite_notes * delete 後面只有一個參數,不是hash(注意note被宣告了等於筆記params內容) * 順便找到剛剛用 axios 送的 id 讓它顯示出來 * render json: { status: "removed" } 裡面可隨意取名 ```ruby= class App::Controller::Api::V1::NotesController < ApplicationController before_action :check_login! # 登入後才能加最愛 def favorite note = Note.find(params[:id]) if bookmarks.exists?(note: note) current_user.favorite_notes.delete(note) #移除最愛 render json: { status: "移除了", id: id} else current_user.favorite_notes << note #增加我的最愛 render json: { status: "增加了", id: id} end end ``` * 如果出現 500 錯誤可以去看終端機的 server 內容 ### 用postman 美化API 取外部資訊(政府平台) 會遇到問題 => 搜尋關鍵字:CORS 在終端機 > curl -X POST 本機路徑 > ## AJAX 例:愛心效果 本來考慮使用Font Awesome引入愛心符號,先用div畫範圍。 製作按鈕,按下去時用AJAX方式送資料到後端去。 ### 外觀 **在show**畫出一個超連結包住的div。 ```htmlmixed= <a href="#"> <div class="favorite_icon"></div> </a> ``` 在確定有被application.css所include進來的css檔案(ui.scss)中寫格式,主要是: ```css= .favorite_icon{ display: block; .favorite_icon { width: 40px; height: 40px; border: 1px solid gray; } } ``` show調整結構,與抬頭放到同一個新div(note-heading)中管轄。 ```htmlmixed= <div class="note-heading"> <h1>筆記: <%= @note.title %></h1> <a href="#" id="favorite_btn"> <div class="favorite_icon"></div> </a> </div> </div> ``` CSS都合併入.note-heading,讓東西橫排 ```css= .note-heading{ display: flex; align-items: center; h1 { margin-right: 10px; } a { display: block; .favorite_icon{ width: 40px; height: 40px; border: 1px solid gray; } } } ``` ### 按鈕功能 在最高層的application.js(javascript/packs)加監聽器(後來會移到favorite_controller),讓目標div具備按鈕特徵 * 將按鈕class名稱宣告一次,丟進if,若監聽到click就有動作=**document.querySelector("#favorite_btn");** * 監聽東西是否載入完畢=document.addEventListener("DOMContentLoaded") * 若按鈕 if (btn)存在,那click時就替 addFavorite(id) 加個2(測試 @addFavorite(2))或console.log * 按下去時要send資料給後端,配合下一個function改為addFavorite() ```ruby= document.addEventListener("DOMContentLoaded", () => { const btn = document.querySelector("#favorite_btn") if (btn){ btn.addEventListener("click", () => { addFavorite() }); } } ) ``` * 表頭import的 ActiveStorage 代表=把所有東西匯進來 * 呼叫ActiveStorage的start方法 > import * as ActiveStorage from "@rails/activestorage" > ActiveStorage.start() ### 發資料給後端/ fetch 同樣在application.js 按下去時,要往後端發送資訊。加一個 function addFavorite(id),預備包**fetch方法** 若前面寫addFavorite(2),id就帶入2,代表喜歡2號文章 欲顯示正確的id,就要放在按鈕上 ```javascript= function addFavorite(id) { console.log(id) } ``` ### 打API 要有data-id 承上,發資料要用到幾個套件。內建是fetch,會得到一個promise * 要先找路徑 * then是callback方法 * 透過方法解開後,return的json回傳值,為promise物件 * 再接then下去,可以再把json回傳值印出來 例 > resp.json()前面有個return省略了 > data拿到的資料類似陣列 [{}, {}, {}] > 可以印出全部=console.log(data),或其中username欄的內容如下 > **data.forEach((d)** => 可以寫成 **for (let d of data)** ``` fetch("https://jsonplaceholder.typicode.com/users") .then((resp) => resp.json()) .then((data) => { data.forEach((d) => { console.log(d.username); }); }) ``` --- 未完 從0819影片開始 第6支 ```javascript= function addFavorite(id) { console.log(id); //fetch().then().then() } ``` **在show**,網頁中的 **按鈕區域(favorite_btn)** 加上data-id。 在下面,靠Model中的js監聽器抓到data-id,基於ORM傳到這裡網頁的<%= @note.id %>,印出來。 ```htmlmixed= <a href="#" id="favorite_btn" data-id="<%= @note.id %>"> <div class="favorite_icon"></div> </a> ``` 要抓到正確data-id,而他寫在show上面 * preventDefault 在此是取消click的預設功能? * 怎麼拿到show網頁的\<a>連結(favorite_btn)?用console.log印出 * 目前監視器綁在\<a>連結上,理論上currentTarget 與 Tatget一樣。但實際上前者才有效。 * dataset是取出data某某的值。 ```javascript= if (btn){ btn.addEventListener("click", (e) => { e.preventDefault(); console.log(e.currentTarget.dataset.id) //抓指定id } ``` 然而,因為Rails沒有真正煥頁(Rails預設使用turbolinks) * 這邊(對btn)的DOM監聽器會失效("DOMContentLoaded"),是因為 Rails 有用 turbolinks * DOM 是整個網站重新載入時會運作,但使用 turbolinks 換網頁時只會換裡面的 body。所以用 DOM 不會運作。 實際運作: * 這邊第一次進到的畫面是沒有灰色格子的,DOM 跑完不會撈到 btn 按鈕資料 * 到下一頁的時候,因為使用 turbolinks 所以不會重新載入,故一樣不會撈到 btn 按鈕資料 * 最後導致無法正常運作,要將DOM改成 turbolinks 事件才會正常運作 ```javascript= document.addEventListener("turbolinks:load", () => { const btn = document.querySelector("#favorite_btn"); if (btn) { btn.addEventListener("click", (e) => { e.preventDefault() addFavorite(e.currentTarget.dataset.id) // 拿到指定id }); } ``` ### fetch抓外部資料回來 回到 application.js 定義一個 addFavorite 函式 * 把id塞到按鈕裡面 * 定義 url = 要按讚的筆記的網址 * const url = `/api/v1/notes/${id}/favorite`; 哪裡來的? * 定義 token = 瀏覽器 meta 標籤裡面的 **name 屬性的 csrf-token 的內文** * 用axios,要import * 用 **axios 拿到合法 token**,才有辦法去打 API,不然會被 Rails 擋住(Rails 的 csrf token 防護機制) * 如果狀態是 added 就移除 favorite-off,新增 favorite-on class ```javascript= import ax from "axios"; // 沒加 { } = 有預設值 function addFavorite(id) { const url = `/api/v1/notes/${id}/favorite`; const token = document.querySelector("meta[name=csrf-token]").content; ax.defaults.headers.common["X-CSRF-Token"] = token; //塞給token ax.post(url) //資料送出 .then((res) => { const icon = document.querySelector(".favorite_icon"); //回應值 if (res.data.status === "added") { icon.classList.remove("favorite-off"); icon.classList.add("favorite-on"); } else { icon.classList.remove("favorite-on"); icon.classList.add("favorite-off"); } }) .catch((err) => { console.log(err); //若出錯 印出來 }); } ``` 不建議在v1的controller關閉token檢查(skip_before_action :verify_authenticity_token) * 網頁原始碼中有token可以撿(csrf-token) * 用JS拿TOKEN 塞給按鈕 * 也要做 登入驗證(check_login!) * 此處並未寫入資料,只是透過id抓資料,不需要資料清洗permit 這段???? ```ruby= before_action :check_login! def favorite note = Note.find(params[:id]) if bookmarks.exists?(note: note) #若存在筆記於最愛裡 current_user.favorite_notes.delete(note) #移除最愛 render json: { status: "removed", id: id} else current_user.favorite_notes << note #新增最愛 render json: { status: "added", id: id} end ``` 接著,按下按鈕後,要送到API(?) ###### tags: `Rails` ###### tags: `JavaScript`