---
# System prepended metadata

title: RL-06 會員系統/AJAX/多對多/fetch
tags: [JavaScript, Rails]

---

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
















