Skip to main content

お気に入り

これはその名の通り、Userモデルが何らかのモデルをお気に入りするモジュールである。 お気に入り機能は、ほぼ全てのサービスに存在する。TwitterのハートやFacebokのいいねなどがそれに当たる。

この実装はモデルが多対多の関係になるため、やや実装が複雑になるが、Railsならば中間テーブルを作成することで割と簡単に実装ができる。

お気に入りシステムというのは複数のユーザーが同一のPostに対してお気に入りできる仕組みである。 言い換えれば、Postモデルはお気に入りしているユーザーを複数持つことになるし、Userモデルは複数のお気に入りしているPostを持つことになる。

これをデーターベースで管理するには多対多の関係性だけを保持するデーターベースを作る必要がある。 これを中間テーブルと呼ぶ。

この章では試しにUserとPostの多対多のお気に入りシステムを実装する。

中間モデルの作成

最初にFavoritePostという中間テーブルを作成する。 このモデルはuser_idとpost_idだけを持つ。上で説明しているように、要は関係性だけを保持するテーブルである。 命名規則は特にないが、経験的にユーザー目線に立ち、FavoriteXXXXXXはお気に入りしたいモデル名にしている。

bin/rails g model FavoritePost user_id:integer post_id:integer

作成したマイグレーションファイルを開き、null制限とインデックスを使いする。

db/migrate/xxxxxxxx_create_favorite_posts.rb
class CreateFavoritePosts < ActiveRecord::Migration[6.1]
def change
create_table :favorite_posts do |t|
t.integer :user_id, null: false
t.integer :post_id, null: false
t.timestamps
end
add_index :favorite_posts, [:user_id, :post_id], unique: true
end
end

マイグレーションを読み込む。

bin/rails db:migrate

モデルにリレーションを宣言

Railsのモデルファイルにリレーションを記述する。 どのモデルとどのモデルが多対多の関係にあるかを明記する必要がある。

user.rbを開き、リレーションを記述する。

app/models/user.rb
has_many :favorite_posts
has_many :favorited_posts, through: :favorite_posts, source: :post

最初のfavorite_postsは上で作成した中間テーブルと関係があることを明記している。 2行目は、その中間テーブルを介して、ユーザーがお気に入りしたPostへアクセスする記述である。 つまり、@favorited_post = User.find(1).favorited_posts.allと記述すればuser_id = 1のお気に入りにしたPostが配列で取得できる。

app/models/favorite_post.rbを開いて、UserモデルとPostモデルに紐づくと宣言する。

app/models/favorite_post.rb
class FavoritePost < ApplicationRecord
belongs_to :user
belongs_to :post
end

ここまででPostとUserのFavoriteリレーションが完了した。

git add .
git commit -m "Create Favorite model and association"

Favorite Controller

モデルのリレーションが完成したので、お気に入りを制御するControllerを作成する。 お気に入りは結局Postを返しているので、その傘下にコントローラーを配置する。

bin/rails g controller Posts::Favorites

favorites_controllerにはcreate destroyの2つのアクションを作る。

app/controllers/posts/favorites_controller.rb
class Posts::FavoritesController < ApplicationController
before_action :authenticate_user!

def create
@post = Post.find(params[:post_id])
if FavoritePost.find_or_create_by(user_id: current_user.id, post_id: @post.id)
render json: @post, status: :created, location: @post
else
render json: @post.errors.full_messages, status: :unprocessable_entity
end
end

def destroy
FavoritePost.find_by!(post_id: params[:post_id], user_id: current_user.id).destroy
end
end

createはpost_idを受け取り、Postの存在を確認してから中間モデルを作成する。 destroyはCRUDの原則に従うのならばFavoritePostのidを受け取り削除するが、post_idから制御した方が便利なのでそうする。

よって、ルーティングも特殊になる。resources :postsにネストを噛ませて、うまくルーティングを作成する。

config/routes.rb
resources :posts do
scope module: :posts do
resources :favorites, only: %i[create] do
collection do
delete :destroy
end
end
end
end

ネストの最上部は単純なpostsのresourcesである。そこから1つネストしてscope moduleはURLは変えずにapp/controllers/posts/favorites_controller.rbのファイル構成を維持する記述である。その構造のままfavoritesのcreate resourcesだけを宣言する。それのネストでcollection delete destroyはRESTの:idを付与しないコードである。RESTのdestroyはresourcesでルーティングすると/posts/:post_id/favorites/:idみたいになる。これを/posts/:post_id/favoritesにするのがそれである。

参考となるサイトはこちら。

Railsのroutingにおけるscope namespace module の違い

railsのroutes.rbのmemberとcollectionの違いは?

