(rails,jQuery)クリックでカウントアップとデータの保存

タイトルのように、クリックするごとに数字をカウントアップし、非同期でデータを保存する方法を紹介します

イメージとしては、動画を確認してください⬇️

<blockquote class="twitter-tweet"><p lang="und" dir="ltr"><a href="https://t.co/NaBy8p8d72">pic.twitter.com/NaBy8p8d72</a></p>&mdash; Kamotetu (@Kamotetu2) <a href="https://twitter.com/Kamotetu2/status/1199510244939853824?ref_src=twsrc%5Etfw">November 27, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

自分もプログラミング初学者なので、もっとやり方あると思いますが、ググっても良い記事がなく、同じような実装に悩まれている方が多かったので、「とりあえず実装できる」レベルですが、誰かの参考になればと思います

あと、ここで例として紹介しているアプリケーションの内容は小説を定義(maintitle)し、その小説の内容(story)を随時アップしていくという内容になっていますので、なんとなくイメージしていただくと記事の内容が理解しやすいかなと思います

また、マイグレーションファイルの作成や、コントローラの作成など、ここで紹介していない部分の実装等については、別の方の記事等を参考にしてください

それではちょっと長くなりますが紹介していきます

- ruby 2.5.1
- rails 5.2.3

使用しているgem

- 'gon'
- 'device'
- 'jquery-rails'

```○○_create_reviews.rb
class CreateReviews < ActiveRecord::Migration[5.2]
  def change
    create_table :reviews do |t|
      t.integer :user_id
      t.integer :story_id
      t.integer :maintitle_id
      t.integer :review
      t.timestamps
    end
  end
end
```

```app/models/review.rb
class Review < ApplicationRecord
  belongs_to :user
  belongs_to :story
  belongs_to :maintitle
end
```

```config/routes.rb
resources :maintitles do
    # 省略
    resources :stories do
      post 'like_review'
      delete 'unlike_review' #まだ実装できてません・・
      resources :comments, only: [:create]
    end
  end
```

```app/controllers/stories.controller.rb
before_action :set_maintitle, only: [:new,
                                     :create,
                                     :show,
                                     :like_review,
                                     # 省略]
before_action :set_story, only: [:show,
                                   # 省略]

////////////////////////////////////////////////////////
def show

    #set_maintitle
    #set_story
    
    @user = User.find(@story.user_id)
    if user_signed_in?
      @review = Review.find_by(story_id: @story.id, user_id: current_user.id)
      if @review != nil
        gon.my_review_count = @review.review + 1
      else
        @review = Review.create(user_id: current_user.id, story_id: @story.id, maintitle_id: @maintitle.id, review: 0)
        gon.my_review_count = @review.review + 1
      end
    end
    @reviews = Review.where(story_id: @story.id)
    @a = []
    @reviews.each do |review|
      p = review.review
      @a.push(p)
    end
    @review_all_count = @a.sum
    gon.review_all_count = @a.sum + 1
    
  end

////////////////////////////////////////////////////////

def like_review
  # set_maintitle
  @story = Story.find(params[:story_id])
  @review = Review.find_by(story_id: @story.id, user_id: current_user.id)
  p = @review.review + 1
  @review.update(review: p)
  render json: @story.id
end

////////////////////////////////////////////////////////

　private

  def set_maintitle
    @maintitle = Maintitle.find(params[:maintitle_id])
  end

  def set_story
    @story = Story.find(params[:id])
  end
```

```app/views/stories/show.html.erb
# 省略
<%if @story.user_id != current_user.id%>
  <div class="like_review_area">
    <%=link_to maintitle_story_like_review_path(@maintitle, @story), method: :post, remote: :true, class: :like_review_btn do%>
      <div class="like_review_link">
        <div class="like_review", id="<%=@maintitle.id%>">
          面白かった数だけここをクリック！
        </div>
      </div>
    <%end%>
  </div>
<%end%>
<%if user_signed_in?%>
  <%if @story.user_id != current_user.id%>
    <div class="show_review_my_count_area">
      あなたの評価：
      <i class="fa fa-thumbs-o-up ">
      </i> 
      <i class="review_my_count_area">
        <%if @review.present?%>
          <i class="my_count">
            <%=@review.review%>
          </i>
        <%else%>
          <i class="my_count">
            0
          </i>
        <%end%>
      </i>
    </div>
  <%end%>
<%end%>
```



