Rails7でコメント機能に星評価を追加する

## はじめに
Rails7のアプリケーションに 星評価機能付きのコメント機能を実装した方法を紹介します。
今回は`raty.js`を利用して、ユーザーがコメントを投稿する際に星評価をつけられるようにしました。
また、投稿されたコメントには星評価が表示されるようにしています。
本記事は、tweetsコントローラーのshowページ(投稿の詳細ページ)にコメント機能が実装されていることを前提に進行します。

完成イメージ👇
![スクリーンショット 2025-02-08 18.43.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3700876/7ec2a1de-2777-a962-e30e-93f65e3913e6.png)


### 開発環境
- Ruby 3.3.7
- Rails 7.1.3
- Node 18
- esbuildを使用したJavaScriptバンドル

## 1. raty.js の導入
まず、`raty.js`をRails7の`app/javascript`ディレクトリに追加します。

### 1.JavaScript のセットアップ
`app/javascript/raty.js`を作成し、以下のコードを記述します。
```js
class Raty {
  constructor(element, options = {}) {
    this.element = element;
    this.opt = { 
      ...this.defaultOptions(), 
      ...options, 
      ...this._parseOptions(element.dataset) 
    };
    this.init();
  }

  defaultOptions() {
    return {
      cancelButton: false,
      cancelClass: 'raty-cancel',
      cancelHint: 'Cancel this rating!',
      cancelOff: 'cancel-off.png',
      cancelOn: 'cancel-on.png',
      number: 5,
      readOnly: false,
      score: undefined,
      scoreName: 'comment[rate]', // フォームで使用するフィールド名
      starHalf: '/assets/star-half.png',
      starOff: '/assets/star-off.png',
      starOn: '/assets/star-on.png',
      target: "#comment_rate",
      targetKeep: true,
    };
  }

  init() {
    this._setPath();
    this._createStars();
    this._createScoreField();

    if (this.opt.readOnly) {
      this._lock();
    } else {
      this._bindEvents();
    }

    this.setScore(this.opt.score);
  }

  setScore(score) {
    score = this._adjustScore(score);
    this._apply(score);
    this._setHiddenField(score);
  }

  getScore() {
    return this.scoreField ? this.scoreField.value : 0;
  }

  _apply(score) {
    this._fillStars(score);
    if (score) {
      this.scoreField.value = score;
    }
  }

  _setHiddenField(score) {
    if (this.opt.target) {
      const hiddenField = document.querySelector(this.opt.target);
      if (hiddenField) {
        hiddenField.value = score;
      }
    }
  }

  _fillStars(score) {
    this.stars.forEach((star, index) => {
      if (index + 1 <= score) {
        star.src = this.opt.starOn;
      } else if (index < score && index + 0.5 <= score) {
        star.src = this.opt.starHalf;
      } else {
        star.src = this.opt.starOff;
      }
    });
  }

  _createStars() {
    this.stars = [];
    for (let i = 1; i <= this.opt.number; i++) {
      const star = document.createElement("img");
      star.src = this.opt.starOff;
      star.dataset.score = i;
      this.element.appendChild(star);
      this.stars.push(star);
    }
  }

  _createScoreField() {
    this.scoreField = document.createElement("input");
    this.scoreField.type = "hidden";
    this.scoreField.name = this.opt.scoreName;
    this.element.appendChild(this.scoreField);
  }

  _bindEvents() {
    this.stars.forEach((star) => {
      star.addEventListener("click", (event) => {
        const score = event.target.dataset.score;
        this.setScore(score);
      });
    });
  }

  _lock() {
    this.stars.forEach(star => star.style.pointerEvents = "none");
  }

  _adjustScore(score) {
    return Math.min(Math.max(parseFloat(score) || 0, 0), this.opt.number);
  }

  _setPath() {
    this.opt.path = this.opt.path || '';
  }

  _parseOptions(dataset) {
    return Object.keys(dataset).reduce((acc, key) => {
      let value = dataset[key] === "true" ? true : dataset[key] === "false" ? false : dataset[key];
      if (!isNaN(value) && Number.isInteger(parseFloat(value))) {
        value = Number(value);
      }
      acc[key] = value;
      return acc;
    }, {});
  }
}

export default Raty;
```
### 2.画像をapp/assets/imagesに追加
https://github.com/wbotelhos/raty/tree/main/src/images
`star-on.png`, `star-off.png`, `star-half.png`を`app/assets/images/`に配置します。