ターミナルでルーティングを表示する。

bin/rails routes
...
post_favorites DELETE /posts/:post_id/favorites(.:format) posts/favorites#destroy
POST /posts/:post_id/favorites(.:format) posts/favorites#create

良さげなルーティングに仕上がる。

git add .
git commit -m "Create a FavoritePost controller and actions"

お気に入りを数える

Postにお気に入りされた数をカウントする機能を追加する。

bin/rails g counter_culture Post favorite_posts_count

マイグレーションをデータベースに反映させる。

bin/rails db:migrate

app/models/favorite_post.rbを開き、カウンターのコードを記述する。

app/models/favorite_post.rb
counter_culture :post

これで、Postがお気に入りされたら、favorite_posts_countが1増えて、削除された1減る。

git add .
git commit -m "Count the favorite post"

ログインユーザーのお気に入りフラグ

Postの一覧を取得すると、現在このようなJsonが配列に入って返ってくる。

    {
"id": 45,
"user_id": 1,
"title": "hoge",
"created_at": "2022-07-26T06:07:21Z",
"favorite_posts_count": 0,
"user": {
"id": 1,
"name": null,
"nickname": null,
"image": null,
"posts_count": 5
}
},

このjsonにはログインユーザーがpost_id = 45をすでにお気に入りしているのかどうかの情報が含まれていない。 そうなると、クライアント側でお気に入りボタンを設置した時に、これからお気に入りするボタンを表示するのか、削除するボタンを表示するのか判定できない。

このjsonにis_favoriteを持たせて、判定できる様にする。

なお、実装方法はさまざまあると思うが、現状これがサーバーの付加的にも最適だと思って実装している。 より良い方法があれば提案して欲しい。

postモデルとfavorite_postsのリレーションをhas_manyで張る。 is_favoriteのattr_accessorを追加する。

app/models/post.rb
has_many :favorite_posts
attr_accessor :is_favorite

同じファイルのjson seriazlierでis_favoriteを返すようにする。 json_for_listはindexアクションで複数のpostを返す時に利用する。 json_for_objectはshowアクションで単一のpostを返す時に利用する。

app/models/post.rb
private

def json_for_list
{
except: [
:content,
:updated_at,
:thumbnail
],
methods:[
:is_favorite
]
}
end

def json_for_object
{
methods:[
:is_favorite
]
}
end

postsコントローラーのindexアクションを編集する。

app/controllers/posts_controller.rb
def index
@posts = Post.eager_load(:user,:favorite_posts ).map { |post|
post.is_favorite = post.favorite_posts.filter_map{|favorite_post| favorite_post.user_id == (user_signed_in? ? current_user.id : nil) }.first
post
}

render json: @posts, json_for: :list, include: {user: {json_for: :public}}
end

is_favroite の値は各postのリレーションを見て、存在してたらtrueそうでない場合はnullになる。 合致したら1で、そうでない場合は0がis_favoriteになる。

    {
"id": 44,
"user_id": 1,
"title": "ddd",
"created_at": "2022-07-26T06:06:12Z",
"favorite_posts_count": 0,
"is_favorite": true,
"user": {
"id": 1,
"name": null,
"nickname": null,
"image": null,
"posts_count": 5
}
},
{
"id": 45,
"user_id": 1,
"title": "hoge",
"created_at": "2022-07-26T06:07:21Z",
"favorite_posts_count": 0,
"is_favorite": null,
"user": {
"id": 1,
"name": null,
"nickname": null,
"image": null,
"posts_count": 5
}
}

上のjsonはaliceがログインした上でpostsを取得した時、id=44はis_favorite": trueなのでお気に入りしている。 id=45は "is_favorite": nullなのでお気に入りしていない。

show actionも同じコードに書き換える。

def show
@post = Post.eager_load(:user, :favorite_posts).find( params[:id])
@post.is_favorite = @post.favorite_posts.filter_map{|favorite_post| favorite_post.user_id == (user_signed_in? ? current_user.id : nil) }.first
render json: @post, json_for: :object, include: {user: {json_for: :public}}
end
git add .
git commit -m "Get a post's is_favorite variable"

フロントのお気に入り開発

ここからNext.js側の実装を行う。

まずは、お気に入りを作成と削除のAPIを叩く関数を作成する。

lib/api/post.js
export const createFavoritePost = (id) => {
if (!Cookies.get('_access_token') || !Cookies.get('_client') || !Cookies.get('_uid')) return
return client.post(
`/posts/${id}/favorites`,
{},
{
headers: {
'access-token': Cookies.get('_access_token') || '',
client: Cookies.get('_client') || '',
uid: Cookies.get('_uid') || '',
},
},
)
}

export const destroyFavoritePost = (id) => {
if (!Cookies.get('_access_token') || !Cookies.get('_client') || !Cookies.get('_uid')) return
return client.delete(`/posts/${id}/favorites`, {
headers: {
'access-token': Cookies.get('_access_token') || '',
client: Cookies.get('_client') || '',
uid: Cookies.get('_uid') || '',
},
})
}

次に、一覧ページにお気に入りの表示、およびボタンを実装する。

上で作成した関数をインポートする。

pages/index.js
import { getPosts, destroyPost, createFavoritePost, destroyFavoritePost } from '../lib/api/post'

お気に入り処理を行うhandleFavoritePost関数を作成する。 作成と削除で関数を分けるかなどを考えたが、ひとまとめにしてみた。

pages/index.js
const handleFavoritePost = async (post) => {
try {
const res = post.isFavorite > 0 ? await destroyFavoritePost(post.id) : await createFavoritePost(post.id)
console.log(res)

if (res.status === 201 || res.status === 204) {
const res = await getPosts()
setPosts(res?.data)
} else {
console.log('Something went wrong')
}
} catch (err) {
console.log(err)
}
}

jsxのエディットボタンや削除ボタンがある場所に、お気に入りボタンを設置する。

pages/index.js
<Box display="flex" position="absolute" top={[16, null, 18]} right={[6, null, 8.5]}>
<Clickable onClick={() => handleFavoritePost(post)} display="flex">
<Svg name="Star" height={14} width={14} my="auto" fill={post.isFavorite > 0 ? 'gold' : null} />
</Clickable>
<Link display="flex" href={`/posts/${post.id}/edit`} ml={15}>
<Svg name="Edit" height={12} width={12} my="auto" />
</Link>
<Clickable onClick={() => handleDestroyPost(post)} display="flex" ml={15}>
<Svg name="Trash" height={12} width={12} my="auto" />
</Clickable>
</Box>

記事作成日を表示している場所にいいねの数を表示する。

pages/index.js
<Box display="flex" position="absolute" bottom={0} right={0}>
<Text display="flex" color="dimgray" fontSize={12} mr={15}>
{post.favoritePostsCount}
<Text display="inline" ml={2}>
いいね
</Text>
</Text>
<Text color="dimgray" fontSize={12}>
{post.createdAt}
</Text>
</Box>

favorite ui

Post詳細ページにもお気に入り情報を表示する。

/pages/posts/[id].jsxを開き、お気に入りの作成と削除の関数をインポート。

/pages/posts/[id].jsx
import { getPost, destroyPost, createFavoritePost, destroyFavoritePost } from '../../lib/api/post'

お気にりの作成と削除を制御する関数を作成。

/pages/posts/[id].jsx
const handleFavoritePost = async (post) => {
try {
const res = post.isFavorite > 0 ? await destroyFavoritePost(post.id) : await createFavoritePost(post.id)
console.log(res)

if (res.status === 201 || res.status === 204) {
const res = await getPost()
setPost(res?.data)
} else {
console.log('Something went wrong')
}
} catch (err) {
console.log(err)
}
}

一覧の時と同じように、編集と削除ボタンのところにお気に入りボタンを追加。

/pages/posts/[id].jsx
<Box position="absolute" display="flex" top={14} right={[6, null, 8.5]}>
<Clickable onClick={() => handleFavoritePost(post)} display="flex">
<Svg name="Star" height={14} width={14} my="auto" fill={post?.isFavorite > 0 ? 'gold' : null} />
</Clickable>
<Link display="flex" href={`/posts/${post?.id}/edit`} ml={15}>
<Svg name="Edit" height={12} width={12} my="auto" />
</Link>
<Clickable onClick={() => handleDestroyPost(post)} display="flex" ml={15}>
<Svg name="Trash" height={12} width={12} my="auto" />
</Clickable>
</Box>

お気に入りの数は「コンテンツ」の下に追加して表示する。

/pages/posts/[id].jsx
<Text fontSize={14} lineHeight="1.71" letterSpacing={0.7} color="dimgray" mt={30}>
コンテンツ
</Text>
<Text fontSize={18} lineHeight="1.71" letterSpacing={0.7} color="dimgray">
{post?.content}
</Text>
<Text fontSize={14} lineHeight="1.71" letterSpacing={0.7} color="dimgray" mt={30}>
お気に入り数
</Text>
<Text fontSize={18} lineHeight="1.71" letterSpacing={0.7} color="dimgray">
{post?.favoritePostsCount}
</Text>

post show

以上で、お気に入り機能の実装が完成した。

git add .
git commit -m "Develop client side favorite system"