```app/assets/javascripts/like_review.js
function appendFaThoumbsOUpMore(my_review_count) {
  var fa_thumbs_o_up_more = $(".review_my_count_area");
  var my_count_more = `<i class="my_count">
                        ${my_review_count}
                        </i>`
  fa_thumbs_o_up_more.append(my_count_more);
};

function appendFaThoumbsOUpAll(all_review_count) {
  var fa_thumbs_o_up_all = $(".review_all_count_area");
  var all_count = `<i class="all_count">
                        ${all_review_count}
                        </i>`
  fa_thumbs_o_up_all.append(all_count);
};

$(function() {
  $(document).on("ajax:success", ".like_review_btn", function(e) {
    e.preventDefault();
    $(".my_count").remove();
    $(".all_count").remove();
    var my_review_count = gon.my_review_count++ ;
    var all_review_count = gon.review_all_count++ ;
    appendFaThoumbsOUpMore(my_review_count);
    appendFaThoumbsOUpAll(all_review_count);
  });
});
```

ずらずら記載してすいません

ということでポイントを説明していきます
# reviewテーブルを作成

自分は、誰がどの投稿に対して評価をつけたかわかるように、

```○○_create_reviews.rb
class CreateReviews < ActiveRecord::Migration[5.2]
  def change
    create_table :reviews do |t|
      t.integer :user_id
      t.integer :story_id
      t.integer :maintitle_id
      t.integer :review
      t.timestamps
    end
  end
end
```

としましたが、単に評価の数だけならuser_idとかいらないと思います
あとそれぞれに

```
 null: false
 foreign_key: true
```

もつけた方がいいかもですね

# modelにアソシエーションを定義

```app/models/review.rb
class Review < ApplicationRecord
  belongs_to :user
  belongs_to :story
  belongs_to :maintitle
end
```
これはこのままでいいと思います
※　コードは載せませんが、ここでアソシエーションを組んでいる相手方には、has_many :reviewsを書いてください

# routeを設定

```config/routes.rb
resources :maintitles do
    # 省略
    resources :stories do
      post 'like_review'
      delete 'unlike_review' #まだ実装できてません・・
      resources :comments, only: [:create]
    end
  end
```

maintitlesにstoriesをネストさせ、storiesコントローラ内に

- like_review
- unlike_review

のアクションを設定します（後述）
ネストさせる意味は、reviewテーブルにmaintitle_idとstory_idを保存するためにやってます

# controllerを設定

```app/controllers/stories.controller.rb
before_action :set_maintitle, only: [:new,
                                     :create,
                                     :show,
                                     :like_review,
                                     # 省略]
before_action :set_story, only: [:show,
                                   # 省略]

////////////////////////////////////////////////////////
def show

    #set_maintitle
    #set_story
    
    @user = User.find(@story.user_id)
    if user_signed_in?
      @review = Review.find_by(story_id: @story.id, user_id: current_user.id)
      if @review != nil
        gon.my_review_count = @review.review + 1
      else
        @review = Review.create(user_id: current_user.id, story_id: @story.id, maintitle_id: @maintitle.id, review: 0)
        gon.my_review_count = @review.review + 1
      end
    end
    @reviews = Review.where(story_id: @story.id)
    @a = []
    @reviews.each do |review|
      p = review.review
      @a.push(p)
    end
    @review_all_count = @a.sum
    gon.review_all_count = @a.sum + 1
    
  end

////////////////////////////////////////////////////////

def like_review
  # set_maintitle
  @story = Story.find(params[:story_id])
  @review = Review.find_by(story_id: @story.id, user_id: current_user.id)
  p = @review.review + 1
  @review.update(review: p)
  render json: @story.id
end

////////////////////////////////////////////////////////

　private

  def set_maintitle
    @maintitle = Maintitle.find(params[:maintitle_id])
  end

  def set_story
    @story = Story.find(params[:id])
  end
```

