Help us understand the problem. What is going on with this article?

RailsでインスタやTwitterのようなハッシュタグ検索を実装(gemなし)

概要

私は現在DMMWEBCAMPというプログラミングスクールに通っておりまして
3ヶ月目の課題であるポートフォリオにインスタグラムやTwitterで使用されているような
ハッシュタグもどきを実装しました。
今後実装される方の参考になればと思います。

参考サイト

Railsで作ったインスタもどきのキャプションにハッシュタグを実装
https://qiita.com/goppy001/items/791c946abdb41c9495bb

大まかな流れは上記サイトと同じですが、うまく出来なかったところを変更しています。

完成図

投稿画面

スクリーンショット 2020-06-24 11.28.23.png

投稿詳細画面

スクリーンショット 2020-06-24 11.28.55.png

ハッシュタグ一覧及びハッシュタグ投稿一覧画面

スクリーンショット 2020-06-24 11.29.11.png

事前準備DB

投稿テーブル
スクリーンショット 2020-06-24 11.36.41.png

最初はbodyとuser_idのみのテーブル構成でしたが、ハッシュタグを入力するためのhashbodyカラムを追加しています。
ちなみに画像はポートフォリオの仕様上他テーブルへ保存しておりますが、imageのカラムがこのテーブル内にあっても問題はありません。

モデル(DB)の作成

ハッシュタグモデル

$rails g model Hashtag hashname:string

ハッシュタグ保存用のモデルを作成します。
hashnameカラムにハッシュタグが保存されます。

マイグレーションファイルの編集

create_hashtags.rb
class CreateHashtags < ActiveRecord::Migration[5.2]
  def change
    create_table :hashtags do |t|
      t.string :hashname

      t.timestamps
    end
    add_index :hashtags, :hashname, unique: true
  end
end

中間テーブルの作成

$ rails g model HashtagPostImage post_image:references hashtag:references

HashtagテーブルとPostImage(投稿)テーブルの中間テーブルです。
参考にさせて頂いた記事ではここのコマンドが若干違います。
中間テーブルなので外部キーとしてhashtagとpostimageのidを持ってきます。
references型なので作成時にhashtag_idと打ってしまうと
出来上がったカラム名がhashtag_id_idとなってしまうので注意が必要です。

マイグレーションファイル

create_hashtag_post_images.rb
class CreateHashtagPostImages < ActiveRecord::Migration[5.2]
  def change
    create_table :hashtag_post_images do |t|
      t.references :post_image, foreign_key: true
      t.references :hashtag, foreign_key: true
    end
  end
end

マイグレート

$ rails db:migrate

作成されたDB

スクリーンショット 2020-07-01 12.13.30.png

モデルのアソシエーションとバリデーションの設定

ハッシュタグモデル

hashtag.rb
class Hashtag < ApplicationRecord
  validates :hashname, presence: true, length: { maximum: 50 }
  has_many :hashtag_post_images, dependent: :destroy
  has_many :post_images, through: :hashtag_post_images
end

とりあえず50文字を上限にしました。

中間テーブル

hashtag_post_image.rb
class HashtagPostImage < ApplicationRecord
  belongs_to :post_image
  belongs_to :hashtag
  validates :post_image_id, presence: true
  validates :hashtag_id, presence: true
end

PostImageモデル

post_image.rb
class PostImage < ApplicationRecord
  has_many :hashtag_post_images, dependent: :destroy
  has_many :hashtags, through: :hashtag_post_images
end

PostImageモデルに下記を追加