### 3.モデルの修正
#### ①Commentモデルにrateカラムを追加
```bash
rails g migration AddRateToComments rate:float
rails db:migrate
```
#### ②Strong Parametersの修正
`app/controllers/comments_controller.rb~にストロングパラメータ`rate`を許可します。
```Ruby
  def comment_params
    params.require(:comment).permit(:content, :rate)
  end
```
### 4.Viewの修正
#### ①show.html.erbの修正
`app/views/tweets/show.html.erb`に以下のコードを追加します。
```ruby
 <% @comments.each do |c| %>
  <div>
    <%= c.user.email unless c.user.blank? %>
    <%= c.content %>
     <!-- 追加 -->
    <div id="comment-<%= c.id %>-rating"></div>
  </div>
<% end %>

<% if user_signed_in? %>
  <div id="rating-form">
    <%= form_with(model: [@tweet, @comment], local: true) do |f| %>
      <%= f.text_area :content %>
        <!-- 追加 -->
      <%= f.hidden_field :rate, value: 0, id: "comment_rate" %>
      <%= button_tag type: "submit" do %>
        <i class="far fa-comments"></i> コメントする
      <% end %>
    <% end %>
  </div>
<% end %>
```
初期化のスクリプトも追記します。
```js
<script>
  document.addEventListener("DOMContentLoaded", () => {
    console.log("DOMContentLoaded イベント発火");

    // フォームの星評価の初期化
    const ratingForm = document.getElementById("rating-form");
    if (ratingForm && !ratingForm.dataset.ratyInitialized) {
      console.log("ratingForm に Raty を適用");
      new Raty(ratingForm, {
        starOn: "<%= asset_path('star-on.png') %>",
        starOff: "<%= asset_path('star-off.png') %>",
        starHalf: "<%= asset_path('star-half.png') %>",
        scoreName: "comment[rate]"
      });
      ratingForm.dataset.ratyInitialized = "true"; // Raty が適用されたことを記録
    }

    // 各コメントの星評価の初期化
    const comments = <%= raw(@comments.map { |c| { id: c.id, rate: c.rate || 0 } }.to_json) %>;
    comments.forEach(comment => {
      const commentRatingElement = document.getElementById(`comment-${comment.id}-rating`);
      if (commentRatingElement && !commentRatingElement.dataset.ratyInitialized) {
        console.log(`comment-${comment.id}-rating に Raty を適用`);
        new Raty(commentRatingElement, {
          starOn: "<%= asset_path('star-on.png') %>",
          starOff: "<%= asset_path('star-off.png') %>",
          starHalf: "<%= asset_path('star-half.png') %>",
          readOnly: true,
          score: comment.rate
        });
        commentRatingElement.dataset.ratyInitialized = "true"; // Raty が適用されたことを記録
      }
    });
  });
</script>
```
### 5.Rails の設定
#### ①manifest.jsの修正
`app/assets/config/manifest.js`に`application.js`を追加します。
```js
//= link_tree ../images
//= link application.js
```
#### ②application.jsに`raty.js`を追加
`app/javascript/application.js`に以下のコードを追記してください。
```js
import Raty from "./raty";
window.Raty = Raty
```
これを行うことで、Railsの`application.js`に自動的に`Raty.js`が組み込まれます。

#### ⑥動作確認
コメントを投稿して星評価が保存され、表示されるか確認
- rails db:migrate を実行
- rails s でサーバーを起動

### チェックリスト
画像（星のアイコン）が上手く表示されない場合は以下を確認してみてください
```arduino
✅ `app/assets/images`に`star-on.png`,`star-off.png`,`star-half.png`が存在しているか？
✅ `asset_path`または`image_tag`で正しいパスを指定しているか？
✅ `rails assets:precompile`を実行したか？
✅ `public/assets`にプリコンパイルされた画像が出力されているか？
✅ `config/environments/development.rb`で`config.assets.compile = true`になっているか？（本番環境では false にすることが推奨）
```

それでも上手くできない場合はデバックの出力やコンソールの実行結果を確認しながら、初期化が作動していないのか、コンパイルできていないのか、パスが正確でないのかなど、どこで問題が発生しているのか落ち着いて突き止めてくださいね。

## まとめ
今回、Rails7で星評価付きのコメント機能を実装する方法をまとめました！
ご質問や改善点があれば、ぜひコメントしてください！😊