ごちゃごちゃしててすいません・・
ここから実装についてのポイントになってきます

まず、before_actionで、set_maintitleとset_storyをshowアクションに反映させます

で、ログインしていれば評価ができる仕様にしてますので、

```
if user_signed_in?
```
を使用します

```
@review = Review.find_by(story_id: @story.id, user_id: current_user.id)   
```
これで、現在閲覧している小説の内容のidと、自分のidを持ったreviewレコードを探して取得します
すでに訪れていた場合はデータがありますが（後述）、初めて訪れた場合はもちろんnilとなります

```
if @review != nil
  gon.my_review_count = @review.review + 1
else
  @review = Review.create(user_id: current_user.id, story_id: @story.id, maintitle_id: @maintitle.id, review: 0)
  gon.my_review_count = @review.review + 1
end
```
これで、すでに訪れていた場合、gon.my_review_countというjsに渡す変数に、現在のreviewの数に＋１させた数字を代入し、初めて訪れた場合はreviewに０を入れてcreateし、さらに前記同様変数に＋１させた数字を代入します（後述）

```
@reviews = Review.where(story_id: @story.id)
  @a = []
  @reviews.each do |review|
    p = review.review
    @a.push(p)
  end
  @review_all_count = @a.sum
  gon.review_all_count = @a.sum + 1
```


この部分はstoryに対する各userの総評価数を取得する記述です

変数@reviewsに、whereでReviewテーブルから@story.idに一致するテーブルを全て取得し代入します

ここで、取得したデータからreviewカラムのデータを格納する空の配列@aを定義しておきます

whereで取得すると、配列でデータが取得されるので、eachで回し、reviewカラムのデータを抽出し、変数pへ代入し、空配列@aへpushにより格納していきます

そして、変数@review_all_countへ@aに入ったreviewデータの合計を.sumを用いて計算し、代入します
そして、jsへデータを渡すため、変数gon.review_all_countへ@a.sum + 1と、総評価数に＋１をして代入します

# like_reviewアクションを定義する
```
def like_review
  # set_maintitle
  @story = Story.find(params[:story_id])
  @review = Review.find_by(story_id: @story.id, user_id: current_user.id)
  p = @review.review + 1
  @review.update(review: p)
  render json: @story.id
end
```

このアクションで、reviewカラムにアクション毎（クリック毎）に＋１させていきます

まず、現在のstoryのデータを取得し、変数@storyへ代入します

そして、変数@reviewへReviewテーブルからstory_id,user_idで条件指定したレコードを取得します

変数pへ、取得したReviewレコードのreviewカラムのデータへ+1したデータを代入します

最後に、そのテーブルのreviewカラムをアップデートします

※ render json: @story.idについては自分もよくわかっていませんが、この記述がないと、like_reviewのviewに飛ぼうとします
結果like_reviewアクションにタイムロスが生じてしまいますので、とりあえず書いておきましょう（ターミナルで確認するとわかります）
でもエラーにはならないんだよなーよくわからん

# viewを作成する

```
<%=link_to maintitle_story_like_review_path(@maintitle, @story), method: :post, remote: :true, class: :like_review_btn do%>
```
viewは好みで作成していただくとして、大事なのは⬆️の一文です

link_toでlike_reviewアクションを実行します（pathはrake routesで確認してください）
このとき「remote: :true」を記述することにより、ajaxにより非同期でアクションが実行されます
よって、データ上の処理はこの時点でアクション毎に＋１されていきます
（自分はまだajaxの仕組みをよく理解していませんので、詳しく知りたい方は別の記事等ググって調べてみてください）

また、showに初めて訪れた際、reviewカラムに0を入れてcreateすると前述しましたが、初めて訪れた直後というのは、何もデータが作成されていません
初めて訪れたときの処理の流れを説明すると、

- showに訪れる
- createされる

という順番のため、createしたデータを渡したくてもエラーになります

そのため、

```
<%if @review.present?%>
  <i class="my_count">
    <%=@review.review%>
  </i>
<%else%>
  <i class="my_count">
    0
  </i>
<%end%>
```
と、@reviewにデータが入っていない条件分岐を定義してあげて、エラーを回避＆初期のreviewのデータ「０」を表示させなければなりません