post_image.rb
after_create do
    post_image = PostImage.find_by(id: id)
    # hashbodyに打ち込まれたハッシュタグを検出
    hashtags = hashbody.scan(/[##][\w\p{Han}ぁ-ヶヲ-゚ー]+/)
    hashtags.uniq.map do |hashtag|
      # ハッシュタグは先頭の#を外した上で保存
      tag = Hashtag.find_or_create_by(hashname: hashtag.downcase.delete('#'))
      post_image.hashtags << tag
    end
  end
  #更新アクション
  before_update do
    post_image = PostImage.find_by(id: id)
    post_image.hashtags.clear
    hashtags = hashbody.scan(/[##][\w\p{Han}ぁ-ヶヲ-゚ー]+/)
    hashtags.uniq.map do |hashtag|
      tag = Hashtag.find_or_create_by(hashname: hashtag.downcase.delete('#'))
      post_image.hashtags << tag
    end
  end

作成と更新時にこのアクションが実行されるように記入してあります。

・post_image = PostImage.find_by(id: id)
作成した投稿を探させます。

・ hashtags = hashbody.scan(/[##][\w\p{Han}ぁ-ヶヲ-゚ー]+/)
ここでは入力されたハッシュタグ、先頭に[##]がつく入力値を探します。
hashbodyは私のDBのカラム名ですので、ここはアプリケーションにより異なります。
投稿テーブルのテキスト入力用のカラムであれば何でも良いです。

・ hashtags.uniq.map do |hashtag|
# ハッシュタグは先頭の#を外した上で保存
tag = Hashtag.find_or_create_by(hashname: hashtag.downcase.delete('#'))
post_image.hashtags << tag
end

mapで繰り返すことにより、複数のハッシュタグがpostimageに保存されます。

・post_image.hashtags.clear
更新時、一回ハッシュタグを消しているようです。

routeの記載

routes.rb
get '/post_image/hashtag/:name' => 'post_images#hashtag'
get '/post_image/hashtag' => 'post_images#hashtag'

私の場合はハッシュタグ一覧ページを作りたかったので二つのルートを用意しました。

PostImage ヘルパーの編集

post_images_helper.rb
module PostImagesHelper
  def render_with_hashtags(hashbody)
    hashbody.gsub(/[##][\w\p{Han}ぁ-ヶヲ-゚ー]+/) { |word| link_to word, "/post_image/hashtag/#{word.delete("#")}",data: {"turbolinks" => false} }.html_safe
  end
end

link_to word, "/post_image/hashtag/#{word.delete("#")}"
ここのurlはアプリケーションの内容によって異なります。
ハッシュタグをクリックするとここのurlに飛びますという意味です。
先ほどrouteに書いたurlを打ち込みましょう。

PostImagコントローラー

controllers/post_images_controller.rb
class PostImagesController < ApplicationController
  def new
    @postimagenew = PostImage.new
    @postimagenew.post_image_images.new
  end

  def create
    @postimagenew = PostImage.new(post_image_params)
    @postimagenew.user_id = current_user.id

    if @postimagenew.save
      redirect_to post_images_path
    else
      render('post_images/new')
    end
  end

  def destroy
    @postimage = PostImage.find(params[:id])
    @postimage.destroy
    redirect_to post_images_path
  end

private
 def post_image_params
    params.require(:post_image).permit(:body, :hashbody, :user_id, post_image_images_images: [], hashtag_ids: [])
 end

ストロングパラメーター内の画像を他テーブルに配列で保存するためのpost_image_images_images:[]は気にしないでください。
hashtag_idsはハッシュタグをPostImageのcreate時に複数個登録するので記入してあります。

View

投稿フォーム

views/post_images/new.html.erb
<div class= "row">
    <div class="col-lg-2 col-md-2">
    </div>
    <div class="col-xs-12 col-lg-8 col-md-8 col-sm-12">
        <div class= "postimage-new-box">
            <% if @postimagenew.errors.any? %>
                <div id="error_explanation">
                    <h3><%= @postimagenew.errors.count %>件の入力エラーにより、投稿出来ませんでした</h3>
                    <ul>
                        <% @postimagenew.errors.full_messages.each do |msg| %>
                        <li><%= msg %></li>
                        <% end %>
                    </ul>
                </div>
            <% end %>
            <h3>新規投稿</h3>
            <div class="previw">
            </div>
            <%= form_with model:@postimagenew, local: true do |f| %>
                <div class="attachment-field">
                    <p>画像を選択(複数指定可)</p>
                    <%= f.attachment_field :post_image_images_images, multiple:true %>
                </div>
                <br>
                <br>
                <div class= "postimage-body-box">
                    <p>投稿の詳細を入力してください</p>
                    <%= f.text_area :body, size:"55x12" %>
                    <br>
                    <p>ハッシュタグ入力欄</p>
                    <%= f.text_area :hashbody, size:"55x3" %>
                    <br>
                    <div class= "postimage-new">
                    <%= f.submit "新規投稿" ,class:'postimage-new-button' %>
                    </div>
            <% end %>
                </div>
        </div>
    </div>
    <div class="col-lg-2 col-md-2">
    </div>
</div>

投稿本文内で実際にハッシュタグを表示するところ

スクリーンショット 2020-06-24 11.28.55.png

Viewにて以下を表記します。

post_images/show.html.erb
<%= render_with_hashtags(@postimage.hashbody) %>

上記は先ほどhelperで作成したメソッドを呼び出しています。
ちなみにpost_images/showのコントローラーはこちらです。

controllers/post_images_controller.rb
def show
    @postimage = PostImage.find(params[:id])
end

単純に@postimageのhashtag入力欄の情報をヘルバーに渡しているんではないかと考えています。

ハッシュタグ一覧ページ

スクリーンショット 2020-06-24 11.29.11.png

クリックするとこんな感じです。
スクリーンショット 2020-06-24 13.12.07.png

PostImageコントローラーにhashtagアクションの追加

post_images_controller.rb
def hashtag
    @user = current_user
    if params[:name].nil?
      @hashtags = Hashtag.all.to_a.group_by{ |hashtag| hashtag.post_images.count}
    else
      @hashtag = Hashtag.find_by(hashname: params[:name])
      @postimage = @hashtag.post_images.page(params[:page]).per(20).reverse_order
      @hashtags = Hashtag.all.to_a.group_by{ |hashtag| hashtag.post_images.count}
    end
  end

ここの表記は作成するサイトにより変わります。
私はハッシュタグ一覧がみれるページを作りたかったので、params[:name].nil?の場合は
post_imageを表示しないという条件分岐をしています。
またgroup_byですが、hashtagに紐づく投稿が多い順番でハッシュタグを表示できるように
このような表記をしています。

ハッシュタグのView

post_images/hashtag.html.erb
<div class="row">
        <% if params[:name] == nil %>

        <% else %>
        <div class= "col-xs-12 col-lg-12 col-md-12 col-sm-12">
            <div class="hashtag-post-box">
                <h3 class="search-title">#<%= @hashtag.hashname %>:  <%= @postimage.count %> 件 </h3>
                <div class="flex-box">
                <% @postimage.each do |postimage| %>
                    <div class= "post-image-index-post-box">
                        <p class="index-post-box-top">
                            <%= postimage.created_at.strftime("%Y/%m/%d") %>
                        </p>
                        <span class='far fa-comments index-comment-count' id='comment-count_<%= postimage.id %>' style="color: #777777;">
                            <%= render 'post_image_comments/comment-count', postimage:postimage %>
                        </span>

                        <span id = "favorite-button_<%= postimage.id %>"class="post-box-top-favorite">
                            <%= render 'post_image_favorites/favorite',postimage: postimage %>
                        </span>
                        <%= link_to post_image_path(postimage),data: {"turbolinks" => false}  do %>
                            <ul class="slider">
                                    <% postimage.post_image_images.each do |post| %>
                                        <li>
                                        <%= attachment_image_tag post, :image ,size:'430x360', format:'jpg',class:"image" %>
                                    </li>
                                <% end %>
                            </ul>
                        <% end %>
                        <p class="hashtag-post-box-name">
                            <%= link_to user_path(postimage.user) do %>
                                <%= attachment_image_tag postimage.user, :profile_image,size:'30x30', format:'jpg',fallback:'no_image.jpg',class:'min-image' %>
                                <span class="index-post-box-user"><%= postimage.user.name %>
                                </span>
                            <% end %>
                        </p>
                        <div class="image-show-body-hash" style="padding:2%">
                            <%= simple_format(postimage.body.truncate(50))%>
                            <% if postimage.body.length > 50 %>
                                <span class="text-prev"><%= link_to '続きを読む', post_image_path(postimage), data: {"turbolinks" => false} %>
                                </span>
                            <% end %>
                        </div>
                    </div>
                <% end %>
                </div>
            </div>
            <div class="image-index-pagination" data-turbolinks="false">
                <%= paginate @postimage,class:"paginate" %>
            </div>
        </div>
        <% end %>
    </div>
    <div class="row">
        <div class= "col-xs-12 col-lg-12 col-md-12 col-sm-12">
            <div class= "hashtag-name">
                <% @hashtags.sort.reverse.each do |count| %>
                        <% count[1].each do |hashtag| %>
                        <p><%= link_to  "##{hashtag.hashname} (#{hashtag.post_images.count}) 件","/post_image/hashtag/#{hashtag.hashname}",data: {"turbolinks" => false} %>
                        </p>
                        <% end %>
                <% end %>
            </div>
        </div>
    </div>
</div>

クラス名とかを使いまわしていて訳わかんなくなっていてすいません。
大事なところは以下です。

post_images/hashtag.html.erb
<% if params[:name] == nil %>

<% else %>

<% end %>

この表記で先ほどrouteに書いたpost_image/hashtagとpost_image/hashtag/:nameでの条件分岐をしています。
paramsがnilのときの処理をコントローラーとViewそれぞれに書くことでエラーを起こさせないようにしています。

post_image/hashtag.html.erb
<div class= "hashtag-name">
    <% @hashtags.sort.reverse.each do |count| %>
        <% count[1].each do |hashtag| %>
            <p><%= link_to  "##{hashtag.hashname} (#{hashtag.post_images.count}) 件","/post_image/hashtag/#{hashtag.hashname}",data: {"turbolinks" => false} %>
            </p>
        <% end %>
    <% end %>
</div>

ここにハッシュタグ一覧を表示しています。
表示はハッシュタグに紐づく投稿が多い順に表示しています。

まとめ

ハッシュタグ入力欄を別にしないと、投稿の説明文のところにそのまま文章としてハッシュタグが残ってしまうため別カラムへの保存という形で実装しました。
投稿と一緒にハッシュタグを表示させている箇所では、意図的にhashbodyを非表示にしています。
view上にやたらと存在するturbolinks falseですがjsがうまく動かなくて書いてあるものなので、無視して頂いて大丈夫です。

初めての投稿で分かりづらい箇所があれば申し訳ないです。
これからポートフォリオを作成する方の参考になれば幸いです。

追記

2020/7/1
中間テーブルのマイグレーションファイルを修正しました。
id Falseの設定をかけるとdestroy出来なくなる現象がありましたので、修正しております。
同時にモデルのhas_manyに対しdependent: :destroyを追加しまいました。

また、ハッシュタグと投稿の保存の部分に関してコントローラーとViewを追加しました。
ご迷惑おかけします。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした