【Rails】【JavaScript】Ajaxを用いてメモ帳の様なアプリをフォームで作る方法

大阪でRailsを中心に学習している薬剤師エンジニア（初学者）こと、ヨマ(@yoma_2003)です！
**Ajaxを用いてメモ帳の様なアプリをフォームで作る方法**をまとめます。

※おことわり※
断定口調でまとめますが、初学者であるため間違い等あればご指摘頂けると嬉しいです。

# はじめに
### やりたいこと
以下の様な**自動更新機能のあるメモ帳（追加・削除できる）アプリ**を作りたい。

![fyj9o-w9rr8.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2711042/42ed6ece-62d4-4ab8-5d90-3d0b5e097e24.gif)

### アプリの要件
具体的には以下の様な要件を満たすアプリを作成します。
- ページ遷移がない（index.htmlのみ）
- フォームに入力すると、自動で更新される
- 新規ボタンを押すと、フォームが追加される
- 削除ボタンを押すと、フォームが削除される
- 操作後に更新日時が更新される

# 本記事のポイント

**・データ更新処理
・フォーム削除処理
・フォーム追加処理**

上記の3点をAjaxを用いて実装するにあたり、わかりにくかった2点を中心にまとめます。

- `Ajax`を用いて`PUT`、`DELETE`、`POST`リクエストを送信する方法
- `FormDataオブジェクト`を自分で定義して`csrfトークン`を埋め込む方法

# バックエンド実装（前提）
### モデル

```ruby:post.rb
class Post < ApplicationRecord
end
```
### データベース
| Column  | Type   |
| ------- | ------ |
| text    | string |

### ルーティング
```ruby:routes.rb
Rails.application.routes.draw do
  root 'posts#index'
  resources :posts, only: [:create, :update, :destroy]
end
```

### コントローラー
```ruby:posts_controller.rb
class PostsController < ApplicationController
  def index
    @posts = Post.all.order("id DESC")
  end

  def create
    new_post = Post.create(post_params)
    render json: { new_post: new_post }
  end

  def update
    post = Post.find(params[:id])
    post.update(post_params)
  end

  def destroy
    post = Post.find(params[:id])
    post.destroy
  end

  private
  def post_params
    params.require(:post).permit(:text)
  end
end
```

# フロントエンド実装（前提）

### ビュー
```erb:index.html.erb
<%= javascript_pack_tag "update-form", "new-form", "delete-form", "update-time" %>

<h1>Ajaxメモ帳アプリ</h1>
<div id="last_update">更新日時：</div>
<button id="new_btn">新規</button>
<div id="texts">
  <% unless @posts.count == 0 %>
    <% @posts.each do |post| %>
      <%= form_with model:post, data: post.id do |f| %>
        <%= f.label :text, "No.#{post.id}" %>
        <%= f.text_area :text, class: "text_input"%>
        <button class="delete_btn">削除</button>
      <% end %>
    <% end %>
  <% end %>
</div>
```

# Ajaxの実装ポイント

### 1. Ajaxを用いてPUT、DELETE、POSTリクエストを送信する方法

ページ遷移をせずにデータ更新、フォームの追加・削除する機能を作成するためには、`Ajax`を用いて各種リクエストを送信する処理を実装します。

```js:Ajax例
const form = document.querySelectorAll(".post_form");  //フォーム要素を取得
const formId = form.getAttribute("data");  //フォームのIDを取得 （カスタムデータ属性で定義しておく）
const formData = new FormData(form);  //フォームデータからFormDataオブジェクトを作成
const XHR = new XMLHttpRequest();
```

```js:PUTメソッドの場合
XHR.open("PUT", `/posts/${formId}`, true);  //updateアクションへのPUTリクエストを定義
XHR.send(formData);  //リクエストを送信
```

```js:DELETEメソッドの場合
XHR.open("DELETE", `/posts/${formId}`, true);  //destroyアクションへのDELETEリクエストを定義
XHR.send(formData);  //リクエストを送信
```

```js:POSTメソッドの場合
XHR.open("POST", `/posts`, true);  //createアクションへのPOSTリクエストを定義
XHR.send(formData);  //リクエストを送信
```

### 2. FormDataオブジェクトを自分で定義してcsrfトークンを埋め込む方法

新規ボタンクリックで新規データを保存したい場合、フォームデータから`FormDataオブジェクト`を作成できないため、自分で`FormDataオブジェクト`を定義します。

この際、Railsのコントローラーでそのパラメーターを受け取れるようにするには、CSRF対策の`csrfトークン`をパラメーター内に含める必要があります。

