# RL-06 會員系統/AJAX/多對多/fetch
## 功能說明
### Route: shallow nesting 淺槽狀 (專案必用)
路徑上的差別,看起來比較不囉唆
看3號 comment 不用從2號 note 裡面找
因為 **:index :new :create 三個路徑沒有 id**
其他的有,才需要這樣分

- 原效果:
```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)就好

但是,若每篇文有很多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
```

## 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

### 建立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

### 套件: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`