# jsでアクション毎に数字を表示する

```app/assets/javascripts/like_review.js
function appendFaThoumbsOUpMore(my_review_count) {
  var fa_thumbs_o_up_more = $(".review_my_count_area");
  var my_count_more = `<i class="my_count">
                        ${my_review_count}
                        </i>`
  fa_thumbs_o_up_more.append(my_count_more);
};

function appendFaThoumbsOUpAll(all_review_count) {
  var fa_thumbs_o_up_all = $(".review_all_count_area");
  var all_count = `<i class="all_count">
                        ${all_review_count}
                        </i>`
  fa_thumbs_o_up_all.append(all_count);
};

$(function() {
  $(document).on("ajax:success", ".like_review_btn", function(e) {
    e.preventDefault();
    $(".my_count").remove();
    $(".all_count").remove();
    var my_review_count = gon.my_review_count++ ;
    var all_review_count = gon.review_all_count++ ;
    appendFaThoumbsOUpMore(my_review_count);
    appendFaThoumbsOUpAll(all_review_count);
  });
});
```

さて、最後の説明になります
まず、jsの大まかな処理の流れとして、

- like_reviewアクションが実行されたら（ajax通信が行われたら）イベント発火
- 古いデータの入ったhtml要素を排除
- 新しいデータの入ったhtml要素を挿入

という流れになります

まず、上段のhtmlは置いておいて、下段を見ていきます

```
  $(document).on("ajax:success", ".like_review_btn", function(e) {
```
この記述は「like_review_btn」というクラスを持ったhtml要素がajax通信を成功したら処理を行うという意味になります

```
$(".my_count").remove();
$(".all_count").remove();
```
この記述で、それぞれのクラスを持ったhtml要素を排除します

```
var my_review_count = gon.my_review_count++ ;
var all_review_count = gon.review_all_count++ ;
```

これは、先ほどgonで定義した変数に＋１したものを、それぞれ変数へ代入するものとなっています

ここで、controllerで定義したgonの変数について説明します
controllerでなぜ＋１したかというと、データの表示はあくまで、「アクションが実行された後のデータ」を表示しなければなりません
なので、jsの＋＋のみでは、1度目のアクションでの表示が「showに訪れた時のデータ」が表示されてしまいます
よって、コントローラで（jsでもできると思いますが）＋１してあげることで、1度目のアクションでの表示がちゃんと＋１されて表示することができるのです
（console.log等で確認してみるとわかります）

```
appendFaThoumbsOUpMore(my_review_count);
appendFaThoumbsOUpAll(all_review_count);

///////////////////////////////////////////////
function appendFaThoumbsOUpMore(my_review_count) {
  var fa_thumbs_o_up_more = $(".review_my_count_area");
  var my_count_more = `<i class="my_count">
                        ${my_review_count}
                        </i>`
  fa_thumbs_o_up_more.append(my_count_more);
};

function appendFaThoumbsOUpAll(all_review_count) {
  var fa_thumbs_o_up_all = $(".review_all_count_area");
  var all_count = `<i class="all_count">
                        ${all_review_count}
                        </i>`
  fa_thumbs_o_up_all.append(all_count);
};
```

ここでは、先ほどremoveで排除したhtml要素へ、データを更新したhtml要素を挿入する記述となっています



以上で説明を終了します！！
長々読んでいただきありがとうございました

文章書くの苦手なので非常にわかりにくかったかとは思いますが、これでとりあえずは実装できます
また、これで「評価の高い投稿」や、「自分が評価した投稿」等の検索や並び替えなども可能となります（と思う）

あと、今回の実装では最初、「1度目のアクションがcreateアクション」「２度目からupdateアクション」という２段構えでやっていたのですが、どうにもうまく行かなかったため、「showへ訪れたらcreateアクション」するという方法にシフトチェンジしました
なので、評価していなくてもデータが作成されるため、本当にこの実装の仕方でいいのか疑問が残っています
ここは要検討ですね


また、冒頭でも言いましたが、自分は初学者ですので、もっと簡単にできる等あれば、是非教えていただけるとありがたく感じます

ということで失礼します！