**FormDataオブジェクトを自分で作成する例**

```js:JavaScript
newBtn.addEventListener("click", () => {
  const csrfToken = document.querySelector("meta[name='csrf-token']").content;  //csrfトークンを取得
  const formData = new FormData();  //空のFormオブジェクトを作成
  formData.set("authenticity_token", `${csrfToken}`);  //csrfトークンをFormDataにセット
  formData.set("post[text]","");  //空のデータをFormDataにセット

  //以下Ajax処理
  const XHR = new XMLHttpRequest();
  XHR.open("POST", `/posts`, true);
  XHR.responseType = "json";
  XHR.send(formData);
  ・・・
```
- `csrfトークン`とは、リクエスト送信のたびに真正性を確認するための一意のトークンのこと。リクエストを捏造して認証済みユーザーを装うCSRF攻撃から保護するための仕組み。

フォームデータから`FormDataオブジェクト`を作成した場合は、以下の様にパラメーターに`csrfトークン`が含まれるため、コントローラーでパラメーターを受け取る事ができます。

**フォームデータからFormDataオブジェクトを作成してリクエスト送信した場合のパラメーター**
```rb:
pry(#<PostsController>)> params
=> <ActionController::Parameters {
  "_method"=>"patch",
  "authenticity_token"=>"9KVGa3jp5Q0qmKDsG9Hxrb6zjy9Se9ORlCA8yqs8+dwc7bE3y5md3j1B19lmpjRqLXLP5PTTP0rQuHY69PRa9A==",
  #↑自分で定義したFormDataオブジェクトにもこの部分（csrfトークン）を含める必要がある。
  "post"=><ActionController::Parameters {"text"=>"aaa"} permitted: false>,
  "controller"=>"posts",
  "action"=>"update",
  "id"=>"118"
  } permitted: false>
```

この`csrfトークン`はapplicationビューの記載にて生成されているため、JSを用いてHTMLから取得し、`FormDataオブジェクト`にセットします。

**ビューにて生成されるcsrfトークン**
```erb:application.html.erb
<!DOCTYPE html>
<html>
  <head>
    <title>AjaxApp</title>
    <%= csrf_meta_tags %>
    <%# ↑この記述で生成されている %>
    ・・・
```

**生成されたcsrfトークン**
```html:index.html
<head>
  <title>AjaxApp</title>
  <meta name="csrf-param" content="authenticity_token">
  <meta name="csrf-token" content="hBYghOWyAU4IG3aFzV59TFGR5nqmX7YSiej1ezscMIJsXtfYVsJ5nR/CAbCwKbiLwlCmsQD3WsnNcL+LZNSTqg==">
  <!--↑これを取得したい-->
  ・・・
```

JSで`csrfトークン`を取得し、`FormDataオブジェクト`にセットします。
```js:JavaScript
const csrfToken = document.querySelector("meta[name='csrf-token']").content;
const formData = new FormData();
formData.set("authenticity_token", `${csrfToken}`);
```

### 参考
https://developer.mozilla.org/en-US/docs/Web/API/FormData/set

https://techracho.bpsinc.jp/hachi8833/2021_11_26/46891

https://blog.willnet.in/entry/20080509/1210338845

https://zenn.dev/yukito0616/articles/7eacd83727f0f0


# JS処理の実装

上記のポイントを踏まえ、以下４つの処理をJSで実装します。

- データ更新処理
- 削除ボタン処理
- 新規ボタン処理
- 更新日時処理

### データ更新処理（JS）
フォーム入力後、「フォーム外をクリックした時」「入力状態のままページ遷移した時」に、該当データを更新する処理
```js:update-form.js
import { updateData } from "./update-data";

const updateForm = () => {
  const textInputs = document.querySelectorAll(".text_input");
  textInputs.forEach((textInput) => {
    textInput.addEventListener("blur", () => {  //フォームからブラーした時
      updateData(textInput);  //データ更新処理
    });
    textInput.addEventListener("focus", () => {  //フォームがフォーカスした時
      window.addEventListener("beforeunload", () => {  //ページ遷移イベント前
        updateData(textInput);  //データ更新処理
      });
    });
  });
}

window.addEventListener("load", updateForm);
```

Ajaxを用いてPUTリクエストを送信する処理

```js:update-data.js
import { updateTime } from "./update-time";

const updateData = (textInput) => {
  const form = textInput.parentElement;
  const formId = form.getAttribute("data");
  const formData = new FormData(form);
  const XHR = new XMLHttpRequest();
  XHR.open("PUT", `/posts/${formId}`, true);
  XHR.send(formData);
  updateTime();
};

export { updateData };
```

### 削除ボタン処理（JS）

「削除ボタンを押した時」に、該当データを削除する処理

```js:dalete-form.js
import { deleteData } from "./delete-data";

const deleteForm = () => {
  const deleteBtns = document.querySelectorAll(".delete_btn");
  deleteBtns.forEach((deleteBtn) => {
    deleteBtn.addEventListener("click", () => { //削除ボタンをクリックした時
      deleteData(deleteBtn);  //データ削除処理
    });
  });
};

window.addEventListener("load", deleteForm);
```
Ajaxを用いてDELETEリクエストを送信する処理

```js:delete-data.js
import { updateTime } from "./update-time";

const deleteData = (deleteBtn) => {
  const form = deleteBtn.parentElement;
  const formId = form.getAttribute("data");
  const formData = new FormData(form);
  const XHR = new XMLHttpRequest();
  XHR.open("DELETE", `/posts/${formId}`, true);
  XHR.send(formData);
  XHR.onload = () => {
    form.remove();
    updateTime();
  };
}

export { deleteData };
```


### 新規ボタン処理（JS）

「新規ボタンを押した時」の処理は以下の2つに別れます。
- 空データをデータベースに登録し、新規フォームを追加する処理
- 新規フォームに対して「データ更新処理」「削除ボタン処理」を埋め込む処理

**空データを登録する理由**
追加するフォームに更新機能をもたせるためには、紐づくデータを先に登録しておく必要があるため。

**新規フォームに対して「データ更新処理」「削除ボタン処理」を埋め込む理由**
新規フォームに対してそれぞれ処理を埋め込まなければ、既存の処理と干渉してエラーが生じるため。
※新規フォーム追加時には既存フォームと区別できるクラス名（new・・・等）をつけます。


```js:new-form.js
import { deleteData } from "./delete-data";
import { updateData } from "./update-data";
import { updateTime } from "./update-time";

const newForm = () => {
  const newBtn = document.getElementById("new_btn");
  newBtn.addEventListener("click", () => {

    //空データを作成し、POSTリクエストを送信する処理
    const csrfToken = document.querySelector("meta[name='csrf-token']").content;
    const formData = new FormData();
    formData.set("authenticity_token", `${csrfToken}`);
    formData.set("post[text]","");
    const XHR = new XMLHttpRequest();
    XHR.open("POST", `/posts`, true);
    XHR.responseType = "json";
    XHR.send(formData);

    //登録した空データを受け取り、新規フォームを追加する処理
    XHR.onload = () => {
      const newPost = XHR.response.new_post;
      const html = 
        `<form data="${newPost.id}" action="/posts/${newPost.id}" accept-charset="UTF-8" data-remote="true" method="post">
          <input type="hidden" name="_method" value="patch">
          <input type="hidden" name="authenticity_token" value="${csrfToken}">
          <label for="post_text">No.${newPost.id}</label>
          <textarea class="new_text_input_${newPost.id}" name="post[text]" id="post_text"></textarea>
          <button class="new_delete_btn_${newPost.id}">削除</button>
        </form>`;
      const texts = document.getElementById("texts");
      texts.insertAdjacentHTML("afterbegin", html);

      updateNewForm(newPost.id);
      deleteNewForm(newPost.id);
      updateTime();
    };
  });

  //新規フォームに対しての「データ更新処理」
  const updateNewForm = (newPostId) => {
    const newTextInput = document.querySelector(`.new_text_input_${newPostId}`);
    newTextInput.addEventListener("blur", () => {
      updateData(newTextInput);
    });
    newTextInput.addEventListener("focus", () => {
      window.addEventListener("beforeunload", () => {
        updateData(newTextInput);
      });
    });
  };

  //新規フォームに対しての「削除ボタン処理」
  const deleteNewForm = (newPostId) => {
    const newDeleteBtn = document.querySelector(`.new_delete_btn_${newPostId}`);
    newDeleteBtn.addEventListener("click", () => {
      deleteData(newDeleteBtn);
    });
  };
};

window.addEventListener("load", newForm);
```

### 更新日時処理（JS）

データが更新された事がわかるよう更新日時を表示させます。

```js:update-time.js
const updateTime = () => {
  const now = new Date();
  const localTime = now.toLocaleString();
  const lastUpdate = document.getElementById("last_update");
  const html = `更新日時：${localTime}`;
  lastUpdate.innerHTML = html;
};

export { updateTime };
```

# おわりに
正直もっといい方法があるかもしれませんが、初めてのアプリ制作にて思考錯誤した方法を紹介させて頂きました。